Skip to content
Open
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
1,524 changes: 2 additions & 1,522 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