Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1f1cff0
feat: add normalizeSymbol utility for stable JSON schema
carlos-alm Mar 3, 2026
4d4aa95
chore: resolve conflicts with main (narsil-mcp.md + queries.js)
carlos-alm Mar 3, 2026
3802b9f
feat: add expanded edge types — contains, parameter_of, receiver (Pha…
carlos-alm Mar 3, 2026
7633f1d
feat: add intraprocedural control flow graph (CFG) construction
carlos-alm Mar 3, 2026
698540c
feat: add stored queryable AST nodes (calls, new, string, regex, thro…
carlos-alm Mar 3, 2026
8341c59
fix: correct misleading comment for break without enclosing loop/switch
carlos-alm Mar 3, 2026
0809f67
feat: add `exports <file>` command for per-symbol consumer analysis
carlos-alm Mar 3, 2026
768b95e
Merge remote-tracking branch 'origin/main' into fix/274-greptile-feed…
carlos-alm Mar 3, 2026
96273f3
refactor: consolidate CLI by removing 5 redundant commands
carlos-alm Mar 3, 2026
773ae3c
chore: resolve cli.js conflict with origin/main
carlos-alm Mar 3, 2026
09f1f75
docs: update all docs to reflect CLI consolidation
carlos-alm Mar 3, 2026
826c3c9
Merge branch 'refactor/consolidate-cli-commands' of https://github.co…
carlos-alm Mar 3, 2026
61c1232
revert: remove docs changes from CLI consolidation PR
carlos-alm Mar 3, 2026
ab08184
Merge branch 'refactor/consolidate-cli-commands' of https://github.co…
carlos-alm Mar 3, 2026
b7883ca
revert: remove docs changes from CLI consolidation PR
carlos-alm Mar 3, 2026
82360a6
fix: remove unused --limit, --offset, --ndjson from path command
carlos-alm Mar 3, 2026
f7726b5
chore: resolve conflicts with main in src/queries.js
carlos-alm Mar 3, 2026
bea8597
feat: expand node types with parameter, property, constant kinds (Pha…
carlos-alm Mar 3, 2026
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
273 changes: 128 additions & 145 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { Command } from 'commander';
import { audit } from './audit.js';
import { BATCH_COMMANDS, batch, batchQuery, multiBatchData, splitTargets } from './batch.js';
import { BATCH_COMMANDS, batch, multiBatchData, splitTargets } from './batch.js';
import { buildGraph } from './builder.js';
import { loadConfig } from './config.js';
import { findCycles, formatCycles } from './cycles.js';
Expand Down Expand Up @@ -142,6 +142,7 @@ program
process.exit(1);
}
if (opts.path) {
console.error('Note: "query --path" is deprecated, use "codegraph path <from> <to>" instead');
symbolPath(name, opts.path, opts.db, {
maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10,
edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
Expand All @@ -166,6 +167,36 @@ program
}
});

program
.command('path <from> <to>')
.description('Find shortest path between two symbols')
.option('-d, --db <path>', 'Path to graph.db')
.option('--reverse', 'Follow edges backward')
.option('--kinds <kinds>', 'Comma-separated edge kinds to follow (default: calls)')
.option('--from-file <path>', 'Disambiguate source symbol by file')
.option('--to-file <path>', 'Disambiguate target symbol by file')
.option('--depth <n>', 'Max traversal depth', '10')
.option('-k, --kind <kind>', 'Filter to a specific symbol kind')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.option('-j, --json', 'Output as JSON')
.action((from, to, opts) => {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
}
symbolPath(from, to, opts.db, {
maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10,
edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined,
reverse: opts.reverse,
fromFile: opts.fromFile,
toFile: opts.toFile,
kind: opts.kind,
noTests: resolveNoTests(opts),
json: opts.json,
});
});

program
.command('impact <file>')
.description('Show what depends on this file (transitive)')
Expand Down Expand Up @@ -341,43 +372,36 @@ program
});
});

program
.command('explain <target>')
.description('Structural summary of a file or function (no LLM needed)')
.option('-d, --db <path>', 'Path to graph.db')
.option('--depth <n>', 'Recursively explain dependencies up to N levels deep', '0')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.option('-j, --json', 'Output as JSON')
.option('--limit <number>', 'Max results to return')
.option('--offset <number>', 'Skip N results (default: 0)')
.option('--ndjson', 'Newline-delimited JSON output')
.action((target, opts) => {
explain(target, opts.db, {
depth: parseInt(opts.depth, 10),
noTests: resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
});
});

