Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .abcd/development/brief/04-surfaces/04-launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 8 additions & 3 deletions commands/launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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
Expand Down
77 changes: 77 additions & 0 deletions internal/surface/cli/rendercut_findings_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 13 additions & 0 deletions internal/surface/cli/ship.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Loading