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:
189
hooks/intel-index.js
Executable file
189
hooks/intel-index.js
Executable 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user