@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 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");
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.61.0",
7
+ "version": "0.63.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -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
- recordRecommendation(cwd, { hook: 'edit', action: 'hint', answered: true });
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
- const output = execSync('code-graph-mcp map --compact', {
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
- runSessionInit();
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.61.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.61.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.61.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.61.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.61.0",
42
- "@sdsrs/code-graph-win32-x64": "0.61.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
  }