@sdsrs/code-graph 0.116.0 → 0.118.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.
@@ -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,86 @@ 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
+ //
386
+ // Our OWN entry is dropped unless it names the composite this install would
387
+ // register right now. The backup lives in `~/.claude/`, which survives the
388
+ // plugin cache — including an uninstall that refused to rewrite the registry
389
+ // (`detachStatuslineIntegration`'s oneShot refusal leaves it in place by
390
+ // design, because rewriting is how the data got lost the first time). So the
391
+ // NEXT install self-healed the previous install's `code-graph` entry back to
392
+ // life, pointing at a versioned cache directory that no longer exists — a
393
+ // zombie provider in the composite chain (2026-08-16 audit §四). `_previous`
394
+ // and third-party entries are kept: those are the user's data and the reason
395
+ // this backup exists, and nothing else would restore them.
396
+ const backup = readJsonResult(providersBackupFile(), asArray);
397
+ if (backup.value && backup.value.length > 0) {
398
+ // `codeGraphStatuslineCommand()`, NOT `compositeCommand()`. The registry row
399
+ // for `code-graph` is written with the former (see the two
400
+ // `registerStatuslineProvider('code-graph', …)` call sites); the composite is
401
+ // only ever the value of `settings.statusLine.command`. Comparing against the
402
+ // composite made this filter drop the row unconditionally — the CURRENT
403
+ // install's own segment vanished after a cache wipe, which is worse than the
404
+ // stale-entry resurrection the filter exists to prevent (found by the
405
+ // v0.118.0 pre-tag review; CI could not see it).
406
+ const live = codeGraphStatuslineCommand();
407
+ const healed = backup.value.filter(p => p && (p.id !== 'code-graph' || p.command === live));
408
+ if (healed.length > 0) {
409
+ try { writeJsonAtomic(REGISTRY_FILE, healed); } catch { /* ok */ }
410
+ return { registry: healed, refuse: false };
411
+ }
337
412
  }
338
- return [];
413
+ if (backup.corrupt) {
414
+ return { registry: [], refuse: true, why: `${providersBackupFile()} exists but cannot be read as a provider list` };
415
+ }
416
+ return { registry: [], refuse: false };
417
+ }
418
+
419
+ function readRegistry() {
420
+ return readRegistryForWrite().registry;
421
+ }
422
+
423
+ // One place to say why a registry mutation did nothing. Stderr, not silence:
424
+ // the caller returns `false`, which is indistinguishable from "already
425
+ // registered" to everything upstream.
426
+ function warnRegistryUnusable(action, why) {
427
+ console.error(
428
+ `[code-graph] ${why}. Skipping the statusline ${action} — rewriting it would ` +
429
+ 'destroy your previous statusline and any third-party provider entries. ' +
430
+ 'Repair or move the file aside and re-run.'
431
+ );
339
432
  }
340
433
 
