feat(01-02): create intel-index.js PostToolUse hook

- Indexes file exports/imports when Claude writes or edits JS/TS files
- Extracts ES6 imports, CommonJS require, side-effect imports
- Extracts named exports, default exports, declaration exports, TypeScript types
- Updates .planning/intel/index.json incrementally
- Silent failure on errors (always exits 0)
This commit is contained in:
Lex Christopherson
2026-01-19 23:13:52 -06:00
parent ce4fc96ff7
commit 992040fa87

189
hooks/intel-index.js Executable file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
// Codebase Intelligence - PostToolUse Indexing Hook
// Indexes file exports/imports when Claude writes or edits JS/TS files
const fs = require('fs');
const path = require('path');
// JS/TS file extensions to index
const INDEXABLE_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
/**
* Extract import sources from file content
* Returns array of import source paths (e.g., 'react', './utils', '@org/pkg')
*/
function extractImports(content) {
const imports = new Set();
// ES6 imports: import { x } from 'y', import x from 'y', import * as x from 'y'
const es6Named = /import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"]([^'"]+)['"]/g;
let match;
while ((match = es6Named.exec(content)) !== null) {
imports.add(match[1]);
}
// ES6 side-effect imports: import 'y'
const es6SideEffect = /import\s+['"]([^'"]+)['"]/g;
while ((match = es6SideEffect.exec(content)) !== null) {
// Avoid matching 'from' part of previous pattern
if (!content.slice(Math.max(0, match.index - 10), match.index).includes('from')) {
imports.add(match[1]);
}
}
// CommonJS: require('y')
const cjs = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((match = cjs.exec(content)) !== null) {
imports.add(match[1]);
}
return Array.from(imports);
}
/**
* Extract exported symbol names from file content
* Returns array of export names (e.g., 'functionA', 'ClassB', 'default')
*/
function extractExports(content) {
const exports = new Set();
// Named exports: export { x, y, z }
const namedExport = /export\s*\{([^}]+)\}/g;
let match;
while ((match = namedExport.exec(content)) !== null) {
const names = match[1].split(',').map(n => {
// Handle "x as y" syntax - export the alias
const parts = n.trim().split(/\s+as\s+/);
return parts[parts.length - 1].trim();
}).filter(n => n);
names.forEach(n => exports.add(n));
}
// Declaration exports: export const|let|var|function|async function|class
const declExport = /export\s+(?:const|let|var|function\*?|async\s+function|class)\s+(\w+)/g;
while ((match = declExport.exec(content)) !== null) {
exports.add(match[1]);
}
// Default export: export default (with optional identifier)
const defaultExport = /export\s+default\s+(?:function\s*\*?\s*|class\s+)?(\w+)?/g;
while ((match = defaultExport.exec(content)) !== null) {
exports.add('default');
if (match[1]) {
exports.add(match[1]);
}
}
// CommonJS: module.exports = { x, y }
const cjsExport = /module\.exports\s*=\s*\{([^}]+)\}/g;
while ((match = cjsExport.exec(content)) !== null) {
const names = match[1].split(',').map(n => {
// Handle "x: y" syntax - export the key
const parts = n.trim().split(/\s*:\s*/);
return parts[0].trim();
}).filter(n => n && /^\w+$/.test(n));
names.forEach(n => exports.add(n));
}
// CommonJS: module.exports = identifier
const cjsSingleExport = /module\.exports\s*=\s*(\w+)\s*[;\n]/g;
while ((match = cjsSingleExport.exec(content)) !== null) {
exports.add('default');
exports.add(match[1]);
}
// TypeScript: export type X, export interface X
const tsExport = /export\s+(?:type|interface)\s+(\w+)/g;
while ((match = tsExport.exec(content)) !== null) {
exports.add(match[1]);
}
return Array.from(exports);
}
/**
* Update the index.json file with new file entry
* Uses read-modify-write pattern with synchronous operations
*/
function updateIndex(filePath, exports, imports) {
const indexPath = path.join(process.cwd(), '.planning', 'intel', 'index.json');
// Read existing index or create new
let index = { version: 1, updated: null, files: {} };
try {
const content = fs.readFileSync(indexPath, 'utf8');
index = JSON.parse(content);
} catch (e) {
// File doesn't exist or invalid JSON - start fresh
}
// Normalize file path to absolute
const normalizedPath = path.resolve(filePath);
// Update single file entry (incremental)
index.files[normalizedPath] = {
exports,
imports,
indexed: Date.now()
};
index.updated = Date.now();
// Ensure directory exists
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
// Write atomically
fs.writeFileSync(indexPath, JSON.stringify(index, null, 2));
}
// Read JSON from stdin (standard hook pattern)
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
// Only process Write and Edit tools
if (!['Write', 'Edit'].includes(data.tool_name)) {
process.exit(0);
}
const filePath = data.tool_input?.file_path;
if (!filePath) {
process.exit(0);
}
// Only index JS/TS files
const ext = path.extname(filePath).toLowerCase();
if (!INDEXABLE_EXTENSIONS.includes(ext)) {
process.exit(0);
}
// Get file content
// Write tool provides content in tool_input
// Edit tool only provides old_string/new_string, so read from disk
let content = data.tool_input?.content;
if (!content) {
// Edit tool - read file from disk
const resolvedPath = path.resolve(filePath);
if (fs.existsSync(resolvedPath)) {
content = fs.readFileSync(resolvedPath, 'utf8');
} else {
// File doesn't exist (shouldn't happen after Edit, but be safe)
process.exit(0);
}
}
// Extract imports and exports
const exports = extractExports(content);
const imports = extractImports(content);
// Update index
updateIndex(filePath, exports, imports);
process.exit(0);
} catch (error) {
// Silent failure - never block Claude
process.exit(0);
}
});