@sdsrs/code-graph 0.157.0 → 0.159.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.157.0",
7
+ "version": "0.159.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -46,6 +46,8 @@ const {
46
46
  extractCgFlags,
47
47
  cgFlagSet,
48
48
  extractSearchPath,
49
+ bareSourceTarget,
50
+ firstShellClause,
49
51
  normalizeCommandPaths,
50
52
  rebaseRelativePaths,
51
53
  resolveProjectRoot,
@@ -263,6 +265,14 @@ function runMain() {
263
265
  markCooldown(rawCmd, root);
264
266
 
265
267
  const { segment, block } = found;
268
+ // D#73 — a bare dir names `<shell cwd>/src`. From a subdirectory shell (same
269
+ // guard as pre-grep-guide's runMain), or after anything earlier in the same
270
+ // command, that is not provably the root's src: round 2 found `cd x && grep`,
271
+ // round 3 reproduced 19 more forms that move the shell (`builtin cd`, `if cd`,
272
+ // `{ cd …; }`, `\cd`, `popd`, `eval`, a function). A denylist of those never
273
+ // closes, so a bare dir is answered only when the grep is the FIRST segment.
274
+ if (bareSourceTarget(firstShellClause(segment))
275
+ && (relPrefix || splitTopLevelSegments(cmd)[0] !== segment)) return;
266
276
  // Run the answer exactly like the deny path.
267
277
  const rawPattern = pickBlockPattern(segment);
268
278
  // Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
@@ -56,7 +56,7 @@ const {
56
56
  runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand, shellQuoteArg,
57
57
  resolveAnswerBinary,
58
58
  } = require('./cg-answer');
59
- const { emitPreToolRewrite } = require('./hook-emit');
59
+ const { emitPreToolRewrite, emitPreToolContext, MAX_INJECTED_BYTES } = require('./hook-emit');
60
60
 
61
61
  // --- Pure logic (testable) ---
62
62
 
@@ -89,6 +89,30 @@ const SRC_PREFIXES =
89
89
  const SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`);
90
90
  // Anchored variant for whole-token matching in extractSearchPath.
91
91
  const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`);
92
+ // A path operand the rewrite grammar proved: a bare prefix word (`src`), or a
93
+ // `./`-rooted one with any subpath (`./src`, `./src/x/`), which SRC_PATH's
94
+ // lookbehind never matched.
95
+ const SRC_BARE_TOKEN = new RegExp(`^(?:(?:${SRC_PREFIXES})|\\./(?:${SRC_PREFIXES})(?:/.*)?)$`);
96
+
97
+ // D#73 — a source dir written as a bare word (`grep -rn X src`, `rg X tests`,
98
+ // `./src`): the shape models write when a prompt says "under src/", and it
99
+ // matched nothing before. Recognized ONLY where rewritePlan's grammar proves the
100
+ // word is the command's path operand. A regex over the text cannot tell: these
101
+ // prefixes are English words (`server`, `tasks`) and import-path fragments
102
+ // inside patterns, flag values (`rg -t cmd`, `ag --ignore tests`), halves of a
103
+ // two-path or `$(…)` search, or the pattern itself (`grep -n tasks f.py`) —
104
+ // pre-ship review round 1 reproduced each as a wrong hint or inject. Callers
105
+ // in a subdirectory shell must not use it on an un-rebased word: there a bare
106
+ // `src` is `<cwd>/src`, not the root's (see runMain). `.` and no path at all
107
+ // stay out: they reach non-source files, and grep -r ignores .gitignore where
108
+ // cg does not.
109
+ function bareSourceTarget(clause) {
110
+ const plan = rewritePlan(clause);
111
+ return plan && plan.target !== undefined && SRC_BARE_TOKEN.test(plan.target) ? plan.target : null;
112
+ }
113
+ function namesSourcePath(clause) {
114
+ return SRC_PATH.test(clause) || bareSourceTarget(clause) !== null;
115
+ }
92
116
  const PIPE_INTO_GREP = new RegExp(`\\|\\s*(?:${GREP_VERB})\\b`);
93
117
  const CG_INVOKED = /\bcode-graph-mcp\b/;
94
118
  // File argument(s) that end in a config/lockfile/data extension. If, after removing
