@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.
- package/README.md +38 -15
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +210 -50
- package/claude-plugin/scripts/auto-update.js +147 -11
- package/claude-plugin/scripts/doctor.js +158 -12
- package/claude-plugin/scripts/hook-emit.js +80 -11
- package/claude-plugin/scripts/lifecycle.js +193 -28
- package/claude-plugin/scripts/pr-impact-comment.js +33 -1
- package/claude-plugin/scripts/pre-edit-guide.js +16 -8
- package/claude-plugin/scripts/proc-opts.js +15 -0
- package/claude-plugin/scripts/session-init.js +73 -2
- package/claude-plugin/scripts/statusline-chain.js +17 -1
- package/claude-plugin/scripts/statusline-composite.js +3 -0
- package/claude-plugin/scripts/statusline.js +3 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +8 -3
- package/claude-plugin/templates/plugin_code_graph_mcp.md +15 -7
- package/package.json +7 -7
|
@@ -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
|
-
|
|
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 (!
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|