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
1 change: 1 addition & 0 deletions .runechoguardignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ fn
promisify
execFileAsync
parseArgs
MAX_CODEGRAPH_BUFFER
1 change: 1 addition & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ fall back to a raw byte-compare, which does require CI to use the same
## What to Do When Something Breaks

- **"codeshot: 'codegraph' not found on PATH"** — Install CodeGraph and make sure it's on your PATH, then try again.
- **"codeshot: codegraph has no index for '...' yet"** — CodeGraph is installed but this repo has never been indexed. Run the exact command the message gives you (`codegraph init <path>`), then rerun codeshot. Codeshot reads CodeGraph's index; it deliberately doesn't build one for you (indexing is a heavy, persistent operation and CodeGraph's call to make).
- **"codeshot: 'dot' not found on PATH"** — Install Graphviz (`brew install graphviz` on Mac, `apt install graphviz` on Ubuntu/WSL), then try again.
- **The command runs but the diagram is empty or missing edges** — The repo probably hasn't been indexed yet, or the index is stale. Run `codegraph init` (or re-run indexing) in the target repo first. Codeshot no longer draws a blank picture silently: it warns on stderr in the two cases below.
- **"codeshot: '...' has no callers or callees in codegraph's index"** — The symbol exists but nothing calls it and it calls nothing, so the diagram is just that one box. It may be genuinely unused (dead code) or a top-level entry point — or codegraph's index is incomplete for its file (see the sparse-diagram note below). The image is still written; the warning just explains why it's a lone box.
Expand Down
55 changes: 53 additions & 2 deletions render/callgraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ function matchSymbolNotFound(out) {
return m ? m[1] : null;
}

// codegraph prints "✗ CodeGraph not initialized in <path> — Run 'codegraph init'
// first" (plain text, not JSON) when --path points at a repo it has never
// indexed — the single most common first-run failure. Without special-casing it,
// the JSON.parse fallback wraps that message in a confusing "did not return JSON"
// line; here it earns its own clean, actionable message instead. Sibling of
// matchSymbolNotFound. Deliberately does NOT auto-run 'codegraph init' — building
// an index is a heavy, persistent side effect and codegraph's call to make, not
// codeshot's (same detect-and-instruct stance as requireOnPath).
function matchNotInitialized(out) {
return /CodeGraph\s+not\s+initialized/i.test(String(out));
}

// The repo path codeshot passed to codegraph, recovered from the arg array so an
// error message can name it. Every codegraph invocation includes '--path <p>'.
function argRepoPath(args) {
const i = args.indexOf('--path');
return i !== -1 && args[i + 1] !== undefined ? args[i + 1] : '.';
}

// Shared clean exit for the unindexed-repo case. Reached ONLY from runCodegraph's
// catch, matched against codegraph's STDERR on a non-zero exit — never against a
// successful command's stdout. That distinction is load-bearing: codegraph's
// enumerate query (`query -- ''`) returns every indexed node, whose content can
// legitimately include the literal phrase "CodeGraph not initialized" (e.g. this
// file's own source describing the message). Scanning stdout for it would false-
// positive on a repo that is perfectly well indexed. Returns null for non-fatal
// callers, exits 1 otherwise — same contract as the matchSymbolNotFound branch.
function exitNotInitialized(args, fatal) {
if (!fatal) return null;
const repoPath = argRepoPath(args);
console.error(`codeshot: codegraph has no index for '${repoPath}' yet — build one first with 'codegraph init ${repoPath}', then rerun. (codeshot reads codegraph's index; it doesn't create it.)`);
process.exit(1);
}

// `fatal` (default true) matches every existing call site's behavior. Pass
// `fatal: false` when calling this in a loop that probes many symbols and
// must survive an individual bad one (e.g. --architecture's enumeration) —
Expand All @@ -80,6 +114,10 @@ function parseCodegraphOutput(out, args, { fatal = true } = {}) {
console.error(`codeshot: symbol '${notFound}' not found in codegraph's index — check the spelling/casing, or confirm --path points at the repo that contains it.`);
process.exit(1);
}
// NOTE: the "not initialized" case is deliberately NOT handled here on stdout —
// see exitNotInitialized. A successful codegraph response can contain that
// phrase as indexed source content; it's only a real signal on stderr with a
// non-zero exit, which runCodegraph handles.
try {
return JSON.parse(out);
} catch {
Expand All @@ -95,8 +133,20 @@ function parseCodegraphOutput(out, args, { fatal = true } = {}) {
// single codegraph response this tool realistically produces.
const MAX_CODEGRAPH_BUFFER = 64 * 1024 * 1024;
async function runCodegraph(args, { fatal = true } = {}) {
const { stdout } = await execFileAsync('codegraph', args, { encoding: 'utf8', maxBuffer: MAX_CODEGRAPH_BUFFER });
return parseCodegraphOutput(stdout, args, { fatal });
let result;
try {
result = await execFileAsync('codegraph', args, { encoding: 'utf8', maxBuffer: MAX_CODEGRAPH_BUFFER });
} catch (err) {
// codegraph exits NON-ZERO for an unindexed repo, printing "CodeGraph not
// initialized" to STDERR. Match only stderr — never err.stdout — because a
// partial stdout on some other failure could contain that phrase as indexed
// source content and false-positive (the same trap that stdout scanning in
// parseCodegraphOutput would be). Surface this known first-run state cleanly;
// re-throw anything else so a genuine codegraph failure isn't misreported.
if (matchNotInitialized(`${err.stderr || ''}`)) return exitNotInitialized(args, fatal);
throw err;
}
return parseCodegraphOutput(result.stdout, args, { fatal });
}

// `codegraph status` warns when an index was left mid-build ("N references from
Expand Down Expand Up @@ -974,4 +1024,5 @@ module.exports = {
applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs,
svgStructure, decodeXmlEntities,
emptyGraphWarning, emptyArchitectureWarning,
matchNotInitialized, argRepoPath, parseCodegraphOutput,
};
59 changes: 59 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs,
svgStructure, decodeXmlEntities,
emptyGraphWarning, emptyArchitectureWarning,
matchNotInitialized, argRepoPath, parseCodegraphOutput,
} = require('../render/callgraph.js');

let passed = 0;
Expand Down Expand Up @@ -250,6 +251,31 @@ test('matchSymbolNotFound extracts the symbol name from codegraph\'s plain-text
assert.strictEqual(matchSymbolNotFound('{"symbol":"Foo","callers":[]}'), null, 'a real JSON response should never match');
});

test('matchNotInitialized detects codegraph\'s unindexed-repo message, not JSON or other errors', () => {
const esc = String.fromCharCode(27);
assert.strictEqual(matchNotInitialized(`${esc}[31m✗${esc}[0m CodeGraph not initialized in /repo\n Run "codegraph init" first`), true);
assert.strictEqual(matchNotInitialized('codegraph not initialized'), true, 'case-insensitive');
assert.strictEqual(matchNotInitialized('[{"node":{}}]'), false, 'a real JSON response must not match');
assert.strictEqual(matchNotInitialized('Symbol "Foo" not found'), false, 'the not-found message is a different case');
assert.strictEqual(matchNotInitialized(''), false);
});

test('argRepoPath recovers the --path value codeshot passed, defaulting to "."', () => {
assert.strictEqual(argRepoPath(['query', '--path', '/repo/x', '--json', '--', 'Foo']), '/repo/x');
assert.strictEqual(argRepoPath(['callers', '--json']), '.', 'no --path → cwd default');
});

test('parseCodegraphOutput does NOT treat a successful JSON response as "not initialized" just because a node\'s content mentions the phrase', () => {
// Regression: codegraph's enumerate query returns indexed node content, and
// this very file's source contains "CodeGraph not initialized" in a comment.
// Scanning stdout for that phrase falsely reported a well-indexed repo as
// uninitialized (the CI diagrams job caught it). The phrase is a real signal
// only on stderr with a non-zero exit (runCodegraph), never on stdout.
const stdout = JSON.stringify([{ node: { name: 'matchNotInitialized', content: 'detects "CodeGraph not initialized"' } }]);
const parsed = parseCodegraphOutput(stdout, ['query', '--path', '.', '--json', '--', ''], { fatal: false });
assert.deepStrictEqual(parsed, [{ node: { name: 'matchNotInitialized', content: 'detects "CodeGraph not initialized"' } }]);
});

test('buildDot styles a "kind":"file" caller/callee distinctly from a real function call', () => {
const dot = buildDot('Target', [{ name: 'some.js', kind: 'file', filePath: 'src/some.js' }], [{ name: 'other.js', kind: 'file', filePath: 'src/other.js' }]);
assert.match(dot, /"some\.js" -> "Target" \[style=dotted, color="#9ca3af", label="file"\];/);
Expand Down Expand Up @@ -790,6 +816,39 @@ test('CLI --embed into a nonexistent doc is rejected (refresh, not create)', ()
assert.strictEqual(threw, true, 'expected --embed into a missing doc to be rejected');
});

test('CLI on an unindexed repo prints a clean "no index" message, not a raw codegraph error', () => {
const { execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const callgraphJs = path.join(__dirname, '..', 'render', 'callgraph.js');

// Needs codegraph on PATH to produce the real non-zero "not initialized" exit.
try {
execFileSync('codegraph', ['--version'], { stdio: 'pipe' });
} catch {
console.log(' # skipped: `codegraph` not on PATH');
return;
}

// A fresh dir under tmp with no .codegraph anywhere above it → codegraph
// reports "not initialized" rather than resolving a parent index.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codeshot-unindexed-'));
fs.writeFileSync(path.join(dir, 'app.py'), 'def hello():\n pass\n', 'utf8');
let threw = false;
try {
execFileSync('node', [callgraphJs, 'hello', '--path', dir], { encoding: 'utf8', stdio: 'pipe' });
} catch (err) {
threw = true;
assert.match(err.stderr, /has no index for/);
assert.match(err.stderr, /codegraph init/);
assert.doesNotMatch(err.stderr, /did not return JSON|Command failed/, 'must be the clean message, not the raw thrown error');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
assert.strictEqual(threw, true, 'expected exit 1 on an unindexed repo');
});

// --- svgStructure: version-independent --check comparison -------------

test('decodeXmlEntities undoes the entities graphviz emits in a <title>', () => {
Expand Down
Loading