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-2434 — markdownFiles 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-70 — CollectCitedURLs 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.
Wrong assumption
markdownFilestreats 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, theharness/*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-2434—markdownFilesmaps aWalkDirroot-lstatos.IsNotExisttoreturn nil, nil.internal/core/lint/lint.go:203-208— the per-root loop callsmarkdownFiles(rootAbs)and only propagates a non-nil error; it never stats the root.internal/core/lint/config.go(LoadConfig) validatesbanned_tokensstrictly but never validatesroots(not for existence).internal/core/lint/collect.go:48-70—CollectCitedURLsshares 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.gohard-codes"roots": ["docs", "README.md"]into the.abcd/docs-lint.jsonscaffolded into every managed repo. An adopter whose documentation lives in, e.g.,documentation/publishes a banned name indocumentation/guide.mdand:The gate that
abcd banlist add --publicwrites 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 andrecord-lint(amake preflightand CI step) prints nothing at all and exits 0, disarming all 15 banned tokens pluslinks_resolve/no_git_metadata/intent_lifecycle/spec_lifecycle/forbidden_synonyms.stray_root_docsstill runs (it is outside the loop), so the output is not empty fordocs 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-52stats 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.MinGatesdoc (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 forroots.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, soabcd docs cite refresh's wholesale-failure guard (internal/core/cite/refresh.go:278, gated onres.Fetched > 0) does not bind andSaveBaselinewrites an empty baseline, dropping all entries including humanconfirmreceipts. Honest caveat: this loss is not silent —renderRefreshprints adropped (no longer cited)line per entry and the committed baseline shows the deletion ingit diff(recoverable viagit checkout). It remains a real missing zero-cited floor inRefresh, but its practical severity is lower than the gate-disarm above.markdownFilescollapses anyos.IsNotExist(not just the root's) tonil, nil, so a file deleted mid-walk (agit checkout/cleanracing 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(notIsDir), sincerootslegitimately admits files such asREADME.md.Validator confirmations
docs/layout) makes bothdocs lintand 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.collect_test.go) covers only the empty-previouscase; the package elsewhere makes fail-closed-on-unresolvable-config an explicit principle, andRefreshalready refuses the structurally identical loss for a malformed baseline.