@sdsrs/code-graph 0.116.0 → 0.117.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.
@@ -215,6 +215,10 @@ function runHealthCheckCli(binary) {
215
215
  return execFileSync(binary, ['health-check', '--json'], hidden({
216
216
  cwd: process.cwd(),
217
217
  timeout: 5000,
218
+ // Same shape statusline.js hardened (audit P1-17): a wedged binary that
219
+ // ignores SIGTERM makes Node's timeout unreachable, so doctor — the tool
220
+ // you run BECAUSE something is wrong — would hang instead of diagnosing.
221
+ killSignal: 'SIGKILL',
218
222
  encoding: 'utf8',
219
223
  stdio: ['pipe', 'pipe', 'pipe'],
220
224
  })).trim();
@@ -676,6 +680,52 @@ function binaryVersionResolved({
676
680
  return Boolean(actual) && actual === pluginVersion();
677
681
  }
678
682
 
683
+ /**
684
+ * Mirror of BOTH `binary-broken` diagnoses: the binary is on disk but does not
685
+ * run (runDiagnostics step 2, `--version` unreadable) or its health-check failed
686
+ * with no recoverable payload (healthRows' last arm). Re-asks the same two
687
+ * questions the diagnosis asked, so "resolved" cannot mean something weaker than
688
+ * "not raised". Cache dropped first: the promote happens in a CHILD process and
689
+ * find-binary memoizes.
690
+ */
691
+ function binaryBrokenResolved({
692
+ find = findBinary, readVersion = readBinaryVersion, rows = healthRows,
693
+ } = {}) {
694
+ clearBinaryCache();
695
+ const binary = find();
696
+ if (!binary) return false;
697
+ if (!readVersion(binary)) return false;
698
+ try {
699
+ return !rows(binary).some((r) => r.fixId === 'binary-broken');
700
+ } catch { return false; }
701
+ }
702
+
703
+ /**
704
+ * Build the binary in the source checkout. Injectable for the same reason
705
+ * rebuildIndexInPlace is: `execSync` is destructured at load, so a test that
706
+ * patches child_process afterwards would silently run a real 10-minute cargo
707
+ * build. Returns true on exit 0; throws what the build threw.
708
+ */
709
+ function buildBinaryFromSource(cmd) {
710
+ execSync(cmd, hidden({
711
+ cwd: path.resolve(__dirname, '..', '..'),
712
+ stdio: 'inherit',
713
+ timeout: 600000, // embed-model (Candle) builds exceed the old 5min
714
+ }));
715
+ clearBinaryCache();
716
+ return true;
717
+ }
718
+
719
+ /** Manual recovery for a binary we could not repair — the end of every failed arm. */
720
+ function printBinaryRecovery() {
721
+ console.log(' Reinstall: npm install -g @sdsrs/code-graph');
722
+ console.log(' Or download the release asset for your platform:');
723
+ console.log(' https://github.com/sdsrss/code-graph-mcp/releases');
724
+ if (os.platform() === 'darwin') {
725
+ console.log(' macOS may also have quarantined it: xattr -d com.apple.quarantine <binary>');
726
+ }
727
+ }
728
+
679
729
  /** Mirror of the `update-incomplete` diagnosis (runDiagnostics step 5). */
680
730
  function updateIncompleteResolved({ readStateFile = readUpdateState } = {}) {
681
731
  const state = readStateFile();
@@ -770,6 +820,8 @@ function runRepairs(results, {
770
820
  updateResolved = updateIncompleteResolved,
771
821
  integrityOk = integrityResolved,
772
822
  rebuildIndex = rebuildIndexInPlace,
823
+ binaryUsable = binaryBrokenResolved,
824
+ buildBinary = buildBinaryFromSource,
773
825
  } = {}) {
774
826
  const fixable = results.filter(r => r.fixId);
775
827
  if (fixable.length === 0) return 0;
@@ -851,6 +903,63 @@ function runRepairs(results, {
851
903
  break;
852
904
  }
853
905
 
906
+ case 'binary-broken': {
907
+ // The binary EXISTS but cannot run: a truncated or corrupted download, a
908
+ // wrong-arch asset, a missing libc, macOS quarantine, or a real crash.
909
+ // This fixId had no arm at all, so doctor printed "1 issue(s) found.
910
+ // Fixing..." and then "0/1 addressed" with nothing between the two
911
+ // (audit 2026-08-16 P1-13).
912
+ if (devMode()) {
913
+ // A source checkout is never repaired by downloading a release asset.
914
+ // Preserve the feature set for the same reason the binary-stale arm
915
+ // does — never silently downgrade a hybrid dev binary to FTS5-only.
916
+ const embed = detectEmbedModel(findBinary());
917
+ const buildCmd = devBuildCommand(embed === true);
918
+ console.log('\n Binary is present but does not run — rebuilding from source...');
919
+ if (embed === null) {
920
+ console.log(' (could not probe the current feature set — building FTS5-only;');
921
+ console.log(' for semantic search rebuild with `cargo build --release --features embed-model`)');
922
+ }
923
+ console.log(` → ${buildCmd}`);
924
+ try {
925
+ if (!buildBinary(buildCmd)) {
926
+ console.log(' ❌ Build failed');
927
+ break;
928
+ }
929
+ } catch {
930
+ console.log(' ❌ Build failed');
931
+ break;
932
+ }
933
+ } else {
934
+ console.log('\n Binary is present but does not run — re-downloading it...');
935
+ try {
936
+ // The updater's stale-binary self-heal treats an unreadable
937
+ // `--version` as "replace it", so this reaches the verified
938
+ // download+promote path even without a newer release.
939
+ runAutoUpdate();
940
+ } catch {
941
+ console.log(' ❌ Update check failed');
942
+ printBinaryRecovery();
943
+ break;
944
+ }
945
+ }
946
+ // Exit 0 proves the command ran, not that the binary works. Ask it.
947
+ if (binaryUsable()) {
948
+ console.log(' ✅ Binary runs again');
949
+ fixed++;
950
+ } else {
951
+ console.log(' ❌ The binary still cannot run');
952
+ if (!devMode()) {
953
+ const why = autoUpdateNoOpReason();
954
+ if (why) console.log(` Why the re-download may have done nothing: ${why}.`);
955
+ printBinaryRecovery();
956
+ } else {
957
+ console.log(' The build completed but the produced binary still fails --version/health-check.');
958
+ }
959
+ }
960
+ break;
961
+ }
962
+
854
963
  case 'binary-not-exec': {
855
964
  const binary = findBinary();
856
965
  if (binary) {
@@ -1048,7 +1157,7 @@ function runDoctor(opts = {}) {
1048
1157
  return { results, issueCount: issues.length, unresolved };
1049
1158
  }
1050
1159
 
1051
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, autoUpdateNoOpReason };
1160
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason };
1052
1161
 
1053
1162
  // Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
1054
1163
  // doctor …`. It exists as one function because the first version of this guard
@@ -5,22 +5,50 @@
5
5
  // post-grep-inject) cannot drift apart (feedback_hook_class_bug_sweep — no
6
6
  // inline copies of shared logic). DRY mirror of the project-root.js precedent.
7
7
  //
8
- // Why these two shapes:
8
+ // Why these three shapes:
9
9
  // - PreToolUse plain stdout on exit 0 goes to the DEBUG LOG ONLY — it never
10
10
  // reaches the model (CC docs, code.claude.com/docs/en/hooks.md, v2026-06).
11
- // `additionalContext` DOES reach the model, but only alongside a
12
- // permissionDecision. For the safe-tool pre hooks (Read fanout hint, Edit
13
- // impact summary) the elevation to `allow` is negligible and is what makes
14
- // the carried context visible.
11
+ // `additionalContext` is what surfaces the carried text.
12
+ // - `permissionDecision: 'allow'` is NOT a delivery detail. The CC hooks
13
+ // reference defines it as: "skip the interactive permission prompt" (deny
14
+ // rules and connector/`requiresUserInteraction` prompts still apply). On a
15
+ // machine that prompts for a tool, a hook sending `allow` has answered the
16
+ // user's prompt for them. That is defensible for READ-ONLY Read; it is not
17
+ // for Edit, which writes to disk — a delivery hook must never buy context
18
+ // visibility with the user's write consent (audit 2026-08-16 P0-2).
19
+ // Therefore: `emitPreToolAllowContext` is for Read ONLY, and every
20
+ // write-capable tool uses `emitPreToolContext` (no decision at all, which
21
+ // the docs' own PreToolUse example marks as "no decision; normal permission
22
+ // flow applies"). If a future CC drops additionalContext without a decision,
23
+ // the correct outcome is that the Edit impact summary goes quiet — NOT that
24
+ // it re-acquires the elevation.
15
25
  // - PostToolUse honors `additionalContext` permission-neutrally (no
16
26
  // permissionDecision), so the Bash-side grep answer can be injected without
17
27
  // skipping CC's default permission prompt for the underlying tool call.
18
28
 
29
+ /**
30
+ * PreToolUse additionalContext envelope with NO permissionDecision (string, no
31
+ * trailing newline). The permission-neutral shape: the tool's normal permission
32
+ * flow is untouched. Use this for every write-capable tool (Edit/Write/…).
33
+ * @param {string} text
34
+ * @returns {string} JSON line
35
+ */
36
+ function emitPreToolContext(text) {
37
+ return JSON.stringify({
38
+ hookSpecificOutput: {
39
+ hookEventName: 'PreToolUse',
40
+ additionalContext: text,
41
+ },
42
+ });
43
+ }
44
+
19
45
  /**
20
46
  * PreToolUse allow + additionalContext envelope (string, no trailing newline).
21
- * Used by the safe-tool delivery hooks (pre-read-guide / pre-edit-guide) so
22
- * their carried hint/impact text reaches the model instead of dying in the
23
- * debug log.
47
+ *
48
+ * READ-ONLY TOOLS ONLY (pre-read-guide). `allow` skips the user's interactive
49
+ * permission prompt; for Read that grants nothing the model could not already
50
+ * get, and it is what keeps the fanout hint visible. Do not reuse it for a tool
51
+ * that mutates state — see the note above.
24
52
  * @param {string} text
25
53
  * @returns {string} JSON line
26
54
  */
@@ -50,4 +78,4 @@ function emitPostToolContext(text) {
50
78
  });
51
79
  }
52
80
 
53
- module.exports = { emitPreToolAllowContext, emitPostToolContext };
81
+ module.exports = { emitPreToolContext, emitPreToolAllowContext, emitPostToolContext };
@@ -57,7 +57,15 @@ function pluginsCacheDir() { return path.join(claudeHome(), 'plugins', 'cache');
57
57
  // unparseable case and left the unreadable one behind — a `chmod 000`
58
58
  // settings.json was still destroyed, silently, with no backup. `err.code` is the
59
59
  // whole gate; do not widen it back to a bare `catch`.
60
- function readJsonResult(filePath) {
60
+ // `accept` decides what counts as a USABLE parsed value. It defaults to the
61
+ // settings shape (a plain object) but the statusline registry is a top-level
62
+ // ARRAY, which the default predicate calls corrupt — so the registry gets
63
+ // `accept: Array.isArray` rather than a second, drifting copy of this function.
64
+ function isSettingsObject(value) {
65
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
66
+ }
67
+
68
+ function readJsonResult(filePath, { accept = isSettingsObject } = {}) {
61
69
  // Read BYTES, decode separately. `readFileSync(p, 'utf8')` replaces every
62
70
  // invalid byte with U+FFFD, and `raw` is what backupCorruptFile writes to the
63
71
  // `.corrupt-*` copy before the original is overwritten — so a settings.json
@@ -88,7 +96,7 @@ function readJsonResult(filePath) {
88
96
  const value = JSON.parse(raw.trim());
89
97
  // `null` / `"str"` / `[]` parse fine but are not a settings object; treating
90
98
  // them as "absent" would rebuild over them just the same.
91
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
99
+ if (!accept(value)) {
92
100
  return { value: null, missing: false, corrupt: true, raw: bytes };
93
101
  }
94
102
  // VALID JSON can still have been decoded lossily. `toString('utf8')`
@@ -247,9 +255,25 @@ function readManifest() {
247
255
  return readJson(MANIFEST_FILE) || { version: null, config: {} };
248
256
  }
249
257
 
258
+ // Same tolerant shape as tryWriteSettings, and for the same reason: this is the
259
+ // one write in install()/update() that could still throw. `~/.cache` on a
260
+ // read-only mount, a root-owned cache dir left by a `sudo` run, or a full disk
261
+ // turned a SessionStart into a raw ENOSPC/EACCES stack trace out of a hook whose
262
+ // settings work had already SUCCEEDED (audit 2026-08-16 P1-16). Report it,
263
+ // change nothing else, and let the caller decide.
264
+ // @returns {Error|null} the write error, or null on success
250
265
  function writeManifest(manifest) {
251
- fs.mkdirSync(CACHE_DIR, { recursive: true });
252
- writeJsonAtomic(MANIFEST_FILE, manifest);
266
+ try {
267
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
268
+ writeJsonAtomic(MANIFEST_FILE, manifest);
269
+ return null;
270
+ } catch (err) {
271
+ console.error(
272
+ `[code-graph] cannot write ${MANIFEST_FILE} (${err.code || err.name}: ${err.message}). ` +
273
+ 'Settings changes (if any) still applied; the next run will redo the version stamp.'
274
+ );
275
+ return err;
276
+ }
253
277
  }
254
278
 
255
279
  function getPluginVersion() {
@@ -325,17 +349,63 @@ function isOurComposite(settings) {
325
349
  // --- StatusLine Registry ---
326
350
  // Multiple providers can register. The composite script runs them all.
327
351
 
328
- function readRegistry() {
329
- const primary = readJson(REGISTRY_FILE);
330
- if (primary && Array.isArray(primary) && primary.length > 0) return primary;
352
+ // Read the registry for a caller that may WRITE it back.
353
+ //
354
+ // The registry is USER DATA: `_previous` is the statusline they had before we
355
+ // installed (the only record of it), and third-party providers registered
356
+ // through us live beside it. The lenient reader collapsed "exists but
357
+ // unreadable/corrupt" into the same `[]` as "absent", and the very next
358
+ // writeRegistry() then persisted that empty list over the primary AND the
359
+ // durable backup — one `chmod 000` (a stray sudo, a restrictive umask) and the
360
+ // user's original statusline was unrecoverable, silently, from a call that
361
+ // reported success (audit 2026-08-16 P1-12). Exactly the settings.json bug
362
+ // readJsonResult was written for, on the file two functions below it.
363
+ //
364
+ // Returns `{ registry, refuse }`:
365
+ // registry — entries to work with (possibly empty)
366
+ // refuse — a copy EXISTS and could not be read: write nothing, change nothing
367
+ function readRegistryForWrite() {
368
+ const asArray = { accept: Array.isArray };
369
+ const primary = readJsonResult(REGISTRY_FILE, asArray);
370
+ if (primary.value && primary.value.length > 0) return { registry: primary.value, refuse: false };
371
+ if (primary.corrupt) {
372
+ // Fall through to the backup for READING (so callers still see the user's
373
+ // providers) but never write while the primary is unusable: an atomic
374
+ // rename replaces an unreadable file just fine, which is precisely how the
375
+ // data was lost.
376
+ const backup = readJsonResult(providersBackupFile(), asArray);
377
+ return {
378
+ registry: backup.value && backup.value.length > 0 ? backup.value : [],
379
+ refuse: true,
380
+ why: `${REGISTRY_FILE} exists but cannot be read as a provider list`,
381
+ };
382
+ }
331
383
  // Self-heal: primary missing or empty (e.g. user cleaned ~/.cache/code-graph/).
332
384
  // Durable backup in ~/.claude/ retains `_previous` + third-party providers.
333
- const backup = readJson(providersBackupFile());
334
- if (backup && Array.isArray(backup) && backup.length > 0) {
335
- try { writeJsonAtomic(REGISTRY_FILE, backup); } catch { /* ok */ }
336
- return backup;
385
+ const backup = readJsonResult(providersBackupFile(), asArray);
386
+ if (backup.value && backup.value.length > 0) {
387
+ try { writeJsonAtomic(REGISTRY_FILE, backup.value); } catch { /* ok */ }
388
+ return { registry: backup.value, refuse: false };
337
389
  }
338
- return [];
390
+ if (backup.corrupt) {
391
+ return { registry: [], refuse: true, why: `${providersBackupFile()} exists but cannot be read as a provider list` };
392
+ }
393
+ return { registry: [], refuse: false };
394
+ }
395
+
396
+ function readRegistry() {
397
+ return readRegistryForWrite().registry;
398
+ }
399
+
400
+ // One place to say why a registry mutation did nothing. Stderr, not silence:
401
+ // the caller returns `false`, which is indistinguishable from "already
402
+ // registered" to everything upstream.
403
+ function warnRegistryUnusable(action, why) {
404
+ console.error(
405
+ `[code-graph] ${why}. Skipping the statusline ${action} — rewriting it would ` +
406
+ 'destroy your previous statusline and any third-party provider entries. ' +
407
+ 'Repair or move the file aside and re-run.'
408
+ );
339
409
  }
340
410
 
341
411
  function writeRegistry(registry) {
@@ -351,7 +421,11 @@ function writeRegistry(registry) {
351
421
  }
352
422
 
353
423
  function registerStatuslineProvider(id, command, needsStdin) {
354
- const registry = readRegistry();
424
+ const { registry, refuse, why } = readRegistryForWrite();
425
+ if (refuse) {
426
+ warnRegistryUnusable('registration', why);
427
+ return false;
428
+ }
355
429
  const idx = registry.findIndex(p => p.id === id);
356
430
  const entry = { id, command, needsStdin: !!needsStdin };
357
431
  if (idx >= 0) {
@@ -366,7 +440,11 @@ function registerStatuslineProvider(id, command, needsStdin) {
366
440
  }
367
441
 
368
442
  function unregisterStatuslineProvider(id) {
369
- const registry = readRegistry();
443
+ const { registry, refuse, why } = readRegistryForWrite();
444
+ if (refuse) {
445
+ warnRegistryUnusable('removal', why);
446
+ return false;
447
+ }
370
448
  const filtered = registry.filter(p => p.id !== id);
371
449
  if (filtered.length === registry.length) return false;
372
450
  writeRegistry(filtered);
@@ -389,11 +467,37 @@ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
389
467
  return !hasInstalledPluginRecord();
390
468
  }
391
469
 
392
- function detachStatuslineIntegration(settings, { compositeDoomed = true } = {}) {
470
+ function detachStatuslineIntegration(settings, { compositeDoomed = true, oneShot = false } = {}) {
393
471
  let settingsChanged = false;
394
472
 
395
- unregisterStatuslineProvider('code-graph');
396
- const registry = readRegistry();
473
+ // An unusable (not merely absent) registry means we may not WRITE it, and it
474
+ // may leave us unable to tell whether a `_previous` or third-party entry
475
+ // exists — which every branch below that rewrites `settings.statusLine`
476
+ // depends on (batch review of audit 2026-08-16 P1-12: the register path
477
+ // refused correctly while this detach path still destroyed the slot).
478
+ //
479
+ // Whether refusing is safe depends on the CALLER, so it is a parameter:
480
+ // retryable (statusline render) — leave everything alone; the next frame
481
+ // retries once the file is usable. Touching the slot on a bad read is
482
+ // how the user's statusline got destroyed in the first place.
483
+ // oneShot (uninstall) — the composite script dies with the plugin cache in
484
+ // this same run, so leaving the slot pointing at it is PERMANENT
485
+ // breakage with no plugin code left to repair it (pre-tag review). We
486
+ // still must not write the registry, but the entries we already READ are
487
+ // enough to choose the slot: `readRegistryForWrite` reads through to the
488
+ // durable backup even while refusing, so a corrupt primary alone does
489
+ // not lose `_previous`. When even that is unreadable the list is empty
490
+ // and we clear the slot — Claude Code's default beats a dead path.
491
+ const { registry, refuse, why } = readRegistryForWrite();
492
+ if (refuse && !oneShot) {
493
+ warnRegistryUnusable('detach', why);
494
+ return false;
495
+ }
496
+ if (refuse) {
497
+ warnRegistryUnusable('registry rewrite (the settings slot is still neutralized — uninstall cannot retry)', why);
498
+ } else {
499
+ unregisterStatuslineProvider('code-graph');
500
+ }
397
501
  const previous = registry.find(p => p.id === '_previous' && p.command);
398
502
  // Third-party providers registered through our registry (e.g. gsd). They
399
503
  // must not be silently orphaned: with the composite gone from settings
@@ -422,8 +526,9 @@ function detachStatuslineIntegration(settings, { compositeDoomed = true } = {})
422
526
  }
423
527
 
424
528
  // _previous only becomes removable once no third party still relies on the
425
- // registry file (writeRegistry unlinks primary+backup when emptied).
426
- if (thirdParty.length === 0) unregisterStatuslineProvider('_previous');
529
+ // registry file (writeRegistry unlinks primary+backup when emptied). Skipped
530
+ // entirely while refusing: that path may not write the registry at all.
531
+ if (!refuse && thirdParty.length === 0) unregisterStatuslineProvider('_previous');
427
532
  return settingsChanged;
428
533
  }
429
534
 
@@ -1061,13 +1166,17 @@ function install({ reclaimStatusline = false } = {}) {
1061
1166
  manifest.version = version;
1062
1167
  manifest.installedAt = manifest.installedAt || new Date().toISOString();
1063
1168
  manifest.updatedAt = new Date().toISOString();
1064
- writeManifest(manifest);
1169
+ const manifestErr = writeManifest(manifest);
1065
1170
 
1066
1171
  return {
1067
1172
  version,
1068
1173
  settingsChanged,
1069
1174
  statusLineClaimed: manifest.config.statusLine,
1070
1175
  hooksRegistered,
1176
+ // Unstamped manifest: the install DID land in settings.json, but the next
1177
+ // run will not know it and will redo the work (idempotent). Surfaced so
1178
+ // doctor/session-init can say so instead of implying a clean install.
1179
+ manifestUnwritable: manifestErr ? (manifestErr.code || manifestErr.name) : undefined,
1071
1180
  // Non-null => the previous settings.json was REPLACED and lives here now.
1072
1181
  settingsRebuiltFrom: backedUpTo,
1073
1182
  };
@@ -1109,7 +1218,10 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1109
1218
 
1110
1219
  if (settings) {
1111
1220
  // 1. StatusLine: remove code-graph integration and restore prior statusline.
1112
- if (detachStatuslineIntegration(settings)) {
1221
+ // `oneShot`: steps 6-7 below delete the plugin cache, taking
1222
+ // statusline-composite.js with it, so this is the last chance to move the
1223
+ // slot off a script that is about to stop existing (pre-tag review).
1224
+ if (detachStatuslineIntegration(settings, { oneShot: true })) {
1113
1225
  settingsChanged = true;
1114
1226
  }
1115
1227
 
@@ -1136,7 +1248,16 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1136
1248
  }
1137
1249
 
1138
1250
  // 5. Remove all known IDs from installed_plugins.json
1139
- const installedPlugins = readJson(installedPluginsPath());
1251
+ //
1252
+ // Read-modify-write of Claude Code's OWN file. The write is already gated on a
1253
+ // successful parse, so an unusable file is skipped rather than clobbered (the
1254
+ // destructive `|| {}` shape never existed here) — but the skip was SILENT, and
1255
+ // steps 6-7 below still delete the plugin cache. The user then keeps a plugin
1256
+ // record pointing at a directory we removed, with `uninstall` reporting
1257
+ // success. Say so instead (audit 2026-08-16 P1-12 sweep).
1258
+ const installedRead = readJsonResult(installedPluginsPath());
1259
+ const installedPlugins = installedRead.value;
1260
+ let installedPluginsUnusable = false;
1140
1261
  if (installedPlugins && installedPlugins.plugins) {
1141
1262
  let ipChanged = false;
1142
1263
  for (const id of [PLUGIN_ID, ...OLD_PLUGIN_IDS]) {
@@ -1145,7 +1266,24 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1145
1266
  ipChanged = true;
1146
1267
  }
1147
1268
  }
1148
- if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
1269
+ if (ipChanged) {
1270
+ try {
1271
+ writeJsonAtomic(installedPluginsPath(), installedPlugins);
1272
+ } catch (err) {
1273
+ installedPluginsUnusable = true;
1274
+ console.error(
1275
+ `[code-graph] cannot write ${installedPluginsPath()} (${err.code || err.name}). ` +
1276
+ 'Claude Code still lists this plugin — remove it with `/plugin uninstall code-graph-mcp`.'
1277
+ );
1278
+ }
1279
+ }
1280
+ } else if (!installedRead.missing) {
1281
+ installedPluginsUnusable = true;
1282
+ console.error(
1283
+ `[code-graph] cannot read ${installedPluginsPath()} ` +
1284
+ `(${installedRead.error ? installedRead.error.code || installedRead.error.message : 'not a JSON object'}). ` +
1285
+ 'Left untouched — Claude Code may still list this plugin; remove it with `/plugin uninstall code-graph-mcp`.'
1286
+ );
1149
1287
  }
1150
1288
 
1151
1289
  // 5.5. Global npm packages + adoption inventory — read BEFORE step 6 wipes
@@ -1208,7 +1346,7 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1208
1346
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
1209
1347
  }
1210
1348
 
1211
- return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted };
1349
+ return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted, installedPluginsUnusable };
1212
1350
  }
1213
1351
 
1214
1352
  // --- Update (refresh config points) ---
@@ -1270,7 +1408,7 @@ function update() {
1270
1408
  // 6. Update manifest
1271
1409
  manifest.version = version;
1272
1410
  manifest.updatedAt = new Date().toISOString();
1273
- writeManifest(manifest);
1411
+ const manifestErr = writeManifest(manifest);
1274
1412
 
1275
1413
  // 7. Clean up old cached versions (keep the newest few). NOTE: older cache
1276
1414
  // dirs are NOT always inert — a running MCP server's launcher path
@@ -1280,7 +1418,10 @@ function update() {
1280
1418
  // therefore skips any version still referenced by a live process cmdline.
1281
1419
  cleanupOldCacheVersions(5);
1282
1420
 
1283
- return { oldVersion, version, settingsChanged, hooksRegistered, settingsRebuiltFrom: backedUpTo };
1421
+ return {
1422
+ oldVersion, version, settingsChanged, hooksRegistered, settingsRebuiltFrom: backedUpTo,
1423
+ manifestUnwritable: manifestErr ? (manifestErr.code || manifestErr.name) : undefined,
1424
+ };
1284
1425
  }
1285
1426
 
1286
1427
  /**
@@ -1528,7 +1669,7 @@ module.exports = {
1528
1669
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1529
1670
  cleanupDisabledStatusline,
1530
1671
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1531
- readRegistry, writeRegistry,
1672
+ readRegistry, readRegistryForWrite, writeRegistry,
1532
1673
  getPluginVersion, cleanupOldCacheVersions,
1533
1674
  removeHooksFromSettings, isOurHookEntry,
1534
1675
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
@@ -1540,7 +1681,7 @@ module.exports = {
1540
1681
  activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
1541
1682
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
1542
1683
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
1543
- registerStatuslineProvider, unregisterStatuslineProvider,
1684
+ registerStatuslineProvider, unregisterStatuslineProvider, detachStatuslineIntegration,
1544
1685
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
1545
1686
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
1546
1687
  settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
@@ -124,11 +124,20 @@ function computeReview(binary, changedFiles, cwd) {
124
124
  // Per-file test-gap: a changed PRODUCTION (non-test) file is "uncovered" when
125
125
  // running `affected` on it alone surfaces zero test files. Run per-file so the
126
126
  // signal is attributable (the aggregate union can't be split back per file).
127
+ //
128
+ // `runAffected` returns null for BOTH "spawn failed / timed out / non-zero
129
+ // exit" and "unparseable output" — none of which say anything about test
130
+ // coverage. Those files go to `unanalyzed` and are disclosed. Folding them
131
+ // into the same else-branch as "has tests" made a 60s timeout render as a
132
+ // covered file: the most dangerous direction for a test-gap report to fail in.
127
133
  const uncovered = [];
134
+ const unanalyzed = [];
128
135
  for (const f of changed) {
129
136
  if (isTestPath(f)) continue;
130
137
  const single = runAffected(binary, ['affected', f, '--json'], cwd, '');
131
- if (single && (single.tests || []).length === 0) {
138
+ if (!single) {
139
+ unanalyzed.push(f);
140
+ } else if ((single.tests || []).length === 0) {
132
141
  uncovered.push(f);
133
142
  }
134
143
  }
@@ -145,6 +154,7 @@ function computeReview(binary, changedFiles, cwd) {
145
154
  blast_radius: affectedFiles.length,
146
155
  top_affected: topAffected,
147
156
  uncovered: uncovered.sort(),
157
+ unanalyzed: unanalyzed.sort(),
148
158
  };
149
159
  }
150
160
 
@@ -177,6 +187,16 @@ function renderMarkdown(review) {
177
187
  lines.push('');
178
188
  }
179
189
 
190
+ // Absence of a result is not a result. These files are listed apart from the
191
+ // test gaps because the analysis never produced an answer for them.
192
+ const unanalyzed = review.unanalyzed || [];
193
+ if (unanalyzed.length > 0) {
194
+ lines.push(`### ❔ Not analyzed (${unanalyzed.length})`);
195
+ lines.push('The `affected` run for these files failed or timed out, so their test coverage is unknown:');
196
+ for (const p of unanalyzed) lines.push(`- \`${p}\``);
197
+ lines.push('');
198
+ }
199
+
180
200
  if (review.tests.length > 0) {
181
201
  lines.push('<details><summary>Tests to re-run</summary>', '');
182
202
  for (const t of review.tests) lines.push(`- \`${t}\``);
@@ -265,11 +285,23 @@ function main(argv) {
265
285
  process.stdout.write(body + '\n');
266
286
  }
267
287
 
288
+ const unanalyzed = review.unanalyzed || [];
289
+ if (unanalyzed.length > 0) {
290
+ console.error(`[pr-impact] ${unanalyzed.length} changed file(s) could not be analyzed: ${unanalyzed.join(', ')}`);
291
+ }
292
+
268
293
  const failOnRisk = /^(1|true|yes)$/i.test(process.env.CODE_GRAPH_FAIL_ON_RISK || '');
269
294
  if (failOnRisk && review.uncovered.length > 0) {
270
295
  console.error(`[pr-impact] fail-on-risk: ${review.uncovered.length} changed file(s) have no covering test.`);
271
296
  process.exit(1);
272
297
  }
298
+ // A file the analyzer never answered for is unmeasured risk, not cleared
299
+ // risk: under an explicit fail-on-risk gate it blocks like a test gap does,
300
+ // with its own message so the two causes stay distinguishable in CI logs.
301
+ if (failOnRisk && unanalyzed.length > 0) {
302
+ console.error(`[pr-impact] fail-on-risk: ${unanalyzed.length} changed file(s) could not be analyzed.`);
303
+ process.exit(1);
304
+ }
273
305
  }
274
306
 
275
307
  if (require.main === module) {
@@ -14,7 +14,7 @@ const { cgTmpDir, cwdHash } = require('./tmp-dir');
14
14
  const { resolveProjectRoot } = require('./project-root');
15
15
  const { recordRecommendation } = require('./recommendation-log');
16
16
  const { formatCoveringTests } = require('./covering-tests');
17
- const { emitPreToolAllowContext } = require('./hook-emit');
17
+ const { emitPreToolContext } = require('./hook-emit');
18
18
  const { hidden } = require('./proc-opts');
19
19
 
20
20
  // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
@@ -217,10 +217,18 @@ summary += formatCoveringTests(jsonResult.test_callers, editedFile);
217
217
  // fire with the count alone — the verdict must stay coherent either way.
218
218
  summary += ` → Before this edit: confirm each caller of ${symbol}() still holds with your change, or note why it is unaffected.\n`;
219
219
 
220
- // Compound-grep sibling sweep: deliver via the PreToolUse allow+additionalContext
221
- // envelope (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in the
222
- // debug log only and never reaches the model (CC docs v2026-06); additionalContext
223
- // is what actually surfaces the impact summary. Impact must stay PRE-edit (so the
224
- // reconciliation happens before the change), hence allow + additionalContext, not
225
- // a PostToolUse inject.
226
- process.stdout.write(emitPreToolAllowContext(summary) + '\n');
220
+ // Deliver via the PERMISSION-NEUTRAL PreToolUse additionalContext envelope
221
+ // (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in the debug
222
+ // log only and never reaches the model (CC docs v2026-06); additionalContext is
223
+ // what surfaces the impact summary, and it is delivered without any
224
+ // permissionDecision the tool's normal permission flow is untouched.
225
+ //
226
+ // It used to send `permissionDecision: 'allow'` alongside it. That is documented
227
+ // as "skip the interactive permission prompt", so on a machine that prompts for
228
+ // Edit this hook silently answered that prompt for the user, for every symbol
229
+ // with >=1 caller outside the 2-minute cooldown (audit 2026-08-16 P0-2). Context
230
+ // delivery is never worth a write consent: if a future CC requires a decision to
231
+ // carry additionalContext, this summary goes quiet rather than elevating again.
232
+ // Impact must stay PRE-edit (the reconciliation happens before the change), so a
233
+ // PostToolUse inject is not an alternative here.
234
+ process.stdout.write(emitPreToolContext(summary) + '\n');
@@ -16,6 +16,21 @@
16
16
  * ALLOCATING a console — inherited stdio handles still work, so an interactive
17
17
  * `doctor` run in a real terminal is unaffected. No-op on non-Windows.
18
18
  *
19
+ * `killSignal` is deliberately NOT defaulted here. Node's `timeout` option
20
+ * sends SIGTERM and then WAITS, so a child that traps SIGTERM makes the
21
+ * timeout unreachable (audit 2026-08-16 P1-17: one deaf third-party statusline
22
+ * provider blanked the status line on every frame). The two statusline call
23
+ * sites pass `killSignal: 'SIGKILL'` themselves — they run UNTRUSTED provider
24
+ * commands / a possibly-wedged binary on the render hot path, and nothing
25
+ * there shuts down gracefully at timeout anyway. It is not a global default
26
+ * because our other timed children DO need SIGTERM's grace: a timed-out
27
+ * `git pull` hard-killed mid-write leaves `.git/index.lock` behind and every
28
+ * later marketplace refresh then fails silently; npm has equivalent lock
29
+ * files (batch review of the P1-17 fix). New call sites that run untrusted or
30
+ * hang-prone children with a timeout should opt in the same way.
31
+ * (Caveat SIGKILL does not fix: it reaches the direct child only. A grandchild
32
+ * holding the same stdout pipe can still stall a *Sync call until it exits.)
33
+ *
19
34
  * Every child_process call site under claude-plugin/scripts/ must route through
20
35
  * here (or set windowsHide itself); `windows-hide.test.js` fails the build on a
21
36
  * new call site that doesn't.