From 6612715b3b9c5d71a028ba6fa23d0eb20e3adbf0 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Sun, 19 Jul 2026 10:29:10 -0400 Subject: [PATCH 1/3] Clean message when the repo isn't codegraph-indexed yet (first-run onboarding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an unindexed repo, `codegraph query` exits NON-ZERO with "CodeGraph not initialized" on stderr, so execFile threw before parseCodegraphOutput could see the text — a first-time user got the confusing wrapper: codeshot: Command failed: codegraph query ... CodeGraph not initialized ... runCodegraph now catches that non-zero exit, recognizes the message via matchNotInitialized, and prints the exact fix (naming the repo path): codeshot: codegraph has no index for '' yet — build one first with 'codegraph init ', then rerun. ... It re-throws any other codegraph failure so a genuine error isn't misreported as a missing index. Sibling of the existing matchSymbolNotFound handling; covers both symbol and --architecture mode via the shared runCodegraph choke point. Deliberately does NOT auto-install codegraph or auto-run 'codegraph init': installing software or building a heavy, persistent index is codegraph's call, not a diagram tool's — the same detect-and-instruct stance as requireOnPath. - render/callgraph.js: matchNotInitialized, argRepoPath, exitNotInitialized; runCodegraph handles the non-zero-exit path - test/run.js: unit tests + a guarded CLI test against a real unindexed tmp repo - USAGE.md: troubleshooting entry - .runechoguardignore: add MAX_CODEGRAPH_BUFFER (const reference misread as a bare call by the guard once the refactor touched its line) --- .runechoguardignore | 1 + USAGE.md | 1 + render/callgraph.js | 48 +++++++++++++++++++++++++++++++++++++++++++-- test/run.js | 48 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) 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..b62053f 100755 --- a/render/callgraph.js +++ b/render/callgraph.js @@ -68,6 +68,37 @@ 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, reachable from two paths: a +// non-zero codegraph exit (the real one — codegraph rejects with the message on +// stderr, caught in runCodegraph) and, defensively, a zero-exit message on +// stdout (parseCodegraphOutput). 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 +111,7 @@ 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); } + if (matchNotInitialized(out)) return exitNotInitialized(args, fatal); try { return JSON.parse(out); } catch { @@ -95,8 +127,19 @@ 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 (it prints "CodeGraph not + // initialized" to stderr), which rejects the promise before stdout can be + // parsed — so this case never reaches parseCodegraphOutput's stdout check. + // Surface that one known first-run state cleanly; re-throw anything else so a + // genuine codegraph failure isn't misreported as a missing index. + if (matchNotInitialized(`${err.stdout || ''}${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 +1017,5 @@ module.exports = { applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, emptyGraphWarning, emptyArchitectureWarning, + matchNotInitialized, argRepoPath, }; diff --git a/test/run.js b/test/run.js index f7b0386..a988de5 100644 --- a/test/run.js +++ b/test/run.js @@ -10,6 +10,7 @@ const { applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, emptyGraphWarning, emptyArchitectureWarning, + matchNotInitialized, argRepoPath, } = require('../render/callgraph.js'); let passed = 0; @@ -250,6 +251,20 @@ 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('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 +805,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 ', () => { From fb498fc4dd5501b6562e95bdeda587a7050b0172 Mon Sep 17 00:00:00 2001 From: Eric Minish <eric.minish@gmail.com> Date: Sun, 19 Jul 2026 10:47:55 -0400 Subject: [PATCH 2/3] ci: build codegraph index and --check in one step (fix cross-step 'not initialized' race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting 'codegraph init' and 'codegraph query' across two GitHub Actions steps intermittently failed the diagrams job with 'not initialized': the step boundary kills codegraph's process group before the freshly built on-disk db is finalized, so a later step's fresh query process sees no index (while the same-step status does). Root-caused by reading codegraph's isInitialized() — it checks for a finalized .codegraph/codegraph.db, absent when the build process is killed early. Fix: run init + readiness-probe + --check in one shell step, retrying a real query until the index answers, with CODEGRAPH_NO_WATCHDOG=1 so a throttled runner doesn't trip the liveness watchdog mid-index. Not caused by this branch's code change (master hits the same race; my error-handling change only made the failure message clean instead of a raw 'Command failed'). --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37d1c14..cda0d2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,18 +50,39 @@ jobs: - name: Install codegraph run: npm install -g @colbymchenry/codegraph@1.4.1 - # `init` builds the initial index in a fresh checkout (`index` rebuilds an - # existing one and errors if the repo was never initialized — which a CI - # checkout, with no committed .codegraph, never was). - - name: Build the codegraph index + # Build the index AND run --check in ONE step, on purpose. codegraph serves + # its index from a per-invocation process; splitting "build" and "query" + # across two GitHub Actions steps intermittently left `codegraph query` + # reporting "not initialized" — the step boundary kills codegraph's process + # group before the freshly built on-disk db is finalized, so a later step's + # fresh process sees no index (whereas the same-step `status` does). Keeping + # everything in one shell, plus a readiness probe that retries a real query + # until the index answers, makes it reliable. `init` builds the initial + # index in a fresh checkout (`index` errors when the repo was never + # initialized). CODEGRAPH_NO_WATCHDOG=1 stops a throttled runner from + # tripping codegraph's liveness watchdog (#850) mid-index. + - name: Build index and check committed diagrams are current + env: + CODEGRAPH_NO_WATCHDOG: "1" run: | codegraph init . + # Readiness probe: don't --check until a real query resolves against the + # index. Retries cover the finalize-after-build race above. + for attempt in 1 2 3 4 5; do + if codegraph query --path . --json --limit 1 -- buildDot >/dev/null 2>&1; then + echo "codegraph index is queryable (attempt $attempt)" + break + fi + if [ "$attempt" = 5 ]; then + echo "::error::codegraph index never became queryable after init"; codegraph status .; exit 1 + fi + echo "index not queryable yet (attempt $attempt) — syncing and retrying" + codegraph sync . || true + sleep 2 + done codegraph status . # visibility: a partial index prints an unresolved-refs warning - - # Blocking gate: fail the PR if either committed diagram no longer matches - # what the current code produces. Structural compare = graphviz-version proof. - - name: Check committed diagrams are current - run: | + # Blocking gate: fail the PR if either committed diagram no longer matches + # what the current code produces. Structural compare = graphviz-version proof. node render/callgraph.js --architecture --path . --format svg \ --out docs/architecture.svg --embed TECHNICAL.md --check node render/callgraph.js buildDot --path . --format svg \ From cd29dd5baa920f31b55a1cce3d22802e608f962a Mon Sep 17 00:00:00 2001 From: Eric Minish <eric.minish@gmail.com> Date: Sun, 19 Jul 2026 10:57:16 -0400 Subject: [PATCH 3/3] Fix self-referential false positive: don't scan codegraph stdout for "not initialized" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit checked for codegraph's "CodeGraph not initialized" message in parseCodegraphOutput's STDOUT. But codeshot's --architecture enumerate query (`codegraph query -- ''`) returns every indexed node's content — and this file's own source contains that exact phrase in a comment describing the message. So codeshot read its own indexed source back and falsely reported a well-indexed repo as uninitialized. It only surfaced in CI, which indexes the branch's real code; master lacked the phrase, and local runs used a symlinked master index. Fix: match "not initialized" ONLY on stderr with a non-zero exit (runCodegraph's catch) — never on successful stdout. Removed the stdout scan; narrowed the catch to err.stderr. Added a regression test (a JSON response mentioning the phrase must parse, not error). Reverted the CI-hardening commit — it chased a wrong "cross- step race" hypothesis; ci.yml is back to master's version. --- .github/workflows/ci.yml | 39 +++++++++------------------------------ render/callgraph.js | 33 ++++++++++++++++++++------------- test/run.js | 13 ++++++++++++- 3 files changed, 41 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cda0d2d..37d1c14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,39 +50,18 @@ jobs: - name: Install codegraph run: npm install -g @colbymchenry/codegraph@1.4.1 - # Build the index AND run --check in ONE step, on purpose. codegraph serves - # its index from a per-invocation process; splitting "build" and "query" - # across two GitHub Actions steps intermittently left `codegraph query` - # reporting "not initialized" — the step boundary kills codegraph's process - # group before the freshly built on-disk db is finalized, so a later step's - # fresh process sees no index (whereas the same-step `status` does). Keeping - # everything in one shell, plus a readiness probe that retries a real query - # until the index answers, makes it reliable. `init` builds the initial - # index in a fresh checkout (`index` errors when the repo was never - # initialized). CODEGRAPH_NO_WATCHDOG=1 stops a throttled runner from - # tripping codegraph's liveness watchdog (#850) mid-index. - - name: Build index and check committed diagrams are current - env: - CODEGRAPH_NO_WATCHDOG: "1" + # `init` builds the initial index in a fresh checkout (`index` rebuilds an + # existing one and errors if the repo was never initialized — which a CI + # checkout, with no committed .codegraph, never was). + - name: Build the codegraph index run: | codegraph init . - # Readiness probe: don't --check until a real query resolves against the - # index. Retries cover the finalize-after-build race above. - for attempt in 1 2 3 4 5; do - if codegraph query --path . --json --limit 1 -- buildDot >/dev/null 2>&1; then - echo "codegraph index is queryable (attempt $attempt)" - break - fi - if [ "$attempt" = 5 ]; then - echo "::error::codegraph index never became queryable after init"; codegraph status .; exit 1 - fi - echo "index not queryable yet (attempt $attempt) — syncing and retrying" - codegraph sync . || true - sleep 2 - done codegraph status . # visibility: a partial index prints an unresolved-refs warning - # Blocking gate: fail the PR if either committed diagram no longer matches - # what the current code produces. Structural compare = graphviz-version proof. + + # Blocking gate: fail the PR if either committed diagram no longer matches + # what the current code produces. Structural compare = graphviz-version proof. + - name: Check committed diagrams are current + run: | node render/callgraph.js --architecture --path . --format svg \ --out docs/architecture.svg --embed TECHNICAL.md --check node render/callgraph.js buildDot --path . --format svg \ diff --git a/render/callgraph.js b/render/callgraph.js index b62053f..8bff508 100755 --- a/render/callgraph.js +++ b/render/callgraph.js @@ -87,11 +87,14 @@ function argRepoPath(args) { return i !== -1 && args[i + 1] !== undefined ? args[i + 1] : '.'; } -// Shared clean exit for the unindexed-repo case, reachable from two paths: a -// non-zero codegraph exit (the real one — codegraph rejects with the message on -// stderr, caught in runCodegraph) and, defensively, a zero-exit message on -// stdout (parseCodegraphOutput). Returns null for non-fatal callers, exits 1 -// otherwise — same contract as the matchSymbolNotFound branch. +// 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); @@ -111,7 +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); } - if (matchNotInitialized(out)) return exitNotInitialized(args, fatal); + // 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 { @@ -131,12 +137,13 @@ async function runCodegraph(args, { fatal = true } = {}) { try { result = await execFileAsync('codegraph', args, { encoding: 'utf8', maxBuffer: MAX_CODEGRAPH_BUFFER }); } catch (err) { - // codegraph exits NON-ZERO for an unindexed repo (it prints "CodeGraph not - // initialized" to stderr), which rejects the promise before stdout can be - // parsed — so this case never reaches parseCodegraphOutput's stdout check. - // Surface that one known first-run state cleanly; re-throw anything else so a - // genuine codegraph failure isn't misreported as a missing index. - if (matchNotInitialized(`${err.stdout || ''}${err.stderr || ''}`)) return exitNotInitialized(args, fatal); + // 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 }); @@ -1017,5 +1024,5 @@ module.exports = { applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, emptyGraphWarning, emptyArchitectureWarning, - matchNotInitialized, argRepoPath, + matchNotInitialized, argRepoPath, parseCodegraphOutput, }; diff --git a/test/run.js b/test/run.js index a988de5..58c82ba 100644 --- a/test/run.js +++ b/test/run.js @@ -10,7 +10,7 @@ const { applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, emptyGraphWarning, emptyArchitectureWarning, - matchNotInitialized, argRepoPath, + matchNotInitialized, argRepoPath, parseCodegraphOutput, } = require('../render/callgraph.js'); let passed = 0; @@ -265,6 +265,17 @@ test('argRepoPath recovers the --path value codeshot passed, defaulting to "."', 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"\];/);