@sdsrs/code-graph 0.112.1 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -353,7 +353,7 @@ Skip the full local index for team members and CI runners by publishing a
|
|
|
353
353
|
~3-5MB graph snapshot with each GitHub release.
|
|
354
354
|
|
|
355
355
|
**Setup (one-time):**
|
|
356
|
-
1. Copy `node_modules/code-graph-
|
|
356
|
+
1. Copy `node_modules/@sdsrs/code-graph/claude-plugin/templates/code-graph-snapshot.yml`
|
|
357
357
|
into your repo's `.github/workflows/`.
|
|
358
358
|
2. Push a release tag. The workflow uploads
|
|
359
359
|
`code-graph-snapshot-<sha>.db.zst` as a release asset.
|
|
@@ -361,7 +361,7 @@ Skip the full local index for team members and CI runners by publishing a
|
|
|
361
361
|
**Verify:**
|
|
362
362
|
|
|
363
363
|
```bash
|
|
364
|
-
npx code-graph-mcp snapshot inspect ./code-graph-snapshot-<sha>.db.zst
|
|
364
|
+
npx -y -p @sdsrs/code-graph code-graph-mcp snapshot inspect ./code-graph-snapshot-<sha>.db.zst
|
|
365
365
|
```
|
|
366
366
|
|
|
367
367
|
After setup, the auto-fetch is **opt-in per consumer**: an untrusted repo could
|
|
@@ -130,26 +130,56 @@ function saveState(state) {
|
|
|
130
130
|
|
|
131
131
|
// ── Throttle ───────────────────────────────────────────────
|
|
132
132
|
|
|
133
|
+
// The updater has given up on the current target release (MAX_UPDATE_ATTEMPTS
|
|
134
|
+
// consecutive failed installs of the SAME version, retried once a day).
|
|
135
|
+
// `suspendedAt` is stamped only on entry to that state and cleared on success
|
|
136
|
+
// and on a new target, so it alone identifies it; `updateAttempts` is required
|
|
137
|
+
// too so a hand-edited or half-written state file cannot park the updater.
|
|
138
|
+
function isUpdateSuspended(state) {
|
|
139
|
+
return Boolean(state && state.suspendedAt) && (state.updateAttempts || 0) >= MAX_UPDATE_ATTEMPTS;
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
// Whether to hit GitHub now. Keyed to the previous check's outcome, with a force
|
|
134
|
-
// override for high-intent triggers (session start / explicit reload)
|
|
143
|
+
// override for high-intent triggers (session start / explicit reload) and two
|
|
144
|
+
// binary-health overrides. EVERY bypass is decided here — the caller used to
|
|
145
|
+
// short-circuit `binaryMissing`/`binaryStale` outside this function, which put
|
|
146
|
+
// them above the rate-limit arm and made the "wins over everything" below false:
|
|
147
|
+
// a stale binary plus `rateLimited: true` hit the API on every session start
|
|
148
|
+
// (measured: 1 request per check, vs 0 for the same state with a current
|
|
149
|
+
// binary). Ordering:
|
|
135
150
|
// 1. rate-limit backoff (RATE_LIMIT_INTERVAL_MS, 1h = GitHub's own reset
|
|
136
|
-
// window) wins over everything
|
|
137
|
-
// into a GitHub 403
|
|
138
|
-
//
|
|
139
|
-
//
|
|
151
|
+
// window) wins over everything — force and both binary overrides included.
|
|
152
|
+
// Never push more requests into a GitHub 403; a 403 cannot hand us a
|
|
153
|
+
// download URL either, so the bypasses have nothing to gain by outranking
|
|
154
|
+
// it. Safe to outrank force only because it is an hour; the 24h it said
|
|
155
|
+
// before made one 403 a silent day-long no-op for `--force`.
|
|
156
|
+
// 2. binaryMissing → check now. This is the one repair still reachable while
|
|
157
|
+
// the download chain is otherwise parked (the suspension branch in
|
|
158
|
+
// checkForUpdate keeps that heal alive), so it outranks suspension.
|
|
159
|
+
// 3. suspension → neither `binaryStale` nor `force` applies. A stale binary
|
|
160
|
+
// cannot be healed while the chain is parked, and since suspension makes
|
|
161
|
+
// `cachedBinaryStaleVsState` permanently true, that bypass otherwise
|
|
162
|
+
// fired on every single session forever and did nothing with the answer.
|
|
163
|
+
// Both fall through to the ordinary interval, which still notices a newer
|
|
164
|
+
// release (that un-suspends) and still lets the daily retry come due.
|
|
165
|
+
// 4. force → only the short SESSION_START_MIN_GAP_MS floor applies, so opening
|
|
140
166
|
// a new session re-checks immediately while a crash/reopen loop still can't
|
|
141
167
|
// hammer the API.
|
|
142
|
-
//
|
|
168
|
+
// 5. otherwise → an "up to date" result is re-verified on a short cadence
|
|
143
169
|
// (UP_TO_DATE_RECHECK_MS). This is the release-publish race guard: a version
|
|
144
170
|
// can go live seconds AFTER a check that said "up to date", and the plain 6h
|
|
145
171
|
// interval left it invisible for the full 6h (observed live — v0.85.7
|
|
146
172
|
// published 8s after a check pinned v0.85.6). A pending-but-unfinished update
|
|
147
173
|
// keeps the 6h steady-state interval.
|
|
148
|
-
function shouldCheck(state, { force = false } = {}) {
|
|
174
|
+
function shouldCheck(state, { force = false, binaryMissing = false, binaryStale = false } = {}) {
|
|
149
175
|
if (!state.lastCheck) return true;
|
|
150
176
|
const elapsed = Date.now() - new Date(state.lastCheck).getTime();
|
|
151
177
|
if (state.rateLimited) return elapsed >= RATE_LIMIT_INTERVAL_MS;
|
|
152
|
-
if (
|
|
178
|
+
if (binaryMissing) return true;
|
|
179
|
+
if (!isUpdateSuspended(state)) {
|
|
180
|
+
if (binaryStale) return true;
|
|
181
|
+
if (force) return elapsed >= SESSION_START_MIN_GAP_MS;
|
|
182
|
+
}
|
|
153
183
|
const interval = state.updateAvailable === false ? UP_TO_DATE_RECHECK_MS : CHECK_INTERVAL_MS;
|
|
154
184
|
return elapsed >= interval;
|
|
155
185
|
}
|
|
@@ -373,8 +403,12 @@ async function downloadBinary(latest) {
|
|
|
373
403
|
|
|
374
404
|
try {
|
|
375
405
|
fs.mkdirSync(BINARY_CACHE_DIR, { recursive: true });
|
|
406
|
+
// `-f` (fail on HTTP >= 400), same as the sidecar fetch below. Without it
|
|
407
|
+
// curl writes GitHub's 404/503 HTML body to binaryTmp and exits 0, so the
|
|
408
|
+
// error page travelled on as a candidate binary and was only caught two
|
|
409
|
+
// gates later — by the silent size check, which reported nothing about why.
|
|
376
410
|
execFileSync('curl', [
|
|
377
|
-
'-
|
|
411
|
+
'-sfL', '-o', binaryTmp,
|
|
378
412
|
latest.binaryUrl,
|
|
379
413
|
], hidden({ timeout: 60000, stdio: 'pipe' }));
|
|
380
414
|
|
|
@@ -427,8 +461,20 @@ function sha256File(filePath) {
|
|
|
427
461
|
|
|
428
462
|
function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSha256) {
|
|
429
463
|
try {
|
|
464
|
+
// Size floor: every published binary is tens of MB, so anything under 1 MB
|
|
465
|
+
// is a truncated transfer or an error page. It used to return false without
|
|
466
|
+
// a word — and it sits ABOVE the two gates that DO explain themselves, so
|
|
467
|
+
// the most common download failures were also the only silent ones, each
|
|
468
|
+
// burning one of MAX_UPDATE_ATTEMPTS with nothing on stderr to explain it.
|
|
430
469
|
const stat = fs.statSync(binaryTmp);
|
|
431
|
-
if (stat.size <= 1_000_000)
|
|
470
|
+
if (stat.size <= 1_000_000) {
|
|
471
|
+
console.error(
|
|
472
|
+
`[code-graph] Refusing to install: downloaded binary is ${stat.size} bytes — far below the ~1 MB floor, ` +
|
|
473
|
+
'so the transfer was truncated or the server returned an error page. ' +
|
|
474
|
+
'The current binary is unchanged; the next update check retries.'
|
|
475
|
+
);
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
432
478
|
|
|
433
479
|
// Integrity gate BEFORE the file is made executable or run, so a corrupted
|
|
434
480
|
// or tampered download is never exec'd. The published <asset>.sha256 sidecar
|
|
@@ -462,13 +508,26 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
|
|
|
462
508
|
|
|
463
509
|
const actualVersion = readBinaryVersion(binaryTmp);
|
|
464
510
|
if (!actualVersion || (expectedVersion && actualVersion !== expectedVersion)) {
|
|
511
|
+
// Sibling of the size floor above: silent for the same reason and with the
|
|
512
|
+
// same cost. `--version` failing to run at all (wrong arch, missing libc)
|
|
513
|
+
// reads identically to a version mismatch without this.
|
|
514
|
+
console.error(
|
|
515
|
+
`[code-graph] Refusing to install: downloaded binary reports ${actualVersion ? `v${actualVersion}` : 'no runnable --version'}` +
|
|
516
|
+
`${expectedVersion ? `, expected v${expectedVersion}` : ''} — not installing it.`
|
|
517
|
+
);
|
|
465
518
|
return false;
|
|
466
519
|
}
|
|
467
520
|
|
|
468
521
|
fs.renameSync(binaryTmp, binaryDst);
|
|
469
522
|
clearBinaryCache();
|
|
470
523
|
return true;
|
|
471
|
-
} catch {
|
|
524
|
+
} catch (e) {
|
|
525
|
+
// `e.code` is the whole diagnosis for this arm: ENOSPC (full disk), EACCES /
|
|
526
|
+
// EPERM (locked cache dir, or Windows refusing to replace the .exe the MCP
|
|
527
|
+
// server is running), EBUSY, EXDEV. A bare `catch { return false }` made all
|
|
528
|
+
// of them one indistinguishable failure that the caller counted as an
|
|
529
|
+
// attempt and printed nothing about.
|
|
530
|
+
console.error(`[code-graph] Binary promote failed${e && e.code ? ` (${e.code})` : ''}: ${e && e.message}`);
|
|
472
531
|
return false;
|
|
473
532
|
} finally {
|
|
474
533
|
try {
|
|
@@ -834,10 +893,13 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
834
893
|
// (launcher cannot start) and a present-but-stale binary (otherwise it stays
|
|
835
894
|
// pinned to the old version for up to a full check interval — the binary
|
|
836
895
|
// self-heal would never run inside the throttle window). Both bypass to the
|
|
837
|
-
// fetch + self-heal path below
|
|
896
|
+
// fetch + self-heal path below — but they are ARGUMENTS to shouldCheck, not
|
|
897
|
+
// `||`-ed around it: as short-circuits out here they sat above the
|
|
898
|
+
// rate-limit backoff and the suspension state, the two conditions under
|
|
899
|
+
// which a fetch cannot accomplish anything at all.
|
|
838
900
|
const binaryMissing = !fs.existsSync(cachedBinaryPath());
|
|
839
901
|
const binaryStale = cachedBinaryStaleVsState(state);
|
|
840
|
-
if (!
|
|
902
|
+
if (!shouldCheck(state, { force, binaryMissing, binaryStale })) {
|
|
841
903
|
if (state.installedVersion !== installedVersion) {
|
|
842
904
|
saveState({ ...state, installedVersion });
|
|
843
905
|
}
|
|
@@ -1031,6 +1093,7 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
1031
1093
|
|
|
1032
1094
|
module.exports = {
|
|
1033
1095
|
checkForUpdate, commandExists, isDevMode, readState, compareVersions, shouldCheck,
|
|
1096
|
+
isUpdateSuspended,
|
|
1034
1097
|
getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
|
|
1035
1098
|
isSilentMode, isInstallMissingMode, isForceMode, isAutoUpdateDisabled,
|
|
1036
1099
|
MAX_UPDATE_ATTEMPTS,
|
|
@@ -55,6 +55,191 @@ function classifyEmbeddings(hc) {
|
|
|
55
55
|
return { name: 'Embeddings', status: 'ok', detail: `hybrid — embeddings complete (${done}/${total})` };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Classify the `integrity` block that `health-check --json` has carried since
|
|
60
|
+
* v0.113.0 (audit DB-1). Pure, same as `classifyEmbeddings`.
|
|
61
|
+
*
|
|
62
|
+
* Severity is deliberately NOT uniform across the three probes:
|
|
63
|
+
* - `quick_check` complaining means pages do not read back — the index is
|
|
64
|
+
* corrupt, and only a rebuild fixes it. Error + fixId.
|
|
65
|
+
* - `fts_drift != 0` means the FTS5 index and `nodes` disagree, so search
|
|
66
|
+
* silently misses (or invents) symbols. Wrong answers, no crash. Warn + fixId.
|
|
67
|
+
* - `orphan_vectors != 0` is dead weight that skews coverage math; answers stay
|
|
68
|
+
* correct. It is DISCLOSED in the detail but does not raise an issue on its
|
|
69
|
+
* own: the only repair is a full rebuild, and a permanent warn with a
|
|
70
|
+
* disproportionate fix is how `doctor` ends up exiting 1 forever on installs
|
|
71
|
+
* that are fine (the MCP server already reaps these at startup; a CLI-only
|
|
72
|
+
* install is exactly who would be stuck with it).
|
|
73
|
+
* `null` on any probe means "could not be measured", never a fault.
|
|
74
|
+
* @returns {{name:string, status:'ok'|'warn'|'error'|'skip', detail:string, fixId?:string}}
|
|
75
|
+
*/
|
|
76
|
+
function classifyIntegrity(hc) {
|
|
77
|
+
const it = hc && hc.integrity;
|
|
78
|
+
if (!it || typeof it !== 'object') {
|
|
79
|
+
// Binary predates the probes. Say so rather than inventing a verdict — an
|
|
80
|
+
// absent measurement is not a passing one.
|
|
81
|
+
return { name: 'Integrity', status: 'skip', detail: 'not reported by this binary version' };
|
|
82
|
+
}
|
|
83
|
+
const orphanNote = it.orphan_vectors ? `; ${it.orphan_vectors} orphan vector(s)` : '';
|
|
84
|
+
const qc = it.quick_check;
|
|
85
|
+
if (typeof qc === 'string' && qc !== 'ok' && qc !== 'skipped_large') {
|
|
86
|
+
return {
|
|
87
|
+
name: 'Integrity', status: 'error', fixId: 'index-corrupt',
|
|
88
|
+
detail: `database is CORRUPT — quick_check: ${qc}${orphanNote}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (typeof it.fts_drift === 'number' && it.fts_drift !== 0) {
|
|
92
|
+
return {
|
|
93
|
+
name: 'Integrity', status: 'warn', fixId: 'index-corrupt',
|
|
94
|
+
detail: `FTS index drifted from nodes by ${it.fts_drift} row(s) — search silently misses symbols${orphanNote}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (qc === 'skipped_large') {
|
|
98
|
+
// The size gate skipped the page scan on purpose. Reporting `ok` here would
|
|
99
|
+
// claim a check that never ran.
|
|
100
|
+
return { name: 'Integrity', status: 'skip',
|
|
101
|
+
detail: `page check skipped (index over the size gate) — run: code-graph-mcp health-check --deep${orphanNote}` };
|
|
102
|
+
}
|
|
103
|
+
if (qc == null) {
|
|
104
|
+
return { name: 'Integrity', status: 'skip', detail: `page check unavailable${orphanNote}` };
|
|
105
|
+
}
|
|
106
|
+
return { name: 'Integrity', status: 'ok', detail: `quick_check ok, FTS in sync${orphanNote}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Turn a parsed `health-check --json` payload into doctor rows.
|
|
111
|
+
*
|
|
112
|
+
* Extracted so the success path and the recovered-from-stdout path (below)
|
|
113
|
+
* cannot classify the same payload differently. Pure.
|
|
114
|
+
*/
|
|
115
|
+
function classifyHealthReport(hc) {
|
|
116
|
+
// No-index short-circuit — binary deliberately returns a structured JSON with
|
|
117
|
+
// reason='no_index' instead of bailing, so we can route to the index-empty fix
|
|
118
|
+
// without grepping stderr.
|
|
119
|
+
if (hc.reason === 'no_index') {
|
|
120
|
+
return [
|
|
121
|
+
{ name: 'Schema', status: 'ok', detail: 'binary ok (no index yet)' },
|
|
122
|
+
{ name: 'Index', status: 'warn', detail: 'missing — not indexed yet', fixId: 'index-empty' },
|
|
123
|
+
{ name: 'Embeddings', status: 'skip', detail: 'no index' },
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
// The database could not be opened at all, so schema/nodes/embeddings are not
|
|
127
|
+
// unknown-but-probably-fine, they are unmeasurable. Reporting `Schema: ok
|
|
128
|
+
// vnull` off the zeroed payload would be a fabricated pass; the Integrity row
|
|
129
|
+
// below carries the real verdict and the `index-corrupt` fix.
|
|
130
|
+
if (hc.reason === 'corrupt') {
|
|
131
|
+
return [
|
|
132
|
+
{ name: 'Schema', status: 'skip', detail: 'index unreadable' },
|
|
133
|
+
{ name: 'Index', status: 'skip', detail: 'index unreadable' },
|
|
134
|
+
{ name: 'Embeddings', status: 'skip', detail: 'index unreadable' },
|
|
135
|
+
classifyIntegrity(hc),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
const rows = [];
|
|
139
|
+
if (hc.issue && String(hc.issue).includes('schema')) {
|
|
140
|
+
rows.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
|
|
141
|
+
} else {
|
|
142
|
+
rows.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
|
|
143
|
+
}
|
|
144
|
+
if (hc.nodes === 0) {
|
|
145
|
+
rows.push({ name: 'Index', status: 'warn', detail: 'empty', fixId: 'index-empty' });
|
|
146
|
+
} else {
|
|
147
|
+
const age = hc.index_age ? ` (${hc.index_age})` : '';
|
|
148
|
+
rows.push({
|
|
149
|
+
name: 'Index', status: 'ok',
|
|
150
|
+
detail: `${hc.nodes} nodes, ${hc.edges} edges, ${hc.files} files${age}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// Embeddings / vector availability — pure classifier; warns on FTS5-only
|
|
154
|
+
// degradation (model missing/not loaded) instead of false-greening it.
|
|
155
|
+
rows.push(classifyEmbeddings(hc));
|
|
156
|
+
rows.push(classifyIntegrity(hc));
|
|
157
|
+
return rows;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Recover a health-check payload from a buffer, or null if it isn't one.
|
|
162
|
+
*
|
|
163
|
+
* `health-check --json` prints the FULL report to stdout and THEN exits 1 when
|
|
164
|
+
* the index is unhealthy, so `execFileSync` throwing does not mean there is no
|
|
165
|
+
* report — it means there is a report saying something is wrong. Requiring a
|
|
166
|
+
* key only this command emits keeps unrelated stdout (a panic, a wrapper's
|
|
167
|
+
* noise) from being read as a clean bill of health.
|
|
168
|
+
*/
|
|
169
|
+
function parseHealthPayload(buf) {
|
|
170
|
+
if (!buf) return null;
|
|
171
|
+
try {
|
|
172
|
+
const obj = JSON.parse(buf.toString().trim());
|
|
173
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
|
|
174
|
+
// `reason` covers payloads the binary emits BEFORE it can read a schema
|
|
175
|
+
// version (no_index, corrupt). Keying only on `schema_version` meant the
|
|
176
|
+
// corrupt payload — the one case where recovering the report matters most —
|
|
177
|
+
// was rejected and fell through to `binary-broken`. Caught by an end-to-end
|
|
178
|
+
// run against a real clobbered index; neither side's unit tests could see
|
|
179
|
+
// it, because each was asserted against its own fixture.
|
|
180
|
+
const looksLikeReport = 'schema_version' in obj || typeof obj.reason === 'string';
|
|
181
|
+
return looksLikeReport ? obj : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Spawn `health-check --json`. Separate + injectable: see `rebuildIndexInPlace`. */
|
|
188
|
+
function runHealthCheckCli(binary) {
|
|
189
|
+
// Deliberately NOT `--deep`. That flag forces the integrity pragmas the
|
|
190
|
+
// default path skips above INTEGRITY_PRAGMA_MAX_BYTES, and quick_check reads
|
|
191
|
+
// every page at ~2.4 ms/MB — a multi-GB index would blow the 5 s budget below
|
|
192
|
+
// and report a phantom "health-check failed" instead of the integrity answer
|
|
193
|
+
// it went looking for. Raising the timeout to fit trades that for a doctor run
|
|
194
|
+
// that appears hung. `--deep` stays a user-invoked escape hatch until this
|
|
195
|
+
// call can size its own budget from the index.
|
|
196
|
+
return execFileSync(binary, ['health-check', '--json'], hidden({
|
|
197
|
+
cwd: process.cwd(),
|
|
198
|
+
timeout: 5000,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
201
|
+
})).trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Schema / Index / Embeddings / Integrity rows from the binary's health-check.
|
|
206
|
+
*
|
|
207
|
+
* The failure branches are the point of this function existing separately.
|
|
208
|
+
* `health-check --json` prints the FULL report to stdout and THEN exits 1 when
|
|
209
|
+
* the index is unhealthy, so `execFileSync` throwing does not mean the probe
|
|
210
|
+
* failed. The old code read the throw as "the binary is broken" and emitted
|
|
211
|
+
* `Schema: error … fixId:'binary-broken'` — a fixId `runRepairs` has no case
|
|
212
|
+
* for. Net effect: the integrity probes added in v0.113.0 never reached the
|
|
213
|
+
* consumer they were built for, and a genuinely corrupt index was reported as a
|
|
214
|
+
* binary problem with no repair offered.
|
|
215
|
+
*/
|
|
216
|
+
function healthRows(binary, { runHealthCheck = runHealthCheckCli } = {}) {
|
|
217
|
+
try {
|
|
218
|
+
return classifyHealthReport(JSON.parse(runHealthCheck(binary)));
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const recovered = parseHealthPayload(e.stdout);
|
|
221
|
+
if (recovered) return classifyHealthReport(recovered);
|
|
222
|
+
|
|
223
|
+
const rawStderr = e.stderr ? e.stderr.toString() : '';
|
|
224
|
+
const msg = rawStderr ? rawStderr.trim().slice(0, 100) : e.message.slice(0, 100);
|
|
225
|
+
// "No index found" is a missing-index situation, not a broken binary — the
|
|
226
|
+
// index-empty fix path knows how to create one. Without this branch the
|
|
227
|
+
// fixId routes to nothing and the report shows "0/1 addressed".
|
|
228
|
+
if (rawStderr.includes('No index found')) {
|
|
229
|
+
return [
|
|
230
|
+
{ name: 'Schema', status: 'ok', detail: 'binary ok (no index yet)' },
|
|
231
|
+
{ name: 'Index', status: 'warn', detail: 'missing — not indexed yet', fixId: 'index-empty' },
|
|
232
|
+
{ name: 'Embeddings', status: 'skip', detail: 'no index' },
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
return [
|
|
236
|
+
{ name: 'Schema', status: 'error', detail: `health-check failed: ${msg}`, fixId: 'binary-broken' },
|
|
237
|
+
{ name: 'Index', status: 'skip', detail: 'health-check failed' },
|
|
238
|
+
{ name: 'Embeddings', status: 'skip', detail: 'health-check failed' },
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
58
243
|
/**
|
|
59
244
|
* Run all diagnostic checks. Returns an array of:
|
|
60
245
|
* { name: string, status: 'ok'|'warn'|'error'|'skip', detail: string, fixId?: string }
|
|
@@ -134,66 +319,9 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
134
319
|
results.push({ name: 'Source fresh', status: 'skip', detail: 'not dev mode' });
|
|
135
320
|
}
|
|
136
321
|
|
|
137
|
-
// 4. health-check (schema, index, embeddings) via binary --json
|
|
322
|
+
// 4. health-check (schema, index, embeddings, integrity) via binary --json
|
|
138
323
|
if (execOk) {
|
|
139
|
-
|
|
140
|
-
const cwd = process.cwd();
|
|
141
|
-
const hcOutput = execFileSync(binary, ['health-check', '--json'], hidden({
|
|
142
|
-
cwd,
|
|
143
|
-
timeout: 5000,
|
|
144
|
-
encoding: 'utf8',
|
|
145
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
146
|
-
})).trim();
|
|
147
|
-
const hc = JSON.parse(hcOutput);
|
|
148
|
-
|
|
149
|
-
// No-index short-circuit — binary deliberately returns a structured
|
|
150
|
-
// JSON with reason='no_index' instead of bailing, so we can route to
|
|
151
|
-
// the index-empty fix without grepping stderr. Falls through to the
|
|
152
|
-
// rest of runDiagnostics so Auto-update / Hooks still report.
|
|
153
|
-
if (hc.reason === 'no_index') {
|
|
154
|
-
results.push({ name: 'Schema', status: 'ok', detail: 'binary ok (no index yet)' });
|
|
155
|
-
results.push({ name: 'Index', status: 'warn', detail: 'missing — not indexed yet', fixId: 'index-empty' });
|
|
156
|
-
results.push({ name: 'Embeddings', status: 'skip', detail: 'no index' });
|
|
157
|
-
} else {
|
|
158
|
-
// Schema
|
|
159
|
-
if (hc.issue && hc.issue.includes('schema')) {
|
|
160
|
-
results.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
|
|
161
|
-
} else {
|
|
162
|
-
results.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Index
|
|
166
|
-
if (hc.nodes === 0) {
|
|
167
|
-
results.push({ name: 'Index', status: 'warn', detail: 'empty', fixId: 'index-empty' });
|
|
168
|
-
} else {
|
|
169
|
-
const age = hc.index_age ? ` (${hc.index_age})` : '';
|
|
170
|
-
results.push({
|
|
171
|
-
name: 'Index',
|
|
172
|
-
status: 'ok',
|
|
173
|
-
detail: `${hc.nodes} nodes, ${hc.edges} edges, ${hc.files} files${age}`,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Embeddings / vector availability — pure classifier; warns on FTS5-only
|
|
178
|
-
// degradation (model missing/not loaded) instead of false-greening it.
|
|
179
|
-
results.push(classifyEmbeddings(hc));
|
|
180
|
-
}
|
|
181
|
-
} catch (e) {
|
|
182
|
-
const rawStderr = e.stderr ? e.stderr.toString() : '';
|
|
183
|
-
const msg = rawStderr ? rawStderr.trim().slice(0, 100) : e.message.slice(0, 100);
|
|
184
|
-
// "No index found" is a missing-index situation, not a broken binary —
|
|
185
|
-
// the index-empty fix path knows how to create one. Without this branch
|
|
186
|
-
// the fixId routes to nothing and the report shows "0/1 addressed".
|
|
187
|
-
if (rawStderr.includes('No index found')) {
|
|
188
|
-
results.push({ name: 'Schema', status: 'ok', detail: 'binary ok (no index yet)' });
|
|
189
|
-
results.push({ name: 'Index', status: 'warn', detail: 'missing — not indexed yet', fixId: 'index-empty' });
|
|
190
|
-
results.push({ name: 'Embeddings', status: 'skip', detail: 'no index' });
|
|
191
|
-
} else {
|
|
192
|
-
results.push({ name: 'Schema', status: 'error', detail: `health-check failed: ${msg}`, fixId: 'binary-broken' });
|
|
193
|
-
results.push({ name: 'Index', status: 'skip', detail: 'health-check failed' });
|
|
194
|
-
results.push({ name: 'Embeddings', status: 'skip', detail: 'health-check failed' });
|
|
195
|
-
}
|
|
196
|
-
}
|
|
324
|
+
results.push(...healthRows(binary));
|
|
197
325
|
} else {
|
|
198
326
|
results.push({ name: 'Schema', status: 'skip', detail: 'binary not executable' });
|
|
199
327
|
results.push({ name: 'Index', status: 'skip', detail: 'binary not executable' });
|
|
@@ -495,7 +623,135 @@ function devBuildCommand(embed) {
|
|
|
495
623
|
: 'cargo build --release --no-default-features';
|
|
496
624
|
}
|
|
497
625
|
|
|
498
|
-
|
|
626
|
+
// ── Post-repair re-scan for the auto-update-driven arms ────────────────────
|
|
627
|
+
//
|
|
628
|
+
// `auto-update.js check` has NO non-zero exit path: dev mode, the opt-out,
|
|
629
|
+
// suspension, the rate-limit backoff and plain offline all print a line and exit
|
|
630
|
+
// 0. So "execFileSync did not throw" carries no information about whether
|
|
631
|
+
// anything was repaired, and the two arms below counted every one of those
|
|
632
|
+
// no-ops as a fix — including the one the suspension notice sends the user here
|
|
633
|
+
// to run, producing "✅ Update check complete" for a check that is suspended.
|
|
634
|
+
// Re-read the same predicate the DIAGNOSIS used and let that decide, exactly as
|
|
635
|
+
// the `hooks-invalid` arm does with its post-install re-scan.
|
|
636
|
+
|
|
637
|
+
function triggerAutoUpdateCheck() {
|
|
638
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
|
|
639
|
+
timeout: 60000,
|
|
640
|
+
stdio: 'inherit',
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Mirror of the `version-mismatch` diagnosis (runDiagnostics step 2): the binary
|
|
646
|
+
* on disk reports the version the plugin expects. find-binary memoizes, and the
|
|
647
|
+
* promote happened in a CHILD process, so the cache has to be dropped first or
|
|
648
|
+
* this re-reads the pre-repair answer.
|
|
649
|
+
*/
|
|
650
|
+
function binaryVersionResolved({
|
|
651
|
+
find = findBinary, readVersion = readBinaryVersion, pluginVersion = getPluginVersion,
|
|
652
|
+
} = {}) {
|
|
653
|
+
clearBinaryCache();
|
|
654
|
+
const binary = find();
|
|
655
|
+
if (!binary) return false;
|
|
656
|
+
const actual = readVersion(binary);
|
|
657
|
+
return Boolean(actual) && actual === pluginVersion();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Mirror of the `update-incomplete` diagnosis (runDiagnostics step 5). */
|
|
661
|
+
function updateIncompleteResolved({ readStateFile = readUpdateState } = {}) {
|
|
662
|
+
const state = readStateFile();
|
|
663
|
+
return !(state && state.updateAvailable && state.binaryUpdated === false);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Mirror of the `index-corrupt` diagnosis: re-run the probes against the
|
|
668
|
+
* database the rebuild just produced and re-classify with the SAME function the
|
|
669
|
+
* diagnosis used, so "resolved" cannot mean something different from "not
|
|
670
|
+
* raised". A rebuild that exits 0 over failing hardware still fails this.
|
|
671
|
+
*/
|
|
672
|
+
function integrityResolved({ probe = probeHealth, classify = classifyIntegrity } = {}) {
|
|
673
|
+
const hc = probe();
|
|
674
|
+
if (!hc) return false;
|
|
675
|
+
const row = classify(hc);
|
|
676
|
+
return row.status !== 'error' && row.status !== 'warn';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Run `rebuild-index --confirm`. Returns false when there is no binary to run
|
|
681
|
+
* it with; throws whatever the child failed with (including the deliberate bail
|
|
682
|
+
* when another process holds the index lock, added in v0.113.0).
|
|
683
|
+
*
|
|
684
|
+
* Separate + injectable because `execFileSync` is destructured at load here, so
|
|
685
|
+
* a test that patches `child_process.execFileSync` after the fact stubs nothing
|
|
686
|
+
* and silently exercises the real spawn.
|
|
687
|
+
*/
|
|
688
|
+
function rebuildIndexInPlace({ find = findBinary } = {}) {
|
|
689
|
+
const binary = find();
|
|
690
|
+
if (!binary) return false;
|
|
691
|
+
execFileSync(binary, ['rebuild-index', '--confirm'], hidden({
|
|
692
|
+
cwd: process.cwd(),
|
|
693
|
+
stdio: 'inherit',
|
|
694
|
+
timeout: 600000, // full reindex, not a diff — same budget as a build
|
|
695
|
+
}));
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Read a `health-check --json` payload, tolerating the exit-1-on-unhealthy
|
|
701
|
+
* contract (the report is on stdout either way). null when there is no payload.
|
|
702
|
+
*/
|
|
703
|
+
function probeHealth({ find = findBinary } = {}) {
|
|
704
|
+
const binary = find();
|
|
705
|
+
if (!binary) return null;
|
|
706
|
+
const opts = hidden({ cwd: process.cwd(), timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
707
|
+
try {
|
|
708
|
+
return parseHealthPayload(execFileSync(binary, ['health-check', '--json'], opts));
|
|
709
|
+
} catch (e) {
|
|
710
|
+
return parseHealthPayload(e.stdout);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function readUpdateState() {
|
|
715
|
+
try { return readJson(path.join(CACHE_DIR, 'update-state.json')); } catch { return null; }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Why a just-run `auto-update.js check` can have done nothing, in the updater's
|
|
720
|
+
* own terms. Without this the user is told to update manually with no idea that
|
|
721
|
+
* the updater is deliberately parked — which is the state the doctor prompt
|
|
722
|
+
* itself came from.
|
|
723
|
+
* @returns {string|null} one clause, or null when nothing is known to block it
|
|
724
|
+
*/
|
|
725
|
+
function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
|
|
726
|
+
if (env.CODE_GRAPH_NO_AUTO_UPDATE === '1') {
|
|
727
|
+
return 'auto-update is switched off by CODE_GRAPH_NO_AUTO_UPDATE=1';
|
|
728
|
+
}
|
|
729
|
+
if (!state) return null;
|
|
730
|
+
if (state.suspendedAt && (state.updateAttempts || 0) >= MAX_UPDATE_ATTEMPTS) {
|
|
731
|
+
return `auto-update is SUSPENDED after ${state.updateAttempts} failed attempts on v${state.latestVersion} `
|
|
732
|
+
+ '(it retries once a day, and immediately when a newer release is published)';
|
|
733
|
+
}
|
|
734
|
+
if (state.rateLimited) {
|
|
735
|
+
return 'the updater is in its GitHub rate-limit backoff (up to 1h)';
|
|
736
|
+
}
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function reportAutoUpdateNoOp(what) {
|
|
741
|
+
console.log(` ❌ ${what}`);
|
|
742
|
+
const why = autoUpdateNoOpReason();
|
|
743
|
+
if (why) console.log(` Why: ${why}.`);
|
|
744
|
+
console.log(' Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function runRepairs(results, {
|
|
748
|
+
devMode = isDevMode,
|
|
749
|
+
runAutoUpdate = triggerAutoUpdateCheck,
|
|
750
|
+
binaryResolved = binaryVersionResolved,
|
|
751
|
+
updateResolved = updateIncompleteResolved,
|
|
752
|
+
integrityOk = integrityResolved,
|
|
753
|
+
rebuildIndex = rebuildIndexInPlace,
|
|
754
|
+
} = {}) {
|
|
499
755
|
const fixable = results.filter(r => r.fixId);
|
|
500
756
|
if (fixable.length === 0) return 0;
|
|
501
757
|
|
|
@@ -504,17 +760,21 @@ function runRepairs(results) {
|
|
|
504
760
|
switch (issue.fixId) {
|
|
505
761
|
case 'binary-stale':
|
|
506
762
|
case 'version-mismatch': {
|
|
507
|
-
if (!
|
|
763
|
+
if (!devMode()) {
|
|
508
764
|
console.log('\n Triggering binary update...');
|
|
509
765
|
try {
|
|
510
|
-
|
|
511
|
-
timeout: 60000,
|
|
512
|
-
stdio: 'inherit',
|
|
513
|
-
}));
|
|
514
|
-
console.log(' \u2705 Update check complete');
|
|
515
|
-
fixed++;
|
|
766
|
+
runAutoUpdate();
|
|
516
767
|
} catch {
|
|
517
768
|
console.log(' \u274c Update check failed — install manually');
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
// Exited 0 — which says nothing about whether the binary moved (see
|
|
772
|
+
// the re-scan note above). Ask the disk, not the exit code.
|
|
773
|
+
if (binaryResolved()) {
|
|
774
|
+
console.log(' \u2705 Binary now matches the version the plugin expects');
|
|
775
|
+
fixed++;
|
|
776
|
+
} else {
|
|
777
|
+
reportAutoUpdateNoOp('Update check ran, but the binary version still does not match the plugin');
|
|
518
778
|
}
|
|
519
779
|
break;
|
|
520
780
|
}
|
|
@@ -547,7 +807,7 @@ function runRepairs(results) {
|
|
|
547
807
|
|
|
548
808
|
case 'binary-missing': {
|
|
549
809
|
console.log('\n Installing binary...');
|
|
550
|
-
if (
|
|
810
|
+
if (devMode()) {
|
|
551
811
|
// No binary to probe \u2014 build the fast FTS5 binary, but point at the
|
|
552
812
|
// hybrid option so FTS5 isn't silently presented as the only choice.
|
|
553
813
|
console.log(' \u2192 cargo build --release --no-default-features');
|
|
@@ -612,14 +872,18 @@ function runRepairs(results) {
|
|
|
612
872
|
case 'update-incomplete': {
|
|
613
873
|
console.log('\n Completing auto-update...');
|
|
614
874
|
try {
|
|
615
|
-
|
|
616
|
-
timeout: 60000,
|
|
617
|
-
stdio: 'inherit',
|
|
618
|
-
}));
|
|
619
|
-
console.log(' \u2705 Update check complete');
|
|
620
|
-
fixed++;
|
|
875
|
+
runAutoUpdate();
|
|
621
876
|
} catch {
|
|
622
877
|
console.log(' \u274c Update check failed');
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
// Same as the version-mismatch arm: exit 0 is not evidence. Re-read
|
|
881
|
+
// the state file the diagnosis read.
|
|
882
|
+
if (updateResolved()) {
|
|
883
|
+
console.log(' \u2705 Auto-update completed — the binary download is no longer pending');
|
|
884
|
+
fixed++;
|
|
885
|
+
} else {
|
|
886
|
+
reportAutoUpdateNoOp('Update check ran, but the binary download is still recorded as incomplete');
|
|
623
887
|
}
|
|
624
888
|
break;
|
|
625
889
|
}
|
|
@@ -692,6 +956,32 @@ function runRepairs(results) {
|
|
|
692
956
|
break;
|
|
693
957
|
}
|
|
694
958
|
|
|
959
|
+
case 'index-corrupt': {
|
|
960
|
+
// `incremental-index` (the index-empty arm) cannot help here: it opens
|
|
961
|
+
// the same damaged file and diffs against it. The index is a rebuildable
|
|
962
|
+
// cache, and this is the command health-check itself prints.
|
|
963
|
+
console.log('\n Rebuilding corrupt index from scratch...');
|
|
964
|
+
console.log(' → code-graph-mcp rebuild-index --confirm');
|
|
965
|
+
try {
|
|
966
|
+
if (!rebuildIndex()) break; // no binary to run it with
|
|
967
|
+
} catch {
|
|
968
|
+
// Includes the deliberate bail when another process holds the index
|
|
969
|
+
// lock (v0.113.0), which is a refusal, not a failure to repair.
|
|
970
|
+
console.log(' ❌ Index rebuild failed — see the error above');
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
// Exit 0 says the command ran, not that the corruption cleared. Ask the
|
|
974
|
+
// database again, the same way the hooks arm re-scans after install().
|
|
975
|
+
if (integrityOk()) {
|
|
976
|
+
console.log(' ✅ Index rebuilt — integrity checks now pass');
|
|
977
|
+
fixed++;
|
|
978
|
+
} else {
|
|
979
|
+
console.log(' ❌ Rebuild completed but the integrity check still fails');
|
|
980
|
+
console.log(' The problem may be the filesystem or disk, not the index.');
|
|
981
|
+
}
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
|
|
695
985
|
case 'schema-mismatch': {
|
|
696
986
|
console.log('\n Schema migration happens automatically when the binary runs.');
|
|
697
987
|
console.log(' If binary is older than DB, update the binary first.');
|
|
@@ -739,7 +1029,7 @@ function runDoctor(opts = {}) {
|
|
|
739
1029
|
return { results, issueCount: issues.length, unresolved };
|
|
740
1030
|
}
|
|
741
1031
|
|
|
742
|
-
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, detectEmbedModel, devBuildCommand };
|
|
1032
|
+
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, autoUpdateNoOpReason };
|
|
743
1033
|
|
|
744
1034
|
// Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
|
|
745
1035
|
// doctor …`. It exists as one function because the first version of this guard
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
# small (~3-5MB) zstd-compressed SQLite database that lets first-time
|
|
4
4
|
# clones of your repo skip the initial full code-graph index.
|
|
5
5
|
#
|
|
6
|
-
# Requires: code-graph
|
|
6
|
+
# Requires: @sdsrs/code-graph >= 0.23.0 published to npm.
|
|
7
|
+
#
|
|
8
|
+
# The package name is `@sdsrs/code-graph` (the `code-graph-mcp` binary lives
|
|
9
|
+
# inside it). Do NOT shorten this to `npx -y code-graph-mcp` — that unscoped
|
|
10
|
+
# name belongs to an unrelated publisher on npm, and `npx -y` would install and
|
|
11
|
+
# execute their package in your CI with whatever permissions this job holds.
|
|
12
|
+
# Bump the pinned version below when you want a newer snapshot format.
|
|
7
13
|
|
|
8
14
|
name: Code Graph Snapshot
|
|
9
15
|
on:
|
|
@@ -24,7 +30,7 @@ jobs:
|
|
|
24
30
|
node-version: '20'
|
|
25
31
|
- name: Build snapshot
|
|
26
32
|
run: |
|
|
27
|
-
npx -y code-graph-mcp
|
|
33
|
+
npx -y -p @sdsrs/code-graph@0.114.0 code-graph-mcp snapshot create --out snapshot.db
|
|
28
34
|
zstd -9 snapshot.db -o snapshot.db.zst
|
|
29
35
|
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
|
|
30
36
|
- name: Upload to release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.114.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.114.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.114.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.114.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.114.0"
|
|
43
43
|
}
|
|
44
44
|
}
|