@sdsrs/code-graph 0.61.0 → 0.63.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/bin/cli.js +18 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/pre-edit-guide.js +13 -1
- package/claude-plugin/scripts/pre-edit-guide.test.js +17 -0
- package/claude-plugin/scripts/pre-grep-guide.js +8 -0
- package/claude-plugin/scripts/pre-grep-guide.test.js +16 -0
- package/claude-plugin/scripts/session-init.js +233 -4
- package/claude-plugin/scripts/session-init.test.js +166 -1
- package/package.json +6 -6
package/bin/cli.js
CHANGED
|
@@ -12,6 +12,24 @@ process.env._FIND_BINARY_ROOT = path.resolve(__dirname, "..");
|
|
|
12
12
|
// Lets `code-graph-mcp adopt` / `unadopt` work uniformly across plugin / npm / npx.
|
|
13
13
|
const sub = process.argv[2];
|
|
14
14
|
if (sub === "adopt" || sub === "unadopt") {
|
|
15
|
+
// `--help`/`-h` must be side-effect-free: adopt() writes the memory file +
|
|
16
|
+
// MEMORY.md sentinel, unadopt() removes them. The Rust binary guards this for
|
|
17
|
+
// direct invocation, but npm/npx routes through this wrapper, which intercepts
|
|
18
|
+
// adopt/unadopt *before* the binary — so the guard must be repeated here, or
|
|
19
|
+
// `code-graph-mcp adopt --help` rewrites MEMORY.md (the common new-user path).
|
|
20
|
+
if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
|
|
21
|
+
process.stdout.write(sub === "adopt"
|
|
22
|
+
? "code-graph-mcp adopt — install the code-graph memory file + MEMORY.md sentinel\n\n" +
|
|
23
|
+
"USAGE:\n code-graph-mcp adopt\n\n" +
|
|
24
|
+
"Writes plugin_code_graph_mcp.md and a sentinel block into this project's\n" +
|
|
25
|
+
"~/.claude memory so Claude Code auto-loads the decision table. Run\n" +
|
|
26
|
+
"`code-graph-mcp unadopt` to remove it.\n"
|
|
27
|
+
: "code-graph-mcp unadopt — remove the code-graph memory file + sentinel\n\n" +
|
|
28
|
+
"USAGE:\n code-graph-mcp unadopt\n\n" +
|
|
29
|
+
"Reverses `code-graph-mcp adopt`: deletes the memory file and the MEMORY.md\n" +
|
|
30
|
+
"sentinel block. User content outside the sentinel is kept.\n");
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
15
33
|
const { adopt, unadopt, formatResult } = require("../claude-plugin/scripts/adopt");
|
|
16
34
|
const result = sub === "unadopt" ? unadopt() : adopt();
|
|
17
35
|
process.stdout.write(formatResult(sub, result) + "\n");
|
|
@@ -168,7 +168,9 @@ if (directCallers < 1) process.exit(0);
|
|
|
168
168
|
try { fs.writeFileSync(cooldownFile, ''); } catch { /* ok */ }
|
|
169
169
|
|
|
170
170
|
// Funnel visibility (v0.49): an injected impact summary is a delivered answer.
|
|
171
|
-
|
|
171
|
+
// v0.63 — ack:true marks that this injection carries a salience-forcing directive
|
|
172
|
+
// (the per-caller verdict line below), so a later A/B can segment ack vs non-ack.
|
|
173
|
+
recordRecommendation(cwd, { hook: 'edit', action: 'hint', answered: true, ack: true });
|
|
172
174
|
|
|
173
175
|
// --- Inject compact impact summary ---
|
|
174
176
|
const routeCount = jsonResult.affected_routes || 0;
|
|
@@ -186,4 +188,14 @@ if (callers.length > 0) {
|
|
|
186
188
|
summary += ' Callers: ' + callers.map(c => `${c.name} (${c.file})`).join(', ') + '\n';
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
// Salience forcing (v0.63) — an injected impact summary that the model merely
|
|
192
|
+
// reads is wasted context. mem's PreToolUse edit hook lifts cite-recall to ~94%
|
|
193
|
+
// by making the model ACT on the injection ("apply each lesson or rule it out")
|
|
194
|
+
// rather than passively receive it. Mirror that: force an explicit per-caller
|
|
195
|
+
// verdict so the blast radius is reconciled against the edit, not skimmed.
|
|
196
|
+
// Wording references "each caller of X()" not "above" (finding #5): the name list
|
|
197
|
+
// is only printed when callers[] is populated, but the directCallers>=1 gate can
|
|
198
|
+
// fire with the count alone — the verdict must stay coherent either way.
|
|
199
|
+
summary += ` → Before this edit: confirm each caller of ${symbol}() still holds with your change, or note why it is unaffected.\n`;
|
|
200
|
+
|
|
189
201
|
process.stdout.write(summary);
|
|
@@ -150,6 +150,23 @@ test('fn-extract: short strings return null', () => {
|
|
|
150
150
|
// ── Pattern consistency check ───────────────────────────
|
|
151
151
|
// Verify fnPatterns in this test match what's in pre-edit-guide.js
|
|
152
152
|
|
|
153
|
+
// ── Salience forcing (v0.63) ────────────────────────────
|
|
154
|
+
// pre-edit-guide.js top-level-exits on require (reads stdin / checks db), so we
|
|
155
|
+
// assert on the source text — same convention as pattern-sync below.
|
|
156
|
+
|
|
157
|
+
test('salience: impact summary forces a per-caller verdict before the edit', () => {
|
|
158
|
+
const fs = require('node:fs');
|
|
159
|
+
const path = require('node:path');
|
|
160
|
+
const source = fs.readFileSync(path.join(__dirname, 'pre-edit-guide.js'), 'utf8');
|
|
161
|
+
// mem lifts cite-recall to ~94% by making the model ACT on the injection; the
|
|
162
|
+
// impact summary must do the same rather than be passively skimmed. Wording
|
|
163
|
+
// references "each caller of X()" not "above" (finding #5) so it stays coherent
|
|
164
|
+
// when only the caller COUNT is shown (callers[] empty but directCallers>=1).
|
|
165
|
+
assert.match(source, /Before this edit: confirm each caller of/);
|
|
166
|
+
assert.match(source, /still holds with your change, or note why it is unaffected/);
|
|
167
|
+
assert.doesNotMatch(source, /caller\(s\) above you will update/); // old wording removed
|
|
168
|
+
});
|
|
169
|
+
|
|
153
170
|
test('pattern-sync: fnPatterns count matches source', () => {
|
|
154
171
|
const fs = require('node:fs');
|
|
155
172
|
const path = require('node:path');
|
|
@@ -366,6 +366,12 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail)
|
|
|
366
366
|
lines.push(
|
|
367
367
|
'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
|
|
368
368
|
);
|
|
369
|
+
// NOTE (v0.63): a forced-ack salience line was trialed here and removed. The
|
|
370
|
+
// answer is ALREADY in context, so demanding the model restate which hit it
|
|
371
|
+
// will use risks performative compliance + friction with no measured gain
|
|
372
|
+
// (deny-with-answer already satisfies in-place, 5/5 in the daagu replay). The
|
|
373
|
+
// engagement nudge is kept only where it pays — the pre-edit impact summary,
|
|
374
|
+
// a true before-you-edit reconciliation moment. Re-add here only behind an A/B.
|
|
369
375
|
appendUnansweredTailNote(lines, unansweredTail);
|
|
370
376
|
return lines.join('\n');
|
|
371
377
|
}
|
|
@@ -383,6 +389,8 @@ function buildShowDenyReason(answer, unansweredTail) {
|
|
|
383
389
|
lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
|
|
384
390
|
}
|
|
385
391
|
lines.push('Use these directly instead of re-running the search.');
|
|
392
|
+
// NOTE (v0.63): forced-ack salience trialed + removed here too — see
|
|
393
|
+
// buildBlockReasonWithAnswer. The definitions are already delivered.
|
|
386
394
|
appendUnansweredTailNote(lines, unansweredTail);
|
|
387
395
|
return lines.join('\n');
|
|
388
396
|
}
|
|
@@ -728,6 +728,22 @@ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one den
|
|
|
728
728
|
assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
729
729
|
});
|
|
730
730
|
|
|
731
|
+
test('buildBlockReasonWithAnswer: no salience restatement — answer is already in context (v0.63 removed)', () => {
|
|
732
|
+
// A forced "name the hit you will act on" line was trialed and removed: the
|
|
733
|
+
// answer is delivered, so restatement is performative friction. Keep the deny
|
|
734
|
+
// copy to delivery + the plain "use directly" nudge only.
|
|
735
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
|
|
736
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
737
|
+
});
|
|
738
|
+
assert.doesNotMatch(reason, /name the hit you will act on/i);
|
|
739
|
+
assert.match(reason, /use these results directly/i);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('buildShowDenyReason: no salience restatement (v0.63 removed)', () => {
|
|
743
|
+
const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
|
|
744
|
+
assert.doesNotMatch(reason, /name which definition above you will change/i);
|
|
745
|
+
});
|
|
746
|
+
|
|
731
747
|
test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
|
|
732
748
|
const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
|
|
733
749
|
status: 'hits', text: 'hit', truncated: false,
|
|
@@ -40,6 +40,132 @@ function shouldInjectMap({ available, quietHooks, adopted } = {}) {
|
|
|
40
40
|
return !!(available && !quietHooks && adopted);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// v0.63 — SessionStart "live context": the recent-change blast radius from the
|
|
44
|
+
// AST index. UNLIKE injectProjectMap (default-OFF because the static module map
|
|
45
|
+
// duplicates MEMORY.md + the on-demand project_map tool), this is git-delta-
|
|
46
|
+
// derived — it changes every session and MEMORY.md cannot carry it, so it earns
|
|
47
|
+
// being ON by default for adopted projects. It is the graph-unique, actionable
|
|
48
|
+
// counterpart to mem's SessionStart dashboard (which pushes recent activity to
|
|
49
|
+
// create engagement). Selectivity is automatic: a session with no recently
|
|
50
|
+
// changed *source* files (a deps-only or release commit, a clean checkout off a
|
|
51
|
+
// non-code commit) injects nothing — zero standing-context cost when idle.
|
|
52
|
+
//
|
|
53
|
+
// Gating differs from the static map on purpose: it respects only the hard
|
|
54
|
+
// kill-switch (CODE_GRAPH_QUIET_HOOKS=1) and a dedicated opt-out, NOT the
|
|
55
|
+
// default-quiet flag that suppresses the duplicative map.
|
|
56
|
+
function shouldInjectRecentImpact({ available, adopted, env = {} } = {}) {
|
|
57
|
+
if (!available || !adopted) return false;
|
|
58
|
+
if (env.CODE_GRAPH_QUIET_HOOKS === '1') return false;
|
|
59
|
+
if (env.CODE_GRAPH_NO_RECENT_IMPACT === '1') return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pure: given whether there are uncommitted (WIP) source changes and the
|
|
64
|
+
// SessionStart `source`, decide if the recent-impact injection is worth its
|
|
65
|
+
// standing-context cost. Marginal-information argument, not measured:
|
|
66
|
+
// - WIP present → active work; the blast radius of what you're editing is the
|
|
67
|
+
// highest-value case → always show.
|
|
68
|
+
// - clean tree → we fall back to the LAST COMMIT. On a cold `startup` that's
|
|
69
|
+
// low value (you just made it, or you're starting on something unrelated),
|
|
70
|
+
// and the project's own evidence is that SessionStart dumps tend to go
|
|
71
|
+
// unreferenced ([[project_cross_project_interference]]) → suppress. On a
|
|
72
|
+
// resume (`clear`/`compact`/`resume`) the same last-commit reminder helps
|
|
73
|
+
// re-establish context → show.
|
|
74
|
+
// Unknown source (direct calls / tests) defaults to showing — only an explicit
|
|
75
|
+
// cold `startup` with no WIP is suppressed.
|
|
76
|
+
function recentImpactWorthShowing({ isWip, source } = {}) {
|
|
77
|
+
if (isWip) return true;
|
|
78
|
+
return source !== 'startup';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Source-file extensions the indexer extracts symbols from (AST-bearing). Config
|
|
82
|
+
// /lockfile/doc changes have no graph blast radius, so they're filtered out
|
|
83
|
+
// before the `affected` call — keeps the "Changed:" line free of Cargo.lock noise
|
|
84
|
+
// and avoids spending the CLI call on a commit that only bumped versions.
|
|
85
|
+
const RECENT_SRC_EXT =
|
|
86
|
+
/\.(rs|ts|tsx|js|jsx|mjs|cjs|py|go|java|rb|php|swift|kt|kts|dart|c|h|cc|cpp|hpp|cs|sh|bash)$/i;
|
|
87
|
+
|
|
88
|
+
// Pure: filter file paths to indexed source files, capped. Accepts EITHER a raw
|
|
89
|
+
// `git diff --name-only` blob (string, split on newline) OR an already-parsed
|
|
90
|
+
// path array (from parseGitStatusPaths) — the array form is essential: passing
|
|
91
|
+
// the parser's array output to a string-only signature silently returned [] and
|
|
92
|
+
// broke WIP detection (caught by the composing test). Cap guards message length
|
|
93
|
+
// and the affected call's argv on a sweeping refactor.
|
|
94
|
+
function filterSourceFiles(input, cap = 25) {
|
|
95
|
+
const lines = Array.isArray(input)
|
|
96
|
+
? input
|
|
97
|
+
: (typeof input === 'string' ? input.split('\n') : []);
|
|
98
|
+
return lines
|
|
99
|
+
.map(s => s.trim())
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.filter(f => RECENT_SRC_EXT.test(f))
|
|
102
|
+
.slice(0, cap);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pure: extract file paths from `git status --porcelain` output. One call covers
|
|
106
|
+
// tracked changes (staged + unstaged) AND untracked files — the diff-only path
|
|
107
|
+
// MISSED untracked new source files you're actively editing (finding #3). Format
|
|
108
|
+
// is `XY␣PATH`, or `XY␣ORIG -> PATH` for renames (take the new path). Git quotes
|
|
109
|
+
// paths with special chars; best-effort unquote.
|
|
110
|
+
function parseGitStatusPaths(statusOutput) {
|
|
111
|
+
if (!statusOutput || typeof statusOutput !== 'string') return [];
|
|
112
|
+
const paths = [];
|
|
113
|
+
for (const line of statusOutput.split('\n')) {
|
|
114
|
+
if (line.length < 4) continue; // need 2 status chars + space + ≥1 path char
|
|
115
|
+
let rest = line.slice(3); // skip the XY status columns + separator space
|
|
116
|
+
const arrow = rest.indexOf(' -> '); // rename/copy → the path after the arrow is current
|
|
117
|
+
if (arrow >= 0) rest = rest.slice(arrow + 4);
|
|
118
|
+
rest = rest.trim();
|
|
119
|
+
if (rest.startsWith('"') && rest.endsWith('"')) rest = rest.slice(1, -1);
|
|
120
|
+
if (rest) paths.push(rest);
|
|
121
|
+
}
|
|
122
|
+
return paths;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Above ~15 direct dependents the per-name list is noise, not signal:
|
|
126
|
+
// information scales INVERSELY with blast size. A high-fanout node (a constants
|
|
127
|
+
// module, a shared util) "touches everything", so its actionable content
|
|
128
|
+
// collapses to "this is high-risk — run the full suite"; the arbitrary first-N
|
|
129
|
+
// dependent names add nothing. Below the threshold the specific dependents ARE
|
|
130
|
+
// the signal (edit one fn → these 3 callers). So scale detail to actionability.
|
|
131
|
+
const FANOUT_LIST_MAX = 15;
|
|
132
|
+
|
|
133
|
+
// Pure: render the injected text from the parsed `affected --json` payload.
|
|
134
|
+
// Returns null when there's nothing graph-relevant to say (no indexed dependents),
|
|
135
|
+
// so the caller injects nothing rather than an empty banner.
|
|
136
|
+
function formatRecentImpact(changed, affected, dependentCap = 6) {
|
|
137
|
+
if (!Array.isArray(changed) || changed.length === 0) return null;
|
|
138
|
+
const all = (affected && Array.isArray(affected.affected_files)) ? affected.affected_files : [];
|
|
139
|
+
if (all.length === 0) return null;
|
|
140
|
+
const direct = all.filter(a => a.depth === 1 && !a.is_test).map(a => a.path);
|
|
141
|
+
const testCount = Array.isArray(affected.tests) ? affected.tests.length : all.filter(a => a.is_test).length;
|
|
142
|
+
|
|
143
|
+
const changedShown = changed.slice(0, 8).join(', ') + (changed.length > 8 ? `, +${changed.length - 8}` : '');
|
|
144
|
+
const lines = [
|
|
145
|
+
'[code-graph] Recent changes — blast radius from the AST index (graph-only; not in MEMORY.md):',
|
|
146
|
+
` Changed: ${changedShown}`,
|
|
147
|
+
];
|
|
148
|
+
if (direct.length > FANOUT_LIST_MAX) {
|
|
149
|
+
// High fanout: the name list is noise; surface risk + test scope only.
|
|
150
|
+
lines.push(` High-fanout change — ${all.length} file(s) impacted (${direct.length} direct); run the full suite (${testCount} test file(s)).`);
|
|
151
|
+
} else {
|
|
152
|
+
lines.push(` Impacts ${all.length} file(s) (${direct.length} direct dependent(s)), ${testCount} test file(s) to re-run.`);
|
|
153
|
+
if (direct.length > 0) {
|
|
154
|
+
const shown = direct.slice(0, dependentCap).join(', ');
|
|
155
|
+
const more = direct.length > dependentCap ? `, +${direct.length - dependentCap} more` : '';
|
|
156
|
+
lines.push(` Direct dependents: ${shown}${more}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Runnable verbatim when ≤4 changed; above that, show 4 + an explicit count
|
|
160
|
+
// rather than a bare "…" — a pasted "…" command yields a SMALLER blast than the
|
|
161
|
+
// numbers above (the computation used all changed files, up to the cap) — finding #4.
|
|
162
|
+
const runCmd = changed.length <= 4
|
|
163
|
+
? `code-graph-mcp affected ${changed.join(' ')}`
|
|
164
|
+
: `code-graph-mcp affected ${changed.slice(0, 4).join(' ')} (+${changed.length - 4} more changed file(s) — pass all for the full blast)`;
|
|
165
|
+
lines.push(` Re-run impacted tests: ${runCmd}`);
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
43
169
|
function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
|
|
44
170
|
try {
|
|
45
171
|
const child = spawnFn(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check', '--silent'], {
|
|
@@ -292,7 +418,7 @@ function consistencyCheck(binary) {
|
|
|
292
418
|
return issues;
|
|
293
419
|
}
|
|
294
420
|
|
|
295
|
-
function runSessionInit() {
|
|
421
|
+
function runSessionInit({ source } = {}) {
|
|
296
422
|
if (isPluginInactive()) {
|
|
297
423
|
cleanupDisabledStatusline();
|
|
298
424
|
return { inactive: true, lifecycle: 'noop', autoUpdateLaunched: false };
|
|
@@ -358,12 +484,18 @@ function runSessionInit() {
|
|
|
358
484
|
const mapInjected = shouldInjectMap({ available: binaryCheck.available, quietHooks, adopted })
|
|
359
485
|
? injectProjectMap()
|
|
360
486
|
: false;
|
|
487
|
+
// v0.63 — live context: recent-change blast radius. Default-ON for adopted
|
|
488
|
+
// projects (separate gate from the duplicative static map); self-selecting
|
|
489
|
+
// (nothing injected when no source files changed recently).
|
|
490
|
+
const recentImpactInjected = shouldInjectRecentImpact({ available: binaryCheck.available, adopted, env: process.env })
|
|
491
|
+
? injectRecentImpact({ source })
|
|
492
|
+
: false;
|
|
361
493
|
const consistencyIssues = binaryCheck.available
|
|
362
494
|
? consistencyCheck(binaryCheck.binary)
|
|
363
495
|
: [];
|
|
364
496
|
return {
|
|
365
497
|
inactive: false, lifecycle,
|
|
366
|
-
autoUpdateLaunched, indexFreshness, mapInjected, binaryCheck, consistencyIssues,
|
|
498
|
+
autoUpdateLaunched, indexFreshness, mapInjected, recentImpactInjected, binaryCheck, consistencyIssues,
|
|
367
499
|
quietHooks, adopted, autoAdopted: autoAdopt.attempted,
|
|
368
500
|
};
|
|
369
501
|
}
|
|
@@ -378,7 +510,15 @@ function injectProjectMap() {
|
|
|
378
510
|
const dbPath = path.join(cwd, '.code-graph', 'index.db');
|
|
379
511
|
if (!fs.existsSync(dbPath)) return false;
|
|
380
512
|
|
|
381
|
-
|
|
513
|
+
// findBinary, not bare 'code-graph-mcp' on PATH (finding #8): a stale/global
|
|
514
|
+
// PATH binary reads a different/older index and returns "(empty project)"
|
|
515
|
+
// even when the local index is populated — the sibling bug injectRecentImpact
|
|
516
|
+
// already avoided via findBinary().
|
|
517
|
+
const { findBinary } = require('./find-binary');
|
|
518
|
+
const bin = findBinary();
|
|
519
|
+
if (!bin) return false;
|
|
520
|
+
|
|
521
|
+
const output = execFileSync(bin, ['map', '--compact'], {
|
|
382
522
|
cwd,
|
|
383
523
|
timeout: 5000,
|
|
384
524
|
encoding: 'utf8',
|
|
@@ -397,18 +537,107 @@ function injectProjectMap() {
|
|
|
397
537
|
return false;
|
|
398
538
|
}
|
|
399
539
|
|
|
540
|
+
/**
|
|
541
|
+
* v0.63 — inject the recent-change blast radius (graph-unique "live" context).
|
|
542
|
+
* Changed source files = working-tree changes vs HEAD (WIP), else the last commit.
|
|
543
|
+
* The last-commit fallback is suppressed on a cold `startup` (low marginal value)
|
|
544
|
+
* via recentImpactWorthShowing. One bounded `affected --json` call; degrades to
|
|
545
|
+
* silent no-op on any error. Records a `live_impact` event so `stats` can see the
|
|
546
|
+
* feature fire (else it's a dark metric). Returns true iff something was injected.
|
|
547
|
+
*/
|
|
548
|
+
function injectRecentImpact({ source } = {}) {
|
|
549
|
+
try {
|
|
550
|
+
const cwd = process.cwd();
|
|
551
|
+
const dbPath = path.join(cwd, '.code-graph', 'index.db');
|
|
552
|
+
if (!fs.existsSync(dbPath)) return false;
|
|
553
|
+
|
|
554
|
+
// WIP = ALL working-tree changes (staged + unstaged + UNTRACKED) in one
|
|
555
|
+
// `git status` call — the old diff-only path missed untracked new source
|
|
556
|
+
// files you're actively editing (finding #3). Clean tree → fall back to the
|
|
557
|
+
// last commit. Timeouts tightened (finding #1): worst-case cap sum is now
|
|
558
|
+
// status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
|
|
559
|
+
// SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
|
|
560
|
+
const gitOpts = { cwd, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
|
|
561
|
+
let changed = [];
|
|
562
|
+
let isWip = false;
|
|
563
|
+
try {
|
|
564
|
+
changed = filterSourceFiles(parseGitStatusPaths(
|
|
565
|
+
execSync('git status --porcelain --untracked-files=all', gitOpts)));
|
|
566
|
+
isWip = changed.length > 0;
|
|
567
|
+
// Clean tree → fall back to what the last commit touched.
|
|
568
|
+
if (!isWip) {
|
|
569
|
+
changed = filterSourceFiles(execSync('git diff --name-only HEAD~1 HEAD', gitOpts));
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
return false; // not a git repo / no commits — nothing to diff
|
|
573
|
+
}
|
|
574
|
+
if (changed.length === 0) return false;
|
|
575
|
+
|
|
576
|
+
// Marginal-value gate: cold startup + clean tree (last-commit fallback) → skip,
|
|
577
|
+
// before spending the affected CLI call.
|
|
578
|
+
if (!recentImpactWorthShowing({ isWip, source })) return false;
|
|
579
|
+
|
|
580
|
+
const { findBinary } = require('./find-binary');
|
|
581
|
+
const bin = findBinary();
|
|
582
|
+
if (!bin) return false;
|
|
583
|
+
|
|
584
|
+
let affected;
|
|
585
|
+
try {
|
|
586
|
+
const raw = execFileSync(bin, ['affected', ...changed, '--json'], {
|
|
587
|
+
cwd, timeout: 1500, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
588
|
+
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
589
|
+
});
|
|
590
|
+
affected = JSON.parse(raw);
|
|
591
|
+
} catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const text = formatRecentImpact(changed, affected);
|
|
596
|
+
if (!text) return false;
|
|
597
|
+
process.stdout.write(text + '\n');
|
|
598
|
+
|
|
599
|
+
// Instrumentation (step 3a): record that the injection fired so `stats`
|
|
600
|
+
// surfaces it instead of the feature being dark. Carries the blast/direct
|
|
601
|
+
// counts + WIP flag for later reference-rate / A-B analysis. Best-effort.
|
|
602
|
+
try {
|
|
603
|
+
const all = Array.isArray(affected.affected_files) ? affected.affected_files : [];
|
|
604
|
+
const direct = all.filter(a => a.depth === 1 && !a.is_test).length;
|
|
605
|
+
const { recordRecommendation } = require('./recommendation-log');
|
|
606
|
+
recordRecommendation(cwd, { hook: 'session', action: 'live_impact', blast: all.length, direct, wip: isWip });
|
|
607
|
+
} catch { /* telemetry must never break the injection */ }
|
|
608
|
+
return true;
|
|
609
|
+
} catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
400
614
|
module.exports = {
|
|
401
615
|
launchBackgroundAutoUpdate,
|
|
402
616
|
syncLifecycleConfig,
|
|
403
617
|
ensureIndexFresh,
|
|
404
618
|
injectProjectMap,
|
|
619
|
+
injectRecentImpact,
|
|
405
620
|
verifyBinary,
|
|
406
621
|
consistencyCheck,
|
|
407
622
|
runSessionInit,
|
|
408
623
|
computeQuietHooks,
|
|
409
624
|
shouldInjectMap,
|
|
625
|
+
shouldInjectRecentImpact,
|
|
626
|
+
recentImpactWorthShowing,
|
|
627
|
+
filterSourceFiles,
|
|
628
|
+
parseGitStatusPaths,
|
|
629
|
+
formatRecentImpact,
|
|
410
630
|
};
|
|
411
631
|
|
|
412
632
|
if (require.main === module) {
|
|
413
|
-
|
|
633
|
+
// SessionStart passes {source:"startup"|"clear"|"compact"|"resume"} on stdin.
|
|
634
|
+
// Best-effort + TTY-guarded: a hook gets piped JSON (EOF closes it), but a
|
|
635
|
+
// manual `node session-init.js` in a terminal must not block on fd 0.
|
|
636
|
+
let source;
|
|
637
|
+
try {
|
|
638
|
+
if (!process.stdin.isTTY) {
|
|
639
|
+
source = JSON.parse(fs.readFileSync(0, 'utf8')).source;
|
|
640
|
+
}
|
|
641
|
+
} catch { /* no/garbled stdin → treat as unknown source */ }
|
|
642
|
+
runSessionInit({ source });
|
|
414
643
|
}
|
|
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
|
|
7
|
-
const { launchBackgroundAutoUpdate, syncLifecycleConfig, ensureIndexFresh, verifyBinary, computeQuietHooks, shouldInjectMap } = require('./session-init');
|
|
7
|
+
const { launchBackgroundAutoUpdate, syncLifecycleConfig, ensureIndexFresh, verifyBinary, computeQuietHooks, shouldInjectMap, shouldInjectRecentImpact, recentImpactWorthShowing, filterSourceFiles, parseGitStatusPaths, formatRecentImpact } = require('./session-init');
|
|
8
8
|
|
|
9
9
|
test('syncLifecycleConfig is exported as a callable helper', () => {
|
|
10
10
|
assert.equal(typeof syncLifecycleConfig, 'function');
|
|
@@ -161,6 +161,171 @@ test('shouldInjectMap: only injects when available + not-quiet + adopted', () =>
|
|
|
161
161
|
assert.equal(shouldInjectMap(), false);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
165
|
+
// v0.63 — SessionStart "live context": recent-change blast radius injection.
|
|
166
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
test('shouldInjectRecentImpact: default-ON for adopted projects (separate gate from the static map)', () => {
|
|
169
|
+
// Unlike shouldInjectMap, this does NOT require the verbose opt-in — it earns
|
|
170
|
+
// standing context because it's git-delta-derived, not duplicative of MEMORY.md.
|
|
171
|
+
assert.equal(shouldInjectRecentImpact({ available: true, adopted: true, env: {} }), true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('shouldInjectRecentImpact: hard kill-switch and dedicated opt-out suppress it', () => {
|
|
175
|
+
assert.equal(shouldInjectRecentImpact({ available: true, adopted: true, env: { CODE_GRAPH_QUIET_HOOKS: '1' } }), false);
|
|
176
|
+
assert.equal(shouldInjectRecentImpact({ available: true, adopted: true, env: { CODE_GRAPH_NO_RECENT_IMPACT: '1' } }), false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('shouldInjectRecentImpact: needs binary + adoption', () => {
|
|
180
|
+
assert.equal(shouldInjectRecentImpact({ available: false, adopted: true, env: {} }), false);
|
|
181
|
+
assert.equal(shouldInjectRecentImpact({ available: true, adopted: false, env: {} }), false);
|
|
182
|
+
assert.equal(shouldInjectRecentImpact(), false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('filterSourceFiles: keeps AST-bearing source, drops config/lock/doc', () => {
|
|
186
|
+
const diff = [
|
|
187
|
+
'src/domain.rs', 'Cargo.lock', 'Cargo.toml', 'CHANGELOG.md',
|
|
188
|
+
'package.json', 'src/parser/relations/mod.rs', 'claude-plugin/scripts/session-init.js',
|
|
189
|
+
'npm/linux-x64/package.json',
|
|
190
|
+
].join('\n');
|
|
191
|
+
assert.deepEqual(filterSourceFiles(diff), [
|
|
192
|
+
'src/domain.rs', 'src/parser/relations/mod.rs', 'claude-plugin/scripts/session-init.js',
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('parseGitStatusPaths: extracts paths from modified / staged / untracked lines (finding #3)', () => {
|
|
197
|
+
// `git status --porcelain` columns: " M" unstaged-mod, "M " staged, "??" untracked,
|
|
198
|
+
// "A " added. The untracked line is exactly what diff-only missed.
|
|
199
|
+
const out = [
|
|
200
|
+
' M src/domain.rs',
|
|
201
|
+
'M src/cli.rs',
|
|
202
|
+
'?? src/brand_new.rs',
|
|
203
|
+
'A src/staged_new.rs',
|
|
204
|
+
'D src/gone.rs',
|
|
205
|
+
].join('\n');
|
|
206
|
+
assert.deepEqual(parseGitStatusPaths(out), [
|
|
207
|
+
'src/domain.rs', 'src/cli.rs', 'src/brand_new.rs', 'src/staged_new.rs', 'src/gone.rs',
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('parseGitStatusPaths: rename takes the NEW path; quoted path is unquoted', () => {
|
|
212
|
+
assert.deepEqual(parseGitStatusPaths('R src/old.rs -> src/new.rs'), ['src/new.rs']);
|
|
213
|
+
assert.deepEqual(parseGitStatusPaths('?? "src/with space.rs"'), ['src/with space.rs']);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('parseGitStatusPaths: blank / too-short / non-string input → []', () => {
|
|
217
|
+
assert.deepEqual(parseGitStatusPaths(''), []);
|
|
218
|
+
assert.deepEqual(parseGitStatusPaths(null), []);
|
|
219
|
+
assert.deepEqual(parseGitStatusPaths('\n\n'), []);
|
|
220
|
+
assert.deepEqual(parseGitStatusPaths('??'), []); // no path after status
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('parseGitStatusPaths composes with filterSourceFiles: untracked source kept, config dropped', () => {
|
|
224
|
+
const out = [' M Cargo.toml', '?? src/new_feature.rs', '?? notes.txt'].join('\n');
|
|
225
|
+
assert.deepEqual(filterSourceFiles(parseGitStatusPaths(out)), ['src/new_feature.rs']);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('formatRecentImpact: re-run command is runnable verbatim when ≤4 changed (finding #4)', () => {
|
|
229
|
+
const affected = { affected_files: [{ depth: 1, is_test: false, path: 'src/a.rs' }], tests: [] };
|
|
230
|
+
const text = formatRecentImpact(['src/x.rs', 'src/y.rs'], affected);
|
|
231
|
+
assert.match(text, /Re-run impacted tests: code-graph-mcp affected src\/x\.rs src\/y\.rs$/m);
|
|
232
|
+
assert.doesNotMatch(text, /more changed file/);
|
|
233
|
+
assert.doesNotMatch(text, / …/); // no bare ellipsis
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('formatRecentImpact: >4 changed → explicit "+N more", not a bare ellipsis (finding #4)', () => {
|
|
237
|
+
const affected = { affected_files: [{ depth: 1, is_test: false, path: 'src/a.rs' }], tests: [] };
|
|
238
|
+
const changed = ['s/1.rs', 's/2.rs', 's/3.rs', 's/4.rs', 's/5.rs', 's/6.rs'];
|
|
239
|
+
const text = formatRecentImpact(changed, affected);
|
|
240
|
+
assert.match(text, /code-graph-mcp affected s\/1\.rs s\/2\.rs s\/3\.rs s\/4\.rs {2}\(\+2 more changed file\(s\)/);
|
|
241
|
+
assert.doesNotMatch(text, / …/); // the misleading bare ellipsis is gone
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('filterSourceFiles: caps the list and tolerates blank/garbage input', () => {
|
|
245
|
+
assert.deepEqual(filterSourceFiles(''), []);
|
|
246
|
+
assert.deepEqual(filterSourceFiles(null), []);
|
|
247
|
+
const many = Array.from({ length: 40 }, (_, i) => `src/m${i}.rs`).join('\n');
|
|
248
|
+
assert.equal(filterSourceFiles(many).length, 25);
|
|
249
|
+
assert.equal(filterSourceFiles(many, 3).length, 3);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('formatRecentImpact: renders changed + blast radius + direct dependents', () => {
|
|
253
|
+
const affected = {
|
|
254
|
+
affected_files: [
|
|
255
|
+
{ depth: 1, is_test: false, path: 'src/cli.rs' },
|
|
256
|
+
{ depth: 1, is_test: false, path: 'src/graph/impact.rs' },
|
|
257
|
+
{ depth: 1, is_test: true, path: 'src/parser/relations/tests.rs' },
|
|
258
|
+
{ depth: 2, is_test: false, path: 'src/main.rs' },
|
|
259
|
+
],
|
|
260
|
+
changed: ['src/domain.rs'],
|
|
261
|
+
tests: ['src/parser/relations/tests.rs', 'tests/integration.rs'],
|
|
262
|
+
};
|
|
263
|
+
const text = formatRecentImpact(['src/domain.rs'], affected);
|
|
264
|
+
assert.match(text, /Recent changes/);
|
|
265
|
+
assert.match(text, /Changed: src\/domain\.rs/);
|
|
266
|
+
assert.match(text, /Impacts 4 file\(s\) \(2 direct dependent\(s\)\), 2 test file\(s\)/);
|
|
267
|
+
assert.match(text, /Direct dependents: src\/cli\.rs, src\/graph\/impact\.rs/);
|
|
268
|
+
assert.match(text, /code-graph-mcp affected src\/domain\.rs/);
|
|
269
|
+
// It is graph-unique — the copy says so (the whole point vs the static map).
|
|
270
|
+
assert.match(text, /not in MEMORY\.md/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('recentImpactWorthShowing: WIP always shows, regardless of source', () => {
|
|
274
|
+
assert.equal(recentImpactWorthShowing({ isWip: true, source: 'startup' }), true);
|
|
275
|
+
assert.equal(recentImpactWorthShowing({ isWip: true, source: 'compact' }), true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('recentImpactWorthShowing: clean tree (last-commit fallback) suppressed on cold startup, shown on resume', () => {
|
|
279
|
+
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'startup' }), false);
|
|
280
|
+
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'clear' }), true);
|
|
281
|
+
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'compact' }), true);
|
|
282
|
+
assert.equal(recentImpactWorthShowing({ isWip: false, source: 'resume' }), true);
|
|
283
|
+
// Unknown source (direct call / test) defaults to showing — only explicit
|
|
284
|
+
// cold startup is the suppressed case.
|
|
285
|
+
assert.equal(recentImpactWorthShowing({ isWip: false }), true);
|
|
286
|
+
assert.equal(recentImpactWorthShowing(), true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('formatRecentImpact: high-fanout change drops the noisy name list, keeps risk + test scope', () => {
|
|
290
|
+
// >15 direct dependents = a constants/util node "touches everything"; the
|
|
291
|
+
// first-N names are arbitrary noise, so only risk + test count is surfaced.
|
|
292
|
+
const affected = {
|
|
293
|
+
affected_files: Array.from({ length: 20 }, (_, i) => ({ depth: 1, is_test: false, path: `src/f${i}.rs` })),
|
|
294
|
+
tests: ['tests/a.rs', 'tests/b.rs'],
|
|
295
|
+
};
|
|
296
|
+
const text = formatRecentImpact(['src/domain.rs'], affected);
|
|
297
|
+
assert.match(text, /High-fanout change/);
|
|
298
|
+
assert.match(text, /run the full suite \(2 test file\(s\)\)/);
|
|
299
|
+
assert.doesNotMatch(text, /Direct dependents:/); // name list suppressed
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('formatRecentImpact: at/under the fanout threshold the name list IS the signal', () => {
|
|
303
|
+
const affected = {
|
|
304
|
+
affected_files: Array.from({ length: 15 }, (_, i) => ({ depth: 1, is_test: false, path: `src/f${i}.rs` })),
|
|
305
|
+
tests: [],
|
|
306
|
+
};
|
|
307
|
+
const text = formatRecentImpact(['src/x.rs'], affected);
|
|
308
|
+
assert.doesNotMatch(text, /High-fanout/);
|
|
309
|
+
assert.match(text, /Direct dependents:/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('formatRecentImpact: caps direct-dependent list with a "+N more" overflow', () => {
|
|
313
|
+
const affected = {
|
|
314
|
+
affected_files: Array.from({ length: 10 }, (_, i) => ({ depth: 1, is_test: false, path: `src/f${i}.rs` })),
|
|
315
|
+
tests: [],
|
|
316
|
+
};
|
|
317
|
+
const text = formatRecentImpact(['src/domain.rs'], affected);
|
|
318
|
+
assert.match(text, /\+4 more/); // 10 direct, cap 6 → 4 hidden
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('formatRecentImpact: returns null when nothing graph-relevant (no dependents / no changes)', () => {
|
|
322
|
+
// A deps-only commit: changed files filtered to empty upstream → caller skips.
|
|
323
|
+
assert.equal(formatRecentImpact([], { affected_files: [] }), null);
|
|
324
|
+
// Changed source but zero indexed dependents → nothing actionable to say.
|
|
325
|
+
assert.equal(formatRecentImpact(['src/x.rs'], { affected_files: [], tests: [] }), null);
|
|
326
|
+
assert.equal(formatRecentImpact(['src/x.rs'], {}), null);
|
|
327
|
+
});
|
|
328
|
+
|
|
164
329
|
test('consistencyCheck returns version-mismatch when versions differ', (t) => {
|
|
165
330
|
const os = require('os');
|
|
166
331
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.63.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.63.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.63.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.63.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.63.0"
|
|
43
43
|
}
|
|
44
44
|
}
|