@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.
- package/README.md +19 -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 +88 -6
- package/claude-plugin/scripts/doctor.js +110 -1
- package/claude-plugin/scripts/hook-emit.js +37 -9
- package/claude-plugin/scripts/lifecycle.js +169 -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 +44 -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 +14 -6
- package/package.json +6 -6
|
@@ -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
|
|
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`
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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,63 @@ 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
|
-
const backup =
|
|
334
|
-
if (backup
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 {
|
|
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
|
|
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.
|