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
1 change: 1 addition & 0 deletions .abcd/record-lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"no_brittle_line_refs": {"enabled": true, "severity": "warn"},
"directory_coverage": {"enabled": true, "severity": "warn", "exempt": []},
"intent_lifecycle": {"enabled": true, "severity": "blocker", "intents_dir": "intents"},
"issue_id_unique": {"enabled": true, "severity": "blocker", "issues_dir": ".abcd/work/issues"},
"spec_lifecycle": {"enabled": true, "severity": "blocker", "specs_dir": "specs", "intents_dir": "intents"},
"persona_registry": {"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"},
"context_status_free": {"enabled": true, "severity": "blocker", "target": ".abcd/work/CONTEXT.md"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ category: "bug"
source: "user-observation"
found_during: "abcd-run-design"
found_at: ".abcd/work/issues/open"
resolution: "Added issue_id_unique record-lint rule scanning open/resolved/wontfix for any iss-N claimed by >=2 files; shares the validateIDUnique primitive with the intent-id check"
---

Duplicate iss-56 id: two open issues share id iss-56 — iss-56-iss-36-lists-abcd-logbook-as-a-retired-location-but-the-ship.md and iss-56-managed-pre-commit-gates.md. The allocator scans max N across open/resolved/wontfix under flock+O_EXCL, so a collision implies a manual add that bypassed it or an allocator gap. Ledger-integrity bug: derived priority, blocked_by edges, and resolve/wontfix by id are all ambiguous while two files answer to iss-56. Detector (per unrecognized-input-never-writes / ledger integrity): a capture/record-lint check that ids are unique across the three status dirs; acceptance corpus = the two iss-56 files. Fix: renumber the later-created one to the next free id and repoint any inbound blocked_by.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ called out in a **Breaking** section.

### Added

- **`issue_id_unique` record-lint rule — a duplicate issue id is a blocker.**
The lint pass now scans the issue ledger's three status directories
(`open/`, `resolved/`, `wontfix/`) and flags any `iss-N` id claimed by two or
more files, mirroring the existing intent-id uniqueness check (both share one
`validateIDUnique` primitive). The capture allocator already rejects a
duplicate on the reservation path, but a hand-added issue file that bypassed
it slipped straight through until now; this is the backstop that catches it.
Every file in a colliding set is flagged, since the linter cannot know which
claimant is authoritative.
- **`abcd intent ready <itd-N>` — the implement-readiness gate.** A read-only
verb reporting whether an intent may be implemented now: planned
(directory-as-truth), enumerable Acceptance Criteria, a bidirectional spec
Expand Down
4 changes: 4 additions & 0 deletions internal/core/lint/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ type RuleConfig struct {
// SpecsDir is the spec_lifecycle specs subdirectory (relative to a root),
// mirroring IntentsDir. Default "specs".
SpecsDir string `json:"specs_dir"`
// IssuesDir is the issue_id_unique ledger root (repo-relative), holding the
// open/, resolved/, and wontfix/ status directories. Default .abcd/work/issues.
// It lies outside Roots — the rule reads the ledger and runs once.
IssuesDir string `json:"issues_dir"`
// Allowlist is the stray_root_docs permitted basename-stem list (upper-cased,
// extension-stripped) for top-level markdown files.
Allowlist []string `json:"allowlist"`
Expand Down
98 changes: 92 additions & 6 deletions internal/core/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ var (
// Intent id embedded in a filename or a superseded_by value.
intentIDRe = regexp.MustCompile(`itd-\d+`)
intentFileRe = regexp.MustCompile(`^itd-\d+.*\.md$`)
// Issue id embedded in a ledger filename (issue_id_unique).
issueIDRe = regexp.MustCompile(`iss-\d+`)
issueFileRe = regexp.MustCompile(`^iss-\d+.*\.md$`)
// Surface registry Command cell: the bare "/abcd" top-level, or "/abcd:<name>".
surfaceCmdRe = regexp.MustCompile(`^/abcd(?::([a-z0-9-]+))?$`)
// receipt_gate arming inputs are release-time and become externally supplied
Expand Down Expand Up @@ -73,6 +76,9 @@ var (
"drafts": true, "planned": true, "shipped": true,
"disciplines": true, "superseded": true,
}
// issueStatusDirs are the issue ledger's status directories (issue_id_unique
// scans all three for a duplicated iss-N id).
issueStatusDirs = []string{"open", "resolved", "wontfix"}
)

// Lint runs every enabled check family against the record under repoRoot and
Expand Down Expand Up @@ -226,6 +232,17 @@ func Lint(cfg Config, repoRoot string) ([]Finding, error) {
findings = append(findings, gl...)
}

// issue_id_unique scans the issue ledger (.abcd/work/issues/{open,resolved,
// wontfix} — outside cfg.Roots) for an iss-N id claimed by two or more files,
// so it too runs once here.
if iiCfg, ok := cfg.Rules["issue_id_unique"]; ok && iiCfg.Enabled {
ii, err := checkIssueIDUnique(repoRoot, iiCfg)
if err != nil {
return nil, err
}
findings = append(findings, ii...)
}

sortFindings(findings)
return findings, nil
}
Expand Down Expand Up @@ -1125,11 +1142,21 @@ func checkIntentLifecycle(repoRoot, rootAbs string, cfg RuleConfig, top Config)
return out, nil
}

