Skip to content

Commit 95bb5b7

Browse files
committed
improvement(audits): add check:comment-hygiene
Fails on banner separator comments and on line comments whose text parses as TypeScript (commented-out code). Comments come from the Babel token stream so strings are never inspected; prose lines split comment groups, and labels, literals and one-off glosses are rejected, giving zero false positives across the tree. Change-history phrasing was measured and left out because most hits describe live state.
1 parent 0d69dc4 commit 95bb5b7

3 files changed

Lines changed: 359 additions & 0 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"check:migrations": "bun run scripts/check-migrations-safety.ts",
7171
"check:native-typecheck": "bun run scripts/check-native-typecheck.ts",
7272
"check:source-text": "bun run scripts/check-source-text.ts",
73+
"check:comment-hygiene": "bun run scripts/check-comment-hygiene.ts",
7374
"check:site-urls": "bun run scripts/check-site-urls.ts",
7475
"check:library-content": "bun run scripts/check-library-content.ts",
7576
"check:spec-example-ids": "bun run scripts/check-spec-example-ids.ts",
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { findViolations } from './check-comment-hygiene'
3+
4+
function rules(source: string, file = 'example.ts') {
5+
return findViolations(file, source).map(({ rule, line }) => `${rule}@${line}`)
6+
}
7+
8+
describe('banner', () => {
9+
it('flags decorated separators in line and single-line block comments', () => {
10+
const source = [
11+
'// ==========',
12+
'// --- Helpers ---',
13+
' // ─── Runs ───',
14+
'/* ------ v2 grammar ------ */',
15+
'const a = 1',
16+
].join('\n')
17+
expect(rules(source)).toEqual(['banner@1', 'banner@2', 'banner@3', 'banner@4'])
18+
})
19+
20+
it('ignores separators inside strings and multi-line TSDoc', () => {
21+
const source = [
22+
'const frontmatter = `',
23+
'// ---',
24+
'`',
25+
'/**',
26+
' * ---',
27+
' * name: skill',
28+
' * ---',
29+
' */',
30+
'export const b = frontmatter',
31+
].join('\n')
32+
expect(rules(source)).toEqual([])
33+
})
34+
})
35+
36+
describe('commented-out-code', () => {
37+
it('flags a commented-out object under a prose heading', () => {
38+
const source = [
39+
'const blocks = [',
40+
' // Disabled until the endpoint is back',
41+
' // {',
42+
" // id: 'query',",
43+
" // condition: { field: 'operation', value: 'search' },",
44+
' // },',
45+
']',
46+
].join('\n')
47+
expect(rules(source)).toEqual(['commented-out-code@3'])
48+
})
49+
50+
it('flags snippets that only parse separately', () => {
51+
const source = [
52+
'// {',
53+
"// id: 'x',",
54+
'// },',
55+
"// export type Mode = 'raw' | 'pretty'",
56+
].join('\n')
57+
expect(rules(source)).toEqual(['commented-out-code@1'])
58+
})
59+
60+
it('flags single commented-out statements', () => {
61+
expect(rules("// export type Mode = 'raw' | 'pretty'")).toEqual(['commented-out-code@1'])
62+
expect(rules('// await flushQueue(queue);')).toEqual(['commented-out-code@1'])
63+
})
64+
65+
it('ignores prose that happens to look like code', () => {
66+
const source = [
67+
'// firstRow: text=bg1, bold, fill=accent',
68+
'// Total = 22',
69+
'// limit=3 → [3,3,1]',
70+
'// Shows (Podcasts)',
71+
'// Title (for issue creation)',
72+
'// Example: [{ fields: { "Field 1": "Value1" } }]',
73+
'// return early when nothing changed.',
74+
'',
75+
'// cos(wd2, 2160000)',
76+
'',
77+
'// settings.get(key).value',
78+
'',
79+
'// Total: { count: 1 }',
80+
'// Shape: { id: 2 }',
81+
'const c = 1',
82+
].join('\n')
83+
expect(rules(source)).toEqual([])
84+
})
85+
86+
it('ignores trailing comments, tool directives and allowed samples', () => {
87+
const source = [
88+
'run() // await retry(run);',
89+
'// biome-ignore lint/suspicious/noExplicitAny: external payload',
90+
'const d: any = 1',
91+
'// comment-hygiene-allow: documents the call shape the worker expects',
92+
'// await worker.run({ id });',
93+
].join('\n')
94+
expect(rules(source)).toEqual([])
95+
})
96+
97+
it('parses JSX in tsx files', () => {
98+
expect(rules('// return <Panel open={open} />;', 'view.tsx')).toEqual(['commented-out-code@1'])
99+
})
100+
})

