diff --git a/.claude/hooks/update-graph.sh b/.claude/hooks/update-graph.sh index ab8a15946..cf2a2cbdf 100644 --- a/.claude/hooks/update-graph.sh +++ b/.claude/hooks/update-graph.sh @@ -24,15 +24,35 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Only rebuild for source files codegraph tracks -# Skip docs, configs, test fixtures, and non-code files -case "$FILE_PATH" in - *.js|*.ts|*.tsx|*.jsx|*.py|*.go|*.rs|*.java|*.cs|*.php|*.rb|*.tf|*.hcl) - ;; - *) - exit 0 - ;; -esac +PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +# Only rebuild for source files codegraph tracks. +# Skip docs, configs, test fixtures, and non-code files. +# +# The real allowlist is EXTENSIONS (src/shared/constants.ts), derived from +# LANGUAGE_REGISTRY (src/domain/parser.ts) — the single source of truth for +# every language codegraph parses. `npm run build` snapshots it to +# dist/hook-extensions.txt (see scripts/gen-hook-extensions.mjs) so this +# hook can do a fast native bash/grep check on every Edit/Write instead of +# spawning a second Node process, or hand-copying the list, on every edit. +# +# The case statement below is only a fallback for before the first build +# (no dist/hook-extensions.txt yet). tests/unit/hook-extensions.test.ts +# fails if it ever drifts behind EXTENSIONS — keep it updated when +# LANGUAGE_REGISTRY gains a new extension. +EXT=".${FILE_PATH##*.}" +GENERATED_EXT_LIST="$PROJECT_DIR/dist/hook-extensions.txt" +if [ -f "$GENERATED_EXT_LIST" ]; then + grep -qxF "$EXT" "$GENERATED_EXT_LIST" || exit 0 +else + case "$EXT" in + .R|.bash|.c|.cc|.cjs|.clj|.cljc|.cljs|.cpp|.cs|.cu|.cuh|.cxx|.dart|.erl|.ex|.exs|.fs|.fsi|.fsx|.gemspec|.gleam|.go|.groovy|.gvy|.h|.hcl|.hpp|.hrl|.hs|.java|.jl|.js|.jsx|.kt|.kts|.lua|.m|.mjs|.ml|.mli|.php|.phtml|.py|.pyi|.r|.rake|.rb|.rs|.scala|.sh|.sol|.sv|.swift|.tf|.ts|.tsx|.v|.zig) + ;; + *) + exit 0 + ;; + esac +fi # Skip test fixtures — they're copied to tmp dirs anyway if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then @@ -40,7 +60,6 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then fi # Guard: codegraph DB must exist (project has been built at least once) -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" DB_PATH="$PROJECT_DIR/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 diff --git a/package.json b/package.json index d018511ab..3bdd162c7 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "node": ">=22.12.0" }, "scripts": { - "build": "tsc && node -e \"require('fs').writeFileSync('dist/index.cjs',require('fs').readFileSync('src/index.cjs','utf8').replaceAll('./index.ts','./index.js'))\"", + "build": "tsc && node -e \"require('fs').writeFileSync('dist/index.cjs',require('fs').readFileSync('src/index.cjs','utf8').replaceAll('./index.ts','./index.js'))\" && node scripts/gen-hook-extensions.mjs", "build:wasm": "node scripts/node-ts.js scripts/build-wasm.ts", "typecheck": "tsc --noEmit", "verify-imports": "node scripts/node-ts.js scripts/verify-imports.ts", diff --git a/scripts/gen-hook-extensions.mjs b/scripts/gen-hook-extensions.mjs new file mode 100644 index 000000000..bdae9c9ce --- /dev/null +++ b/scripts/gen-hook-extensions.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Generates dist/hook-extensions.txt: a plain-text, one-extension-per-line + * snapshot of EXTENSIONS (src/shared/constants.ts), which is itself derived + * from LANGUAGE_REGISTRY (src/domain/parser.ts) — the single source of + * truth for every language codegraph parses. + * + * Consumed by .claude/hooks/update-graph.sh, a PostToolUse hook that fires + * on every Edit/Write in this repo. Reading this pre-generated list with a + * native `grep -qxF` lets the hook decide in ~1ms whether an edited file's + * extension is one codegraph tracks, without spawning a second Node process + * (tens of ms of startup cost) just to check a file extension, and without + * hand-copying the extension list a second time where it can silently drift + * out of sync (see issue #1736 — `.mjs`/`.cjs` were missing from the old + * hardcoded copy). + * + * Runs as part of `npm run build`, right after `tsc`, so the snapshot is + * regenerated automatically whenever LANGUAGE_REGISTRY changes. + */ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { EXTENSIONS } from '../dist/shared/constants.js'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const outFile = join(root, 'dist', 'hook-extensions.txt'); +const sorted = [...EXTENSIONS].sort(); + +writeFileSync(outFile, `${sorted.join('\n')}\n`); +console.log(`[gen-hook-extensions] wrote ${outFile} (${sorted.length} extensions)`); diff --git a/tests/unit/hook-extensions.test.ts b/tests/unit/hook-extensions.test.ts new file mode 100644 index 000000000..d309c5e41 --- /dev/null +++ b/tests/unit/hook-extensions.test.ts @@ -0,0 +1,86 @@ +/** + * Drift guard for .claude/hooks/update-graph.sh's extension allowlist. + * + * That PostToolUse hook fires on every Edit/Write and needs a fast + * (no extra Node-startup) way to decide whether an edited file's extension + * is one codegraph tracks. It prefers dist/hook-extensions.txt — generated + * from EXTENSIONS by scripts/gen-hook-extensions.mjs as part of + * `npm run build` — and falls back to a hardcoded `case "$EXT" in` list for + * before the first build. + * + * That fallback list is a hand-maintained second copy of EXTENSIONS, so it + * can silently drift out of sync (this is exactly what happened in issue + * #1736: `.mjs`/`.cjs` were missing, so editing those files never + * triggered a rebuild). This test fails the moment EXTENSIONS and the + * hook's fallback list disagree, in either direction. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { EXTENSIONS } from '../../src/shared/constants.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const HOOK_PATH = path.join(REPO_ROOT, '.claude', 'hooks', 'update-graph.sh'); +const DIST_CONSTANTS_PATH = path.join(REPO_ROOT, 'dist', 'shared', 'constants.js'); + +/** Extracts the `.ext|.ext|...` pattern from the hook's `case "$EXT" in` fallback arm. */ +function parseFallbackAllowlist(script: string): string[] { + const match = script.match(/case "\$EXT" in\s*\n\s*([.\w|-]+)\)/); + if (!match) { + throw new Error( + 'Could not find `case "$EXT" in )` fallback allowlist in update-graph.sh', + ); + } + return match[1].split('|'); +} + +describe('update-graph.sh hook extension allowlist', () => { + const script = fs.readFileSync(HOOK_PATH, 'utf8'); + + it('fallback allowlist matches EXTENSIONS exactly (no drift)', () => { + const fallback = new Set(parseFallbackAllowlist(script)); + + const missingFromHook = [...EXTENSIONS].filter((ext) => !fallback.has(ext)).sort(); + const staleInHook = [...fallback].filter((ext) => !EXTENSIONS.has(ext)).sort(); + + expect( + missingFromHook, + `EXTENSIONS has extensions the update-graph.sh fallback allowlist doesn't know ` + + `about: ${missingFromHook.join(', ')}. Sync the \`case "$EXT" in\` list in ` + + '.claude/hooks/update-graph.sh with EXTENSIONS (src/shared/constants.ts).', + ).toEqual([]); + expect( + staleInHook, + `update-graph.sh fallback allowlist has extensions no longer in EXTENSIONS: ` + + `${staleInHook.join(', ')}. Remove them from the \`case "$EXT" in\` list in ` + + '.claude/hooks/update-graph.sh.', + ).toEqual([]); + }); + + it('reads the generated dist/hook-extensions.txt snapshot as its primary source', () => { + expect(script).toMatch(/GENERATED_EXT_LIST="\$PROJECT_DIR\/dist\/hook-extensions\.txt"/); + expect(script).toMatch(/grep -qxF "\$EXT" "\$GENERATED_EXT_LIST"/); + }); + + // Only meaningful once `npm run build` has produced dist/shared/constants.js. + // Skipped (not failed) otherwise so this test doesn't require a build step + // that isn't a normal prerequisite of `npm test` itself. + it.skipIf(!fs.existsSync(DIST_CONSTANTS_PATH))( + 'scripts/gen-hook-extensions.mjs generates a snapshot covering every EXTENSIONS entry', + () => { + const generatedPath = path.join(REPO_ROOT, 'dist', 'hook-extensions.txt'); + execFileSync('node', [path.join(REPO_ROOT, 'scripts', 'gen-hook-extensions.mjs')], { + cwd: REPO_ROOT, + }); + + expect(fs.existsSync(generatedPath)).toBe(true); + const generated = new Set(fs.readFileSync(generatedPath, 'utf8').split('\n').filter(Boolean)); + + expect([...EXTENSIONS].filter((ext) => !generated.has(ext))).toEqual([]); + expect([...generated].filter((ext) => !EXTENSIONS.has(ext))).toEqual([]); + }, + ); +});