Skip to content

Commit 73765e9

Browse files
jouwdanclaude
andauthored
feat(agents): make the no-inline-comments rule enforceable by every tool (#254)
* feat(agents): reject an inline comment where it is written AGENTS.md has forbidden inline comments since it was written, and the rule was still being broken — including by the agent that wrote several of the comments now in packages/core. Nothing loaded the file and nothing checked the rule, so it was advice that arrived only if someone thought to look. CLAUDE.md is a symlink to AGENTS.md. Claude Code loads CLAUDE.md by name; a symlink means the two cannot drift. .claude/settings.json registers a PostToolUse hook that runs after every file write. It counts the comments in the file just written, counts the ones in the same file at HEAD, and rejects the write if the set grew, naming each comment added. Comparing against HEAD rather than a checked-in baseline keeps it quiet on the 1,700 comment lines already in the tree while refusing every new one, with no list to maintain. .gitignore ignored .claude wholesale, so no per-repository configuration could travel between sessions. It now ignores the contents and tracks settings.json and hooks/ — git cannot re-include a file whose parent directory is excluded, hence `.claude/*` rather than `.claude/`. The rule in AGENTS.md now names its exceptions, because the ambiguity is what made it easy to break: /** */ counts as much as //, but suppressions, compiler-read type annotations, and the prose in the six files a generated reference is built from do not. Deleting the latter deletes a page. scripts/comment-scan.test.ts is why the scanner can be trusted. One that reads https:// inside a string as a comment would block honest work; one that misses a comment after a regular expression would wave through what it exists to catch. Both directions are covered. This is deliberately not a CI gate. The rule is about how the codebase is written, so the enforcement sits where the writing happens. * feat(agents): enforce the comment rule for every tool, not only Claude Code The PostToolUse hook added in the previous commit only fires in Claude Code. Codex, another agent, or a person committing by hand met no resistance at all, so the rule held for exactly one of the tools that write this code. AGENTS.md now says what it is: the contract for every agent and for people. Codex reads AGENTS.md by name and Claude Code reads CLAUDE.md, which is a symlink to it, so both load one text rather than two that drift. .githooks/pre-commit refuses a commit that adds a comment, whoever staged it. core.hooksPath is pointed at .githooks by a prepare script, so pnpm install arms it once. `git commit --no-verify` is the way past, which leaves a deliberate choice behind rather than an accident. pnpm comments:check is the same check as a command, comparing the working tree against HEAD and naming every comment the change adds. AGENTS.md asks for it before finishing, so an agent that reads instructions and one that does not are both covered. All three layers share scripts/comment-scan.mjs, and none of them are in pnpm verify or CI — the rule is about how the code is written, so it is enforced where writing and committing happen. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6722aa commit 73765e9

12 files changed

Lines changed: 565 additions & 3 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from 'node:child_process'
3+
import { readFile } from 'node:fs/promises'
4+
import { relative, resolve } from 'node:path'
5+
6+
import { commentLines } from '../../scripts/comment-scan.mjs'
7+
8+
const ROOT = resolve(new URL('../..', import.meta.url).pathname)
9+
const SOURCE = /\.(?:ts|tsx|mts|cts|mjs|cjs|js|jsx)$/
10+
const EXCLUDED = /^templates\/|\.d\.ts$/
11+
const DOC_SOURCES = new Set([
12+
'packages/theme-kit/src/slots.ts',
13+
'packages/theme-kit/src/api.ts',
14+
'packages/theme-kit/src/view-models.ts',
15+
'packages/plugin-kit/src/hooks.ts',
16+
'packages/plugin-kit/src/payloads.ts',
17+
'packages/plugin-kit/src/regions.ts',
18+
])
19+
20+
async function read(stream) {
21+
const chunks = []
22+
for await (const chunk of stream) chunks.push(chunk)
23+
return Buffer.concat(chunks).toString('utf8')
24+
}
25+
26+
let event
27+
try {
28+
event = JSON.parse(await read(process.stdin))
29+
} catch {
30+
process.exit(0)
31+
}
32+
33+
const edited = event?.tool_input?.file_path
34+
if (typeof edited !== 'string') process.exit(0)
35+
36+
const rel = relative(ROOT, resolve(edited))
37+
if (rel.startsWith('..') || !SOURCE.test(rel) || EXCLUDED.test(rel) || DOC_SOURCES.has(rel)) {
38+
process.exit(0)
39+
}
40+
41+
let source
42+
try {
43+
source = await readFile(resolve(ROOT, rel), 'utf8')
44+
} catch {
45+
process.exit(0)
46+
}
47+
48+
const now = commentLines(source)
49+
if (now.size === 0) process.exit(0)
50+
51+
const head = spawnSync('git', ['show', `HEAD:${rel}`], {
52+
cwd: ROOT,
53+
encoding: 'utf8',
54+
maxBuffer: 32 * 1024 * 1024,
55+
})
56+
const before = head.status === 0 ? new Set(commentLines(head.stdout).values()) : new Set()
57+
58+
const added = [...now.entries()].filter(([, text]) => !before.has(text))
59+
if (added.length === 0) process.exit(0)
60+
61+
const listed = added
62+
.slice(0, 5)
63+
.map(([line, text]) => ` ${rel}:${line} ${text.slice(0, 80)}`)
64+
.join('\n')
65+
66+
const more = added.length > 5 ? `\n …and ${added.length - 5} more` : ''
67+
68+
console.error(
69+
`AGENTS.md: no inline code comments. This change adds ${added.length} to ${rel}:\n\n` +
70+
`${listed}${more}\n\n` +
71+
'Remove them. If one explains something a reader needs, that explanation belongs in ' +
72+
'the relevant document under docs/, changed in the same commit — never in the code. ' +
73+
'Suppressions (biome-ignore, @ts-expect-error), type annotations (@type, @satisfies) ' +
74+
'and the JSDoc a generated reference is built from are not counted, so these are real ' +
75+
'comments. See docs/contributing/development.md, "No inline comments".',
76+
)
77+
process.exit(2)

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"PostToolUse": [
4+
{
5+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "node .claude/hooks/no-inline-comments.mjs"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

.githooks/pre-commit

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/sh
2+
# Rejects a commit that adds an inline code comment. AGENTS.md carries the rule;
3+
# docs/contributing/development.md, "No inline comments", explains this hook.
4+
# Bypass for a commit that genuinely needs one with `git commit --no-verify`.
5+
exec node scripts/comment-check.mjs --staged

.gitignore

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,12 @@ test-results/
5555
# Common ignores
5656
node_modules
5757
.DS_Store
58-
.claude/
58+
59+
# Claude Code's per-repository directory. `.claude/*` rather than `.claude/`,
60+
# because git cannot re-include a file whose parent directory is excluded — and
61+
# settings.json and hooks/ are the guardrails every session in this repository
62+
# loads, so they have to be tracked. Worktrees and personal overrides are not.
63+
.claude/*
64+
!.claude/settings.json
65+
!.claude/hooks/
66+
.claude/settings.local.json

AGENTS.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Agent Guide
22

3+
**This file is the contract for every coding agent working in this repository
4+
— Claude Code, Codex, or anything else — and for people.** Codex reads
5+
`AGENTS.md` by name; `CLAUDE.md` is a symlink to this file so Claude Code
6+
loads the same text rather than a copy that drifts. The rules below are not
7+
advisory: the ones that can be checked are checked, by a git `pre-commit`
8+
hook that runs whatever produced the change.
9+
310
Meith: community forum software. A pnpm workspace — the board (`apps/community`),
411
meith.dev (`apps/web`), the worker and the operator CLI in `apps/`; domain
512
packages in `packages/`; `themes/`, `plugins/`, `examples/`. The deployment
@@ -10,7 +17,17 @@ interface lives in `docker/`. Documentation lives in `docs/` and nowhere else
1017

1118
- **No inline code comments.** If something needs explaining, the explanation
1219
belongs in the relevant document under `docs/`, updated in the same change —
13-
never in the code.
20+
never in the code. This covers `/** */` as well as `//`: a JSDoc block that
21+
explains, justifies or gives context is an inline comment. Four things are
22+
not, and are the only exceptions: a `biome-ignore` suppression, a
23+
`@ts-expect-error`, a type annotation the compiler reads (`@type`,
24+
`@satisfies`, `/// <reference>`), and the prose in the six files a generated
25+
reference is built from — `packages/theme-kit/src/{slots,api,view-models}.ts`
26+
and `packages/plugin-kit/src/{hooks,payloads,regions}.ts`, where the comment
27+
*is* the published document. Run `pnpm comments:check` before you finish: it
28+
lists every comment your change added, and it is the same check the git
29+
`pre-commit` hook runs, so a commit carrying one is refused whoever or
30+
whatever wrote it. `docs/contributing/development.md` explains both.
1431
- **Update the docs with every change.** Behavior described in `docs/` changes
1532
in the same commit that changes the behavior. A new document is registered in
1633
`apps/web/content/docs.manifest.json` and linked from `docs/README.md`.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

docs/contributing/development.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,72 @@ but not symmetrically.**
521521
CI's other jobs build the image, drive a browser, and run the migrations
522522
against real Postgres.
523523

524+
## No inline comments
525+
526+
`AGENTS.md` carries the rule: an explanation belongs in the document under
527+
`docs/` that covers the behaviour, changed in the same commit, never in the
528+
code. The reason is that a comment is invisible to everyone who is not
529+
already reading that function — an operator, a theme author, somebody
530+
deciding whether the software does what they need — and it rots without
531+
anything noticing, because nothing checks a comment against the code beside
532+
it. A paragraph in `docs/` is read by all of them and is checked: the links
533+
gate holds its anchors, the index gate holds its registration, and the
534+
generated references fail when the contract they describe moves.
535+
536+
The rule covers `/** */` as much as `//`. A JSDoc block that explains why a
537+
function does what it does is an inline comment with a decorative syntax.
538+
539+
Four kinds of comment are not, and are the only exceptions:
540+
541+
- **`biome-ignore` suppressions**, which the linter reads, and which the rule
542+
above this section requires to carry a reason.
543+
- **`@ts-expect-error`**, which the compiler reads.
544+
- **Type annotations the compiler reads**`@type`, `@satisfies`,
545+
`/// <reference>` — mostly in `.mjs` files that have no other way to say it.
546+
- **The prose in the six files a generated reference is built from**:
547+
`packages/theme-kit/src/slots.ts`, `api.ts` and `view-models.ts`, which
548+
`pnpm theme:docs` publishes as
549+
[the theme slot reference](../reference/theme-slots.md); and
550+
`packages/plugin-kit/src/hooks.ts`, `payloads.ts` and `regions.ts`, which
551+
`pnpm plugin:docs` publishes as
552+
[the plugin hook reference](../reference/plugin-hooks.md). There the comment
553+
*is* the published document, and deleting it deletes a page.
554+
555+
### How it is enforced
556+
557+
Three layers, none of them CI, in the order they catch something.
558+
559+
**`pnpm comments:check`** lists every comment your change adds, comparing the
560+
working tree against `HEAD`. Run it before you finish. Comparing against
561+
`HEAD` rather than a checked-in list of allowed comments is what lets all
562+
three layers stay quiet about the comments already in the tree while refusing
563+
every new one, with nothing to maintain.
564+
565+
**The git `pre-commit` hook**`.githooks/pre-commit` — runs the same check
566+
over the staged tree and refuses the commit. `core.hooksPath` is set to
567+
`.githooks` by the `prepare` script, so `pnpm install` arms it once and it
568+
applies to every commit made in the repository afterwards, by any agent and by
569+
any person. This is the layer that does not care what wrote the code:
570+
`git commit --no-verify` is the deliberate way past it, and it leaves a
571+
visible choice behind rather than an accident.
572+
573+
**A `PostToolUse` hook**, `.claude/hooks/no-inline-comments.mjs`, registered in
574+
`.claude/settings.json`, runs after every file write a Claude Code session
575+
makes and rejects the write, naming each comment added. It is the fastest
576+
feedback of the three because it fires before the code is even staged, but it
577+
only covers that one tool — which is why it is not the layer the rule rests
578+
on.
579+
580+
`scripts/comment-scan.mjs` does the scanning for all three, and
581+
`scripts/comment-scan.test.ts` is why it can be trusted: a scanner that reads
582+
`https://` inside a string as a comment, or misses one after a regular
583+
expression, would either block honest work or wave through the thing it exists
584+
to catch. Both directions are covered there.
585+
586+
Nothing in `pnpm verify` or CI checks for comments. That is deliberate: the
587+
rule is about how the codebase is written, so the enforcement sits where the
588+
writing and the committing happen, not on the branch.
589+
524590
## Formatting and lint
525591

526592
One tool does both: [Biome](https://biomejs.dev/), configured in `biome.json`

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@
6262
"board:eject:smoke": "tsx scripts/board-eject-smoke.mts",
6363
"perf": "tsx packages/testkit/src/load/run.ts",
6464
"perf:docs": "node scripts/perf-docs.mjs",
65-
"perf:docs:check": "node scripts/perf-docs.mjs --check"
65+
"perf:docs:check": "node scripts/perf-docs.mjs --check",
66+
"prepare": "git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath .githooks || true",
67+
"comments:check": "node scripts/comment-check.mjs"
6668
},
6769
"devDependencies": {
6870
"@biomejs/biome": "2.5.8",

scripts/comment-check.mjs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from 'node:child_process'
3+
import { readFile } from 'node:fs/promises'
4+
import { join } from 'node:path'
5+
6+
import { commentLines } from './comment-scan.mjs'
7+
import { ROOT } from './repo-files.mjs'
8+
9+
const SOURCE = /\.(?:ts|tsx|mts|cts|mjs|cjs|js|jsx)$/
10+
const EXCLUDED = /^templates\/|\.d\.ts$/
11+
12+
const DOC_SOURCES = new Map([
13+
['packages/theme-kit/src/slots.ts', 'pnpm theme:docs'],
14+
['packages/theme-kit/src/api.ts', 'pnpm theme:docs'],
15+
['packages/theme-kit/src/view-models.ts', 'pnpm theme:docs'],
16+
['packages/plugin-kit/src/hooks.ts', 'pnpm plugin:docs'],
17+
['packages/plugin-kit/src/payloads.ts', 'pnpm plugin:docs'],
18+
['packages/plugin-kit/src/regions.ts', 'pnpm plugin:docs'],
19+
])
20+
21+
const STAGED = process.argv.includes('--staged')
22+
23+
function git(args, options = {}) {
24+
return spawnSync('git', args, {
25+
cwd: ROOT,
26+
encoding: 'utf8',
27+
maxBuffer: 64 * 1024 * 1024,
28+
...options,
29+
})
30+
}
31+
32+
const listing = STAGED
33+
? git(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'])
34+
: git(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR', '-z'])
35+
36+
if (listing.status !== 0) {
37+
console.error('✗ inline comments: could not list changed files')
38+
process.exit(1)
39+
}
40+
41+
const changed = listing.stdout
42+
.split('\0')
43+
.filter((rel) => rel !== '' && SOURCE.test(rel) && !EXCLUDED.test(rel) && !DOC_SOURCES.has(rel))
44+
45+
if (changed.length === 0) {
46+
console.log('✓ inline comments: no source file changed')
47+
process.exit(0)
48+
}
49+
50+
async function after(rel) {
51+
if (!STAGED) return readFile(join(ROOT, rel), 'utf8').catch(() => null)
52+
const staged = git(['show', `:${rel}`])
53+
return staged.status === 0 ? staged.stdout : null
54+
}
55+
56+
const problems = []
57+
58+
for (const rel of changed) {
59+
const source = await after(rel)
60+
if (source === null) continue
61+
62+
const now = commentLines(source)
63+
if (now.size === 0) continue
64+
65+
const head = git(['show', `HEAD:${rel}`])
66+
const before = head.status === 0 ? new Set(commentLines(head.stdout).values()) : new Set()
67+
68+
const added = [...now.entries()].filter(([, text]) => !before.has(text))
69+
if (added.length === 0) continue
70+
71+
problems.push({ rel, added })
72+
}
73+
74+
if (problems.length === 0) {
75+
console.log(`✓ inline comments: ${changed.length} changed source file(s), no comment added`)
76+
process.exit(0)
77+
}
78+
79+
const total = problems.reduce((sum, { added }) => sum + added.length, 0)
80+
console.error(`✗ inline comments: ${total} added across ${problems.length} file(s)\n`)
81+
82+
for (const { rel, added } of problems) {
83+
for (const [line, text] of added.slice(0, 5)) {
84+
console.error(` ${rel}:${line} ${text.slice(0, 80)}`)
85+
}
86+
if (added.length > 5) console.error(` …and ${added.length - 5} more in ${rel}`)
87+
}
88+
89+
console.error(
90+
'\n AGENTS.md: no inline code comments. An explanation belongs in the relevant\n' +
91+
' document under docs/, changed in the same commit — never in the code.\n\n' +
92+
' Suppressions (biome-ignore, @ts-expect-error), compiler-read type annotations\n' +
93+
' (@type, @satisfies) and the prose in the six files a generated reference is\n' +
94+
' built from are not counted, so these are real comments.\n\n' +
95+
' docs/contributing/development.md, "No inline comments", has the whole rule.\n',
96+
)
97+
process.exit(1)

0 commit comments

Comments
 (0)