From 6559f9afd5b293d117291491f2266f621c7dd7d2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:28:10 +0100 Subject: [PATCH 1/3] fix: name uncommitted open records on the findings line beside other findings The findings gate refuses an open issue record that differs from HEAD, but the cut raises that reason as a refusal of its own only when no unfixed or deleted finding is present. Beside either, the uncommitted paths reached the operator only through --json, so fixing the named findings led to a second refusal the first run could have shown. The findings line now names them in that case, sanitised for the terminal. The launch command page and the brief's launch chapter now say that the unfixed-finding refusal also covers an open record that differs from HEAD (an uncommitted edit, waiver or regrade), at any severity, and where its paths are rendered. Refs: iss-2610072347238476 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 7 ++ commands/launch.md | 11 ++- .../surface/cli/rendercut_findings_test.go | 77 +++++++++++++++++++ internal/surface/cli/ship.go | 13 ++++ 4 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 internal/surface/cli/rendercut_findings_test.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 31d618013..9b16215dd 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -529,6 +529,13 @@ than a design target. - **What blocks** is a severity of `major` or `critical`, and also a severity that is absent, misspelled, or outside the ledger's enum. An unreadable grade has not been judged, and "not judged" must not read as "not serious". +- **An uncommitted open record** refuses under `unfixed-finding` too, at any + severity. The gate reads the ledger at `HEAD`, so an issue record under + `open/` that differs from `HEAD` (an uncommitted edit, waiver or regrade, a + capture, a removal) is a finding it cannot judge; the refusal names every + such path, and `--allow-dirty` does not waive it. When an unfixed or deleted + finding is present too, the findings line names the uncommitted paths beside + their counts, so one run names every reason. - **The waiver** is the frontmatter pair `deferred_after` plus `deferral_reason`, both schema-accepted keys, written by the ledger's deferral verb ([`06-capture.md`](06-capture.md)). `deferred_after` names the cut's diff --git a/commands/launch.md b/commands/launch.md index 737009c67..eadad383d 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -494,7 +494,9 @@ the issue ledger, and they are the two most easily skipped lines in the report: **What refuses.** An issue record that entered the ledger *since the anchor tag*, is graded `major` or `critical`, and is still in `open/`, refuses the cut under -the refusal kind `unfixed-finding`. A refused cut carries **no derived version**, +the refusal kind `unfixed-finding`. That kind is not about grades alone: an open +issue record that differs from HEAD refuses under it too, at any severity (see +*An uncommitted open record* below). A refused cut carries **no derived version**, so nothing downstream has a release to make. A record whose `severity` is missing, misspelled, or outside the ledger's enum refuses too: it has not been judged, and "not judged" must not read as "not serious". @@ -512,8 +514,11 @@ reads the ledger at HEAD, so it cannot judge a finding captured, regraded, deferred or removed in the working tree: whenever any issue record under `.abcd/work/issues/open/` differs from HEAD — added, edited, deleted or only staged, at any severity — the cut refuses and the `reason` names every path. -`--allow-dirty` never waives it, because the gate would still read HEAD. Commit -the records (or discard the change) and run again. +That covers an uncommitted edit, an uncommitted waiver and an uncommitted +regrade alike. When an unfixed or deleted finding is present too, the refusal +names those findings and the `findings:` line names the uncommitted paths, so +one run names every reason. `--allow-dirty` never waives it, because the gate +would still read HEAD. Commit the records (or discard the change) and run again. The anchor is what bounds it. Records that already existed at the last tag are the standing backlog and are never this cut's to answer; only what this cycle diff --git a/internal/surface/cli/rendercut_findings_test.go b/internal/surface/cli/rendercut_findings_test.go new file mode 100644 index 000000000..b8570b82e --- /dev/null +++ b/internal/surface/cli/rendercut_findings_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/release" +) + +// The detector iss-2610072347238476 owes. The findings gate refuses a working +// tree whose open/ issue records differ from HEAD, but the cut raises that +// reason as its own refusal only when no unfixed or deleted finding is present +// (the backstop in release.Emit). With either of those present, the refusal +// carries their half of the prose alone, and the uncommitted paths reached the +// operator only through --json: they fixed the named findings, ran again, and +// met a refusal they could have been shown the first time. The findings line +// is where the terminal render names them, so one run names every reason. + +// findingsLineOf returns the `findings:` line renderCut writes for a cut. +func findingsLineOf(t *testing.T, cut release.Cut) string { + t.Helper() + var buf bytes.Buffer + renderCut(&buf, "abcd launch ship", cut) + for _, line := range strings.Split(buf.String(), "\n") { + if strings.HasPrefix(line, " findings:") { + return line + } + } + t.Fatalf("renderCut wrote no findings line:\n%s", buf.String()) + return "" +} + +func TestFindingsLineNamesUncommittedRecordsBesideUnfixedAndDeletedFindings(t *testing.T) { + const dirtyA = ".abcd/work/issues/open/iss-3-regraded.md" + const dirtyB = ".abcd/work/issues/open/iss-4-captured.md" + cases := []struct { + name string + guard changelog.FindingGuard + }{ + {"beside an unfixed finding", changelog.FindingGuard{ + BaseTag: "v0.7.0", Status: changelog.FindingGuardFailed, + Unfixed: []changelog.Finding{{ID: "iss-1", Path: ".abcd/work/issues/open/iss-1-a.md", Severity: "major"}}, + Uncommitted: []string{dirtyA, dirtyB}, + }}, + {"beside a deleted finding", changelog.FindingGuard{ + BaseTag: "v0.7.0", Status: changelog.FindingGuardFailed, + Deleted: []changelog.Finding{{ID: "iss-2", Path: ".abcd/work/issues/open/iss-2-b.md", Severity: "critical"}}, + Uncommitted: []string{dirtyA, dirtyB}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + line := findingsLineOf(t, release.Cut{BaseTag: "v0.7.0", Findings: tc.guard}) + for _, want := range []string{"2 open record(s) differ from HEAD", dirtyA, dirtyB} { + if !strings.Contains(line, want) { + t.Errorf("findings line %q does not carry %q", line, want) + } + } + }) + } +} + +// TestFindingsLineSanitisesAnUncommittedPath holds brief invariant 13 for the +// new fragment: a path is a filename from the working tree, which reaches a +// terminal here like any other record-derived value. +func TestFindingsLineSanitisesAnUncommittedPath(t *testing.T) { + line := findingsLineOf(t, release.Cut{BaseTag: "v0.7.0", Findings: changelog.FindingGuard{ + BaseTag: "v0.7.0", Status: changelog.FindingGuardFailed, + Unfixed: []changelog.Finding{{ID: "iss-1", Severity: "major"}}, + Uncommitted: []string{".abcd/work/issues/open/iss-5-\x1b[31mred.md"}, + }}) + if strings.Contains(line, "\x1b") { + t.Errorf("an escape byte survived the findings line: %q", line) + } +} diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index 7b6feae74..b761285d0 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -918,6 +918,19 @@ func findingsLine(g changelog.FindingGuard) string { if len(g.Deleted) > 0 { line += fmt.Sprintf(" (%d record(s) deleted from the ledger since %s)", len(g.Deleted), g.BaseTag) } + // The uncommitted open records are named here when an unfixed or deleted + // finding is present too (iss-2610072347238476). The cut raises them as a + // refusal of their own only when neither list carries anything, so beside + // either the paths reached the operator through --json alone, and fixing the + // named findings led straight to a second refusal this run could have shown. + if len(g.Uncommitted) > 0 && (len(g.Unfixed) > 0 || len(g.Deleted) > 0) { + paths := make([]string, len(g.Uncommitted)) + for i, p := range g.Uncommitted { + paths[i] = termsafe.Sanitize(p) + } + line += fmt.Sprintf(" (%d open record(s) differ from HEAD, commit or discard them: %s)", + len(g.Uncommitted), strings.Join(paths, ", ")) + } if len(g.Waived) > 0 { line += fmt.Sprintf(" (%d deferred)", len(g.Waived)) } From 337bddcb06261e0b10d8ac474565fd9790daa583 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:41:34 +0100 Subject: [PATCH 2/3] docs: keep the flag name out of the launch chapter's guardrail prose The chapter prose above the generated appendix states no flag; the uncommitted-record bullet now says no override waives the refusal, the same wording the chapter's uncommitted-records row uses. Refs: iss-2610072347238476 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/04-launch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 9b16215dd..db12499fa 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -533,7 +533,7 @@ than a design target. severity. The gate reads the ledger at `HEAD`, so an issue record under `open/` that differs from `HEAD` (an uncommitted edit, waiver or regrade, a capture, a removal) is a finding it cannot judge; the refusal names every - such path, and `--allow-dirty` does not waive it. When an unfixed or deleted + such path, and no override waives it. When an unfixed or deleted finding is present too, the findings line names the uncommitted paths beside their counts, so one run names every reason. - **The waiver** is the frontmatter pair `deferred_after` plus From 54eeecaf96a8d42031df6e09bd7c4593dc954b1e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:06:08 +0100 Subject: [PATCH 3/3] chore(record): land lane-1 of run-2610101725397639 The implement loop's landing for iss-2610072347238476: resolved iss-2610072347238476 with 6559f9afd5b2. Resolves: iss-2610072347238476 Assisted-by: Claude:claude-opus-5-5 --- ...s-launch-md-and-the-brief-s-launch-chapter-describe.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md (64%) diff --git a/.abcd/work/issues/open/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md b/.abcd/work/issues/resolved/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md similarity index 64% rename from .abcd/work/issues/open/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md rename to .abcd/work/issues/resolved/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md index 1f9db6b43..729e420d0 100644 --- a/.abcd/work/issues/open/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md +++ b/.abcd/work/issues/resolved/iss-2610072347238476-commands-launch-md-and-the-brief-s-launch-chapter-describe.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "commands/launch.md" remedy: "Say in commands/launch.md and the brief's launch chapter that the unfixed-finding refusal also names an open issue record that differs from HEAD (an uncommitted edit, waiver or regrade), and render the uncommitted paths in the findings line when unfixed or deleted findings are also present, so one run names every reason." +resolution: "The findings line now names the uncommitted open records when an unfixed or deleted finding is present too, and commands/launch.md and the brief's launch chapter say the unfixed-finding refusal also covers an open record that differs from HEAD." +impact: fix +resolved_by: + commit: "6559f9afd5b293d117291491f2266f621c7dd7d2" --- commands/launch.md and the brief's launch chapter describe the unfixed-finding refusal as major/critical-only, but the findings gate now also emits it for any open issue record that differs from HEAD; and when unfixed or deleted findings are present too, the uncommitted paths appear only in --json, so the operator loops once more before seeing them. + +## Grounds + +- pursued: a cut with an unfixed or deleted finding and an uncommitted open record renders every uncommitted path on its findings line, so one run names every reason; a findings line in that case without the paths would show it wrong