Skip to content
1,525 changes: 4 additions & 1,521 deletions src/cli.js

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions src/cli/commands/ast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const command = {
name: 'ast [pattern]',
description: 'Search stored AST nodes (calls, new, string, regex, throw, await) by pattern',
queryOpts: true,
options: [
['-k, --kind <kind>', 'Filter by AST node kind (call, new, string, regex, throw, await)'],
['-f, --file <path>', 'Scope to file (partial match)'],
],
async execute([pattern], opts, ctx) {
const { AST_NODE_KINDS, astQuery } = await import('../../ast.js');
if (opts.kind && !AST_NODE_KINDS.includes(opts.kind)) {
console.error(`Invalid AST kind "${opts.kind}". Valid: ${AST_NODE_KINDS.join(', ')}`);
process.exit(1);
}
astQuery(pattern, opts.db, {
kind: opts.kind,
file: opts.file,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
ndjson: opts.ndjson,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
});
},
};
46 changes: 46 additions & 0 deletions src/cli/commands/audit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { audit } from '../../commands/audit.js';
import { EVERY_SYMBOL_KIND } from '../../queries.js';
import { explain } from '../../queries-cli.js';

export const command = {
name: 'audit <target>',
description: 'Composite report: explain + impact + health metrics per function',
options: [
['-d, --db <path>', 'Path to graph.db'],
['--quick', 'Structural summary only (skip impact analysis and health metrics)'],
['--depth <n>', 'Impact/explain depth', '3'],
['-f, --file <path>', 'Scope to file (partial match)'],
['-k, --kind <kind>', 'Filter by symbol kind'],
['-T, --no-tests', 'Exclude test/spec files from results'],
['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
['-j, --json', 'Output as JSON'],
['--limit <number>', 'Max results to return (quick mode)'],
['--offset <number>', 'Skip N results (quick mode)'],
['--ndjson', 'Newline-delimited JSON output (quick mode)'],
],
validate([_target], opts) {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
return `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`;
}
},
execute([target], opts, ctx) {
if (opts.quick) {
explain(target, opts.db, {
depth: parseInt(opts.depth, 10),
noTests: ctx.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,
kind: opts.kind,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
});
},
};
67 changes: 67 additions & 0 deletions src/cli/commands/batch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import fs from 'node:fs';
import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../batch.js';
import { batch } from '../../commands/batch.js';
import { EVERY_SYMBOL_KIND } from '../../queries.js';

export const command = {
name: 'batch <command> [targets...]',
description: `Run a query against multiple targets in one call. Output is always JSON.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
options: [
['-d, --db <path>', 'Path to graph.db'],
['--from-file <path>', 'Read targets from file (JSON array or newline-delimited)'],
['--stdin', 'Read targets from stdin (JSON array)'],
['--depth <n>', 'Traversal depth passed to underlying command'],
['-f, --file <path>', 'Scope to file (partial match)'],
['-k, --kind <kind>', 'Filter by symbol kind'],
['-T, --no-tests', 'Exclude test/spec files from results'],
['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
],
validate([_command, _targets], opts) {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
return `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`;
}
},
async execute([command, positionalTargets], opts, ctx) {
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 = {
depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
file: opts.file,
kind: opts.kind,
noTests: ctx.resolveNoTests(opts),
};

const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command;
if (isMulti) {
const data = multiBatchData(targets, opts.db, batchOpts);
console.log(JSON.stringify(data, null, 2));
} else {
batch(command, targets, opts.db, batchOpts);
}
},
};
21 changes: 21 additions & 0 deletions src/cli/commands/branch-compare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const command = {
name: 'branch-compare <base> <target>',
description: 'Compare code structure between two branches/refs',
options: [
['--depth <n>', 'Max transitive caller depth', '3'],
['-T, --no-tests', 'Exclude test/spec files'],
['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
['-j, --json', 'Output as JSON'],
['-f, --format <format>', 'Output format: text, mermaid, json', 'text'],
],
async execute([base, target], opts, ctx) {
const { branchCompare } = await import('../../commands/branch-compare.js');
await branchCompare(base, target, {
engine: ctx.program.opts().engine,
depth: parseInt(opts.depth, 10),
noTests: ctx.resolveNoTests(opts),
json: opts.json,
format: opts.format,
});
},
};
26 changes: 26 additions & 0 deletions src/cli/commands/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import path from 'node:path';
import { buildGraph } from '../../builder.js';

export const command = {
name: 'build [dir]',
description: 'Parse repo and build graph in .codegraph/graph.db',
options: [
['--no-incremental', 'Force full rebuild (ignore file hashes)'],
['--no-ast', 'Skip AST node extraction (calls, new, string, regex, throw, await)'],
['--no-complexity', 'Skip complexity metrics computation'],
['--no-dataflow', 'Skip data flow edge extraction'],
['--no-cfg', 'Skip control flow graph building'],
],
async execute([dir], opts, ctx) {
const root = path.resolve(dir || '.');
const engine = ctx.program.opts().engine;
await buildGraph(root, {
incremental: opts.incremental,
ast: opts.ast,
complexity: opts.complexity,
engine,
dataflow: opts.dataflow,
cfg: opts.cfg,
});
},
};
30 changes: 30 additions & 0 deletions src/cli/commands/cfg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { EVERY_SYMBOL_KIND } from '../../queries.js';

export const command = {
name: 'cfg <name>',
description: 'Show control flow graph for a function',
queryOpts: true,
options: [
['--format <fmt>', 'Output format: text, dot, mermaid', 'text'],
['-f, --file <path>', 'Scope to file (partial match)'],
['-k, --kind <kind>', 'Filter by symbol kind'],
],
validate([_name], opts) {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
return `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`;
}
},
async execute([name], opts, ctx) {
const { cfg } = await import('../../commands/cfg.js');
cfg(name, opts.db, {
format: opts.format,
file: opts.file,
kind: opts.kind,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
ndjson: opts.ndjson,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
});
},
};
76 changes: 76 additions & 0 deletions src/cli/commands/check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { EVERY_SYMBOL_KIND } from '../../queries.js';

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

if (!isDiffMode && !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('../../commands/manifesto.js');
manifesto(opts.db, {
file: opts.file,
kind: opts.kind,
noTests: ctx.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;
}

const { check } = await import('../../commands/check.js');
check(opts.db, {
ref,
staged: opts.staged,
cycles: opts.cycles || undefined,
blastRadius: opts.blastRadius ? parseInt(opts.blastRadius, 10) : undefined,
signatures: opts.signatures || undefined,
boundaries: opts.boundaries || undefined,
depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
});

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('../../commands/manifesto.js');
manifesto(opts.db, {
file: opts.file,
kind: opts.kind,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
ndjson: opts.ndjson,
});
}
},
};
31 changes: 31 additions & 0 deletions src/cli/commands/children.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { EVERY_SYMBOL_KIND } from '../../queries.js';
import { children } from '../../queries-cli.js';

export const command = {
name: 'children <name>',
description: 'List parameters, properties, and constants of a symbol',
options: [
['-d, --db <path>', 'Path to graph.db'],
['-f, --file <path>', 'Scope search to symbols in this file (partial match)'],
['-k, --kind <kind>', 'Filter to a specific symbol kind'],
['-T, --no-tests', 'Exclude test/spec files from results'],
['-j, --json', 'Output as JSON'],
['--limit <number>', 'Max results to return'],
['--offset <number>', 'Skip N results (default: 0)'],
],
validate([_name], opts) {
if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) {
return `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`;
}
},
execute([name], opts, ctx) {
children(name, opts.db, {
file: opts.file,
kind: opts.kind,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
});
},
};
64 changes: 64 additions & 0 deletions src/cli/commands/co-change.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export const command = {
name: 'co-change [file]',
description:
'Analyze git history for files that change together. Use --analyze to scan, or query existing data.',
options: [
['--analyze', 'Scan git history and populate co-change data'],
['--since <date>', 'Git date for history window (default: "1 year ago")'],
['--min-support <n>', 'Minimum co-occurrence count (default: 3)'],
['--min-jaccard <n>', 'Minimum Jaccard similarity 0-1 (default: 0.3)'],
['--full', 'Force full re-scan (ignore incremental state)'],
['-n, --limit <n>', 'Max results', '20'],
['-d, --db <path>', 'Path to graph.db'],
['-T, --no-tests', 'Exclude test/spec files'],
['--include-tests', 'Include test/spec files (overrides excludeTests config)'],
['-j, --json', 'Output as JSON'],
['--offset <number>', 'Skip N results (default: 0)'],
['--ndjson', 'Newline-delimited JSON output'],
],
async execute([file], opts, ctx) {
const { analyzeCoChanges, coChangeData, coChangeTopData } = await import('../../cochange.js');
const { formatCoChange, formatCoChangeTop } = await import('../../commands/cochange.js');

if (opts.analyze) {
const result = analyzeCoChanges(opts.db, {
since: opts.since || ctx.config.coChange?.since,
minSupport: opts.minSupport
? parseInt(opts.minSupport, 10)
: ctx.config.coChange?.minSupport,
maxFilesPerCommit: ctx.config.coChange?.maxFilesPerCommit,
full: opts.full,
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.error) {
console.error(result.error);
process.exit(1);
} else {
console.log(
`\nCo-change analysis complete: ${result.pairsFound} pairs from ${result.commitsScanned} commits (since: ${result.since})\n`,
);
}
return;
}

const queryOpts = {
limit: parseInt(opts.limit, 10),
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
minJaccard: opts.minJaccard ? parseFloat(opts.minJaccard) : ctx.config.coChange?.minJaccard,
noTests: ctx.resolveNoTests(opts),
};

if (file) {
const data = coChangeData(file, opts.db, queryOpts);
if (!ctx.outputResult(data, 'partners', opts)) {
console.log(formatCoChange(data));
}
} else {
const data = coChangeTopData(opts.db, queryOpts);
if (!ctx.outputResult(data, 'pairs', opts)) {
console.log(formatCoChangeTop(data));
}
}
},
};
Loading
Loading