|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — gitmodules-comment-guard. |
| 3 | +// |
| 4 | +// Blocks Edit/Write tool calls that introduce a `[submodule "..."]` |
| 5 | +// section into `.gitmodules` without the canonical `# <name>-<version>` |
| 6 | +// comment immediately above it. Without that comment, the harness |
| 7 | +// can't surface upstream version drift in the `lockstep` reports — the |
| 8 | +// fleet relies on this annotation to know what version each pinned |
| 9 | +// submodule represents. |
| 10 | +// |
| 11 | +// What's enforced: |
| 12 | +// - Every `[submodule "PATH"]` line must be preceded (immediately, |
| 13 | +// no blank line) by `# <slug>-<version>` where <slug> matches |
| 14 | +// `[a-z0-9]([a-z0-9-]*[a-z0-9])?` and <version> is whatever the |
| 15 | +// upstream uses (`v25.9.0`, `0.1.0`, `1.7.19`, `liburing-2.14`, |
| 16 | +// `epochs/three_hourly/2026-02-24_21H`, etc.). The version is |
| 17 | +// the part after the FIRST hyphen — we don't try to parse it |
| 18 | +// beyond "non-empty". |
| 19 | +// - `ignore = dirty` is conventional but not enforced here (it's a |
| 20 | +// parallel-Claude-sessions concern; submodule add without it is |
| 21 | +// not a build break). |
| 22 | +// |
| 23 | +// Scope: |
| 24 | +// - Fires on Edit and Write tool calls. |
| 25 | +// - Only inspects `.gitmodules` at the repo root. |
| 26 | +// - Lines marked `# socket-hook: allow gitmodules-no-comment` are |
| 27 | +// exempt for one-off legitimate cases. |
| 28 | +// |
| 29 | +// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad |
| 30 | +// hook deploy can't brick the session. |
| 31 | + |
| 32 | +import process from 'node:process' |
| 33 | + |
| 34 | +const ALLOW_MARKER = '# socket-hook: allow gitmodules-no-comment' |
| 35 | + |
| 36 | +// Match `[submodule "PATH"]` with PATH captured. Tolerant of |
| 37 | +// whitespace and quoting variations. |
| 38 | +const SUBMODULE_RE = /^\s*\[submodule\s+"([^"]+)"\s*\]\s*$/ |
| 39 | + |
| 40 | +// Match `# <slug>-<version>` where the version is whatever follows |
| 41 | +// the first hyphen. We only require: starts with `# `, contains a |
| 42 | +// hyphen, has non-empty version part. |
| 43 | +const COMMENT_RE = /^#\s+[a-z0-9]+([a-z0-9-]*[a-z0-9])?-[^\s]/ |
| 44 | + |
| 45 | +interface Hook { |
| 46 | + // tool_name and tool_input shape — keeping it loose because the |
| 47 | + // PreToolUse payload schema isn't versioned beyond JSON-with-body. |
| 48 | + tool_name?: string |
| 49 | + tool_input?: { |
| 50 | + file_path?: string |
| 51 | + new_string?: string |
| 52 | + content?: string |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +// Read newline-separated lines for analysis. |
| 57 | +function findOrphanSubmoduleSections(text: string): string[] { |
| 58 | + const lines = text.split('\n') |
| 59 | + const orphans: string[] = [] |
| 60 | + for (let i = 0; i < lines.length; i++) { |
| 61 | + const line = lines[i] |
| 62 | + if (!line) continue |
| 63 | + const match = SUBMODULE_RE.exec(line) |
| 64 | + if (!match) continue |
| 65 | + // Allow marker on the [submodule] line or the line above is |
| 66 | + // a one-off escape hatch. |
| 67 | + if (line.includes(ALLOW_MARKER)) continue |
| 68 | + if (i > 0 && lines[i - 1]?.includes(ALLOW_MARKER)) continue |
| 69 | + // The previous line must be a comment matching `# <slug>-<ver>`. |
| 70 | + const prev = i > 0 ? lines[i - 1] : '' |
| 71 | + if (!prev || !COMMENT_RE.test(prev)) { |
| 72 | + orphans.push(match[1] ?? line) |
| 73 | + } |
| 74 | + } |
| 75 | + return orphans |
| 76 | +} |
| 77 | + |
| 78 | +function main() { |
| 79 | + let stdin = '' |
| 80 | + process.stdin.on('data', chunk => { |
| 81 | + stdin += chunk |
| 82 | + }) |
| 83 | + process.stdin.on('end', () => { |
| 84 | + let payload: Hook |
| 85 | + try { |
| 86 | + payload = JSON.parse(stdin) as Hook |
| 87 | + } catch { |
| 88 | + // Bad payload — fail open. |
| 89 | + process.exit(0) |
| 90 | + } |
| 91 | + const tool = payload.tool_name |
| 92 | + if (tool !== 'Edit' && tool !== 'Write') { |
| 93 | + process.exit(0) |
| 94 | + } |
| 95 | + const filePath = payload.tool_input?.file_path |
| 96 | + if (!filePath || !filePath.endsWith('/.gitmodules')) { |
| 97 | + process.exit(0) |
| 98 | + } |
| 99 | + // Edit gives us new_string (the replacement); Write gives us |
| 100 | + // content (the full new file). Either way, we scan the proposed |
| 101 | + // text for the orphan condition. For Edit calls the new_string |
| 102 | + // may be a fragment that doesn't contain a [submodule] header — |
| 103 | + // that's fine, the check passes. |
| 104 | + const proposed = |
| 105 | + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' |
| 106 | + const orphans = findOrphanSubmoduleSections(proposed) |
| 107 | + if (orphans.length === 0) { |
| 108 | + process.exit(0) |
| 109 | + } |
| 110 | + // Block the tool call. Exit code 2 makes Claude Code refuse and |
| 111 | + // surface the stderr to the model so it can retry. |
| 112 | + process.stderr.write( |
| 113 | + `[gitmodules-comment-guard] refusing edit: ${orphans.length} ` + |
| 114 | + `submodule section(s) lack the canonical ` + |
| 115 | + `# <slug>-<version> comment immediately above:\n` + |
| 116 | + orphans.map(o => ` [submodule "${o}"]`).join('\n') + |
| 117 | + '\n\nFix: prepend a comment line on the line BEFORE each\n' + |
| 118 | + '[submodule "..."] section. Example:\n' + |
| 119 | + '\n # semver-7.7.4\n [submodule "packages/.../upstream/semver"]\n' + |
| 120 | + '\nThe slug should be a short name (no path); the version is\n' + |
| 121 | + 'whatever the upstream tags (v25.9.0, 1.7.19, liburing-2.14, etc.).\n' + |
| 122 | + '\nOne-off override: append `# socket-hook: allow gitmodules-no-comment`\n' + |
| 123 | + 'to the [submodule] line.\n', |
| 124 | + ) |
| 125 | + process.exit(2) |
| 126 | + }) |
| 127 | + // If stdin is closed before any data, treat as empty payload. |
| 128 | + if (process.stdin.readable === false) { |
| 129 | + process.exit(0) |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +main() |
0 commit comments