@sdsrs/code-graph 0.112.1 → 0.113.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-mcp/templates/code-graph-snapshot.yml`
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
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.112.1",
7
+ "version": "0.113.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -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). Ordering:
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, force included never push more requests
137
- // into a GitHub 403. Safe to outrank force only because it is an hour; the
138
- // 24h it said before made one 403 a silent day-long no-op for `--force`.
139
- // 2. force only the short SESSION_START_MIN_GAP_MS floor applies, so opening
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
- // 3. otherwise → an "up to date" result is re-verified on a short cadence
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 (force) return elapsed >= SESSION_START_MIN_GAP_MS;
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
- '-sL', '-o', binaryTmp,
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) return false;
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 (!binaryMissing && !binaryStale && !shouldCheck(state, { force })) {
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,
@@ -138,6 +138,13 @@ function runDiagnostics({ checkOnly = false } = {}) {
138
138
  if (execOk) {
139
139
  try {
140
140
  const cwd = process.cwd();
141
+ // Deliberately NOT `--deep`. That flag forces the integrity pragmas the
142
+ // default path skips above INTEGRITY_PRAGMA_MAX_BYTES, and quick_check
143
+ // reads every page at ~2.4 ms/MB — a multi-GB index would blow the 5 s
144
+ // budget below and report a phantom "health-check failed" instead of the
145
+ // integrity answer it went looking for. Raising the timeout to fit trades
146
+ // that for a doctor run that appears hung. `--deep` stays a user-invoked
147
+ // escape hatch until this call can size its own budget from the index.
141
148
  const hcOutput = execFileSync(binary, ['health-check', '--json'], hidden({
142
149
  cwd,
143
150
  timeout: 5000,
@@ -495,7 +502,85 @@ function devBuildCommand(embed) {
495
502
  : 'cargo build --release --no-default-features';
496
503
  }
497
504
 
498
- function runRepairs(results) {
505
+ // ── Post-repair re-scan for the auto-update-driven arms ────────────────────
506
+ //
507
+ // `auto-update.js check` has NO non-zero exit path: dev mode, the opt-out,
508
+ // suspension, the rate-limit backoff and plain offline all print a line and exit
509
+ // 0. So "execFileSync did not throw" carries no information about whether
510
+ // anything was repaired, and the two arms below counted every one of those
511
+ // no-ops as a fix — including the one the suspension notice sends the user here
512
+ // to run, producing "✅ Update check complete" for a check that is suspended.
513
+ // Re-read the same predicate the DIAGNOSIS used and let that decide, exactly as
514
+ // the `hooks-invalid` arm does with its post-install re-scan.
515
+
516
+ function triggerAutoUpdateCheck() {
517
+ execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
518
+ timeout: 60000,
519
+ stdio: 'inherit',
520
+ }));
521
+ }
522
+
523
+ /**
524
+ * Mirror of the `version-mismatch` diagnosis (runDiagnostics step 2): the binary
525
+ * on disk reports the version the plugin expects. find-binary memoizes, and the
526
+ * promote happened in a CHILD process, so the cache has to be dropped first or
527
+ * this re-reads the pre-repair answer.
528
+ */
529
+ function binaryVersionResolved({
530
+ find = findBinary, readVersion = readBinaryVersion, pluginVersion = getPluginVersion,
531
+ } = {}) {
532
+ clearBinaryCache();
533
+ const binary = find();
534
+ if (!binary) return false;
535
+ const actual = readVersion(binary);
536
+ return Boolean(actual) && actual === pluginVersion();
537
+ }
538
+
539
+ /** Mirror of the `update-incomplete` diagnosis (runDiagnostics step 5). */
540
+ function updateIncompleteResolved({ readStateFile = readUpdateState } = {}) {
541
+ const state = readStateFile();
542
+ return !(state && state.updateAvailable && state.binaryUpdated === false);
543
+ }
544
+
545
+ function readUpdateState() {
546
+ try { return readJson(path.join(CACHE_DIR, 'update-state.json')); } catch { return null; }
547
+ }
548
+
549
+ /**
550
+ * Why a just-run `auto-update.js check` can have done nothing, in the updater's
551
+ * own terms. Without this the user is told to update manually with no idea that
552
+ * the updater is deliberately parked — which is the state the doctor prompt
553
+ * itself came from.
554
+ * @returns {string|null} one clause, or null when nothing is known to block it
555
+ */
556
+ function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
557
+ if (env.CODE_GRAPH_NO_AUTO_UPDATE === '1') {
558
+ return 'auto-update is switched off by CODE_GRAPH_NO_AUTO_UPDATE=1';
559
+ }
560
+ if (!state) return null;
561
+ if (state.suspendedAt && (state.updateAttempts || 0) >= MAX_UPDATE_ATTEMPTS) {
562
+ return `auto-update is SUSPENDED after ${state.updateAttempts} failed attempts on v${state.latestVersion} `
563
+ + '(it retries once a day, and immediately when a newer release is published)';
564
+ }
565
+ if (state.rateLimited) {
566
+ return 'the updater is in its GitHub rate-limit backoff (up to 1h)';
567
+ }
568
+ return null;
569
+ }
570
+
571
+ function reportAutoUpdateNoOp(what) {
572
+ console.log(` ❌ ${what}`);
573
+ const why = autoUpdateNoOpReason();
574
+ if (why) console.log(` Why: ${why}.`);
575
+ console.log(' Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)');
576
+ }
577
+
578
+ function runRepairs(results, {
579
+ devMode = isDevMode,
580
+ runAutoUpdate = triggerAutoUpdateCheck,
581
+ binaryResolved = binaryVersionResolved,
582
+ updateResolved = updateIncompleteResolved,
583
+ } = {}) {
499
584
  const fixable = results.filter(r => r.fixId);
500
585
  if (fixable.length === 0) return 0;
501
586
 
@@ -504,17 +589,21 @@ function runRepairs(results) {
504
589
  switch (issue.fixId) {
505
590
  case 'binary-stale':
506
591
  case 'version-mismatch': {
507
- if (!isDevMode()) {
592
+ if (!devMode()) {
508
593
  console.log('\n Triggering binary update...');
509
594
  try {
510
- execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
511
- timeout: 60000,
512
- stdio: 'inherit',
513
- }));
514
- console.log(' \u2705 Update check complete');
515
- fixed++;
595
+ runAutoUpdate();
516
596
  } catch {
517
597
  console.log(' \u274c Update check failed — install manually');
598
+ break;
599
+ }
600
+ // Exited 0 — which says nothing about whether the binary moved (see
601
+ // the re-scan note above). Ask the disk, not the exit code.
602
+ if (binaryResolved()) {
603
+ console.log(' \u2705 Binary now matches the version the plugin expects');
604
+ fixed++;
605
+ } else {
606
+ reportAutoUpdateNoOp('Update check ran, but the binary version still does not match the plugin');
518
607
  }
519
608
  break;
520
609
  }
@@ -547,7 +636,7 @@ function runRepairs(results) {
547
636
 
548
637
  case 'binary-missing': {
549
638
  console.log('\n Installing binary...');
550
- if (isDevMode()) {
639
+ if (devMode()) {
551
640
  // No binary to probe \u2014 build the fast FTS5 binary, but point at the
552
641
  // hybrid option so FTS5 isn't silently presented as the only choice.
553
642
  console.log(' \u2192 cargo build --release --no-default-features');
@@ -612,14 +701,18 @@ function runRepairs(results) {
612
701
  case 'update-incomplete': {
613
702
  console.log('\n Completing auto-update...');
614
703
  try {
615
- execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], hidden({
616
- timeout: 60000,
617
- stdio: 'inherit',
618
- }));
619
- console.log(' \u2705 Update check complete');
620
- fixed++;
704
+ runAutoUpdate();
621
705
  } catch {
622
706
  console.log(' \u274c Update check failed');
707
+ break;
708
+ }
709
+ // Same as the version-mismatch arm: exit 0 is not evidence. Re-read
710
+ // the state file the diagnosis read.
711
+ if (updateResolved()) {
712
+ console.log(' \u2705 Auto-update completed — the binary download is no longer pending');
713
+ fixed++;
714
+ } else {
715
+ reportAutoUpdateNoOp('Update check ran, but the binary download is still recorded as incomplete');
623
716
  }
624
717
  break;
625
718
  }
@@ -739,7 +832,7 @@ function runDoctor(opts = {}) {
739
832
  return { results, issueCount: issues.length, unresolved };
740
833
  }
741
834
 
742
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, detectEmbedModel, devBuildCommand };
835
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, autoUpdateNoOpReason };
743
836
 
744
837
  // Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
745
838
  // 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-mcp >= 0.23.0 published to npm.
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@latest snapshot create --out snapshot.db
33
+ npx -y -p @sdsrs/code-graph@0.113.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.112.1",
3
+ "version": "0.113.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.112.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.112.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.112.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.112.1",
42
- "@sdsrs/code-graph-win32-x64": "0.112.1"
38
+ "@sdsrs/code-graph-linux-x64": "0.113.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.113.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.113.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.113.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.113.0"
43
43
  }
44
44
  }