Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions .claude/hooks/update-graph.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,42 @@ 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
exit 0
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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions scripts/gen-hook-extensions.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
86 changes: 86 additions & 0 deletions tests/unit/hook-extensions.test.ts
Original file line number Diff line number Diff line change
@@ -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 <list>)` 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([]);
},
);
});
Loading