Skip to content

A roots entry pointing at a directory that does not exist makes abcd docs lint / record-lint pass with zero findings — every per-file blocker rule for that tree is silently disarmed, and the shipped scaffold's roots: ["docs", …] default triggers it in any adopter repo whose docs live elsewhere #360

Description

@REPPL

Wrong assumption

markdownFiles treats a missing root the same as a root that exists but contains no markdown — conflating "absent" with "clean". Every per-file lint rule (banned tokens, links_resolve, forbidden_synonyms, the harness/* published-surface name gates, the whole citation family) walks that tree, so a root that does not resolve silently disarms all of them and the gate exits 0.

Location

  • internal/core/lint/lint.go:2417-2434markdownFiles maps a WalkDir root-lstat os.IsNotExist to return nil, nil.
  • internal/core/lint/lint.go:203-208 — the per-root loop calls markdownFiles(rootAbs) and only propagates a non-nil error; it never stats the root.
  • internal/core/lint/config.go (LoadConfig) validates banned_tokens strictly but never validates roots (not for existence).
  • internal/core/lint/collect.go:48-70CollectCitedURLs shares the same walk; an itd-101 hardening pass rewrote this exact loop to validate root containment and still left existence unchecked.

Trigger (observed vs correct)

No typo is even required. internal/core/ahoy/banlist_scaffold.go hard-codes "roots": ["docs", "README.md"] into the .abcd/docs-lint.json scaffolded into every managed repo. An adopter whose documentation lives in, e.g., documentation/ publishes a banned name in documentation/guide.md and:

abcd docs lint  →  abcd docs lint — 0 finding(s), 0 blocker(s)   (exit 0)

The gate that abcd banlist add --public writes into — the name-leak gate — is disarmed with no diagnostic. The same happens on an ordinary record-tree rename: typo/rename .abcd/record-lint.json's roots to a nonexistent path and record-lint (a make preflight and CI step) prints nothing at all and exits 0, disarming all 15 banned tokens plus links_resolve / no_git_metadata / intent_lifecycle / spec_lifecycle / forbidden_synonyms. stray_root_docs still runs (it is outside the loop), so the output is not empty for docs lint, but every per-file family goes silent.

Correct behaviour: a configured root that does not resolve is misconfiguration and must fail loudly, not pass vacuously.

Why it fails silently

Both the finding count and the exit code are zero; the operator sees a green gate. Nothing points at the unresolved root.

This contradicts the project's own stated convention (four places)

  • internal/surface/cli/lint.go:41-52 stats the root before evaluating, specifically so ENOENT cannot produce a wrong verdict.
  • internal/core/repolint/rule_docs.go:44-54 — a missing target "must not then read as a silent pass"; it emits a warn.
  • RuleConfig.MinGates doc (config.go) — a non-empty floor exists precisely so a "heading/job rename silently dropped gates" fails closed. Same failure mode, fixed for one rule, left open for roots.
  • ArmReceiptGate — an empty list "fails closed, rather than inheriting a config a committer could have shrunk". Same threat model, opposite handling here.

Sibling sweep

  • CollectCitedURLs (collect.go) — same walk, same hole; a scope-collapsed root yields zero cited URLs, so abcd docs cite refresh's wholesale-failure guard (internal/core/cite/refresh.go:278, gated on res.Fetched > 0) does not bind and SaveBaseline writes an empty baseline, dropping all entries including human confirm receipts. Honest caveat: this loss is not silent — renderRefresh prints a dropped (no longer cited) line per entry and the committed baseline shows the deletion in git diff (recoverable via git checkout). It remains a real missing zero-cited floor in Refresh, but its practical severity is lower than the gate-disarm above.
  • Mid-walk ENOENT: markdownFiles collapses any os.IsNotExist (not just the root's) to nil, nil, so a file deleted mid-walk (a git checkout/clean racing a pre-commit gate) discards the whole root's accumulated results for that run. Low reachability (a race), same one-line swallow, same fix site.

CWE

CWE-636 (fail-open / incorrect-default) and CWE-390 (error condition without action).

Fix direction (one line)

Stat each configured root and fail loudly when it does not exist — using os.Stat (not IsDir), since roots legitimately admits files such as README.md.

Validator confirmations

  • REACHABILITY: CONFIRMED — reproduced on a repo copy; a one-character root change (or the shipped scaffold default meeting a non-docs/ layout) makes both docs lint and the CI record gate report 0/nothing at exit 0, no hostile actor and no second layer catching it; contradicts four in-repo fail-closed conventions.
  • CORRECTNESS: CONFIRMED — reproduced the same, and confirmed the documented bootstrap-skip (collect_test.go) covers only the empty-previous case; the package elsewhere makes fail-closed-on-unresolvable-config an explicit principle, and Refresh already refuses the structurally identical loss for a malformed baseline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-fixvalidated bug awaiting a fixseverity:majorwrong result on reachable input

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions