diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 31d618013..db12499fa 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 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 `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/.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 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)) }