341
434
  function writeRegistry(registry) {
@@ -351,7 +444,11 @@ function writeRegistry(registry) {
351
444
  }
352
445
 
353
446
  function registerStatuslineProvider(id, command, needsStdin) {
354
- const registry = readRegistry();
447
+ const { registry, refuse, why } = readRegistryForWrite();
448
+ if (refuse) {
449
+ warnRegistryUnusable('registration', why);
450
+ return false;
451
+ }
355
452
  const idx = registry.findIndex(p => p.id === id);
356
453
  const entry = { id, command, needsStdin: !!needsStdin };
357
454
  if (idx >= 0) {
@@ -366,7 +463,11 @@ function registerStatuslineProvider(id, command, needsStdin) {
366
463
  }
367
464
 
368
465
  function unregisterStatuslineProvider(id) {
369
- const registry = readRegistry();
466
+ const { registry, refuse, why } = readRegistryForWrite();
467
+ if (refuse) {
468
+ warnRegistryUnusable('removal', why);
469
+ return false;
470
+ }
370
471
  const filtered = registry.filter(p => p.id !== id);
371
472
  if (filtered.length === registry.length) return false;
372
473
  writeRegistry(filtered);
@@ -389,11 +490,37 @@ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
389
490
  return !hasInstalledPluginRecord();
390
491
  }
391
492
 
392
- function detachStatuslineIntegration(settings, { compositeDoomed = true } = {}) {
493
+ function detachStatuslineIntegration(settings, { compositeDoomed = true, oneShot = false } = {}) {
393
494
  let settingsChanged = false;
394
495
 
395
- unregisterStatuslineProvider('code-graph');
396
- const registry = readRegistry();
496
+ // An unusable (not merely absent) registry means we may not WRITE it, and it
497
+ // may leave us unable to tell whether a `_previous` or third-party entry
498
+ // exists — which every branch below that rewrites `settings.statusLine`
499
+ // depends on (batch review of audit 2026-08-16 P1-12: the register path
500
+ // refused correctly while this detach path still destroyed the slot).
501
+ //
502
+ // Whether refusing is safe depends on the CALLER, so it is a parameter:
503
+ // retryable (statusline render) — leave everything alone; the next frame
504
+ // retries once the file is usable. Touching the slot on a bad read is
505
+ // how the user's statusline got destroyed in the first place.
506
+ // oneShot (uninstall) — the composite script dies with the plugin cache in
507
+ // this same run, so leaving the slot pointing at it is PERMANENT
508
+ // breakage with no plugin code left to repair it (pre-tag review). We
509
+ // still must not write the registry, but the entries we already READ are
510
+ // enough to choose the slot: `readRegistryForWrite` reads through to the
511
+ // durable backup even while refusing, so a corrupt primary alone does
512
+ // not lose `_previous`. When even that is unreadable the list is empty
513
+ // and we clear the slot — Claude Code's default beats a dead path.
514
+ const { registry, refuse, why } = readRegistryForWrite();
515
+ if (refuse && !oneShot) {
516
+ warnRegistryUnusable('detach', why);
517
+ return false;
518
+ }
519
+ if (refuse) {
520
+ warnRegistryUnusable('registry rewrite (the settings slot is still neutralized — uninstall cannot retry)', why);
521
+ } else {
522
+ unregisterStatuslineProvider('code-graph');
523
+ }
397
524
  const previous = registry.find(p => p.id === '_previous' && p.command);
398
525
  // Third-party providers registered through our registry (e.g. gsd). They
399
526
  // must not be silently orphaned: with the composite gone from settings
@@ -422,8 +549,9 @@ function detachStatuslineIntegration(settings, { compositeDoomed = true } = {})
422
549
  }
423
550
 
424
551
  // _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');
552
+ // registry file (writeRegistry unlinks primary+backup when emptied). Skipped
553
+ // entirely while refusing: that path may not write the registry at all.
554
+ if (!refuse && thirdParty.length === 0) unregisterStatuslineProvider('_previous');
427
555
  return settingsChanged;
428
556
  }
429
557
 
@@ -1061,13 +1189,17 @@ function install({ reclaimStatusline = false } = {}) {
1061
1189
  manifest.version = version;
1062
1190
  manifest.installedAt = manifest.installedAt || new Date().toISOString();
1063
1191
  manifest.updatedAt = new Date().toISOString();
1064
- writeManifest(manifest);
1192
+ const manifestErr = writeManifest(manifest);
1065
1193
 
1066
1194
  return {
1067
1195
  version,
1068
1196
  settingsChanged,
1069
1197
  statusLineClaimed: manifest.config.statusLine,
1070
1198
  hooksRegistered,
1199
+ // Unstamped manifest: the install DID land in settings.json, but the next
1200
+ // run will not know it and will redo the work (idempotent). Surfaced so
1201
+ // doctor/session-init can say so instead of implying a clean install.
1202
+ manifestUnwritable: manifestErr ? (manifestErr.code || manifestErr.name) : undefined,
1071
1203
  // Non-null => the previous settings.json was REPLACED and lives here now.
1072
1204
  settingsRebuiltFrom: backedUpTo,
1073
1205
  };
@@ -1109,7 +1241,10 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1109
1241
 
1110
1242
  if (settings) {
1111
1243
  // 1. StatusLine: remove code-graph integration and restore prior statusline.
1112
- if (detachStatuslineIntegration(settings)) {
1244
+ // `oneShot`: steps 6-7 below delete the plugin cache, taking
1245
+ // statusline-composite.js with it, so this is the last chance to move the
1246
+ // slot off a script that is about to stop existing (pre-tag review).
1247
+ if (detachStatuslineIntegration(settings, { oneShot: true })) {
1113
1248
  settingsChanged = true;
1114
1249
  }
1115
1250
 
@@ -1136,7 +1271,16 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1136
1271
  }
1137
1272
 
1138
1273
  // 5. Remove all known IDs from installed_plugins.json
1139
- const installedPlugins = readJson(installedPluginsPath());
1274
+ //
1275
+ // Read-modify-write of Claude Code's OWN file. The write is already gated on a
1276
+ // successful parse, so an unusable file is skipped rather than clobbered (the
1277
+ // destructive `|| {}` shape never existed here) — but the skip was SILENT, and
1278
+ // steps 6-7 below still delete the plugin cache. The user then keeps a plugin
1279
+ // record pointing at a directory we removed, with `uninstall` reporting
1280
+ // success. Say so instead (audit 2026-08-16 P1-12 sweep).
1281
+ const installedRead = readJsonResult(installedPluginsPath());
1282
+ const installedPlugins = installedRead.value;
1283
+ let installedPluginsUnusable = false;
1140
1284
  if (installedPlugins && installedPlugins.plugins) {
1141
1285
  let ipChanged = false;
1142
1286
  for (const id of [PLUGIN_ID, ...OLD_PLUGIN_IDS]) {
@@ -1145,7 +1289,24 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1145
1289
  ipChanged = true;
1146
1290
  }
1147
1291
  }
1148
- if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
1292
+ if (ipChanged) {
1293
+ try {
1294
+ writeJsonAtomic(installedPluginsPath(), installedPlugins);
1295
+ } catch (err) {
1296
+ installedPluginsUnusable = true;
1297
+ console.error(
1298
+ `[code-graph] cannot write ${installedPluginsPath()} (${err.code || err.name}). ` +
1299
+ 'Claude Code still lists this plugin — remove it with `/plugin uninstall code-graph-mcp`.'
1300
+ );
1301
+ }
1302
+ }
1303
+ } else if (!installedRead.missing) {
1304
+ installedPluginsUnusable = true;
1305
+ console.error(
1306
+ `[code-graph] cannot read ${installedPluginsPath()} ` +
1307
+ `(${installedRead.error ? installedRead.error.code || installedRead.error.message : 'not a JSON object'}). ` +
1308
+ 'Left untouched — Claude Code may still list this plugin; remove it with `/plugin uninstall code-graph-mcp`.'
1309
+ );
1149
1310
  }
1150
1311
 
1151
1312
  // 5.5. Global npm packages + adoption inventory — read BEFORE step 6 wipes
@@ -1208,7 +1369,7 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1208
1369
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
1209
1370
  }
1210
1371
 
1211
- return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted };
1372
+ return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted, installedPluginsUnusable };
1212
1373
  }