// validateIntentIDUnique flags every file in a colliding set, not just one:
// the linter cannot know which claimant is authoritative, and flagging a single
// file would imply the others are fine.
// validateIntentIDUnique flags a duplicated intent id, delegating to the shared
// validateIDUnique primitive (the issue-id rule uses the same logic).
func validateIntentIDUnique(repoRoot, rel, name string, fields map[string]fmField, idFiles map[string][]string, severity string) []Finding {
id := intentIDRe.FindString(name)
return validateIDUnique(repoRoot, rel, id, "intent", "intent_lifecycle", severity, fields, idFiles)
}

// validateIDUnique flags every file in a colliding id set, not just one: the
// linter cannot know which claimant is authoritative, and flagging a single file
// would imply the others are fine. It is the one primitive behind both the
// intent-id (intent_lifecycle) and issue-id (issue_id_unique) uniqueness rules —
// an id is a record's identity across its register and must be unique within it.
// noun names the record kind in the message; ruleID and severity tag the emitted
// Finding. idFiles maps each id to the repo-absolute paths that claim it.
func validateIDUnique(repoRoot, rel, id, noun, ruleID, severity string, fields map[string]fmField, idFiles map[string][]string) []Finding {
claimants := idFiles[id]
if len(claimants) < 2 {
return nil
Expand All @@ -1147,12 +1174,71 @@ func validateIntentIDUnique(repoRoot, rel, name string, fields map[string]fmFiel
line = f.line
}
return []Finding{{
File: rel, Line: line, RuleID: "intent_lifecycle", Severity: severity,
Message: "duplicate intent id " + id + "; also claimed by " + strings.Join(others, ", ") +
" — an id is the intent's identity across the record and must be unique",
File: rel, Line: line, RuleID: ruleID, Severity: severity,
Message: "duplicate " + noun + " id " + id + "; also claimed by " + strings.Join(others, ", ") +
" — an id is the " + noun + "'s identity across the record and must be unique",
}}
}

// checkIssueIDUnique flags any iss-N id claimed by two or more files across the
// issue ledger's status directories (open/, resolved/, wontfix/). The capture
// allocator rejects a duplicate on the reservation path, but a hand-added issue
// file that bypassed it — how a past iss-56 collision arose — slips straight
// through; this is the record-lint backstop that catches it. It is the issue-side
// mirror of the intent-id uniqueness rule and shares the same validateIDUnique
// primitive. The ledger lives outside cfg.Roots, so the rule runs once, not
// per-root. A missing ledger is not an error — it yields no findings.
func checkIssueIDUnique(repoRoot string, cfg RuleConfig) ([]Finding, error) {
issuesDir := cfg.IssuesDir
if issuesDir == "" {
issuesDir = ".abcd/work/issues"
}
issuesRoot := filepath.Join(repoRoot, issuesDir)
if _, err := os.Stat(issuesRoot); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}

// Track every file each iss-N id claims across the three status dirs: an id is
// the issue's identity across the ledger, and a bypassed-allocator file (or two
// parallel branches) can land the same id silently otherwise.
idFiles := map[string][]string{}
var files []string
for _, sub := range issueStatusDirs {
entries, err := os.ReadDir(filepath.Join(issuesRoot, sub))
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
for _, e := range entries {
if e.IsDir() || !issueFileRe.MatchString(e.Name()) {
continue
}
fileAbs := filepath.Join(issuesRoot, sub, e.Name())
id := issueIDRe.FindString(e.Name())
idFiles[id] = append(idFiles[id], fileAbs)
files = append(files, fileAbs)
}
}

var out []Finding
for _, fileAbs := range files {
id := issueIDRe.FindString(filepath.Base(fileAbs))
content, err := os.ReadFile(fileAbs)
if err != nil {
return nil, err
}
rel := repoRel(repoRoot, fileAbs)
fields := frontmatterFields(strings.Split(string(content), "\n"))
out = append(out, validateIDUnique(repoRoot, rel, id, "issue", "issue_id_unique", cfg.Severity, fields, idFiles)...)
}
return out, nil
}

