@sdsrs/code-graph 0.47.1 → 0.49.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.47.1",
7
+ "version": "0.49.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -45,8 +45,8 @@ const INDEX_LINE =
45
45
  '- [code-graph-mcp](plugin_code_graph_mcp.md) ' +
46
46
  '[impact-analysis, callgraph, find-references, module-overview, semantic-search, ast-search, dead-code, find-similar-code, dependency-graph, trace-http-chain] — ' +
47
47
  '改 X 影响面/谁调用 X/X 被谁用/看 X 源码/Y 模块长啥样/概念查询 优先于 Grep;字面匹配走 Grep。' +
48
- '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
49
- '+ 进阶 5impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
48
+ 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
49
+ 'MCP 核心 7get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
50
50
 
51
51
  // memdir L1 升格 (per sdscc 重构方案 §5.0): the INDEX_LINE that lands in
52
52
  // MEMORY.md is what Claude sees first on every keyword match. Tailoring it
@@ -234,9 +234,12 @@ function detectProjectType(cwd = process.cwd(), env = process.env) {
234
234
  // most for THIS project.
235
235
  function buildIndexLine(projectType = 'generic') {
236
236
  const prefix = '- [code-graph-mcp](plugin_code_graph_mcp.md) ';
237
+ // v0.49 — CLI form leads: in Claude Code the MCP tools are deferred (need a
238
+ // ToolSearch load before first call) while Bash is always live; the only
239
+ // conversions observed in real coding nights were CLI invocations.
237
240
  const coreSuffix =
238
- '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
239
- '+ 进阶 5impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
241
+ 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
242
+ 'MCP 核心 7get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
240
243
  switch (projectType) {
241
244
  case 'web-rs':
242
245
  case 'web-node':
@@ -48,6 +48,24 @@ function truncateAtLine(text, maxBytes) {
48
48
  return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
49
49
  }
50
50
 
51
+ /**
52
+ * v0.48 — drop glob segments from a search path. The hook extracts path tokens
53
+ * verbatim from the denied command, and spawnSync runs WITHOUT a shell, so a
54
+ * literal `backend/…/llm_engine/*.py` reaches rg as a nonexistent file →
55
+ * exit 1 → `unavailable` → static deny with no answer (daagu 2026-06-11: the
56
+ * night's only deny failed exactly this way). Truncate at the first segment
57
+ * containing a glob metacharacter; widening the scope to the parent dir is
58
+ * always safe. A leading glob (`*.py`) drops the scope entirely (repo-wide).
59
+ */
60
+ function sanitizeSearchPath(searchPath) {
61
+ if (!searchPath || typeof searchPath !== 'string') return undefined;
62
+ const segs = searchPath.split('/');
63
+ const i = segs.findIndex((s) => /[*?[\]{}]/.test(s));
64
+ if (i === -1) return searchPath;
65
+ const kept = segs.slice(0, i).join('/');
66
+ return kept || undefined;
67
+ }
68
+
51
69
  /**
52
70
  * Run `code-graph-mcp grep <pattern> [searchPath]` synchronously.
53
71
  *
@@ -81,14 +99,20 @@ function runGrepAnswer(opts = {}) {
81
99
  }
82
100
  if (!binary) return { status: 'unavailable' };
83
101
 
102
+ // Defensive re-sanitize: callers should pass a clean path, but a glob
103
+ // reaching argv is a guaranteed nonzero exit (see sanitizeSearchPath).
104
+ const scope = sanitizeSearchPath(searchPath);
84
105
  const args = ['grep', pattern];
85
- if (searchPath) args.push(searchPath);
106
+ if (scope) args.push(scope);
86
107
  const res = spawnSync(binary, args, {
87
108
  cwd,
88
109
  timeout: timeoutMs,
89
110
  encoding: 'utf8',
90
111
  maxBuffer: 4 * 1024 * 1024,
91
112
  stdio: ['ignore', 'pipe', 'ignore'],
113
+ // Hook-internal run: a delivered answer, not a model-initiated conversion.
114
+ // The CLI skips its recommendations.jsonl `use` record when this is set.
115
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
92
116
  });
93
117
  if (res.error || res.signal || res.status !== 0) {
94
118
  return { status: 'unavailable' };
@@ -104,4 +128,95 @@ function runGrepAnswer(opts = {}) {
104
128
  }
105
129
  }
106
130
 
107
- module.exports = { runGrepAnswer, truncateAtLine };
131
+ /**
132
+ * v0.49 — Run `code-graph-mcp show <symbol>` for up to 3 declaration symbols
133
+ * and concatenate the bodies. Powers the show-mode deny (declaration-anchor +
134
+ * context-flag greps: the model wants to READ the functions, so hand it the
135
+ * functions). Same bounded/best-effort posture as runGrepAnswer; symbols that
136
+ * fail to resolve are skipped, all-fail → no-hits (caller falls back to grep).
137
+ */
138
+ function runShowAnswer(opts = {}) {
139
+ const {
140
+ cwd,
141
+ symbols,
142
+ timeoutMs = DEFAULT_TIMEOUT_MS,
143
+ maxBytes = DEFAULT_MAX_BYTES,
144
+ } = opts;
145
+ try {
146
+ if (!Array.isArray(symbols) || symbols.length === 0) {
147
+ return { status: 'unavailable' };
148
+ }
149
+ let binary = opts.binary;
150
+ if (binary === undefined) {
151
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
152
+ }
153
+ if (!binary) return { status: 'unavailable' };
154
+
155
+ const parts = [];
156
+ for (const sym of symbols.slice(0, 3)) {
157
+ if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
158
+ const res = spawnSync(binary, ['show', sym], {
159
+ cwd,
160
+ timeout: timeoutMs,
161
+ encoding: 'utf8',
162
+ maxBuffer: 4 * 1024 * 1024,
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
165
+ });
166
+ if (res.error || res.signal || res.status !== 0) continue;
167
+ const out = (res.stdout || '').trim();
168
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
169
+ parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
170
+ }
171
+ if (parts.length === 0) return { status: 'no-hits' };
172
+ const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
173
+ return { status: 'hits', text, truncated };
174
+ } catch {
175
+ return { status: 'unavailable' };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * v0.49 — Run `code-graph-mcp overview <dir>` for the read-fanout hint, so the
181
+ * hint DELIVERS the module map instead of advising a tool call (hints measured
182
+ * 0/40 transfer on 2026-06-12; delivered answers satisfied 5/5 in place).
183
+ */
184
+ function runOverviewAnswer(opts = {}) {
185
+ const {
186
+ cwd,
187
+ dir,
188
+ timeoutMs = DEFAULT_TIMEOUT_MS,
189
+ maxBytes = DEFAULT_MAX_BYTES,
190
+ } = opts;
191
+ try {
192
+ if (!dir || typeof dir !== 'string' || dir.length > 300) {
193
+ return { status: 'unavailable' };
194
+ }
195
+ let binary = opts.binary;
196
+ if (binary === undefined) {
197
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
198
+ }
199
+ if (!binary) return { status: 'unavailable' };
200
+ const res = spawnSync(binary, ['overview', dir], {
201
+ cwd,
202
+ timeout: timeoutMs,
203
+ encoding: 'utf8',
204
+ maxBuffer: 4 * 1024 * 1024,
205
+ stdio: ['ignore', 'pipe', 'ignore'],
206
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
207
+ });
208
+ if (res.error || res.signal || res.status !== 0) {
209
+ return { status: 'unavailable' };
210
+ }
211
+ const out = (res.stdout || '').trim();
212
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) return { status: 'no-hits' };
213
+ const { text, truncated } = truncateAtLine(out, maxBytes);
214
+ return { status: 'hits', text, truncated };
215
+ } catch {
216
+ return { status: 'unavailable' };
217
+ }
218
+ }
219
+
220
+ module.exports = {
221
+ runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine, sanitizeSearchPath,
222
+ };
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const { runGrepAnswer, truncateAtLine } = require('./cg-answer');
7
+ const { runGrepAnswer, runShowAnswer, truncateAtLine } = require('./cg-answer');
8
8
 
9
9
  // Stub "binary": a node script that reacts to its first real arg so one stub
10
10
  // covers hits / no-hits / error / timeout cases.
@@ -58,6 +58,19 @@ test('runGrepAnswer: passes grep subcommand, pattern and path as argv', () => {
58
58
  assert.match(r.text, /args=\["grep","fts5_search","src\/storage\/"\]/);
59
59
  });
60
60
 
61
+ test('runGrepAnswer: child env carries CODE_GRAPH_INTERNAL=1 (not a funnel conversion)', () => {
62
+ // Stub variant that echoes the marker back in its output.
63
+ const envStub = path.join(stubDir, 'cg-env-stub.js');
64
+ fs.writeFileSync(envStub, `#!/usr/bin/env node
65
+ process.stdout.write('internal=' + (process.env.CODE_GRAPH_INTERNAL || '') + '\\n');
66
+ `);
67
+ fs.chmodSync(envStub, 0o755);
68
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'whatever', binary: envStub });
69
+ assert.equal(r.status, 'hits');
70
+ assert.match(r.text, /internal=1/,
71
+ 'hook-internal CLI runs must be marked so record_cli_use skips them');
72
+ });
73
+
61
74
  test('runGrepAnswer: omits path argv when no searchPath', () => {
62
75
  const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() });
63
76
  assert.match(r.text, /args=\["grep","fts5_search"\]/);
@@ -133,3 +146,61 @@ test('truncateAtLine: single oversized line → hard cut', () => {
133
146
  assert.equal(truncated, true);
134
147
  assert.equal(Buffer.byteLength(text, 'utf8'), 10);
135
148
  });
149
+
150
+ // ── v0.48 sanitizeSearchPath: glob args reach rg literally (no shell) ──
151
+
152
+ test('sanitizeSearchPath: truncates at first glob segment (daagu denied command)', () => {
153
+ const { sanitizeSearchPath } = require('./cg-answer');
154
+ assert.equal(
155
+ sanitizeSearchPath('backend/app/services/llm_engine/*.py'),
156
+ 'backend/app/services/llm_engine');
157
+ });
158
+
159
+ test('sanitizeSearchPath: clean path unchanged; leading glob drops scope; falsy → undefined', () => {
160
+ const { sanitizeSearchPath } = require('./cg-answer');
161
+ assert.equal(sanitizeSearchPath('src/storage/'), 'src/storage/');
162
+ assert.equal(sanitizeSearchPath('*.py'), undefined);
163
+ assert.equal(sanitizeSearchPath('src/**/x.rs'), 'src');
164
+ assert.equal(sanitizeSearchPath('src/file[1].rs'), 'src');
165
+ assert.equal(sanitizeSearchPath(''), undefined);
166
+ assert.equal(sanitizeSearchPath(undefined), undefined);
167
+ });
168
+
169
+ test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)', () => {
170
+ const r = runGrepAnswer({
171
+ cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/*.rs', binary: stubBinary(),
172
+ });
173
+ assert.equal(r.status, 'hits');
174
+ assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
175
+ });
176
+
177
+ // ── runShowAnswer (v0.49) — show-mode deny bodies ────────────────────
178
+
179
+ test('runShowAnswer: concatenates per-symbol show output with $ headers', () => {
180
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['alpha_one', 'beta_two'], binary: stubBinary() });
181
+ assert.equal(r.status, 'hits');
182
+ assert.match(r.text, /\$ code-graph-mcp show alpha_one/);
183
+ assert.match(r.text, /\$ code-graph-mcp show beta_two/);
184
+ });
185
+
186
+ test('runShowAnswer: skips non-identifier symbols, all-skipped → unavailable-safe no-hits', () => {
187
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['$(rm -rf)', 'a|b'], binary: stubBinary() });
188
+ assert.equal(r.status, 'no-hits');
189
+ });
190
+
191
+ test('runShowAnswer: caps at 3 symbols', () => {
192
+ const r = runShowAnswer({
193
+ cwd: stubDir, symbols: ['s_one', 's_two', 's_three', 's_four'], binary: stubBinary(),
194
+ });
195
+ assert.equal(r.status, 'hits');
196
+ assert.doesNotMatch(r.text, /show s_four/);
197
+ });
198
+
199
+ test('runShowAnswer: empty symbol list → unavailable', () => {
200
+ assert.equal(runShowAnswer({ cwd: stubDir, symbols: [], binary: stubBinary() }).status, 'unavailable');
201
+ });
202
+
203
+ test('runShowAnswer: failing binary → no-hits (caller falls back to grep answer)', () => {
204
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['ExplodePlease'], binary: stubBinary() });
205
+ assert.equal(r.status, 'no-hits');
206
+ });
@@ -11,10 +11,14 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { findBinary } = require('./find-binary');
13
13
  const { cgTmpDir } = require('./tmp-dir');
14
+ const { resolveProjectRoot } = require('./project-root');
15
+ const { recordRecommendation } = require('./recommendation-log');
14
16
 
15
- const cwd = process.cwd();
16
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
17
- if (!fs.existsSync(dbPath)) process.exit(0);
17
+ // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
18
+ // gate kept this hook dark for entire sessions after `cd backend/` — daagu
19
+ // 2026-06-12: 115 edits, zero impact injections.
20
+ const cwd = resolveProjectRoot(process.cwd());
21
+ if (cwd === null) process.exit(0);
18
22
 
19
23
  // Resolve binary the same way the other hooks do — bare PATH lookup misses
20
24
  // npm-global installs on systems where the global bin dir isn't on PATH for
@@ -22,10 +26,15 @@ if (!fs.existsSync(dbPath)) process.exit(0);
22
26
  const binary = findBinary();
23
27
  if (!binary) process.exit(0);
24
28
 
29
+ // Hook-internal CLI runs are deliveries, not model-initiated conversions —
30
+ // the marker keeps them out of the recommendations.jsonl `use` funnel leg.
31
+ const internalEnv = { ...process.env, CODE_GRAPH_INTERNAL: '1' };
32
+
25
33
  // --- Parse tool input ---
26
34
  let input;
27
35
  try {
28
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
36
+ // fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
37
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
29
38
  } catch { process.exit(0); }
30
39
 
31
40
  const oldStr = (input.tool_input && input.tool_input.old_string) || '';
@@ -72,6 +81,7 @@ if (!symbol || symbol.length < 3) {
72
81
  try {
73
82
  const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], {
74
83
  cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
84
+ env: internalEnv,
75
85
  });
76
86
  const grepResult = JSON.parse(raw);
77
87
  // Pick this candidate if it has few matches (precise location)
@@ -122,11 +132,14 @@ let jsonResult;
122
132
  try {
123
133
  const args = ['impact', symbol, '--json'];
124
134
  if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
125
- const raw = execFileSync('code-graph-mcp', args, {
135
+ // v0.49 use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
136
+ // diverging from the findBinary() result the rest of the hook trusts).
137
+ const raw = execFileSync(binary, args, {
126
138
  cwd,
127
139
  timeout: 2500,
128
140
  encoding: 'utf8',
129
141
  stdio: ['pipe', 'pipe', 'pipe'],
142
+ env: internalEnv,
130
143
  });
131
144
  jsonResult = JSON.parse(raw);
132
145
  } catch {
@@ -154,6 +167,9 @@ if (directCallers < 1) process.exit(0);
154
167
  // Mark cooldown
155
168
  try { fs.writeFileSync(cooldownFile, ''); } catch { /* ok */ }
156
169
 
170
+ // Funnel visibility (v0.49): an injected impact summary is a delivered answer.
171
+ recordRecommendation(cwd, { hook: 'edit', action: 'hint', answered: true });
172
+
157
173
  // --- Inject compact impact summary ---
158
174
  const routeCount = jsonResult.affected_routes || 0;
159
175
  const testCount = jsonResult.tests_affected || 0;