1213
1374
 
1214
1375
  // --- Update (refresh config points) ---
@@ -1270,7 +1431,7 @@ function update() {
1270
1431
  // 6. Update manifest
1271
1432
  manifest.version = version;
1272
1433
  manifest.updatedAt = new Date().toISOString();
1273
- writeManifest(manifest);
1434
+ const manifestErr = writeManifest(manifest);
1274
1435
 
1275
1436
  // 7. Clean up old cached versions (keep the newest few). NOTE: older cache
1276
1437
  // dirs are NOT always inert — a running MCP server's launcher path
@@ -1280,7 +1441,10 @@ function update() {
1280
1441
  // therefore skips any version still referenced by a live process cmdline.
1281
1442
  cleanupOldCacheVersions(5);
1282
1443
 
1283
- return { oldVersion, version, settingsChanged, hooksRegistered, settingsRebuiltFrom: backedUpTo };
1444
+ return {
1445
+ oldVersion, version, settingsChanged, hooksRegistered, settingsRebuiltFrom: backedUpTo,
1446
+ manifestUnwritable: manifestErr ? (manifestErr.code || manifestErr.name) : undefined,
1447
+ };
1284
1448
  }
1285
1449
 
1286
1450
  /**
@@ -1528,11 +1692,12 @@ module.exports = {
1528
1692
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1529
1693
  cleanupDisabledStatusline,
1530
1694
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1531
- readRegistry, writeRegistry,
1695
+ readRegistry, readRegistryForWrite, writeRegistry,
1532
1696
  getPluginVersion, cleanupOldCacheVersions,
1533
1697
  removeHooksFromSettings, isOurHookEntry,
1534
1698
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
1535
1699
  surveyHookCoverage, compositeCommand, compositeSlotIsStale, // v0.49.1 — version-aware self-heal
1700
+ codeGraphStatuslineCommand, // exported so a test asserts the row shape the product really writes
1536
1701
  hookCmdScript, // the ONE hook-command path parser (session-init reuses it)
1537
1702
  cacheDirVersion, // exported for the separator-agnostic test
1538
1703
 
@@ -1540,7 +1705,7 @@ module.exports = {
1540
1705
  activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
1541
1706
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
1542
1707
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
1543
- registerStatuslineProvider, unregisterStatuslineProvider,
1708
+ registerStatuslineProvider, unregisterStatuslineProvider, detachStatuslineIntegration,
1544
1709
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
1545
1710
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
1546
1711
  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.
@@ -216,6 +216,21 @@ function reportRebuild(r) {
216
216
  `still need (model / env / permissions / your own hooks) back by hand.\n`
217
217
  );
218
218
  }
219
+ // install()/update() have reported `manifestUnwritable` since they learned not
220
+ // to throw on it, and NOBODY read the field — so a manifest that could not be
221
+ // written (EACCES after a stray sudo, EROFS, a full disk) produced a silent
222
+ // partial install. It is not cosmetic: `syncLifecycleConfig` keys entirely off
223
+ // `manifest.version`, so an unwritten manifest makes every future SessionStart
224
+ // re-run install() and re-report 'installed', forever, with nothing to show
225
+ // for it (audit 2026-08-16 review Minor tail).
226
+ if (r && r.manifestUnwritable) {
227
+ process.stdout.write(
228
+ `[code-graph] The plugin manifest could not be written (${r.manifestUnwritable}). ` +
229
+ 'Hooks are registered but the install will not be remembered, so this runs again ' +
230
+ 'every session. Check permissions on ~/.claude/plugins/, then run ' +
231
+ '`code-graph-mcp doctor`.\n'
232
+ );
233
+ }
219
234
  return r;
220
235
  }
221
236
  function installReporting(...args) { return reportRebuild(install(...args)); }
@@ -609,7 +624,36 @@ function runSessionInit({ source } = {}) {
609
624
  // 上下文感知默认:插件模式下首次 SessionStart 自动安装(创建/注入 CLAUDE.md 块 +
610
625
  // .claude/ detail 文件),并清理旧 memory-dir 制品(升级自动迁移)。shipped 漂移
611
626
  // 时刷新。三种情况发一次 stderr 提示,让用户知道发生了什么 + 如何回退。
612
- const autoAdopt = isRelic ? { attempted: false, result: null } : maybeAutoAdopt({ scriptPath: __dirname });
627
+ // Adoption is OPTIONAL; the rest of this hook is not. It touches files the
628
+ // user owns (CLAUDE.md, .claude/) which can be unreadable, a directory, or on
629
+ // a read-only mount — and a throw here used to abort every remaining step
630
+ // (map injection, recent impact, consistency check, both hook canaries) with a
631
+ // raw stack trace (audit 2026-08-16 P1-16). adopt() now returns reasons rather
632
+ // than throwing; this is the belt to that suspenders, so a future unguarded
633
+ // read inside it cannot take the session down again.
634
+ let autoAdopt = { attempted: false, result: null };
635
+ if (!isRelic) {
636
+ try {
637
+ autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
638
+ } catch (e) {
639
+ autoAdopt = { attempted: true, reason: 'threw', result: null, error: (e && e.message) || String(e) };
640
+ process.stderr.write(
641
+ `[code-graph] Skipped CLAUDE.md adoption for this project (${(e && e.code) || (e && e.message) || 'unknown error'}).\n` +
642
+ ' Everything else in this session start continues normally.\n'
643
+ );
644
+ }
645
+ }
646
+ if (autoAdopt.result && autoAdopt.result.ok === false &&
647
+ (autoAdopt.result.reason === 'claude-md-unreadable' || autoAdopt.result.reason === 'claude-md-unwritable' ||
648
+ autoAdopt.result.reason === 'detail-unwritable')) {
649
+ // A refusal is not a silent no-op: the user's steering block is NOT
650
+ // installed/refreshed, and only this line says so.
651
+ process.stderr.write(
652
+ `[code-graph] Could not install the CLAUDE.md steering block (${autoAdopt.result.reason}: ` +
653
+ `${autoAdopt.result.error || 'unknown'}). Nothing was changed.\n` +
654
+ ' Opt out permanently: CODE_GRAPH_NO_AUTO_ADOPT=1\n'
655
+ );
656
+ }
613
657
  const migrated = autoAdopt.migrated || {};
614
658
  if (migrated.memoryIndexPruned || migrated.legacyDetailRemoved) {
615
659
  process.stderr.write(
@@ -637,6 +681,20 @@ function runSessionInit({ source } = {}) {
637
681
  ' Reverse: code-graph-mcp unadopt\n'
638
682
  );
639
683
  }
684
+ // `adopt()` has returned `registryRecorded` since it stopped throwing on a
685
+ // broken registry, and nothing read it. The consequence is not cosmetic:
686
+ // `uninstall()` walks that registry to strip our managed block from every
687
+ // adopted project's CLAUDE.md, so an unrecorded project keeps the block
688
+ // FOREVER after uninstall, with no plugin code left to remove it — the
689
+ // teardown-asymmetry class this repo has already been bitten by (audit
690
+ // 2026-08-16 review Minor tail).
691
+ if (autoAdopt.result.registryRecorded === false) {
692
+ process.stderr.write(
693
+ '[code-graph] Note: this project could not be recorded in the adopted-projects registry,\n' +
694
+ ' so `/plugin uninstall` will NOT strip the block from this CLAUDE.md.\n' +
695
+ ' Remove it by hand with `code-graph-mcp unadopt` before uninstalling.\n'
696
+ );
697
+ }
640
698
  }
641
699
 
642
700
  // quietHooks: default quiet (project_map injection duplicates MEMORY.md +
@@ -889,5 +947,18 @@ if (require.main === module) {
889
947
  source = JSON.parse(fs.readFileSync(0, 'utf8')).source;
890
948
  }
891
949
  } catch { /* no/garbled stdin → treat as unknown source */ }