program
.command('audit <target>')
.description('Composite report: explain + impact + health metrics per function')
.option('-d, --db <path>', 'Path to graph.db')
.option('--depth <n>', 'Impact analysis depth', '3')
.option('--quick', 'Structural summary only (skip impact analysis and health metrics)')
.option('--depth <n>', 'Impact/explain depth', '3')
.option('-f, --file <path>', 'Scope to file (partial match)')
.option('-k, --kind <kind>', 'Filter by symbol kind')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.option('-j, --json', 'Output as JSON')
.option('--limit <number>', 'Max results to return (quick mode)')
.option('--offset <number>', 'Skip N results (quick mode)')
.option('--ndjson', 'Newline-delimited JSON output (quick mode)')
.action((target, opts) => {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
}
if (opts.quick) {
explain(target, opts.db, {
depth: parseInt(opts.depth, 10),
noTests: resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
});
return;
}
audit(target, opts.db, {
depth: parseInt(opts.depth, 10),
file: opts.file,
Expand Down Expand Up @@ -443,18 +467,48 @@ program

program
.command('check [ref]')
.description('Run validation predicates against git changes (CI gate)')
.description(
'CI gate: run manifesto rules (no args), diff predicates (with ref/--staged), or both (--rules)',
)
.option('-d, --db <path>', 'Path to graph.db')
.option('--staged', 'Analyze staged changes')
.option('--rules', 'Also run manifesto rules alongside diff predicates')
.option('--cycles', 'Assert no dependency cycles involve changed files')
.option('--blast-radius <n>', 'Assert no function exceeds N transitive callers')
.option('--signatures', 'Assert no function declaration lines were modified')
.option('--boundaries', 'Assert no cross-owner boundary violations')
.option('--depth <n>', 'Max BFS depth for blast radius (default: 3)')
.option('-f, --file <path>', 'Scope to file (partial match, manifesto mode)')
.option('-k, --kind <kind>', 'Filter by symbol kind (manifesto mode)')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.option('-j, --json', 'Output as JSON')
.option('--limit <number>', 'Max results to return (manifesto mode)')
.option('--offset <number>', 'Skip N results (manifesto mode)')
.option('--ndjson', 'Newline-delimited JSON output (manifesto mode)')
.action(async (ref, opts) => {
const isDiffMode = ref || opts.staged;

if (!isDiffMode && !opts.rules) {
// No ref, no --staged → run manifesto rules on whole codebase
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
}
const { manifesto } = await import('./manifesto.js');
manifesto(opts.db, {
file: opts.file,
kind: opts.kind,
noTests: resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
});
return;
}

// Diff predicates mode
const { check } = await import('./check.js');
check(opts.db, {
ref,
Expand All @@ -467,6 +521,24 @@ program
noTests: resolveNoTests(opts),
json: opts.json,
});

// If --rules, also run manifesto after diff predicates
if (opts.rules) {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
}
const { manifesto } = await import('./manifesto.js');
manifesto(opts.db, {
file: opts.file,
kind: opts.kind,
noTests: resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
});
}
});

// ─── New commands ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -925,38 +997,6 @@ program
}
});

program
.command('hotspots')
.description(
'Find structural hotspots: files or directories with extreme fan-in, fan-out, or symbol density',
)
.option('-d, --db <path>', 'Path to graph.db')
.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('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.option('-j, --json', 'Output as JSON')
.option('--offset <number>', 'Skip N results (default: 0)')
.option('--ndjson', 'Newline-delimited JSON output')
.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),
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
noTests: resolveNoTests(opts),
});
if (opts.ndjson) {
printNdjson(data, 'hotspots');
} else if (opts.json) {
console.log(JSON.stringify(data, null, 2));
} else {
console.log(formatHotspots(data));
}
});

program
.command('roles')
.description('Show node role classification: entry, core, utility, adapter, dead, leaf')
Expand Down Expand Up @@ -1226,35 +1266,6 @@ program
});
});

