diff --git a/internal/actrace/actrace.go b/internal/actrace/actrace.go index ac3ebe5..022d0d0 100644 --- a/internal/actrace/actrace.go +++ b/internal/actrace/actrace.go @@ -114,6 +114,12 @@ var ( // statusLineRE matches a `**Status:**` line (also `- **Status**:`), // capturing the rest of the line, mirroring internal/tools/adrstatus. statusLineRE = regexp.MustCompile(`(?i)^[-*\s]*\*\*status:?\*\*:?\s*(.*)$`) + // headRE matches a `## Status` heading on its own line. The status value + // then lives on the next non-empty line, not inline. Mirrors + // internal/tools/adrstatus, which supported both the `**Status:**` line and + // the `## Status` heading form; an ADR using the heading form must classify + // the same way it did before the extraction. + headRE = regexp.MustCompile(`(?i)^#{1,6}\s+status\s*$`) // adrCiteRe captures a bare-text `ADR-NNNN` reference and the two characters // that follow, so a linked `[ADR-0042](../adr/...)` (followed by "](") can be // excluded — those stay covered by `task docs-check`. @@ -987,11 +993,28 @@ func loadADRStatusByNumber(root string) (map[int]string, error) { if readErr != nil { return nil, fmt.Errorf("reading %s: %w", base, readErr) } - for _, ln := range strings.Split(string(b), "\n") { + // A present ADR file always gets an entry. The original adrstatus.parseADR + // defaulted an unrecognized status to "unknown" rather than omitting the + // ADR; the backward gate treats a missing entry as a missing ADR (a + // violation), so omitting a present-but-statusless ADR would wrongly flag + // its TestADR_* pins. "unknown" is not retired, so it is not a violation. + byNum[n] = "unknown" + lines := strings.Split(string(b), "\n") + for i, ln := range lines { if m := statusLineRE.FindStringSubmatch(ln); m != nil { byNum[n] = classifyStatus(m[1]) break } + // `## Status` heading: classify the next non-empty line. + if headRE.MatchString(ln) { + for _, next := range lines[i+1:] { + if w := strings.TrimSpace(next); w != "" { + byNum[n] = classifyStatus(w) + break + } + } + break + } } } return byNum, nil diff --git a/internal/actrace/actrace_test.go b/internal/actrace/actrace_test.go index f99c298..044db24 100644 --- a/internal/actrace/actrace_test.go +++ b/internal/actrace/actrace_test.go @@ -514,6 +514,47 @@ func TestLoadSupersessorByNumber_ParsesRetiredStatusLines(t *testing.T) { assert.Equal(t, want, got, "retired ADR number → supersessor number") } +func TestLoadADRStatusByNumber_HandlesBothStatusForms(t *testing.T) { + t.Parallel() + + root := t.TempDir() + adrDir := filepath.Join(root, "docs", "adr") + require.NoError(t, os.MkdirAll(adrDir, 0o755)) + write := func(name, body string) { + require.NoError(t, os.WriteFile(filepath.Join(adrDir, name), []byte(body), 0o644)) + } + + // The common `**Status:**` bold-line form. + write("0042-bold-line.md", + "# ADR-0042\n\n**Status:** Accepted, 2026-01-01.\n") + // The `## Status` heading form, value on the next non-empty line. Both + // internal/tools/adrstatus and this package must classify it the same way; + // dropping this branch silently read such an ADR as missing and flagged its + // TestADR_* pins (the ADR-0043 regression). + write("0043-heading-form.md", + "# ADR-0043: A decision\n\n## Status\n\nAccepted\n\n## Context\n") + // A retired ADR via the heading form classifies as superseded, not accepted. + write("0006-retired-heading.md", + "# ADR-0006\n\n## Status\n\n~~accepted~~. Superseded by ADR-0043.\n") + // A present ADR with no recognizable status form gets a "unknown" entry, not + // no entry. A missing entry reads as a missing ADR in the backward gate, so + // omitting a present file would wrongly flag its TestADR_* pins. Mirrors the + // original adrstatus.parseADR default. + write("0099-no-status.md", + "# ADR-0099: A decision with no status section\n\n## Context\n\nText.\n") + + got, err := loadADRStatusByNumber(root) + require.NoError(t, err) + + want := map[int]string{ + 42: "accepted", + 43: "accepted", + 6: "superseded", + 99: "unknown", + } + assert.Equal(t, want, got, "ADR number → classified status across both status forms") +} + func TestParseACs_FindsVerifyAfterWrappedCriterion(t *testing.T) { t.Parallel() // The criterion text wraps across lines, so the verify: sub-line is not