892
- runSessionInit({ source });
950
+ // Hooks FAIL OPEN. This one had no wrapper at all, so anything unhandled
951
+ // anywhere in the sequence surfaced as a node stack trace plus a non-zero exit
952
+ // in the user's session start — for work that is entirely optional
953
+ // housekeeping (audit 2026-08-16 P1-16). One `[code-graph]` line, exit 0.
954
+ try {
955
+ runSessionInit({ source });
956
+ } catch (e) {
957
+ process.stderr.write(
958
+ `[code-graph] SessionStart hook error (${(e && e.code) || (e && e.name) || 'Error'}): ` +
959
+ `${(e && e.message) || String(e)}\n` +
960
+ ' The session continues; run `code-graph-mcp doctor` if this repeats.\n'
961
+ );
962
+ process.exit(0);
963
+ }
893
964
  }
@@ -16,7 +16,21 @@
16
16
  // (this plugin's own provider). Third parties should use stable ids like
17
17
  // "gsd", "claude-mem", etc.
18
18
 
19
- const { readRegistry, registerStatuslineProvider, unregisterStatuslineProvider } = require('./lifecycle');
19
+ const {
20
+ readRegistry, readRegistryForWrite, registerStatuslineProvider, unregisterStatuslineProvider,
21
+ } = require('./lifecycle');
22
+
23
+ // A registry that EXISTS but cannot be read is a refusal, not a no-op: the
24
+ // mutation functions leave it alone (they must — it holds the user's previous
25
+ // statusline and other plugins' entries), which would otherwise surface here as
26
+ // the cheerful "unchanged <id>" / "not-found <id>" at exit 0. A third-party
27
+ // installer reading that exit code would believe it is wired in.
28
+ function bailIfRegistryUnusable(action) {
29
+ const { refuse, why } = readRegistryForWrite();
30
+ if (!refuse) return;
31
+ process.stderr.write(`error: ${why} — nothing ${action}. Repair or move that file aside and retry.\n`);
32
+ process.exit(2);
33
+ }
20
34
 
