diff --git a/.runechoguardignore b/.runechoguardignore index b14dc0d..e37380f 100644 --- a/.runechoguardignore +++ b/.runechoguardignore @@ -3,3 +3,4 @@ fn promisify execFileAsync parseArgs +MAX_CODEGRAPH_BUFFER diff --git a/USAGE.md b/USAGE.md index c94f0fe..357c3a5 100644 --- a/USAGE.md +++ b/USAGE.md @@ -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 `), 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. diff --git a/render/callgraph.js b/render/callgraph.js index d75cfbc..8bff508 100755 --- a/render/callgraph.js +++ b/render/callgraph.js @@ -68,6 +68,40 @@ function matchSymbolNotFound(out) { return m ? m[1] : null; } +// codegraph prints "✗ CodeGraph not initialized in — 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

'. +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) — @@ -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 { @@ -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 @@ -974,4 +1024,5 @@ module.exports = { applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, emptyGraphWarning, emptyArchitectureWarning, + matchNotInitialized, argRepoPath, parseCodegraphOutput, }; diff --git a/test/run.js b/test/run.js index f7b0386..58c82ba 100644 --- a/test/run.js +++ b/test/run.js @@ -10,6 +10,7 @@ const { applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, emptyGraphWarning, emptyArchitectureWarning, + matchNotInitialized, argRepoPath, parseCodegraphOutput, } = require('../render/callgraph.js'); let passed = 0; @@ -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"\];/); @@ -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 ', () => {