‎scripts/check-comment-hygiene.ts‎

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Fails on the two comment patterns that are never documentation:
4+
*
5+
* - `banner`: a separator comment — `// ====`, `// --- Section ---`, `// ─── Title ───`,
6+
* `/* ------ v2 ------ *\/`. The repo convention is "no separators": structure lives in the
7+
* code (modules, functions, TSDoc), and a banner only restates the next declaration's name.
8+
* - `commented-out-code`: a run of `//` lines whose text parses as TypeScript. Git is the
9+
* history; dead code left in a comment rots, misleads readers and agents into thinking it
10+
* is a live option, and survives every refactor of the code around it.
11+
*
12+
* Both are detected conservatively so a hit is almost always real. Comments are read from the
13+
* Babel token stream, so `//` inside strings and template literals is never inspected. A
14+
* commented-out run only counts when it parses with no recovery errors, contains code
15+
* punctuation, and is not a lone label (`firstRow: bold`), literal, or `a = b` gloss; prose
16+
* lines inside a comment group split it into separate runs. Change-history phrasing
17+
* ("previously", "no longer", "used to") was measured and left out: most hits describe live
18+
* runtime state or a regression a test guards, so it cannot be enforced without false alarms.
19+
*
20+
* A legitimate exception (a code sample that must stay a line comment) takes
21+
* `// comment-hygiene-allow: <reason>` anywhere in the same comment group.
22+
*
23+
* The tree was swept clean when this landed (236 banners, 9 commented-out blocks), so there is
24+
* no baseline: every hit fails.
25+
*
26+
* Run: `bun run check:comment-hygiene`
27+
*/
28+
import { execFileSync } from 'node:child_process'
29+
import { readFileSync } from 'node:fs'
30+
import path from 'node:path'
31+
import { fileURLToPath } from 'node:url'
32+
import { parse } from '@babel/parser'
33+
34+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
35+
const ALLOW = 'comment-hygiene-allow:'
36+
37+
/** Vendored or generated sources whose comments are not ours to edit. */
38+
const EXCLUDED = [/\.d\.ts$/, /(^|\/)bundles\//, /(^|\/)node_modules\//]
39+
40+
export type Rule = 'banner' | 'commented-out-code'
41+
42+
export interface Violation {
43+
rule: Rule
44+
line: number
45+
text: string
46+
}
47+
48+
/** Comment text that opens with a run of separator characters. */
49+
const BANNER = /^(?:={3,}|-{3,}|─{3,}|━{3,}|\*{3,}|~{3,})/
50+
51+
/** At least one token that ordinary prose does not contain. */
52+
const CODE_PUNCTUATION = /[;{}]|=>|\b(?:const|let|return|await|import|export)\s|\w\.\w+\(/
53+
54+
/** Single-line runs must start like a statement or end like a call to count as code. */
55+
const SINGLE_LINE_CODE = /^(?:export|import|const|let|var|return|await|if|for|function)\b|\);?$/
56+
57+
/**
58+
* A line that reads as prose: a capitalized word followed by more words, without a trailing
59+
* code delimiter. These split a comment group, so a prose heading above commented-out code
60+
* does not stop the code from parsing.
61+
*/
62+
const PROSE_LINE = /^[A-Z][\w'-]*(?:\s+[\w'(),./&:—-]+)+\s*$/
63+
const CODE_TAIL = /[;{}]\s*$|[,([]$/
64+
65+
/** Expression kinds that a one-off gloss like `Total = 22` or a bare word would parse as. */
66+
const TRIVIAL_EXPRESSIONS = new Set([
67+
'TemplateLiteral',
68+
'StringLiteral',
69+
'Identifier',
70+
'NumericLiteral',
71+
'BinaryExpression',
72+
'AssignmentExpression',
73+
])
74+
75+
function parsesAsCode(text: string, jsx: boolean): boolean {
76+
const plugins = jsx ? (['typescript', 'jsx'] as const) : (['typescript'] as const)
77+
for (const candidate of [text, `[${text}]`, `({${text}})`]) {
78+
let program
79+
try {
80+
program = parse(candidate, {
81+
sourceType: 'module',
82+
plugins: [...plugins],
83+
allowReturnOutsideFunction: true,
84+
allowAwaitOutsideFunction: true,
85+
}).program
86+
} catch {
87+
continue
88+
}
89+
if (candidate !== text) return true
90+
const trivial = program.body.every(
91+
(node) =>
92+
node.type === 'LabeledStatement' ||
93+
node.type === 'EmptyStatement' ||
94+
(node.type === 'ExpressionStatement' && TRIVIAL_EXPRESSIONS.has(node.expression.type))
95+
)
96+
return !trivial
97+
}
98+
return false
99+
}
100+
101+
interface LineComment {
102+
line: number
103+
value: string
104+
ownLine: boolean
105+
}
106+
107+
/** Splits a group of adjacent own-line `//` comments into runs of non-prose lines. */
108+
function codeRuns(group: LineComment[]): LineComment[][] {
109+
const runs: LineComment[][] = []
110+
let current: LineComment[] | null = null
111+
for (const comment of group) {
112+
const text = comment.value.trim()
113+
const prose = text.startsWith('//') || (PROSE_LINE.test(text) && !CODE_TAIL.test(text))
114+
if (!text || prose) {
115+
current = null
116+
continue
117+
}
118+
if (!current) {
119+
current = []
120+
runs.push(current)
121+
}
122+
current.push(comment)
123+
}
124+
return runs
125+
}
126+
127+
/** Longest run a span search considers; the search is quadratic in run length. */
128+
const MAX_RUN_LINES = 80
129+
130+
function isCode(span: LineComment[], jsx: boolean): boolean {
131+
const text = span
132+
.map((comment) => comment.value.replace(/^ /, ''))
133+
.join('\n')
134+
.trim()
135+
if (!CODE_PUNCTUATION.test(text)) return false
136+
if (span.length === 1 && !SINGLE_LINE_CODE.test(text)) return false
137+
return parsesAsCode(text, jsx)
138+
}
139+
140+
/**
141+
* The first contiguous span of a run that parses as code. A run can hold two unrelated
142+
* snippets back to back (an object, then a type), which only parse separately, so every start
143+
* line is tried against every end line, longest first.
144+
*/
145+
function firstCodeSpan(run: LineComment[], jsx: boolean): LineComment | undefined {
146+
const lines = run.slice(0, MAX_RUN_LINES)
147+
for (let start = 0; start < lines.length; start++) {
148+
for (let end = lines.length; end > start; end--) {
149+
if (isCode(lines.slice(start, end), jsx)) return lines[start]
150+
}
151+
}
152+
return undefined
153+
}
154+
155+
/** Every banner and commented-out-code hit in one source file. */
156+
export function findViolations(file: string, source: string): Violation[] {
157+
const jsx = /\.[jt]sx$/.test(file)
158+
let comments
159+
try {
160+
comments =
161+
parse(source, {
162+
sourceType: 'module',
163+
plugins: ['typescript', ...(jsx ? (['jsx'] as const) : []), 'decorators'],
164+
errorRecovery: true,
165+
}).comments ?? []
166+
} catch {
167+
return []
168+
}
169+
170+
const lines = source.split('\n')
171+
const violations: Violation[] = []
172+
const groups: LineComment[][] = []
173+
let previous: LineComment | undefined
174+
175+
for (const comment of comments) {
176+
const line = comment.loc?.start.line ?? 0
177+
if (comment.type === 'CommentBlock') {
178+
const singleLine = comment.loc?.start.line === comment.loc?.end.line
179+
const text = comment.value.replace(/^\*+/, '').trim()
180+
if (singleLine && BANNER.test(text)) violations.push({ rule: 'banner', line, text })
181+
previous = undefined
182+
continue
183+
}
184+
185+
const text = comment.value.trim()
186+
if (BANNER.test(text)) {
187+
violations.push({ rule: 'banner', line, text })
188+
previous = undefined
189+
continue
190+
}
191+
192+
const ownLine = /^\s*$/.test(lines[line - 1]?.slice(0, comment.loc?.start.column ?? 0) ?? '')
193+
const entry = { line, value: comment.value, ownLine }
194+
if (ownLine && previous?.ownLine && previous.line === line - 1) {
195+
groups[groups.length - 1].push(entry)
196+
} else if (ownLine) {
197+
groups.push([entry])
198+
}
199+
previous = entry
200+
}
201+
202+
for (const group of groups) {
203+
if (group.some((comment) => comment.value.includes(ALLOW))) continue
204+
for (const run of codeRuns(group)) {
205+
const hit = firstCodeSpan(run, jsx)
206+
if (hit)
207+
violations.push({ rule: 'commented-out-code', line: hit.line, text: hit.value.trim() })
208+
}
209+
}
210+
211+
return violations.sort((a, b) => a.line - b.line)
212+
}
213+
214+
function sourceFiles(): string[] {
215+
return execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts', '*.mjs', '*.cjs'], {
216+
cwd: ROOT,
217+
encoding: 'utf8',
218+
})
219+
.split('\n')
220+
.filter((file) => file && !EXCLUDED.some((pattern) => pattern.test(file)))
221+
}
222+
223+
const FIX: Record<Rule, string> = {
224+
banner:
225+
'delete the separator; if the title says something the code does not, keep it as a plain ' +
226+
'`// ...` line or move it into TSDoc on the declaration below',
227+
'commented-out-code':
228+
'delete the commented-out code — git keeps the history. If it is a deliberate code sample, ' +
229+
'put it in a TSDoc `@example` or add `// comment-hygiene-allow: <reason>` to the group',
230+
}
231+
232+
function main(): void {
233+
const hits: string[] = []
234+
for (const file of sourceFiles()) {
235+
let source: string
236+
try {
237+
source = readFileSync(path.join(ROOT, file), 'utf8')
238+
} catch {
239+
continue
240+
}
241+
for (const { rule, line, text } of findViolations(file, source)) {
242+
hits.push(`✗ ${rule}: ${file}:${line} // ${text}\n fix: ${FIX[rule]}`)
243+
}
244+
}
245+
246+
if (hits.length === 0) {
247+
console.log('✓ comment hygiene (no banners or commented-out code)')
248+
return
249+
}
250+
console.error(hits.join('\n'))
251+
console.error(
252+
`\n${hits.length} comment-hygiene violation(s). Comments are TSDoc or a terse inline why — ` +
253+
'see "Comments" in CLAUDE.md.'
254+
)
255+
process.exit(1)
256+
}
257+
258+
if (import.meta.main) main()

0 commit comments

Comments
 (0)