Compare commits

...

1 Commits

Author SHA1 Message Date
Austin Mroz
3d261e04bc Add precommit hook for comments 2026-06-17 10:18:09 -07:00
3 changed files with 492 additions and 1 deletions

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env bash
set -e
pnpm exec lint-staged
pnpm exec tsx scripts/check-unused-i18n-keys.ts
pnpm exec tsx scripts/check-unused-i18n-keys.ts
pnpm exec tsx scripts/check-comment-commits.ts

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import {
analyzeStagedDiff,
commentSyntaxForFile,
freshScanState,
scanLine
} from './check-comment-commits'
function diff(file: string, addedLines: string[], removedLines: string[] = []) {
const body = [
...removedLines.map((l) => `-${l}`),
...addedLines.map((l) => `+${l}`)
].join('\n')
return [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${Math.max(removedLines.length, 1)} +1,${Math.max(addedLines.length, 1)} @@`,
body
].join('\n')
}
describe('scanLine', () => {
const ts = { line: true, block: true, html: false }
it('classifies a full-line comment as comment-only', () => {
const r = scanLine('// a note', freshScanState(), ts)
expect(r.hasComment).toBe(true)
expect(r.hasCode).toBe(false)
})
it('classifies plain code as code-only', () => {
const r = scanLine('const x = 5', freshScanState(), ts)
expect(r.hasComment).toBe(false)
expect(r.hasCode).toBe(true)
})
it('flags a trailing inline comment as both code and comment', () => {
const r = scanLine('const x = 5 // why', freshScanState(), ts)
expect(r.hasComment).toBe(true)
expect(r.hasCode).toBe(true)
})
it('does not treat // inside a string literal as a comment', () => {
const r = scanLine("const u = 'http://comfy.org'", freshScanState(), ts)
expect(r.hasComment).toBe(false)
expect(r.hasCode).toBe(true)
})
it('does not treat an escaped slash in a regex literal as a comment', () => {
const r = scanLine(
"file = target.replace(/^b\\//, '')",
freshScanState(),
ts
)
expect(r.hasComment).toBe(false)
expect(r.hasCode).toBe(true)
})
it('does not treat a regex matching a comment marker as a comment', () => {
const r = scanLine('const re = /^\\/\\*/', freshScanState(), ts)
expect(r.hasComment).toBe(false)
expect(r.hasCode).toBe(true)
})
it('treats JSDoc as a comment (no exemption)', () => {
const r = scanLine('/** docs */', freshScanState(), ts)
expect(r.hasComment).toBe(true)
expect(r.hasCode).toBe(false)
})
it('tracks block comments across lines', () => {
const open = scanLine('/*', freshScanState(), ts)
const mid = scanLine(' * still a comment', open.state, ts)
expect(mid.hasComment).toBe(true)
expect(mid.hasCode).toBe(false)
const close = scanLine(' */', mid.state, ts)
expect(close.hasComment).toBe(true)
expect(close.state.inBlock).toBe(false)
})
})
describe('commentSyntaxForFile', () => {
it('enables html comments for vue', () => {
expect(commentSyntaxForFile('Foo.vue')?.html).toBe(true)
})
it('disables line comments for css', () => {
expect(commentSyntaxForFile('a.css')).toEqual({
line: false,
block: true,
html: false
})
})
it('ignores non-code files', () => {
expect(commentSyntaxForFile('data.json')).toBeNull()
expect(commentSyntaxForFile('readme.md')).toBeNull()
})
})
describe('analyzeStagedDiff', () => {
it('blocks code changes mixed with new comments', () => {
const result = analyzeStagedDiff(
diff('src/a.ts', ['const x = 5', '// explain x'])
)
expect(result.violation).toBe(true)
expect(result.commentAdds).toEqual([
{ file: 'src/a.ts', line: 2, text: '// explain x' }
])
})
it('blocks a trailing comment added to a code line', () => {
const result = analyzeStagedDiff(
diff('src/a.ts', ['const x = 5 // explain'])
)
expect(result.violation).toBe(true)
})
it('allows a comment-only commit', () => {
const result = analyzeStagedDiff(
diff('src/a.ts', ['// a standalone note', '// another line'])
)
expect(result.violation).toBe(false)
expect(result.hasCommentAdd).toBe(true)
expect(result.hasCodeChange).toBe(false)
})
it('allows a code-only commit', () => {
const result = analyzeStagedDiff(
diff('src/a.ts', ['const x = 5', 'const y = 6'])
)
expect(result.violation).toBe(false)
})
it('blocks comments added in one file when code changes in another', () => {
const combined = [
diff('src/a.ts', ['const x = 5']),
diff('src/b.ts', ['// just a note'])
].join('\n')
expect(analyzeStagedDiff(combined).violation).toBe(true)
})
it('treats removing code as a code change', () => {
const result = analyzeStagedDiff(
diff('src/a.ts', ['// note'], ['const gone = 1'])
)
expect(result.violation).toBe(true)
})
it('ignores comment-shaped changes in non-code files', () => {
const result = analyzeStagedDiff(
diff('config.json', [' "key": "value // not a comment"'])
)
expect(result.violation).toBe(false)
expect(result.hasCommentAdd).toBe(false)
expect(result.hasCodeChange).toBe(false)
})
it('uses real new-file line numbers from the hunk header', () => {
const patch = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -40,3 +40,4 @@',
' const before = 1',
'+doWork() // inline',
' const after = 2'
].join('\n')
const result = analyzeStagedDiff(patch)
expect(result.commentAdds[0].line).toBe(41)
})
})

View File

@@ -0,0 +1,315 @@
#!/usr/bin/env tsx
import { execSync } from 'node:child_process'
import { realpathSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export interface CommentSyntax {
line: boolean
block: boolean
html: boolean
}
const TS_LIKE: CommentSyntax = { line: true, block: true, html: false }
const VUE_LIKE: CommentSyntax = { line: true, block: true, html: true }
const BLOCK_ONLY: CommentSyntax = { line: false, block: true, html: false }
export function commentSyntaxForFile(filePath: string): CommentSyntax | null {
const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase()
switch (ext) {
case 'ts':
case 'tsx':
case 'mts':
case 'cts':
case 'js':
case 'mjs':
case 'cjs':
case 'jsx':
return TS_LIKE
case 'vue':
return VUE_LIKE
case 'css':
return BLOCK_ONLY
default:
return null
}
}
export interface ScanState {
inBlock: boolean
inTemplate: boolean
inHtml: boolean
}
export function freshScanState(): ScanState {
return { inBlock: false, inTemplate: false, inHtml: false }
}
export interface LineScan {
hasComment: boolean
hasCode: boolean
state: ScanState
}
export function scanLine(
text: string,
state: ScanState,
syntax: CommentSyntax
): LineScan {
let { inBlock, inTemplate, inHtml } = state
let hasComment = false
let hasCode = false
let i = 0
const n = text.length
while (i < n) {
const c = text[i]
const c2 = text[i + 1]
if (inBlock) {
hasComment = true
if (c === '*' && c2 === '/') {
inBlock = false
i += 2
continue
}
i++
continue
}
if (inHtml) {
hasComment = true
if (c === '-' && c2 === '-' && text[i + 2] === '>') {
inHtml = false
i += 3
continue
}
i++
continue
}
if (inTemplate) {
hasCode = true
if (c === '\\') {
i += 2
continue
}
if (c === '`') {
inTemplate = false
}
i++
continue
}
if (c === ' ' || c === '\t' || c === '\r') {
i++
continue
}
if (c === '\\') {
hasCode = true
i += 2
continue
}
if (syntax.line && c === '/' && c2 === '/') {
hasComment = true
break
}
if (syntax.block && c === '/' && c2 === '*') {
inBlock = true
hasComment = true
i += 2
continue
}
if (
syntax.html &&
c === '<' &&
c2 === '!' &&
text[i + 2] === '-' &&
text[i + 3] === '-'
) {
inHtml = true
hasComment = true
i += 4
continue
}
if (c === "'" || c === '"') {
hasCode = true
i++
while (i < n) {
if (text[i] === '\\') {
i += 2
continue
}
if (text[i] === c) {
i++
break
}
i++
}
continue
}
if (c === '`') {
hasCode = true
inTemplate = true
i++
continue
}
hasCode = true
i++
}
return { hasComment, hasCode, state: { inBlock, inTemplate, inHtml } }
}
export interface CommentAdd {
file: string
line: number
text: string
}
export interface DiffAnalysis {
violation: boolean
hasCommentAdd: boolean
hasCodeChange: boolean
commentAdds: CommentAdd[]
}
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
export function analyzeStagedDiff(diff: string): DiffAnalysis {
let file: string | null = null
let syntax: CommentSyntax | null = null
let newState = freshScanState()
let oldState = freshScanState()
let newLine = 0
let hasCommentAdd = false
let hasCodeChange = false
const commentAdds: CommentAdd[] = []
for (const raw of diff.split('\n')) {
if (raw.startsWith('diff --git ')) {
file = null
syntax = null
continue
}
if (raw.startsWith('+++ ')) {
const target = raw.slice(4).trim()
if (target === '/dev/null') {
file = null
syntax = null
continue
}
file = target.replace(/^b\//, '')
syntax = commentSyntaxForFile(file)
continue
}
if (!file || !syntax) continue
const hunk = HUNK_HEADER.exec(raw)
if (hunk) {
newLine = Number(hunk[1])
newState = freshScanState()
oldState = freshScanState()
continue
}
if (raw.startsWith('--- ')) continue
if (raw.startsWith('\\')) continue
const marker = raw[0]
const content = raw.slice(1)
if (marker === ' ') {
newState = scanLine(content, newState, syntax).state
oldState = scanLine(content, oldState, syntax).state
newLine++
continue
}
if (marker === '+') {
const scan = scanLine(content, newState, syntax)
newState = scan.state
if (scan.hasComment) {
hasCommentAdd = true
commentAdds.push({ file, line: newLine, text: content.trim() })
}
if (scan.hasCode) hasCodeChange = true
newLine++
continue
}
if (marker === '-') {
const scan = scanLine(content, oldState, syntax)
oldState = scan.state
if (scan.hasCode) hasCodeChange = true
}
}
return {
violation: hasCommentAdd && hasCodeChange,
hasCommentAdd,
hasCodeChange,
commentAdds
}
}
function getStagedDiff(): string {
return execSync('git diff --cached --no-color --no-ext-diff -U3', {
encoding: 'utf-8',
maxBuffer: 64 * 1024 * 1024
})
}
function reportViolation(commentAdds: CommentAdd[]): void {
const shown = commentAdds.slice(0, 20)
const lines = shown
.map(({ file, line, text }) => ` ${file}:${line} ${text}`)
.join('\n')
const overflow =
commentAdds.length > shown.length
? `\n …and ${commentAdds.length - shown.length} more`
: ''
process.stderr.write(
[
'Commit blocked: contains both comments and code changes',
'',
'New comments in this commit:',
lines + overflow,
'',
'Due to LLM abuse, commits with both comments and code are forbidden.',
'Delete the comments immediately.',
'If the comments are actually required, they can be rewritten in a',
'standalone commit.',
''
].join('\n')
)
}
function main(): void {
const analysis = analyzeStagedDiff(getStagedDiff())
if (!analysis.violation) return
reportViolation(analysis.commentAdds)
process.exit(1)
}
const invokedDirectly = (() => {
const entry = process.argv[1]
if (!entry) return false
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry)
} catch {
return false
}
})()
if (invokedDirectly) main()