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
40 changes: 33 additions & 7 deletions .claude/hooks/guard-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ if [ -z "$COMMAND" ]; then
exit 0
fi

# Only act on git commands
if ! echo "$COMMAND" | grep -qE '^\s*git\s+'; then
# Act on git and gh commands (may appear after cd "..." &&)
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)(git|gh)\s+'; then
exit 0
fi

Expand Down Expand Up @@ -74,21 +74,47 @@ if echo "$COMMAND" | grep -qE '^\s*git\s+stash'; then
deny "BLOCKED: 'git stash' hides all working tree changes including other sessions' work. In worktree mode, commit your changes directly instead."
fi

# --- Branch name validation on push ---
# --- Branch name validation helper ---

validate_branch_name() {
# Try to get branch from the working directory where the command runs
# Extract cd target if command starts with cd "..." && ...
local work_dir=""
if echo "$COMMAND" | grep -qE '^\s*cd\s+'; then
work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p')
fi

local BRANCH=""
if [ -n "$work_dir" ] && [ -d "$work_dir" ]; then
BRANCH=$(git -C "$work_dir" rev-parse --abbrev-ref HEAD 2>/dev/null) || true
fi
if [ -z "$BRANCH" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || true
fi

if echo "$COMMAND" | grep -qE '^\s*git\s+push'; then
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || true
if [ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "HEAD" ]; then
PATTERN="^(feat|fix|docs|refactor|test|chore|ci|perf|build|release|dependabot|revert)/"
local PATTERN="^(feat|fix|docs|refactor|test|chore|ci|perf|build|release|dependabot|revert)/"
if [[ ! "$BRANCH" =~ $PATTERN ]]; then
deny "BLOCKED: Branch '$BRANCH' does not match required pattern. Branch names must start with: feat/, fix/, docs/, refactor/, test/, chore/, ci/, perf/, build/, release/, revert/"
fi
fi
}

# --- Branch name validation on push ---

if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+push'; then
validate_branch_name
fi

# --- Branch name validation on gh pr create ---

if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)gh\s+pr\s+create'; then
validate_branch_name
fi

# --- Commit validation against edit log ---

if echo "$COMMAND" | grep -qE '^\s*git\s+commit'; then
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"

Expand Down
360 changes: 121 additions & 239 deletions generated/DOGFOOD-REPORT-2.1.0.md

Large diffs are not rendered by default.

25 changes: 16 additions & 9 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,46 +62,51 @@ program
.command('query <name>')
.description('Find a function/class, show callers and callees')
.option('-d, --db <path>', 'Path to graph.db')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((name, opts) => {
queryName(name, opts.db, { json: opts.json });
queryName(name, opts.db, { noTests: !opts.tests, json: opts.json });
});

program
.command('impact <file>')
.description('Show what depends on this file (transitive)')
.option('-d, --db <path>', 'Path to graph.db')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((file, opts) => {
impactAnalysis(file, opts.db, { json: opts.json });
impactAnalysis(file, opts.db, { noTests: !opts.tests, json: opts.json });
});

program
.command('map')
.description('High-level module overview with most-connected nodes')
.option('-d, --db <path>', 'Path to graph.db')
.option('-n, --limit <number>', 'Number of top nodes', '20')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((opts) => {
moduleMap(opts.db, parseInt(opts.limit, 10), { json: opts.json });
moduleMap(opts.db, parseInt(opts.limit, 10), { noTests: !opts.tests, json: opts.json });
});

program
.command('stats')
.description('Show graph health overview: nodes, edges, languages, cycles, hotspots, embeddings')
.option('-d, --db <path>', 'Path to graph.db')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((opts) => {
stats(opts.db, { json: opts.json });
stats(opts.db, { noTests: !opts.tests, json: opts.json });
});

program
.command('deps <file>')
.description('Show what this file imports and what imports it')
.option('-d, --db <path>', 'Path to graph.db')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((file, opts) => {
fileDeps(file, opts.db, { json: opts.json });
fileDeps(file, opts.db, { noTests: !opts.tests, json: opts.json });
});