program
.command('manifesto')
.description('Evaluate manifesto rules (pass/fail verdicts for code health)')
.option('-d, --db <path>', 'Path to graph.db')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.option('-f, --file <path>', 'Scope to file (partial match)')
.option('-k, --kind <kind>', 'Filter by symbol kind')
.option('-j, --json', 'Output as JSON')
.option('--limit <number>', 'Max results to return')
.option('--offset <number>', 'Skip N results (default: 0)')
.option('--ndjson', 'Newline-delimited JSON output')
.action(async (opts) => {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
}
const { manifesto } = await import('./manifesto.js');
manifesto(opts.db, {
file: opts.file,
kind: opts.kind,
noTests: resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
});
});

program
.command('communities')
.description('Detect natural module boundaries using Louvain community detection')
Expand Down Expand Up @@ -1289,7 +1300,16 @@ program
)
.option('-d, --db <path>', 'Path to graph.db')
.option('-n, --limit <number>', 'Max results to return', '20')
.option('--sort <metric>', 'Sort metric: risk | complexity | churn | fan-in | mi', 'risk')
.option(
'--level <level>',
'Granularity: function (default) | file | directory. File/directory level shows hotspots',
'function',
)
.option(
'--sort <metric>',
'Sort metric: risk | complexity | churn | fan-in | mi (function level); fan-in | fan-out | density | coupling (file/directory level)',
'risk',
)
.option('--min-score <score>', 'Only show symbols with risk score >= threshold')
.option('--role <role>', 'Filter by role (entry, core, utility, adapter, leaf, dead)')
.option('-f, --file <path>', 'Scope to a specific file (partial match)')
Expand All @@ -1301,6 +1321,27 @@ program
.option('--ndjson', 'Newline-delimited JSON output')
.option('--weights <json>', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')')
.action(async (opts) => {
if (opts.level === 'file' || opts.level === 'directory') {
// Delegate to hotspots for file/directory level
const { hotspotsData, formatHotspots } = await import('./structure.js');
const metric = opts.sort === 'risk' ? 'fan-in' : opts.sort;
const data = hotspotsData(opts.db, {
metric,
level: opts.level,
limit: parseInt(opts.limit, 10),
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
noTests: resolveNoTests(opts),
});
if (opts.ndjson) {
printNdjson(data, 'hotspots');
} else if (opts.json) {
console.log(JSON.stringify(data, null, 2));
} else {
console.log(formatHotspots(data));
}
return;
}

if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
Expand Down Expand Up @@ -1513,62 +1554,4 @@ program
}
});

program
.command('batch-query [targets...]')
.description(
`Batch symbol lookup — resolve multiple references in one call.\nDefaults to 'where' command. Accepts comma-separated targets.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
)
.option('-d, --db <path>', 'Path to graph.db')
.option('-c, --command <cmd>', 'Query command to run (default: where)', 'where')
.option('--from-file <path>', 'Read targets from file (JSON array or newline-delimited)')
.option('--stdin', 'Read targets from stdin (JSON array)')
.option('--depth <n>', 'Traversal depth passed to underlying command')
.option('-f, --file <path>', 'Scope to file (partial match)')
.option('-k, --kind <kind>', 'Filter by symbol kind')
.option('-T, --no-tests', 'Exclude test/spec files from results')
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
.action(async (positionalTargets, opts) => {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`);
process.exit(1);
}

let targets;
try {
if (opts.fromFile) {
const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim();
if (raw.startsWith('[')) {
targets = JSON.parse(raw);
} else {
targets = raw.split(/\r?\n/).filter(Boolean);
}
} else if (opts.stdin) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean);
} else {
targets = splitTargets(positionalTargets);
}
} catch (err) {
console.error(`Failed to parse targets: ${err.message}`);
process.exit(1);
}

if (!targets || targets.length === 0) {
console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.');
process.exit(1);
}

const batchOpts = {
command: opts.command,
depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
file: opts.file,
kind: opts.kind,
noTests: resolveNoTests(opts),
};

batchQuery(targets, opts.db, batchOpts);
});

program.parse();
2 changes: 1 addition & 1 deletion src/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -810,12 +810,12 @@ export async function startMCPServer(customDbPath, options = {}) {
impactAnalysisData,
moduleMapData,
fileDepsData,
exportsData,
fnDepsData,
fnImpactData,
pathData,
contextData,
childrenData,
exportsData,
explainData,
whereData,
diffImpactData,
Expand Down
Loading