Skip to content

Commit 1ce6e7b

Browse files
committed
Tooling: enforce the doc context diet with two checks
Make the diet self-sustaining instead of convention-hoped: - `claude-md-details-sibling` (error): every non-root CLAUDE.md must have a sibling DETAILS.md and link it, so the C/D tier split is mandatory and the 'should a DETAILS.md exist' decision never recurs. - `resident-doc-budget` (warn): caps the unconditionally-resident agent-doc bundle (root CLAUDE.md + its transitive @-imports + .claude/rules/*.md) so the per-session token cost can't silently regrow. Seeded at the current 9472 words; ratchets down only. Both wired into CI; go-vet and staticcheck clean.
1 parent ac0a648 commit 1ce6e7b

7 files changed

Lines changed: 603 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,9 @@ jobs:
586586
- name: Check no 2-column tables in agent docs
587587
run: ./scripts/check/check --check docs-no-two-col-tables --ci
588588

589+
- name: Check every CLAUDE.md has a sibling DETAILS.md
590+
run: ./scripts/check/check --check claude-md-details-sibling --ci
591+
589592
- name: Check workflow hardening
590593
run: ./scripts/check/check --check workflows-hardening --ci
591594

scripts/check/checks/DETAILS.md

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ freestyle.sh remote execution), see [`../CLAUDE.md`](../CLAUDE.md).
3030
from the root `CLAUDE.md`. Allowlist with the same shrink-wrap/consent semantics as file-length. See § Docs reachable.
3131
- **`docs-reachable-allowlist.json`**: Allowlist for docs-reachable: `{ "files": { "path": reason } }` of docs
3232
intentionally unreachable. Goal is empty. Shrink-wraps gone/now-reachable entries; adding one needs David's OK.
33+
- **`claude-md-details-sibling.go`**: Errors (not warn-only) when any non-root `CLAUDE.md` lacks a sibling `DETAILS.md`
34+
in its directory or doesn't reference a `DETAILS.md`. Mandates the C/D pair so the "should this area have a
35+
`DETAILS.md`?" decision never recurs. No allowlist. See § CLAUDE.md / DETAILS.md sibling.
36+
- **`resident-doc-budget.go`**: Warn-only metric capping the unconditionally-resident agent-doc bundle (the repo-root
37+
`CLAUDE.md`, its transitive `@`-imports, and `.claude/rules/*.md`). The cap is a hardcoded constant that ratchets down
38+
only. See § Resident doc budget.
3339
- **`e2e-durations.go`**: E2E test duration flagger (warn-only): parses the Playwright JSON reports after each E2E run
3440
and flags tests over the 2 s budget. Embedded in both E2E checks, not a registry check. See § E2E test duration
3541
flagger.
@@ -239,6 +245,32 @@ connect docs rather than exempt them. Shrink-wrap drops entries whose file is go
239245
keeping one needs David's OK (`.claude/rules/file-length-allowlist.md`). To inspect the whole tree and spot
240246
deeply-nested or orphaned docs visually, run `pnpm check --docs-graph`.
241247

248+
## CLAUDE.md / DETAILS.md sibling
249+
250+
`claude-md-details-sibling` (`IsFast`, an **error** like `docs-reachable`: the C/D pair is structural) enforces that
251+
every non-root `CLAUDE.md` both has a sibling `DETAILS.md` in its directory and references a `DETAILS.md` (a Markdown
252+
link or a backtick path, syntax-agnostic like `docs-reachable`). This makes the "should this area have a `DETAILS.md`?"
253+
decision a one-time yes so it never recurs per area: the pull tier always exists, and the push-tier doc acknowledges it.
254+
The repo-root `CLAUDE.md` is exempt: it's the `@`-import manifest (its only content is the `@`-imports), not an area
255+
doc, and has no area `DETAILS.md`. The reference check accepts a link to any `DETAILS.md`, not strictly the sibling: the
256+
sibling-exists half is the structural guarantee, the reference half only confirms the author knows the pull tier exists.
257+
No allowlist: a missing `DETAILS.md` is always fixable by creating the file (the depth lives somewhere, so write it
258+
down), so there's nothing to exempt. Reuses `findClaudeMdFiles` (the same walk as `claude-md-length` and
259+
`claude-md-reminder`).
260+
261+
## Resident doc budget
262+
263+
`resident-doc-budget` (warn-only, `IsFast`) caps the unconditionally-resident agent-doc bundle: the repo-root
264+
`CLAUDE.md`, every file it transitively `@`-imports, and every project rule in `.claude/rules/*.md`. Unlike a colocated
265+
`CLAUDE.md` (resident only in sessions that touch its directory), this bundle loads in **every** session, worktree, and
266+
subagent, so each word is paid on every turn of every session. The check sums word counts (via `countWords`, matching
267+
`wc -w`) and warns when the total exceeds `residentDocBudgetWords`, a hardcoded constant seeded at the measured total at
268+
creation time. The cap must only ever ratchet **down** as the docs are trimmed, never up; raising it needs explicit user
269+
consent (same discipline as the allowlists). `@`-imports are resolved against the filesystem (relative to the importing
270+
file's dir first, then the repo root), which naturally drops `@`-prefixed non-imports that share the syntax: npm package
271+
names (`@iconify-json/lucide`), JSDoc tags (`@param`), and emails (`@example.com`). No allowlist file: a single constant
272+
is the whole contract, and the fix for over-budget is to trim a doc, not to bump the number.
273+
242274
## E2E test duration flagger
243275

244276
The E2E suites were hard-won down to under 2 s per test; `e2e-durations.go` defends that. After a successful E2E run,
@@ -307,16 +339,16 @@ RUSTSEC ignores — that's a quarterly task in `docs/maintenance.md`.
307339

308340
## Apps and check counts
309341

310-
| App | Tech | Checks |
311-
| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
312-
| Desktop | Rust | rustfmt, clippy, cargo-audit, cargo-deny, cargo-machete, cargo-udeps (CI-only), jscpd, log-error-macro, error-string-match, lock-poison, bindings-fresh, ipc-enum-camelcase, tests, integration-tests (Docker SMB), tests-linux (slow) |
313-
| Desktop | Svelte | prettier, eslint, svelte-kit-sync, eslint-typecheck-svelte, eslint-typecheck-typescript, stylelint, css-unused, a11y-contrast, btn-restyle, bare-poll, svelte-check, import-cycles, knip, type-drift, tests, e2e-linux-typecheck, e2e-linux (slow), e2e-playwright (slow) |
314-
| Website | Astro | prettier, eslint, typecheck, build, html-validate, bundle-size (warn-only), e2e |
315-
| Website | Docker | docker-build |
316-
| API server | TS | oxfmt, eslint, typecheck, tests |
317-
| Scripts | Go | gofmt, go-vet, staticcheck, ineffassign, misspell, gocyclo, nilaway, deadcode, go-tests, govulncheck |
318-
| Other | Metrics | file-length (warn-only), CLAUDE.md-reminder (warn-only), claude-md-length (warn-only), docs-reachable (errors when a CLAUDE.md/DETAILS.md/docs file isn't reachable from the root CLAUDE.md), docs-no-two-col-tables (errors on any 2-column table in agent-facing docs), changelog-commit-links, workflows-rustup (forbids `rustup target/component add` in workflows), ci-coverage (registry-to-workflows contract) |
319-
| Other | Security | workflows-hardening (SHA-pinning, no `pull_request_target`, job-scoped `id-token: write`) |
342+
| App | Tech | Checks |
343+
| ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
344+
| Desktop | Rust | rustfmt, clippy, cargo-audit, cargo-deny, cargo-machete, cargo-udeps (CI-only), jscpd, log-error-macro, error-string-match, lock-poison, bindings-fresh, ipc-enum-camelcase, tests, integration-tests (Docker SMB), tests-linux (slow) |
345+
| Desktop | Svelte | prettier, eslint, svelte-kit-sync, eslint-typecheck-svelte, eslint-typecheck-typescript, stylelint, css-unused, a11y-contrast, btn-restyle, bare-poll, svelte-check, import-cycles, knip, type-drift, tests, e2e-linux-typecheck, e2e-linux (slow), e2e-playwright (slow) |
346+
| Website | Astro | prettier, eslint, typecheck, build, html-validate, bundle-size (warn-only), e2e |
347+
| Website | Docker | docker-build |
348+
| API server | TS | oxfmt, eslint, typecheck, tests |
349+
| Scripts | Go | gofmt, go-vet, staticcheck, ineffassign, misspell, gocyclo, nilaway, deadcode, go-tests, govulncheck |
350+
| Other | Metrics | file-length (warn-only), CLAUDE.md-reminder (warn-only), claude-md-length (warn-only), resident-doc-budget (warn-only; caps the always-resident root-CLAUDE.md + @-imports + rules bundle), docs-reachable (errors when a CLAUDE.md/DETAILS.md/docs file isn't reachable from the root CLAUDE.md), claude-md-details-sibling (errors when a non-root CLAUDE.md lacks/doesn't reference a sibling DETAILS.md), docs-no-two-col-tables (errors on any 2-column table in agent-facing docs), changelog-commit-links, workflows-rustup (forbids `rustup target/component add` in workflows), ci-coverage (registry-to-workflows contract) |
351+
| Other | Security | workflows-hardening (SHA-pinning, no `pull_request_target`, job-scoped `id-token: write`) |
320352

321353
## Key decisions
322354

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package checks
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"sort"
9+
"strings"
10+
)
11+
12+
// detailsRefRe matches a Markdown link or backtick path whose target ends in
13+
// `DETAILS.md`. We follow docs-reachable's syntax-agnostic stance ("a reference
14+
// is any mention: Markdown link, backtick path, or bare path token"), and accept
15+
// a reference to any DETAILS.md, not strictly the sibling: the structural
16+
// guarantee is the sibling-exists half (checked separately); the reference half
17+
// only confirms the CLAUDE.md author knows the C/D pull tier exists. So a
18+
// Markdown link `[…](…/DETAILS.md)` or a backtick path “ `…/DETAILS.md` “ all
19+
// count. The leading path segment before `DETAILS.md` is unconstrained.
20+
var detailsRefRe = regexp.MustCompile(
21+
"\\]\\([^)]*DETAILS\\.md(?:#[^)]*)?\\)" + // markdown link to any …/DETAILS.md
22+
"|`[^`]*DETAILS\\.md`") // backtick `…/DETAILS.md`
23+
24+
type detailsSiblingViolation struct {
25+
claudeMd string // repo-relative path to the offending CLAUDE.md
26+
reason string // why it failed (no sibling, or no reference)
27+
}
28+
29+
// RunClaudeMdDetailsSibling enforces the C/D pair contract: every non-root
30+
// CLAUDE.md must have a sibling DETAILS.md in its directory AND reference a
31+
// DETAILS.md (a Markdown link or a backtick path). This makes the "should this
32+
// area have a DETAILS.md?" decision a one-time yes, so it never recurs per area:
33+
// the pull tier always exists, and the push-tier doc acknowledges it. The
34+
// repo-root CLAUDE.md is exempt (it's the @-import manifest, not an area doc, and
35+
// has no area DETAILS.md). An error, not a warning: the pair is structural, like
36+
// docs-reachable.
37+
func RunClaudeMdDetailsSibling(ctx *CheckContext) (CheckResult, error) {
38+
claudeFiles, err := findClaudeMdFiles(ctx.RootDir)
39+
if err != nil {
40+
return CheckResult{}, fmt.Errorf("failed to find CLAUDE.md files: %w", err)
41+
}
42+
43+
var violations []detailsSiblingViolation
44+
checked := 0
45+
for _, rel := range claudeFiles {
46+
// The repo-root CLAUDE.md is the @-import manifest, not an area doc.
47+
if filepath.Dir(rel) == "." {
48+
continue
49+
}
50+
checked++
51+
dir := filepath.Dir(rel)
52+
siblingDetails := filepath.Join(dir, "DETAILS.md")
53+
if !fileExists(filepath.Join(ctx.RootDir, siblingDetails)) {
54+
violations = append(violations, detailsSiblingViolation{
55+
claudeMd: rel,
56+
reason: "no sibling DETAILS.md in its directory",
57+
})
58+
continue
59+
}
60+
referencesDetails, readErr := claudeMdReferencesDetails(filepath.Join(ctx.RootDir, rel))
61+
if readErr != nil {
62+
return CheckResult{}, fmt.Errorf("failed to read %s: %w", rel, readErr)
63+
}
64+
if !referencesDetails {
65+
violations = append(violations, detailsSiblingViolation{
66+
claudeMd: rel,
67+
reason: "doesn't reference a DETAILS.md",
68+
})
69+
}
70+
}
71+
72+
if len(violations) == 0 {
73+
return Success(fmt.Sprintf("%d non-root CLAUDE.md %s paired with a linked DETAILS.md",
74+
checked, Pluralize(checked, "file", "files"))), nil
75+
}
76+
77+
return CheckResult{}, fmt.Errorf("%s", formatDetailsSiblingViolations(violations))
78+
}
79+
80+
// claudeMdReferencesDetails reports whether the CLAUDE.md at path references a
81+
// DETAILS.md (Markdown link or backtick path).
82+
func claudeMdReferencesDetails(path string) (bool, error) {
83+
data, err := os.ReadFile(path)
84+
if err != nil {
85+
return false, err
86+
}
87+
return detailsRefRe.Match(data), nil
88+
}
89+
90+
// formatDetailsSiblingViolations builds the failure body listing each CLAUDE.md
91+
// missing its DETAILS.md pair, with the fix.
92+
func formatDetailsSiblingViolations(violations []detailsSiblingViolation) string {
93+
sort.Slice(violations, func(i, j int) bool { return violations[i].claudeMd < violations[j].claudeMd })
94+
95+
var sb strings.Builder
96+
for _, v := range violations {
97+
sb.WriteString(fmt.Sprintf(" - %s: %s\n", v.claudeMd, v.reason))
98+
}
99+
return fmt.Sprintf(
100+
"%d non-root CLAUDE.md %s without a referenced sibling DETAILS.md:\n%s"+
101+
"Every area CLAUDE.md needs a colocated DETAILS.md (the pull tier) and a reference to it. "+
102+
"Create the DETAILS.md and point at it, for example `See [DETAILS.md](DETAILS.md).`",
103+
len(violations), Pluralize(len(violations), "file", "files"),
104+
strings.TrimRight(sb.String(), "\n")+"\n")
105+
}

0 commit comments

Comments
 (0)