program
Expand Down Expand Up @@ -159,7 +164,7 @@ program
.option('-k, --kind <kind>', 'Filter to a specific symbol kind')
.option('--no-source', 'Metadata only (skip source extraction)')
.option('--include-tests', 'Include test source code')
.option('-T, --no-tests', 'Exclude test files from callers')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((name, opts) => {
if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
Expand All @@ -181,7 +186,7 @@ program
.command('explain <target>')
.description('Structural summary of a file or function (no LLM needed)')
.option('-d, --db <path>', 'Path to graph.db')
.option('-T, --no-tests', 'Exclude test/spec files')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((target, opts) => {
explain(target, opts.db, { noTests: !opts.tests, json: opts.json });
Expand All @@ -192,7 +197,7 @@ program
.description('Find where a symbol is defined and used (minimal, fast lookup)')
.option('-d, --db <path>', 'Path to graph.db')
.option('-f, --file <path>', 'File overview: list symbols, imports, exports')
.option('-T, --no-tests', 'Exclude test/spec files')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action((name, opts) => {
if (!name && !opts.file) {
Expand Down Expand Up @@ -395,7 +400,7 @@ program
.option('-d, --db <path>', 'Path to graph.db')
.option('-m, --model <name>', 'Override embedding model (auto-detects from DB)')
.option('-n, --limit <number>', 'Max results', '15')
.option('-T, --no-tests', 'Exclude test/spec files')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--min-score <score>', 'Minimum similarity threshold', '0.2')
.option('-k, --kind <kind>', 'Filter by kind: function, method, class')
.option('--file <pattern>', 'Filter by file path pattern')
Expand Down Expand Up @@ -444,13 +449,15 @@ program
.option('-n, --limit <number>', 'Number of results', '10')
.option('--metric <metric>', 'fan-in | fan-out | density | coupling', 'fan-in')
.option('--level <level>', 'file | directory', 'file')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('-j, --json', 'Output as JSON')
.action(async (opts) => {
const { hotspotsData, formatHotspots } = await import('./structure.js');
const data = hotspotsData(opts.db, {
metric: opts.metric,
level: opts.level,
limit: parseInt(opts.limit, 10),
noTests: !opts.tests,
});
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
Expand Down
14 changes: 10 additions & 4 deletions src/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const BASE_TOOLS = [
description: 'Traversal depth for transitive callers',
default: 2,
},
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
},
required: ['name'],
},
Expand All @@ -41,6 +42,7 @@ const BASE_TOOLS = [
type: 'object',
properties: {
file: { type: 'string', description: 'File path (partial match supported)' },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
},
required: ['file'],
},
Expand All @@ -52,6 +54,7 @@ const BASE_TOOLS = [
type: 'object',
properties: {
file: { type: 'string', description: 'File path to analyze' },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
},
required: ['file'],
},
Expand All @@ -71,6 +74,7 @@ const BASE_TOOLS = [
type: 'object',
properties: {
limit: { type: 'number', description: 'Number of top files to show', default: 20 },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
},
},
},
Expand Down Expand Up @@ -282,6 +286,7 @@ const BASE_TOOLS = [
description: 'Rank files or directories',
},
limit: { type: 'number', description: 'Number of results to return', default: 10 },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
},
},
},
Expand Down Expand Up @@ -408,13 +413,13 @@ export async function startMCPServer(customDbPath, options = {}) {
let result;
switch (name) {
case 'query_function':
result = queryNameData(args.name, dbPath);
result = queryNameData(args.name, dbPath, { noTests: args.no_tests });
break;
case 'file_deps':
result = fileDepsData(args.file, dbPath);
result = fileDepsData(args.file, dbPath, { noTests: args.no_tests });
break;
case 'impact_analysis':
result = impactAnalysisData(args.file, dbPath);
result = impactAnalysisData(args.file, dbPath, { noTests: args.no_tests });
break;
case 'find_cycles': {
const db = new Database(findDbPath(dbPath), { readonly: true });
Expand All @@ -424,7 +429,7 @@ export async function startMCPServer(customDbPath, options = {}) {
break;
}
case 'module_map':
result = moduleMapData(dbPath, args.limit || 20);
result = moduleMapData(dbPath, args.limit || 20, { noTests: args.no_tests });
break;
case 'fn_deps':
result = fnDepsData(args.name, dbPath, {
Expand Down Expand Up @@ -536,6 +541,7 @@ export async function startMCPServer(customDbPath, options = {}) {
metric: args.metric,
level: args.level,
limit: args.limit,
noTests: args.no_tests,
});
break;
}
Expand Down
72 changes: 60 additions & 12 deletions src/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,31 +190,38 @@ function kindIcon(kind) {

// ─── Data-returning functions ───────────────────────────────────────────

export function queryNameData(name, customDbPath) {
export function queryNameData(name, customDbPath, opts = {}) {
const db = openReadonlyOrFail(customDbPath);
const nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
const noTests = opts.noTests || false;
let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`);
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
if (nodes.length === 0) {
db.close();
return { query: name, results: [] };
}

const results = nodes.map((node) => {
const callees = db
let callees = db
.prepare(`
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
FROM edges e JOIN nodes n ON e.target_id = n.id
WHERE e.source_id = ?
`)
.all(node.id);

const callers = db
let callers = db
.prepare(`
SELECT n.name, n.kind, n.file, n.line, e.kind as edge_kind
FROM edges e JOIN nodes n ON e.source_id = n.id
WHERE e.target_id = ?
`)
.all(node.id);

if (noTests) {
callees = callees.filter((c) => !isTestFile(c.file));
callers = callers.filter((c) => !isTestFile(c.file));
}

return {
name: node.name,
kind: node.kind,
Expand Down Expand Up @@ -728,11 +735,40 @@ export function listFunctionsData(customDbPath, opts = {}) {
return { count: rows.length, functions: rows };
}

export function statsData(customDbPath) {
export function statsData(customDbPath, opts = {}) {
const db = openReadonlyOrFail(customDbPath);
const noTests = opts.noTests || false;

// Build set of test file IDs for filtering nodes and edges
let testFileIds = null;
if (noTests) {
const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
testFileIds = new Set();
const testFiles = new Set();
for (const n of allFileNodes) {
if (isTestFile(n.file)) {
testFileIds.add(n.id);
testFiles.add(n.file);
}
}
// Also collect non-file node IDs that belong to test files
const allNodes = db.prepare('SELECT id, file FROM nodes').all();
for (const n of allNodes) {
if (testFiles.has(n.file)) testFileIds.add(n.id);
}
}

// Node breakdown by kind
const nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
let nodeRows;
if (noTests) {
const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
const counts = {};
for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
} else {
nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
}
const nodesByKind = {};
let totalNodes = 0;
for (const r of nodeRows) {
Expand All @@ -741,7 +777,18 @@ export function statsData(customDbPath) {
}

// Edge breakdown by kind
const edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
let edgeRows;
if (noTests) {
const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
const filtered = allEdges.filter(
(e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
);
const counts = {};
for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
} else {
edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
}
const edgesByKind = {};
let totalEdges = 0;
for (const r of edgeRows) {
Expand All @@ -756,7 +803,8 @@ export function statsData(customDbPath) {
extToLang.set(ext, entry.id);
}
}
const fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
const byLanguage = {};
for (const row of fileNodes) {
const ext = path.extname(row.file).toLowerCase();
Expand All @@ -779,10 +827,10 @@ export function statsData(customDbPath) {
WHERE n.kind = 'file'
ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
LIMIT 5
`)
.all();
const hotspots = hotspotRows.map((r) => ({
const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
file: r.file,
fanIn: r.fan_in,
fanOut: r.fan_out,
Expand Down Expand Up @@ -881,7 +929,7 @@ export function statsData(customDbPath) {
}

export function stats(customDbPath, opts = {}) {
const data = statsData(customDbPath);
const data = statsData(customDbPath, { noTests: opts.noTests });
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
Expand Down Expand Up @@ -979,7 +1027,7 @@ export function stats(customDbPath, opts = {}) {
// ─── Human-readable output (original formatting) ───────────────────────

export function queryName(name, customDbPath, opts = {}) {
const data = queryNameData(name, customDbPath);
const data = queryNameData(name, customDbPath, { noTests: opts.noTests });
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
Expand Down
Loading