21
35
  function usage(code = 1) {
22
36
  process.stderr.write(
@@ -34,12 +48,14 @@ function runRegister(id, command, needsStdin) {
34
48
  process.exit(2);
35
49
  }
36
50
  if (!id || !command) usage();
51
+ bailIfRegistryUnusable('registered');
37
52
  const changed = registerStatuslineProvider(id, command, needsStdin);
38
53
  process.stdout.write(changed ? `registered ${id}\n` : `unchanged ${id}\n`);
39
54
  }
40
55
 
41
56
  function runUnregister(id) {
42
57
  if (!id) usage();
58
+ bailIfRegistryUnusable('unregistered');
43
59
  const changed = unregisterStatuslineProvider(id);
44
60
  process.stdout.write(changed ? `unregistered ${id}\n` : `not-found ${id}\n`);
45
61
  }
@@ -89,6 +89,9 @@ function runProvider(command, needsStdin, stdin) {
89
89
 
90
90
  const out = execFileSync(argv[0], argv.slice(1), hidden({
91
91
  timeout: 3000,
92
+ // SIGKILL, not the SIGTERM default: a provider that traps SIGTERM makes
93
+ // Node's timeout unreachable and hangs every render (audit P1-17).
94
+ killSignal: 'SIGKILL',
92
95
  stdio: ['pipe', 'pipe', 'pipe'],
93
96
  input: needsStdin ? stdin : '',
94
97
  env,
@@ -187,6 +187,9 @@ try {
187
187
  // "slow health-check" into a rendered "offline"/"updating" instead of a blank.
188
188
  report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], hidden({
189
189
  timeout: 1500,
190
+ // Render hot path: a wedged binary ignoring SIGTERM must not outlive the
191
+ // budget (same reasoning as the composite's provider spawn, audit P1-17).
192
+ killSignal: 'SIGKILL',
190
193
  stdio: ['pipe', 'pipe', 'pipe'],
191
194
  // Run the binary FROM the resolved root so its own project-root resolution
192
195
  // lands on the same DB the gate above picked (a subdir cwd would otherwise