mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
6a2910d45a
* fix(models): refuse an append that would destroy an unreadable structured field (BUG-2627, part 3)
AppendImplementationNote and AppendDecisionLogEntry rebuilt their entry slice
from Extract*, then assigned it over the field key UNCONDITIONALLY. When the
stored value was something Extract* could not decode, Extract* returned nil and
the assign overwrote that value with a one-element slice -- reporting success.
Observed live, not hypothesised: an item whose implementation_notes held a
JSON-ENCODED STRING lost its stored notes to a single `pad item note` call, with
no warning on any surface. Reproduced on a scratch item with two notes; both
were gone and the command printed "Added implementation note".
The guard cannot key on Extract* returning nil, which is the obvious shape and
the wrong one. Extract* returns nil for three different reasons:
1. the key is absent -- the first append on an item. Must proceed.
2. the key holds an empty array -- well-formed, just empty (Extract* has an
explicit len == 0 -> nil). Must proceed.
3. the key holds a value that does not decode -- the defect. Must refuse.
`if Extract(...) == nil { refuse }` passes every refusal test and breaks every
first append. So assertStructuredFieldAppendable tests decodability against the
raw value in the fields map, which is the only check that separates (3) from (1)
and (2). Applied to both helpers; ErrStructuredFieldUnreadable is exported so
callers can match on it.
The refusal message deliberately does NOT name an append path. Per PATTE-135 a
suggested remedy has to work in the state where the message appears, and every
append path is precisely what is being refused; `pad item show --format json` is
read-only and does surface the raw value, so it is the one action safe to
suggest. A test asserts the message never names `pad item note`.
Tests are mutation-verified per assertion. Each mutant was run and the killing
assertion recorded: guard removed -> the refusal legs; the plausible-wrong
`Extract(...) == nil` guard -> the empty-list and explicit-null CONTROL legs
(it passes all three refusal tests, so without those controls the wrong
implementation ships green); returning mutated fields alongside the error -> the
`fields != ""` assertion, which an errors.Is check alone would not catch;
message naming an append path -> the message assertion.
Verified end to end against a binary built from this branch: the trace that
destroyed two notes now exits non-zero and both notes read back byte-identical,
while a healthy item still takes a first note and appends onto an existing one.
This is part 3 of BUG-2627 and ships FIRST by design. Parts 1 (repair the
affected row) and 2 (refuse --field for structured keys at the CLI) follow,
because part 2's error message names a remedy that destroys affected rows until
this guard exists.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* test(models): close the coverage asymmetry Codex round 1 found (BUG-2627, part 3)
Round 1 accepted the guard condition and the error path, and was right that the
tests did not carry the bite the commit message claimed. All four gaps closed,
each mutation-verified rather than assumed:
- decision_log had no control legs of its own. The two helpers carry INDEPENDENT
guard calls, so coverage on the notes side says nothing about the log side --
an Extract-nil guard on AppendDecisionLogEntry passed every existing test.
Mutant run: it now fails the empty-list and explicit-null legs.
- No ordering assertion, so a helper that PREPENDED satisfied every length
check. Mutant run: prepending now fails "existing entry is preserved".
- The non-string refusal shapes (wrong element type, list of strings, bare
number, incompatible nested value) were untested, and the object case asserted
only the error, not the empty fields return.
- No test covered a sibling reserved field surviving a successful append.
Mutant run: rebuilding fieldsMap fresh instead of mutating the parsed one now
fails, where it previously left every notes assertion green. github_pr is the
witness because it shares the fields blob.
Codex also reported two findings that are NOT fixed here, deliberately:
P1 -- moving an item drops implementation_notes / decision_log / github_pr
entirely, because items.MigrateFields drops any key absent from the target
schema and these are reserved metadata that no schema declares. Verified by
reading migrate.go and then reproduced live: a well-formed note written through
`pad item note` was destroyed by `pad item move`, silently, with a success
message. That is worse than the defect this part guards -- it destroys VALID
data on a routine operation -- but it is a different mechanism at a different
door, so it is filed as BUG-2674 rather than folded in.
P2 -- generic field writers (--field implementation_notes=...) still reach the
fields patch and overwrite. That is BUG-2627 part 2, which ships after part 1 by
the ordering already recorded on the item.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* test(models): pin the guard's type parameter against a wrong-but-compiling swap (BUG-2627, part 3)
Codex round 2, and it is a real hole rather than a coverage complaint. The guard
is generic, so instantiating it with the WRONG entry type still compiles:
assertStructuredFieldAppendable[ItemImplementationNote](m, ItemFieldDecisionLog)
Every test written so far passes under that swap. The two structs disagree only
on shapes nothing exercised: `{"decision":{"nested":"object"}}` is ACCEPTED by
ItemImplementationNote (unknown key, ignored by encoding/json) and REJECTED by
ItemDecisionLogEntry (Decision is a string). So the guard would permit an append
that ExtractItemDecisionLog then reads as empty -- silently destroying the stored
entry. That is precisely the guard/extractor divergence this change exists to
prevent, reintroduced one type parameter away.
Both directions are now pinned, each mutation-verified:
- decision-log cases only ItemDecisionLogEntry rejects (a `decision` holding an
object, a `rationale` holding a list). Mutant run: the notes type parameter on
the log guard fails both.
- the mirror for notes (`summary` holding an object, `details` holding a list).
Mutant run: the log type parameter on the notes guard fails both, plus the
pre-existing incompatible-nested-value case.
Correcting the previous commit message: it said round 1's four gaps were "all
closed", which was overstated -- the malformed-entry matrix still ran only
against AppendImplementationNote, which is how this hole survived it.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* docs(models): correct the guard's own comment — four nil cases, and pin the type-parameter warning where it is read (BUG-2627, part 3)
Codex round 3 returned CLEAN with three nit-level accuracy notes against my
commit messages. Two are already self-corrected in a later message; the third
matters because the SAME undercount sits in the code comment, which is the
artifact a maintainer actually reads.
- Extract* returns nil for FOUR reasons, not three: absent key, empty array,
explicit JSON null, and an undecodable value. The code always handled null
(its own branch), the comment just did not count it.
- The type-parameter hazard round 2 found now lives in the function's doc
comment rather than only in a test name. A wrong-but-compiling instantiation
is silently DESTRUCTIVE, not merely wrong, and the next person to add a third
structured field will reach for this function without reading the tests first.
Comment-only; no behaviour change.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
731 lines
28 KiB
Go
731 lines
28 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestExtractItemCodeContextFromGitHubPRFields(t *testing.T) {
|
|
context := ExtractItemCodeContext(`{"github_pr":{"number":42,"url":"https://github.com/PerpetualSoftware/pad/pull/42","title":"Add branch metadata","state":"OPEN","branch":"feat/task-123-branch-pr-metadata","repo":"PerpetualSoftware/pad","updated_at":"2026-04-02T14:00:00Z"}}`)
|
|
if context == nil {
|
|
t.Fatal("expected code context")
|
|
}
|
|
if context.Provider != "github" {
|
|
t.Fatalf("expected provider github, got %q", context.Provider)
|
|
}
|
|
if context.Branch != "feat/task-123-branch-pr-metadata" {
|
|
t.Fatalf("expected branch metadata, got %q", context.Branch)
|
|
}
|
|
if context.Repo != "PerpetualSoftware/pad" {
|
|
t.Fatalf("expected repo PerpetualSoftware/pad, got %q", context.Repo)
|
|
}
|
|
if context.PullRequest == nil {
|
|
t.Fatal("expected pull request metadata")
|
|
}
|
|
if context.PullRequest.Number != 42 {
|
|
t.Fatalf("expected PR #42, got #%d", context.PullRequest.Number)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemCodeContextReturnsNilForUnrelatedFields(t *testing.T) {
|
|
if got := ExtractItemCodeContext(`{"status":"open"}`); got != nil {
|
|
t.Fatal("expected nil code context for unrelated fields")
|
|
}
|
|
}
|
|
|
|
func TestExtractItemConventionMetadataFromStructuredFields(t *testing.T) {
|
|
metadata := ExtractItemConventionMetadata(`{"status":"active","convention":{"category":"build","trigger":"on-pr-create","surfaces":["backend","docs"],"enforcement":"must","commands":["go test ./...","make install"]}}`)
|
|
if metadata == nil {
|
|
t.Fatal("expected convention metadata")
|
|
}
|
|
if metadata.Category != "build" {
|
|
t.Fatalf("expected category build, got %q", metadata.Category)
|
|
}
|
|
if metadata.Trigger != "on-pr-create" {
|
|
t.Fatalf("expected trigger on-pr-create, got %q", metadata.Trigger)
|
|
}
|
|
if metadata.Enforcement != "must" {
|
|
t.Fatalf("expected enforcement must, got %q", metadata.Enforcement)
|
|
}
|
|
if len(metadata.Surfaces) != 2 || metadata.Surfaces[0] != "backend" || metadata.Surfaces[1] != "docs" {
|
|
t.Fatalf("expected surfaces backend/docs, got %#v", metadata.Surfaces)
|
|
}
|
|
if len(metadata.Commands) != 2 || metadata.Commands[0] != "go test ./..." {
|
|
t.Fatalf("expected commands to be preserved, got %#v", metadata.Commands)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemConventionMetadataFallsBackToLegacyFields(t *testing.T) {
|
|
metadata := ExtractItemConventionMetadata(`{"status":"active","category":"quality","trigger":"on-commit","scope":"all","priority":"should"}`)
|
|
if metadata == nil {
|
|
t.Fatal("expected convention metadata")
|
|
}
|
|
if metadata.Category != "quality" || metadata.Trigger != "on-commit" || metadata.Enforcement != "should" {
|
|
t.Fatalf("unexpected convention metadata %#v", metadata)
|
|
}
|
|
if len(metadata.Surfaces) != 1 || metadata.Surfaces[0] != "all" {
|
|
t.Fatalf("expected legacy scope to map to surfaces, got %#v", metadata.Surfaces)
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_NoLeakOnNonConventionItems is the
|
|
// regression test for BUG-987 bug 13. Previously every Task / Idea /
|
|
// Plan with a `priority` field got a phantom
|
|
// `convention.enforcement: <priority>` surfaced on its response,
|
|
// because the legacy fallback in ExtractItemConventionMetadata
|
|
// unconditionally treated `priority` as the Convention enforcement
|
|
// tier. Tasks have priority but aren't Conventions; the metadata
|
|
// must NOT be synthesized for them.
|
|
func TestExtractItemConventionMetadata_NoLeakOnNonConventionItems(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"task with priority", `{"status":"open","priority":"high"}`},
|
|
{"task with priority and category", `{"status":"open","priority":"high","category":"frontend"}`},
|
|
{"idea with priority", `{"status":"new","priority":"medium","impact":"high"}`},
|
|
{"plan with start_date and priority", `{"status":"active","priority":"high","start_date":"2026-01-01"}`},
|
|
{"category alone is not a Convention signal", `{"category":"agent-integration","status":"new"}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(tc.fields)
|
|
if got != nil {
|
|
t.Errorf("expected nil metadata for non-Convention item; got %+v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_ConventionWithLegacyPriority
|
|
// exercises the path where priority→enforcement legacy fallback IS
|
|
// expected to fire — items that carry Convention-specific markers
|
|
// (trigger, scope, etc.) but use the legacy `priority` field for
|
|
// enforcement. The bug 13 fix preserves this path.
|
|
func TestExtractItemConventionMetadata_ConventionWithLegacyPriority(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(`{"status":"active","trigger":"on-commit","scope":"all","priority":"must"}`)
|
|
if got == nil {
|
|
t.Fatal("expected metadata for Convention with legacy priority field")
|
|
}
|
|
if got.Enforcement != "must" {
|
|
t.Errorf("Enforcement = %q, want must (priority legacy fallback)", got.Enforcement)
|
|
}
|
|
if got.Trigger != "on-commit" {
|
|
t.Errorf("Trigger = %q, want on-commit", got.Trigger)
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_LegacyConvention_ScopeOnly is
|
|
// the regression test for Codex's PR #361 round-1 finding: a legacy
|
|
// Convention carrying only `{scope, priority}` (no trigger, no
|
|
// commands, no structured convention field) must still resolve
|
|
// priority→enforcement. Pre-fix, the fallback ran BEFORE scope had
|
|
// flipped hasConventionShape, so enforcement got silently dropped.
|
|
func TestExtractItemConventionMetadata_LegacyConvention_ScopeOnly(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(`{"status":"active","scope":"all","priority":"must"}`)
|
|
if got == nil {
|
|
t.Fatal("expected metadata for legacy Convention with scope+priority")
|
|
}
|
|
if got.Enforcement != "must" {
|
|
t.Errorf("Enforcement = %q, want must (priority fallback after scope flips shape)",
|
|
got.Enforcement)
|
|
}
|
|
if len(got.Surfaces) != 1 || got.Surfaces[0] != "all" {
|
|
t.Errorf("Surfaces = %v, want [all]", got.Surfaces)
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_LegacyConvention_CommandsOnly
|
|
// covers the equivalent path for the commands marker.
|
|
func TestExtractItemConventionMetadata_LegacyConvention_CommandsOnly(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(`{"status":"active","commands":["go test"],"priority":"should"}`)
|
|
if got == nil {
|
|
t.Fatal("expected metadata for legacy Convention with commands+priority")
|
|
}
|
|
if got.Enforcement != "should" {
|
|
t.Errorf("Enforcement = %q, want should (priority fallback after commands flips shape)",
|
|
got.Enforcement)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemImplementationNotes(t *testing.T) {
|
|
notes := ExtractItemImplementationNotes(`{"status":"open","implementation_notes":[{"id":"note-1","summary":"Used SSE refresh","details":"Reload phase tasks on visibility resume","created_at":"2026-04-02T15:00:00Z","created_by":"agent"}]}`)
|
|
if len(notes) != 1 {
|
|
t.Fatalf("expected 1 implementation note, got %d", len(notes))
|
|
}
|
|
if notes[0].Summary != "Used SSE refresh" {
|
|
t.Fatalf("expected note summary, got %q", notes[0].Summary)
|
|
}
|
|
if notes[0].CreatedBy != "agent" {
|
|
t.Fatalf("expected created_by agent, got %q", notes[0].CreatedBy)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemDecisionLog(t *testing.T) {
|
|
log := ExtractItemDecisionLog(`{"status":"open","decision_log":[{"id":"decision-1","decision":"Use explicit setup state","rationale":"needs_setup was too ambiguous","created_at":"2026-04-02T15:05:00Z","created_by":"user"}]}`)
|
|
if len(log) != 1 {
|
|
t.Fatalf("expected 1 decision entry, got %d", len(log))
|
|
}
|
|
if log[0].Decision != "Use explicit setup state" {
|
|
t.Fatalf("expected decision text, got %q", log[0].Decision)
|
|
}
|
|
if log[0].Rationale == "" {
|
|
t.Fatal("expected rationale to be preserved")
|
|
}
|
|
}
|
|
|
|
func TestAppendImplementationNotePreservesExistingFields(t *testing.T) {
|
|
fields, err := AppendImplementationNote(`{"status":"open","priority":"high"}`, ItemImplementationNote{
|
|
ID: "note-1",
|
|
Summary: "Added setup guidance",
|
|
Details: "Updated the login screen copy",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AppendImplementationNote error: %v", err)
|
|
}
|
|
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
|
t.Fatalf("expected 1 note after append, got %#v", got)
|
|
}
|
|
if ExtractItemCodeContext(fields) != nil {
|
|
t.Fatal("did not expect code context")
|
|
}
|
|
}
|
|
|
|
func TestAppendDecisionLogEntryPreservesExistingNotes(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(`{"implementation_notes":[{"id":"note-1","summary":"Keep docker as external-managed"}]}`, ItemDecisionLogEntry{
|
|
ID: "decision-1",
|
|
Decision: "Keep docker in external-managed mode",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AppendDecisionLogEntry error: %v", err)
|
|
}
|
|
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
|
t.Fatalf("expected implementation notes to be preserved, got %#v", got)
|
|
}
|
|
if got := ExtractItemDecisionLog(fields); len(got) != 1 {
|
|
t.Fatalf("expected 1 decision log entry, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// BUG-2627. The Append* helpers assigned the rebuilt slice over the key
|
|
// unconditionally, so an existing value that Extract* could not decode was
|
|
// overwritten and destroyed while the call reported success. Reproduced live: an
|
|
// item whose implementation_notes held a JSON-ENCODED STRING lost its stored
|
|
// note to one `pad item note` call, silently.
|
|
//
|
|
// The refusal is asserted on the ERROR *and* on the returned fields being empty
|
|
// — "returns an error" alone would still pass if the helper handed back a
|
|
// mutated fields blob that a caller then wrote (CONVE-12: assert what the wrong
|
|
// behaviour would DO, not what the right one leaves looking unchanged).
|
|
func TestAppendRefusesWhenStructuredFieldIsUnreadable(t *testing.T) {
|
|
const encodedNotes = `{"implementation_notes":"[{\"summary\":\"stored note\"}]","status":"done"}`
|
|
const encodedDecisions = `{"decision_log":"[{\"decision\":\"stored decision\"}]"}`
|
|
|
|
t.Run("implementation notes", func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(encodedNotes, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
|
|
t.Run("decision log", func(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(encodedDecisions, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
|
|
// Shapes other than the double-encoded string that must also refuse. Each
|
|
// asserts the empty fields return too, not just the error — the refusal is
|
|
// only useful if the caller is left with nothing to write.
|
|
for _, tc := range []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"object instead of list", `{"implementation_notes":{"summary":"not a list"}}`},
|
|
{"list of wrong element type", `{"implementation_notes":[1,2,3]}`},
|
|
{"list of strings", `{"implementation_notes":["a note"]}`},
|
|
{"bare number", `{"implementation_notes":42}`},
|
|
{"known key holding an incompatible nested value", `{"implementation_notes":[{"summary":{"nested":"object"}}]}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(tc.fields, ItemImplementationNote{ID: "note-new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The message is the only thing a human sees at the moment of refusal, and
|
|
// PATTE-135 requires its suggested remedy to work in THIS state. It names a
|
|
// read-only inspection command deliberately — every append path is what is
|
|
// being refused, so none of them is a safe suggestion here.
|
|
t.Run("message names the field and a remedy that is not an append", func(t *testing.T) {
|
|
_, err := AppendImplementationNote(encodedNotes, ItemImplementationNote{ID: "note-new"})
|
|
msg := err.Error()
|
|
for _, want := range []string{"implementation_notes", "pad item show", "BUG-2627"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Errorf("refusal message missing %q: %s", want, msg)
|
|
}
|
|
}
|
|
if strings.Contains(msg, "pad item note") {
|
|
t.Errorf("refusal must not suggest an append path — it is destructive in this state: %s", msg)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The guard must key on "present and undecodable", NOT on Extract* returning
|
|
// nil — Extract* also returns nil for an absent key and for a well-formed empty
|
|
// list, and refusing either would break every first append. These are the
|
|
// control legs: without them a guard of the form `if Extract(...) == nil` would
|
|
// pass the refusal test above and still be wrong.
|
|
func TestAppendProceedsWhenStructuredFieldIsAbsentEmptyOrValid(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fields string
|
|
wantNotes int
|
|
}{
|
|
{"absent key", `{"status":"open"}`, 1},
|
|
{"empty list", `{"implementation_notes":[]}`, 1},
|
|
{"explicit null", `{"implementation_notes":null}`, 1},
|
|
{"existing entry is preserved", `{"implementation_notes":[{"id":"note-1","summary":"stored"}]}`, 2},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(tc.fields, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if err != nil {
|
|
t.Fatalf("append must proceed, got %v", err)
|
|
}
|
|
got := ExtractItemImplementationNotes(fields)
|
|
if len(got) != tc.wantNotes {
|
|
t.Fatalf("expected %d note(s), got %#v", tc.wantNotes, got)
|
|
}
|
|
if got[len(got)-1].ID != "note-new" {
|
|
t.Fatalf("new note should be appended last, got %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// decision_log gets its own control legs rather than riding on the notes ones.
|
|
// The two helpers carry independent guard calls, so coverage on one says
|
|
// nothing about the other — a guard omitted from AppendDecisionLogEntry, or
|
|
// pointed at the wrong field key, would pass every implementation_notes test.
|
|
func TestAppendDecisionLogProceedsWhenFieldIsAbsentEmptyOrValid(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fields string
|
|
wantEntries int
|
|
}{
|
|
{"absent key", `{"status":"open"}`, 1},
|
|
{"empty list", `{"decision_log":[]}`, 1},
|
|
{"explicit null", `{"decision_log":null}`, 1},
|
|
{"existing entry is preserved", `{"decision_log":[{"id":"decision-1","decision":"stored"}]}`, 2},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(tc.fields, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if err != nil {
|
|
t.Fatalf("append must proceed, got %v", err)
|
|
}
|
|
got := ExtractItemDecisionLog(fields)
|
|
if len(got) != tc.wantEntries {
|
|
t.Fatalf("expected %d entr(y|ies), got %#v", tc.wantEntries, got)
|
|
}
|
|
// Ordering, not just count: a helper that PREPENDED would satisfy
|
|
// the length assertion on every leg above.
|
|
if got[len(got)-1].ID != "decision-new" {
|
|
t.Fatalf("new entry should be appended last, got %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The decision-log guard must be instantiated with ItemDecisionLogEntry, not
|
|
// merely with SOME entry type. A wrong-but-compiling type parameter passes every
|
|
// other decision test, because the two structs disagree only on shapes that no
|
|
// other case exercises: `{"decision":{...}}` is ACCEPTED by the notes struct
|
|
// (unknown key, ignored) and REJECTED by the log struct (Decision is a string).
|
|
// The guard would then permit an append that ExtractItemDecisionLog reads as
|
|
// empty — which is the exact guard/extractor divergence this whole change exists
|
|
// to prevent, reintroduced one type parameter away. Codex round 2.
|
|
func TestAppendDecisionLogRefusesEntriesOnlyItsOwnTypeRejects(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"decision holding an object", `{"decision_log":[{"decision":{"nested":"object"}}]}`},
|
|
{"rationale holding a list", `{"decision_log":[{"decision":"ok","rationale":["a","b"]}]}`},
|
|
{"double-encoded string", `{"decision_log":"[{\"decision\":\"stored\"}]"}`},
|
|
{"list of wrong element type", `{"decision_log":[1,2,3]}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(tc.fields, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The mirror of the above: shapes only the NOTES struct rejects, so a
|
|
// decision-log type parameter on the notes guard is caught too. `{"summary":{}}`
|
|
// is rejected by ItemImplementationNote (Summary is a string) and accepted by
|
|
// ItemDecisionLogEntry (unknown key, ignored).
|
|
func TestAppendImplementationNoteRefusesEntriesOnlyItsOwnTypeRejects(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"summary holding an object", `{"implementation_notes":[{"summary":{"nested":"object"}}]}`},
|
|
{"details holding a list", `{"implementation_notes":[{"summary":"ok","details":["a","b"]}]}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(tc.fields, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A successful append must not disturb the item's other reserved metadata.
|
|
// github_pr lives in the same fields blob and is rebuilt from the same map, so
|
|
// a helper that assigned a fresh map instead of mutating the parsed one would
|
|
// drop it while every notes assertion stayed green.
|
|
func TestAppendPreservesSiblingReservedFields(t *testing.T) {
|
|
const withPR = `{"github_pr":{"number":42,"url":"https://github.com/PerpetualSoftware/pad/pull/42","state":"OPEN"},"status":"open"}`
|
|
|
|
fields, err := AppendImplementationNote(withPR, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if err != nil {
|
|
t.Fatalf("AppendImplementationNote error: %v", err)
|
|
}
|
|
if ctx := ExtractItemCodeContext(fields); ctx == nil {
|
|
t.Fatalf("github_pr must survive an implementation-note append, got %q", fields)
|
|
}
|
|
|
|
fields, err = AppendDecisionLogEntry(fields, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if err != nil {
|
|
t.Fatalf("AppendDecisionLogEntry error: %v", err)
|
|
}
|
|
if ctx := ExtractItemCodeContext(fields); ctx == nil {
|
|
t.Fatalf("github_pr must survive a decision-log append, got %q", fields)
|
|
}
|
|
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
|
t.Fatalf("notes must survive a decision-log append, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyItemConventionMetadataPreservesStatusAndWritesAliases(t *testing.T) {
|
|
fields, err := ApplyItemConventionMetadata(`{"status":"active"}`, &ItemConventionMetadata{
|
|
Category: "pm",
|
|
Trigger: "on-task-start",
|
|
Surfaces: []string{"all"},
|
|
Enforcement: "must",
|
|
Commands: []string{"pad item update <ref> --status in-progress"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ApplyItemConventionMetadata error: %v", err)
|
|
}
|
|
|
|
metadata := ExtractItemConventionMetadata(fields)
|
|
if metadata == nil || metadata.Category != "pm" || metadata.Trigger != "on-task-start" {
|
|
t.Fatalf("expected structured convention metadata, got %#v", metadata)
|
|
}
|
|
|
|
if got := ExtractItemImplementationNotes(fields); got != nil {
|
|
t.Fatalf("did not expect implementation notes, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// TestItemUpdateUnmarshalFlexFields covers BUG-1144: PATCH /items
|
|
// must accept `fields` and `tags` as either a JSON-encoded string
|
|
// (the canonical historical shape) or the natural nested object /
|
|
// array shape any reasonable HTTP client would send.
|
|
func TestItemUpdateUnmarshalFlexFields(t *testing.T) {
|
|
t.Run("fields as nested object", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"fields":{"reading_time":6,"status":"open"}}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields == nil {
|
|
t.Fatal("expected u.Fields to be set")
|
|
}
|
|
// Re-decode the canonical string and check semantic equality.
|
|
var got map[string]any
|
|
if err := json.Unmarshal([]byte(*u.Fields), &got); err != nil {
|
|
t.Fatalf("u.Fields not valid JSON: %v (was %q)", err, *u.Fields)
|
|
}
|
|
if got["reading_time"].(float64) != 6 || got["status"].(string) != "open" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("fields as JSON-encoded string (back-compat)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"fields":"{\"reading_time\":6}"}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields == nil || *u.Fields != `{"reading_time":6}` {
|
|
t.Fatalf("expected stringified fields passthrough, got %v", u.Fields)
|
|
}
|
|
})
|
|
|
|
t.Run("fields null leaves pointer nil", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"fields":null}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields != nil {
|
|
t.Fatalf("expected nil pointer for null fields, got %q", *u.Fields)
|
|
}
|
|
})
|
|
|
|
t.Run("fields absent leaves pointer nil", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"title":"hi"}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields != nil {
|
|
t.Fatal("expected u.Fields nil when key absent")
|
|
}
|
|
if u.Title == nil || *u.Title != "hi" {
|
|
t.Fatal("title should still decode normally")
|
|
}
|
|
})
|
|
|
|
t.Run("fields wrong shape returns domain error", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
err := json.Unmarshal([]byte(`{"fields":42}`), &u)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-object non-string fields")
|
|
}
|
|
if !errors.Is(err, ErrInvalidFieldsType) {
|
|
t.Fatalf("expected ErrInvalidFieldsType, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "Go struct field") {
|
|
t.Fatalf("error leaked Go internals: %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("fields as array is invalid (object expected)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
err := json.Unmarshal([]byte(`{"fields":["a","b"]}`), &u)
|
|
if !errors.Is(err, ErrInvalidFieldsType) {
|
|
t.Fatalf("expected ErrInvalidFieldsType for array fields, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as nested array", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"tags":["foo","bar"]}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Tags == nil {
|
|
t.Fatal("expected u.Tags to be set")
|
|
}
|
|
var got []string
|
|
if err := json.Unmarshal([]byte(*u.Tags), &got); err != nil {
|
|
t.Fatalf("u.Tags not valid JSON: %v (was %q)", err, *u.Tags)
|
|
}
|
|
if len(got) != 2 || got[0] != "foo" || got[1] != "bar" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as JSON-encoded string (back-compat)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"tags":"[\"foo\"]"}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Tags == nil || *u.Tags != `["foo"]` {
|
|
t.Fatalf("expected stringified tags passthrough, got %v", u.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as object is invalid (array expected)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
err := json.Unmarshal([]byte(`{"tags":{"x":1}}`), &u)
|
|
if !errors.Is(err, ErrInvalidTagsType) {
|
|
t.Fatalf("expected ErrInvalidTagsType for object tags, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("other fields decode normally", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
body := `{"title":"new","content":"body","pinned":true,"source":"web","fields":{"x":1}}`
|
|
if err := json.Unmarshal([]byte(body), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Title == nil || *u.Title != "new" {
|
|
t.Fatal("title not decoded")
|
|
}
|
|
if u.Content == nil || *u.Content != "body" {
|
|
t.Fatal("content not decoded")
|
|
}
|
|
if u.Pinned == nil || !*u.Pinned {
|
|
t.Fatal("pinned not decoded")
|
|
}
|
|
if u.Source != "web" {
|
|
t.Fatal("source not decoded")
|
|
}
|
|
if u.Fields == nil || *u.Fields != `{"x":1}` {
|
|
t.Fatalf("fields not normalized, got %v", u.Fields)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestItemCreateUnmarshalFlexFields covers BUG-1432: POST /items must
|
|
// accept `fields` and `tags` as either a JSON-encoded string (the
|
|
// canonical historical shape ItemCreate stored internally) or the
|
|
// natural nested object / array shape any reasonable HTTP client —
|
|
// including the MCP HTTP dispatcher — would send.
|
|
//
|
|
// Pre-fix, ItemCreate.Tags rejected `["foo","bar"]` with "cannot
|
|
// unmarshal array into Go struct field ItemCreate.tags of type
|
|
// string" — agents over MCP saw this as a generic validation_failed
|
|
// envelope and (per BUG-1409) blamed "tags is the culprit." The Codex
|
|
// investigation called out the asymmetry with ItemUpdate (which already
|
|
// handled both shapes per BUG-1144) as the root architectural fix.
|
|
func TestItemCreateUnmarshalFlexFields(t *testing.T) {
|
|
t.Run("tags as nested array (agent-natural shape)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","tags":["foo","bar"]}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var got []string
|
|
if err := json.Unmarshal([]byte(c.Tags), &got); err != nil {
|
|
t.Fatalf("c.Tags not valid JSON: %v (was %q)", err, c.Tags)
|
|
}
|
|
if len(got) != 2 || got[0] != "foo" || got[1] != "bar" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as JSON-encoded string (back-compat with CLI)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","tags":"[\"foo\"]"}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Tags != `["foo"]` {
|
|
t.Fatalf("expected stringified tags passthrough, got %q", c.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags absent leaves empty string", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X"}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Tags != "" {
|
|
t.Fatalf("expected empty tags when absent, got %q", c.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags null leaves empty string", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","tags":null}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Tags != "" {
|
|
t.Fatalf("expected empty tags for null, got %q", c.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as object is invalid (array expected)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
err := json.Unmarshal([]byte(`{"title":"X","tags":{"x":1}}`), &c)
|
|
if !errors.Is(err, ErrInvalidTagsType) {
|
|
t.Fatalf("expected ErrInvalidTagsType for object tags, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as number is invalid (array/string expected)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
err := json.Unmarshal([]byte(`{"title":"X","tags":42}`), &c)
|
|
if !errors.Is(err, ErrInvalidTagsType) {
|
|
t.Fatalf("expected ErrInvalidTagsType for numeric tags, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "Go struct field") {
|
|
t.Fatalf("error leaked Go internals: %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("fields as nested object (agent-natural shape)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","fields":{"status":"open","priority":"high"}}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal([]byte(c.Fields), &got); err != nil {
|
|
t.Fatalf("c.Fields not valid JSON: %v (was %q)", err, c.Fields)
|
|
}
|
|
if got["status"].(string) != "open" || got["priority"].(string) != "high" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("fields as JSON-encoded string (back-compat)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","fields":"{\"status\":\"open\"}"}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Fields != `{"status":"open"}` {
|
|
t.Fatalf("expected stringified fields passthrough, got %q", c.Fields)
|
|
}
|
|
})
|
|
|
|
t.Run("fields as array is invalid (object expected)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
err := json.Unmarshal([]byte(`{"title":"X","fields":["a","b"]}`), &c)
|
|
if !errors.Is(err, ErrInvalidFieldsType) {
|
|
t.Fatalf("expected ErrInvalidFieldsType for array fields, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("other fields decode normally", func(t *testing.T) {
|
|
var c ItemCreate
|
|
body := `{"title":"new","content":"body","pinned":true,"source":"web","fields":{"x":1},"tags":["a"]}`
|
|
if err := json.Unmarshal([]byte(body), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Title != "new" {
|
|
t.Fatal("title not decoded")
|
|
}
|
|
if c.Content != "body" {
|
|
t.Fatal("content not decoded")
|
|
}
|
|
if !c.Pinned {
|
|
t.Fatal("pinned not decoded")
|
|
}
|
|
if c.Source != "web" {
|
|
t.Fatal("source not decoded")
|
|
}
|
|
if c.Fields != `{"x":1}` {
|
|
t.Fatalf("fields not normalized, got %q", c.Fields)
|
|
}
|
|
if c.Tags != `["a"]` {
|
|
t.Fatalf("tags not normalized, got %q", c.Tags)
|
|
}
|
|
})
|
|
}
|