func validateIntent(rel, bucket string, fields map[string]fmField, known map[string]bool, severity string) []Finding {
var out []Finding
add := func(line int, msg string) {
Expand Down
49 changes: 49 additions & 0 deletions internal/core/lint/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,55 @@ func TestIntentLifecycleDuplicateID(t *testing.T) {
}
}

// A hand-added issue file that bypassed the capture allocator can land an iss-N
// id another file already claims (how a past iss-56 collision arose — the
// allocator only rejects duplicates on the reservation path). The id is the
// issue's identity across the ledger, so a duplicate is a blocker on BOTH files:
// neither is authoritative, and a cross-reference cannot tell which iss-N it
// means. The rule scans all three status dirs (open/, resolved/, wontfix/).
func TestIssueIDUniqueDuplicate(t *testing.T) {
root := t.TempDir()
base := ".abcd/work/issues"

// Same id, different slugs, different status dirs -- the real collision shape.
writeFile(t, root, base+"/open/iss-56-first-one.md", "---\nid: \"iss-56\"\n---\n# one\n")
writeFile(t, root, base+"/resolved/iss-56-second-one.md", "---\nid: \"iss-56\"\n---\n# two\n")
// A collision within a single status dir must be caught too.
writeFile(t, root, base+"/open/iss-70-here.md", "---\nid: \"iss-70\"\n---\n# a\n")
writeFile(t, root, base+"/wontfix/iss-70-there.md", "---\nid: \"iss-70\"\n---\n# b\n")
// A unique id must stay clean.
writeFile(t, root, base+"/open/iss-71-unique.md", "---\nid: \"iss-71\"\n---\n# ok\n")

cfg := Config{
Rules: map[string]RuleConfig{
"issue_id_unique": {Enabled: true, Severity: "blocker", IssuesDir: ".abcd/work/issues"},
},
}
fs, err := Lint(cfg, root)
if err != nil {
t.Fatal(err)
}

// Every file in a colliding set is flagged -- flagging only one would imply
// the other is the "right" one, which the linter cannot know.
dupes := []string{
filepath.Join(base, "open", "iss-56-first-one.md"),
filepath.Join(base, "resolved", "iss-56-second-one.md"),
filepath.Join(base, "open", "iss-70-here.md"),
filepath.Join(base, "wontfix", "iss-70-there.md"),
}
for _, f := range dupes {
if !hasFinding(fs, f, "issue_id_unique", 2) { // the id: line
t.Errorf("expected duplicate-id finding on %s:2; got %+v", f, fs)
}
}
for _, f := range fs {
if filepath.Base(f.File) == "iss-71-unique.md" {
t.Errorf("unexpected finding on unique issue: %+v", f)
}
}
}

func TestExemptions(t *testing.T) {
root := t.TempDir()
// A banned token in an exempt_paths file → no finding.
Expand Down