@@ -184,11 +208,11 @@ function shouldHint(cmd) {
184
208
  // v0.96 — the source-path gate must see ONLY the grep's own args, not a path in
185
209
  // a non-grep tail (`grep X skills/a.py; wc scripts/b` must not fire on scripts/b).
186
210
  const clause = firstShellClause(cmd);
187
- if (!SRC_PATH.test(clause)) return false; // not against indexed source tree
211
+ if (!namesSourcePath(clause)) return false; // not against indexed source tree
188
212
  // If a config file appears AND no source path remains after stripping it, skip.
189
213
  if (CONFIG_TARGET_ONLY.test(clause)) {
190
214
  const stripped = clause.replace(CONFIG_TARGET_STRIP, ' ');
191
- if (!SRC_PATH.test(stripped)) return false;
215
+ if (!namesSourcePath(stripped)) return false;
192
216
  }
193
217
  return true;
194
218
  }
@@ -679,6 +703,13 @@ function extractSearchPath(cmd) {
679
703
  // v0.96 — scope to the grep's own clause so the answer is never scoped to a
680
704
  // path in a non-grep tail (the file the user actually grepped is the only one
681
705
  // the "already ran for you" answer may claim to have searched).
706
+ // D#73 — when the grammar proves a bare/`./` source operand, it is the scope.
707
+ // It must win over the text scan below, which takes the first path-shaped
708
+ // token and so can take a PATTERN (`grep -rn "./src/x" tests`) or cut a
709
+ // quoted operand at its space (pre-ship review round 2 B1, B2). The grammar
710
+ // rejects `..`, so no traversal reaches this return.
711
+ const bare = bareSourceTarget(firstShellClause(cmd));
712
+ if (bare) return bare;
682
713
  for (const raw of firstShellClause(cmd).split(/\s+/)) {
683
714
  const token = raw.replace(/^["']|["']$/g, '');
684
715
  if (!token || token.startsWith('-')) continue;
@@ -711,6 +742,11 @@ function countNamedPaths(cmd, patterns) {
711
742
  if (pats.has(tok)) continue; // the search pattern, not a path
712
743
  if (tok.includes('/') || /\.[A-Za-z0-9]{1,6}$/.test(tok)) n++; // dir-sep or file extension
713
744
  }
745
+ // D#73 — the grammar's bare-dir operand is a named path too, counted like
746
+ // its `src/` spelling (whose loop pass counts it). The grammar admits one
747
+ // path operand, so this adds at most one.
748
+ const bare = bareSourceTarget(firstShellClause(cmd));
749
+ if (bare && !bare.includes('/') && !/\.[A-Za-z0-9]{1,6}$/.test(bare)) n++;
714
750
  return n;
715
751
  }
716
752
 
@@ -1141,6 +1177,43 @@ function isAnswerDisabled(env = process.env) {
1141
1177
  return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
1142
1178
  }
1143
1179
 
1180
+ // Smallest per-dir share of the context cap worth spending on an overview answer.
1181
+ const MIN_FANOUT_ANSWER_BYTES = 400;
1182
+
1183
+ /// Byte budget for each overview answer when several dirs fire in one command
1184
+ /// (undefined for one dir: the answer keeps its own default). Every hint first
1185
+ /// pays for its own lines — an answer's header, footer and separator are about
1186
+ /// 175 bytes plus the dir name twice, the one-line advice about 165 plus the
1187
+ /// name three times — reserved as 200 plus the name three times, and the
1188
+ /// answers share what is left of the cap. A share under MIN_FANOUT_ANSWER_BYTES
1189
+ /// returns 0: every dir then gets the one-line advice instead.
1190
+ function fanoutAnswerBudget(dirs) {
1191
+ if (dirs.length < 2) return undefined;
1192
+ const reserved = dirs.reduce((sum, d) => sum + 200 + 3 * Buffer.byteLength(d, 'utf8'), 0);
1193
+ const share = Math.floor((MAX_INJECTED_BYTES - reserved) / dirs.length);
1194
+ return share >= MIN_FANOUT_ANSWER_BYTES ? share : 0;
1195
+ }
1196
+
1197
+ /// The fired dirs' hints as one context text. Every dir was marked delivered,
1198
+ /// so each must be named in it: once the next hint would leave no room for a
1199
+ /// line naming the dirs still to come, that line replaces the remaining hints.
1200
+ function joinFanoutHints(dirs, hints) {
1201
+ const rest = (from) => `[code-graph] 5+ Reads also into ${dirs.slice(from).map((d) => `${d}/`).join(', ')} — \`code-graph-mcp overview <dir>/\` gives each dir's symbols+callers in one call.`;
1202
+ const out = [];
1203
+ let used = 0;
1204
+ for (let i = 0; i < hints.length; i++) {
1205
+ const need = Buffer.byteLength(hints[i], 'utf8') + (out.length ? 2 : 0);
1206
+ const tail = i + 1 < hints.length ? Buffer.byteLength(rest(i + 1), 'utf8') + 2 : 0;
1207
+ if (used + need + tail > MAX_INJECTED_BYTES) {
1208
+ out.push(rest(i));
1209
+ break;
1210
+ }
1211
+ out.push(hints[i]);
1212
+ used += need;
1213
+ }
1214
+ return out.join('\n\n');
1215
+ }
1216
+
1144
1217
  function runMain() {
1145
1218
  if (isSilenced()) return;
1146
1219
  // v0.48 — process.cwd() follows the persistent shell; resolve the project
@@ -1162,18 +1235,34 @@ function runMain() {
1162
1235
  // v0.49 — sed-range reads count toward the read-fanout state (the Read hook
1163
1236
  // never sees Bash-side file reads). A fired fanout hint already delivered an
1164
1237
  // overview — skip grep hinting for this command to avoid double output.
1238
+ //
1239
+ // Every dir that fired goes into ONE envelope. Claude Code parses a hook's
1240
+ // stdout as a single JSON value: two envelopes (two dirs crossing in one
1241
+ // compound command) were rejected whole, so the model saw neither hint while
1242
+ // the state recorded both as delivered. The shared context cap is split
1243
+ // between the answers, so a large first overview cannot truncate the second
1244
+ // dir out of the envelope.
1245
+ //
1246
+ // No permission decision: this is a Bash call, and `allow` would skip the
1247
+ // user's prompt for the WHOLE command (`sed -n 1,5p a.js; <anything>`). The
1248
+ // read hook's allow envelope used to leak here because pre-read-guide wrote
1249
+ // it from inside the shared tracker, out of hook-emit.test.js's allowlist view.
1165
1250
  const sedTargets = extractSedReadTargets(rawCmd);
1166
1251
  if (sedTargets.length > 0) {
1167
1252
  const readGuide = require('./pre-read-guide');
1168
- let fanoutFired = false;
1253
+ const firedDirs = [];
1169
1254
  for (const t of sedTargets) {
1170
1255
  if (!readGuide.isSourceFile(t)) continue;
1171
1256
  const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
1172
- if (readGuide.trackReadAndMaybeHint(root, path.relative(root, abs))) {
1173
- fanoutFired = true;
1174
- }
1257
+ const dir = readGuide.trackRead(root, path.relative(root, abs));
1258
+ if (dir !== null) firedDirs.push(dir); // a dir fires once: markHint starts its cooldown
1259
+ }
1260
+ if (firedDirs.length > 0) {
1261
+ const maxBytes = fanoutAnswerBudget(firedDirs);
1262
+ const hints = firedDirs.map((dir) => readGuide.buildFanoutHint(root, dir, { maxBytes }));
1263
+ process.stdout.write(emitPreToolContext(joinFanoutHints(firedDirs, hints)) + '\n');
1264
+ return;
1175
1265
  }
1176
- if (fanoutFired) return;
1177
1266
  }
1178
1267
 
1179
1268
  // v0.47.1 — match against the root-stripped form so absolute paths under the
@@ -1183,6 +1272,9 @@ function runMain() {
1183
1272
  let cmd = normalizeCommandPaths(rawCmd, root);
1184
1273
  const relPrefix = path.relative(root, shellCwd);
1185
1274
  if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
1275
+ // D#73 — from a subdirectory, a bare dir the rebase left alone is `<cwd>/src`,
1276
+ // which the root-relative answer would not search (pre-ship review round 1 H1).
1277
+ if (relPrefix && bareSourceTarget(firstShellClause(cmd))) return;
1186
1278
  if (!shouldHint(cmd)) return;
1187
1279
 
1188
1280
  // v0.64 — fingerprint the grep's pattern once, shared by the emit points below.
@@ -1393,6 +1485,7 @@ module.exports = {
1393
1485
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
1394
1486
  extractPatterns, // v0.32.1 — exposed for tests
1395
1487
  countNamedPaths, // v0.70 — multi-path deny→hint downgrade
1488
+ bareSourceTarget, // D#73 — a bare source dir the rewrite grammar proves is the path operand
1396
1489
  isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
1397
1490
  extractSearchPath, // v0.47.0 — deny-with-answer
1398
1491
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
@@ -157,13 +157,14 @@ function isAnswerDisabled(env = process.env) {
157
157
 
158
158
  // --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
159
159
 
160
- /// Record one read of `rel` (project-root-relative source path) and fire the
161
- /// fanout hint when the threshold crosses. Emits to stdout + records the
162
- /// recommendation. Returns true when a hint fired.
163
- function trackReadAndMaybeHint(root, rel, now = Date.now()) {
164
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
160
+ /// Record one read of `rel` (project-root-relative source path). Returns the
161
+ /// dir when this read crossed the fanout threshold (the hint is marked as
162
+ /// delivered), else null. Writes nothing to stdout: a hook's stdout is parsed as
163
+ /// ONE JSON value, so only the entry point may write, and only once.
164
+ function trackRead(root, rel, now = Date.now()) {
165
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
165
166
  const dir = path.dirname(rel);
166
- if (!dir || dir === '.' || dir === '') return false; // top-level file: not fanout
167
+ if (!dir || dir === '.' || dir === '') return null; // top-level file: not fanout
167
168
 
168
169
  const state = loadState(root, now);
169
170
  recordRead(state, dir, now);
@@ -178,12 +179,21 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
178
179
  // Record it (best-effort) so `stats` can measure the model's read fan-out —
179
180
  // e.g. a read right after cg answered a grep in-place (search-decay).
180
181
  recordRecommendation(root, { hook: 'read', action: 'observe' });
181
- return false;
182
+ return null;
182
183
  }
184
+ return dir;
185
+ }
183
186
 
187
+ /// The fanout hint text for `dir`, with the overview answer embedded when the
188
+ /// CLI delivers one. `maxBytes` bounds that answer — the pre-grep sed path
189
+ /// splits one envelope's budget between several dirs.
190
+ function buildFanoutHint(root, dir, { maxBytes } = {}) {
184
191
  let answer = { status: 'unavailable' };
185
- if (!isAnswerDisabled()) {
186
- answer = runOverviewAnswer({ cwd: root, dir });
192
+ if (maxBytes === 0) {
193
+ // No room left in a shared envelope: the advice line, and no CLI run.
194
+ answer = { status: 'unavailable', reason: 'budget' };
195
+ } else if (!isAnswerDisabled()) {
196
+ answer = runOverviewAnswer({ cwd: root, dir, ...(maxBytes ? { maxBytes } : {}) });
187
197
  }
188
198
  const answered = answer.status === 'hits';
189
199
  recordRecommendation(root, {
@@ -202,14 +212,14 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
202
212
  ...(answered ? {} : { reason: answer.status }),
203
213
  ...(answered || !answer.reason ? {} : { fallthrough_reason: answer.reason }),
204
214
  });
205
- // Compound-grep sibling sweep: emit via the PreToolUse allow+additionalContext
206
- // envelope (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in
207
- // the debug log only and never reaches the model (CC docs v2026-06); the
208
- // additionalContext channel is what actually surfaces the fanout hint. Read is
209
- // a safe tool, so the allow elevation is negligible.
210
- const hintText = answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
211
- process.stdout.write(emitPreToolAllowContext(hintText) + '\n');
212
- return true;
215
+ return answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
216
+ }
217
+
218
+ /// trackRead + buildFanoutHint for one read. Returns the hint text when the
219
+ /// hint fired, else null. The caller emits it.
220
+ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
221
+ const dir = trackRead(root, rel, now);
222
+ return dir === null ? null : buildFanoutHint(root, dir);
213
223
  }
214
224
 
215
225
  // --- Main execution ---
@@ -237,7 +247,13 @@ function runMain() {
237
247
  rel = path.relative(root, filePath);
238
248
  } catch { return; }
239
249
 
240
- trackReadAndMaybeHint(root, rel);
250
+ const hint = trackReadAndMaybeHint(root, rel);
251
+ // Emit via the PreToolUse allow+additionalContext envelope (shared
252
+ // hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in the debug log
253
+ // only and never reaches the model (CC docs v2026-06); the additionalContext
254
+ // channel is what actually surfaces the fanout hint. Read is a safe tool, so
255
+ // the allow elevation is negligible.
256
+ if (hint) process.stdout.write(emitPreToolAllowContext(hint) + '\n');
241
257
  }
242
258
 
243
259
  if (require.main === module) {
@@ -249,5 +265,6 @@ module.exports = {
249
265
  loadState, saveState, recordRead, shouldHint, markHint,
250
266
  buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
251
267
  trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
268
+ trackRead, buildFanoutHint, // the sed path's two halves: one envelope for every dir that fired
252
269
  FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
253
270
  };
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.157.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.159.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.157.0",
3
+ "version": "0.159.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.157.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.157.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.157.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.157.0",
42
- "@sdsrs/code-graph-win32-x64": "0.157.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.159.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.159.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.159.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.159.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.159.0"
43
43
  }
44
44
  }