@sdsrs/code-graph 0.163.0 → 0.164.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.
@@ -88,13 +88,48 @@ const VERB_STRIP = new RegExp(`^\\s*(?:env\\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\
88
88
  // invisible to the hook so it could never scope the answer to the real target.
89
89
  const SRC_PREFIXES =
90
90
  'src|tests|lib|libs|scripts|skills|claude-plugin|tools|pkg|cmd|internal|app|apps|components?|server|client|crates|packages|backend|frontend|services|models|domain|controllers|views|handlers|middleware|routes|repositories|entities|migrations|tasks|jobs|workers|features|modules|api|web';
91
- const SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`);
91
+ // The three source-path tests below are built from SRC_PREFIXES plus the
92
+ // project's own source roots (useSourceRoots), so they are `let`.
93
+ let SRC_PATH;
92
94
  // Anchored variant for whole-token matching in extractSearchPath.
93
- const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`);
95
+ let SRC_PATH_TOKEN;
94
96
  // A path operand the rewrite grammar proved: a bare prefix word (`src`), or a
95
97
  // `./`-rooted one with any subpath (`./src`, `./src/x/`), which SRC_PATH's
96
98
  // lookbehind never matched.
97
- const SRC_BARE_TOKEN = new RegExp(`^(?:(?:${SRC_PREFIXES})|\\./(?:${SRC_PREFIXES})(?:/.*)?)$`);
99
+ let SRC_BARE_TOKEN;
100
+
101
+ // F1 (tasks/specs/grep-hook-source-roots.md) — the prefix list names
102
+ // conventional source dirs, but a Python package lives in a dir named after it
103
+ // (`networkx/`, `django/`): in the 2026-09-28 coding eval not one grep of 15
104
+ // runs reached this hook. The indexer writes the top-level dirs that hold
105
+ // indexed code to `.code-graph/source-roots.json`, and those names join the
106
+ // list. A name must be one plain shell word (no quote, space, slash, leading
107
+ // dot); past MAX_SOURCE_ROOTS the list alone applies, as it does when the file
108
+ // is missing or unreadable.
109
+ const SOURCE_ROOTS_FILE = 'source-roots.json';
110
+ const MAX_SOURCE_ROOTS = 64;
111
+ const SOURCE_ROOT_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.+@-]{0,63}$/;
112
+ const PREFIX_WORDS = new Set(SRC_PREFIXES.replace('components?', 'components|component').split('|'));
113
+
114
+ function readSourceRoots(root) {
115
+ if (!root) return [];
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(fs.readFileSync(path.join(root, '.code-graph', SOURCE_ROOTS_FILE), 'utf8'));
119
+ } catch { return []; }
120
+ const roots = parsed && Array.isArray(parsed.roots) ? parsed.roots : null;
121
+ if (!roots || roots.length > MAX_SOURCE_ROOTS) return [];
122
+ return roots.filter((r) => typeof r === 'string' && SOURCE_ROOT_NAME.test(r) && !PREFIX_WORDS.has(r));
123
+ }
124
+
125
+ function useSourceRoots(roots) {
126
+ const escaped = (roots || []).map((r) => r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
127
+ const alt = [SRC_PREFIXES, ...escaped].join('|');
128
+ SRC_PATH = new RegExp(`(?:^|\\s|["'])(${alt})/`);
129
+ SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${alt})/`);
130
+ SRC_BARE_TOKEN = new RegExp(`^(?:(?:${alt})|\\./(?:${alt})(?:/.*)?)$`);
131
+ }
132
+ useSourceRoots([]);
98
133
 
99
134
  // D#73 — a source dir written as a bare word (`grep -rn X src`, `rg X tests`,
100
135
  // `./src`): the shape models write when a prompt says "under src/", and it
@@ -826,6 +861,135 @@ function extractSedReadTargets(cmd) {
826
861
  return out;
827
862
  }
828
863
 
864
+ // Q1 — `sed -i` and `perl -pi` edit files the edit hooks never see. Their
865
+ // targets are logged for the Stop hook's signature check (never answered).
866
+ // Shapes: tasks/specs/edit-log-coverage.md. A word only counts as a target
867
+ // when it names an existing regular file, so a misread costs a few baseline
868
+ // records the Stop check then finds unchanged; a command the tokenizer cannot
869
+ // read exactly (`$x`, globs) is skipped, which is the state before Q1.
870
+ const MAX_IN_PLACE_TARGETS = 8;
871
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
872
+
873
+ /**
874
+ * Absolute paths of the files an in-place `sed`/`perl` in `cmd` edits, at
875
+ * most MAX_IN_PLACE_TARGETS, in command order.
876
+ * @param {string} cmd
877
+ * @param {string} shellCwd where the command starts
878
+ * @param {{isFile?: (abs:string)=>boolean, isDir?: (abs:string)=>boolean}} [opts]
879
+ * @returns {string[]}
880
+ */
881
+ function extractInPlaceEditTargets(cmd, shellCwd, { isFile = isRegularFile, isDir = isDirectory } = {}) {
882
+ if (!cmd || typeof cmd !== 'string' || cmd.length > 4000 || !/\b(?:sed|perl)\b/.test(cmd)) return [];
883
+ const segments = splitTopLevelSegments(cmd);
884
+ const seps = segmentSeparators(cmd);
885
+ const out = [];
886
+ for (let i = 0; i < segments.length && out.length < MAX_IN_PLACE_TARGETS; i++) {
887
+ const words = shellWords(segments[i]);
888
+ if (!words) continue;
889
+ let k = 0;
890
+ while (k < words.length && !words[k].op && !words[k].anyQuoted && ENV_ASSIGNMENT.test(words[k].text)) k++;
891
+ const head = words[k];
892
+ if (!head || head.op || head.anyQuoted || (head.text !== 'sed' && head.text !== 'perl')) continue;
893
+ const args = [];
894
+ for (let j = k + 1; j < words.length && !words[j].op; j++) args.push(words[j]);
895
+ const operands = head.text === 'sed' ? sedInPlaceFiles(args) : perlInPlaceFiles(args);
896
+ if (operands.length === 0) continue;
897
+ const cwd = segmentCwd(segments, i, shellCwd, { isDir, seps });
898
+ if (cwd === null) continue;
899
+ for (const w of operands) {
900
+ const abs = path.resolve(cwd, w);
901
+ if (!out.includes(abs) && out.length < MAX_IN_PLACE_TARGETS && isFile(abs)) out.push(abs);
902
+ }
903
+ }
904
+ return out;
905
+ }
906
+
907
+ // Quoting does not decide what is an option: the shell removes the quotes
908
+ // before sed or perl sees `--expression='s/a/b/'` or `'-i'`.
909
+ //
910
+ // Plain operand words, a redirect and its target dropped. A word with a bare
911
+ // glob or redirect character is not a file name the shell passes unchanged.
912
+ function operandWords(words) {
913
+ const out = [];
914
+ for (let j = 0; j < words.length; j++) {
915
+ const w = words[j];
916
+ if (w.bareOp) {
917
+ if (/[<>]$/.test(w.text)) j++; // `> out`: the next word is the redirect's target
918
+ continue;
919
+ }
920
+ if (!w.bareSpecial) out.push(w.text);
921
+ }
922
+ return out;
923
+ }
924
+
925
+ // GNU sed: `-i[SUFFIX]` / `--in-place[=SUFFIX]` (a suffix is attached, so in a
926
+ // cluster the rest of the word is it: `-ie` = suffix `e`); `-e`/`-f`/`-l` and
927
+ // their long forms take a value. The first operand is the script unless `-e`
928
+ // or `-f` gave one. BSD `-i ''` reads as GNU `-i` with script `''`, and its
929
+ // real script then fails the existing-file test.
930
+ function sedInPlaceFiles(words) {
931
+ let inPlace = false;
932
+ let haveScript = false;
933
+ let endOfOpts = false;
934
+ const operands = [];
935
+ for (let j = 0; j < words.length; j++) {
936
+ const w = words[j];
937
+ const t = w.text;
938
+ if (endOfOpts || !t.startsWith('-') || t === '-') { operands.push(w); continue; }
939
+ if (t === '--') { endOfOpts = true; continue; }
940
+ if (t.startsWith('--')) {
941
+ const [name, value] = [t.slice(2).split('=')[0], t.includes('=')];
942
+ if (name === 'in-place') inPlace = true;
943
+ else if (name === 'expression' || name === 'file') { haveScript = true; if (!value) j++; }
944
+ else if (name === 'line-length' && !value) j++;
945
+ continue;
946
+ }
947
+ for (let c = 1; c < t.length; c++) {
948
+ const ch = t[c];
949
+ if (ch === 'i') { inPlace = true; break; }
950
+ if (ch === 'e' || ch === 'f' || ch === 'l') {
951
+ if (ch !== 'l') haveScript = true;
952
+ if (c === t.length - 1) j++;
953
+ break;
954
+ }
955
+ }
956
+ }
957
+ if (!inPlace) return [];
958
+ return operandWords(haveScript ? operands : operands.slice(1));
959
+ }
960
+
961
+ // perl: `-i[EXT]` (the rest of the word is the extension: `-pie` = `-p -i`
962
+ // with extension `e`); `-e`/`-E` take the code (rest of the word, else the
963
+ // next word), `-M`/`-I`/`-F` a value the same way. With no `-e`, the first
964
+ // operand is the script file.
965
+ function perlInPlaceFiles(words) {
966
+ let inPlace = false;
967
+ let haveCode = false;
968
+ let endOfOpts = false;
969
+ const operands = [];
970
+ for (let j = 0; j < words.length; j++) {
971
+ const w = words[j];
972
+ const t = w.text;
973
+ if (endOfOpts || !t.startsWith('-') || t === '-') { operands.push(w); continue; }
974
+ if (t === '--') { endOfOpts = true; continue; }
975
+ for (let c = 1; c < t.length; c++) {
976
+ const ch = t[c];
977
+ if (ch === 'i') { inPlace = true; break; }
978
+ if ('eEMIF'.includes(ch)) {
979
+ if (ch === 'e' || ch === 'E') haveCode = true;
980
+ if (c === t.length - 1) j++;
981
+ break;
982
+ }
983
+ }
984
+ }
985
+ if (!inPlace) return [];
986
+ return operandWords(haveCode ? operands : operands.slice(1));
987
+ }
988
+
989
+ function isRegularFile(p) {
990
+ try { return fs.statSync(p).isFile(); } catch { return false; }
991
+ }
992
+
829
993
  // v0.50 — a compound command (`grep …; sed -n 1,60p f` / `grep … && wc`) is
830
994
  // denied WHOLE, but the answer covers only the grep. The 2026-06-13 mem-project
831
995
  // deny swallowed a `; sed` read while the copy said "use these results directly
@@ -1120,7 +1284,7 @@ function buildHint() {
1120
1284
  ' • code-graph-mcp ast-search "<pat>" --type fn # filter by type/returns/params',
1121
1285
  ' • code-graph-mcp callgraph SYMBOL # callers + callees, repo-wide',
1122
1286
  ' • code-graph-mcp show SYMBOL # one symbol: signature + source',
1123
- 'Repo-wide index (LSP only sees open files). Skip this hint if you specifically need raw-text regex.',
1287
+ 'Repo-wide index. Skip this hint if you specifically need raw-text regex.',
1124
1288
  ].join('\n');
1125
1289
  }
1126
1290
 
@@ -1459,7 +1623,48 @@ function rgIgnoreAbove(dir) {
1459
1623
  function isSymlink(p, { missing = true } = {}) {
1460
1624
  try { return fs.lstatSync(p).isSymbolicLink(); } catch { return missing; }
1461
1625
  }
1462
- function searchesSameFiles({ root, target, verb, show = false } = {}) {
1626
+ // F1b — `grep -r` reads git-ignored files and the rewrite does not, so an
1627
+ // ignored entry under the path declines. The one nearly every Python tree has
1628
+ // once its tests ran is a `__pycache__/` of `.pyc` files (the coding-eval
1629
+ // fixtures carry 286), and a binary file yields no matched line: grep notes
1630
+ // "binary file matches" instead (stderr in GNU grep 3.5+, stdout in BSD grep
1631
+ // and ugrep), a notice, not a hit. So a bytecode cache may differ between the
1632
+ // two — for a grep that does not list files (see grepListsBinaryMatches).
1633
+ const BYTECODE_FILE = /\.py[co]$/;
1634
+ function isBytecodeCache(root, rel) {
1635
+ const r = rel.replace(/\/+$/, '');
1636
+ if (BYTECODE_FILE.test(r)) return true;
1637
+ if (path.posix.basename(r) !== '__pycache__') return false;
1638
+ let entries;
1639
+ try { entries = fs.readdirSync(path.join(root, r), { withFileTypes: true }); } catch { return false; }
1640
+ return entries.length <= WALK_LIMIT && entries.every((e) => e.isFile() && BYTECODE_FILE.test(e.name));
1641
+ }
1642
+
1643
+ /// True when the grep would PRINT a matching binary file: it lists files
1644
+ /// (`-l`, `--files-with-matches`) and does not skip binaries (`-I`). The
1645
+ /// rewrite grammar admits no other output mode that prints one for grep
1646
+ /// (`-c`, `-L`, `-a`, `-o` are refused). Only the grep's own clause counts.
1647
+ function grepListsBinaryMatches(cmd) {
1648
+ const words = clauseWords(firstShellClause(cmd || '').replace(VERB_STRIP, ''));
1649
+ if (!words) return true;
1650
+ let lists = false;
1651
+ let skipsBinary = false;
1652
+ // Quoted words count too: the shell strips the quotes and grep still reads
1653
+ // `"-l"` as an option. A quoted PATTERN that merely contains ` -l ` has a
1654
+ // space in it and never matches the one-cluster shape below.
1655
+ for (const w of words) {
1656
+ const t = w.text;
1657
+ if (t === '--files-with-matches') lists = true;
1658
+ else if (t === '--binary-files=without-match') skipsBinary = true;
1659
+ else if (/^-[A-Za-z0-9]+$/.test(t)) {
1660
+ if (t.includes('l')) lists = true;
1661
+ if (t.includes('I')) skipsBinary = true;
1662
+ }
1663
+ }
1664
+ return lists && !skipsBinary;
1665
+ }
1666
+
1667
+ function searchesSameFiles({ root, target, verb, show = false, binaryCacheOk = false } = {}) {
1463
1668
  if (!root || !['grep', 'git', 'rg', 'ag'].includes(verb)) return false;
1464
1669
  const rel = target === undefined ? '' : String(target).replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
1465
1670
  if (rel.split('/').includes('..') || path.isAbsolute(rel)) return false;
@@ -1503,7 +1708,8 @@ function searchesSameFiles({ root, target, verb, show = false } = {}) {
1503
1708
  const anyHidden = (l) => l.some((f) => HIDDEN_SEGMENT.test(f));
1504
1709
  let reads;
1505
1710
  if (verb === 'grep') {
1506
- if (anyHidden(others) || ignoredOthers.length || ignoredTracked.length) return false;
1711
+ const ignored = binaryCacheOk ? ignoredOthers.filter((f) => !isBytecodeCache(root, f)) : ignoredOthers;
1712
+ if (anyHidden(others) || ignored.length || ignoredTracked.length) return false;
1507
1713
  reads = [...tracked, ...others];
1508
1714
  } else if (verb === 'git') {
1509
1715
  if (others.length) return false;
@@ -1998,15 +2204,17 @@ function isAnswerDisabled(env = process.env) {
1998
2204
  // Smallest per-dir share of the context cap worth spending on an overview answer.
1999
2205
  const MIN_FANOUT_ANSWER_BYTES = 400;
2000
2206
 
2001
- /// Byte budget for each overview answer when several dirs fire in one command
2002
- /// (undefined for one dir: the answer keeps its own default). Every hint first
2207
+ /// Byte budget for each overview answer of the dirs that fire in one command.
2208
+ /// A single dir is budgeted too: left at the answer's own 4,000-byte default,
2209
+ /// its header pushed the hint past the envelope cap and the overview was
2210
+ /// replaced by a bare "5+ Reads also into …" line (hook audit P2-5). Every hint first
2003
2211
  /// pays for its own lines — an answer's header, footer and separator are about
2004
2212
  /// 175 bytes plus the dir name twice, the one-line advice about 165 plus the
2005
2213
  /// name three times — reserved as 200 plus the name three times, and the
2006
2214
  /// answers share what is left of the cap. A share under MIN_FANOUT_ANSWER_BYTES
2007
2215
  /// returns 0: every dir then gets the one-line advice instead.
2008
2216
  function fanoutAnswerBudget(dirs) {
2009
- if (dirs.length < 2) return undefined;
2217
+ if (dirs.length < 1) return undefined;
2010
2218
  const reserved = dirs.reduce((sum, d) => sum + 200 + 3 * Buffer.byteLength(d, 'utf8'), 0);
2011
2219
  const share = Math.floor((MAX_INJECTED_BYTES - reserved) / dirs.length);
2012
2220
  return share >= MIN_FANOUT_ANSWER_BYTES ? share : 0;
@@ -2039,6 +2247,7 @@ function runMain() {
2039
2247
  const shellCwd = process.cwd();
2040
2248
  const root = resolveProjectRoot(shellCwd);
2041
2249
  if (root === null) return; // no index anywhere up to $HOME — no hint
2250
+ useSourceRoots(readSourceRoots(root));
2042
2251
 
2043
2252
  let input;
2044
2253
  try {
@@ -2050,6 +2259,18 @@ function runMain() {
2050
2259
 
2051
2260
  const rawCmd = (input.tool_input && input.tool_input.command) || '';
2052
2261
 
2262
+ // Q1 — an in-place `sed`/`perl` is logged for the Stop check, then the
2263
+ // command goes on as it would have (nothing here answers or rewrites it).
2264
+ const inPlace = extractInPlaceEditTargets(rawCmd, shellCwd);
2265
+ if (inPlace.length > 0) {
2266
+ const sessionEdits = require('./session-edits');
2267
+ for (const abs of inPlace) {
2268
+ const rel = path.relative(root, abs);
2269
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
2270
+ sessionEdits.recordFileEdit(root, input.session_id, rel.split(path.sep).join('/'));
2271
+ }
2272
+ }
2273
+
2053
2274
  // v0.49 — sed-range reads count toward the read-fanout state (the Read hook
2054
2275
  // never sees Bash-side file reads). A fired fanout hint already delivered an
2055
2276
  // overview — skip grep hinting for this command to avoid double output.
@@ -2077,8 +2298,15 @@ function runMain() {
2077
2298
  }
2078
2299
  if (firedDirs.length > 0) {
2079
2300
  const maxBytes = fanoutAnswerBudget(firedDirs);
2080
- const hints = firedDirs.map((dir) => readGuide.buildFanoutHint(root, dir, { maxBytes }));
2081
- process.stdout.write(emitPreToolContext(joinFanoutHints(firedDirs, hints)) + '\n');
2301
+ // A dir with no overview contributes nothing (buildFanoutHint → null);
2302
+ // it stays marked, so it does not fire again.
2303
+ const answered = firedDirs
2304
+ .map((dir) => ({ dir, hint: readGuide.buildFanoutHint(root, dir, { maxBytes }) }))
2305
+ .filter((h) => h.hint !== null);
2306
+ if (answered.length > 0) {
2307
+ process.stdout.write(emitPreToolContext(
2308
+ joinFanoutHints(answered.map((h) => h.dir), answered.map((h) => h.hint))) + '\n');
2309
+ }
2082
2310
  return;
2083
2311
  }
2084
2312
  }
@@ -2232,6 +2460,7 @@ function runMain() {
2232
2460
  // D#133, H-2).
2233
2461
  if (plan && !searchesSameFiles({
2234
2462
  root, target: extractSearchPath(cmd), verb: plan.verb, show: answeredMode === 'show',
2463
+ binaryCacheOk: !grepListsBinaryMatches(cmd),
2235
2464
  })) return;
2236
2465
 
2237
2466
  const answered = answer.status === 'hits';
@@ -2311,6 +2540,8 @@ if (require.main === module) {
2311
2540
  }
2312
2541
 
2313
2542
  module.exports = {
2543
+ grepListsBinaryMatches,
2544
+ useSourceRoots, readSourceRoots, MAX_SOURCE_ROOTS,
2314
2545
  shouldHint,
2315
2546
  shouldBlock,
2316
2547
  classifyBlock, // v0.49 — intent-aware block tiers
@@ -2330,6 +2561,7 @@ module.exports = {
2330
2561
  segmentCwd, // D#76 — the directory a compound command's segment runs in
2331
2562
  segmentSeparators, // D#76 — the separator before each top-level segment
2332
2563
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
2564
+ extractInPlaceEditTargets, // Q1 — `sed -i` / `perl -pi` targets feed the Stop check's edit log
2333
2565
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
2334
2566
  extractPatterns, // v0.32.1 — exposed for tests
2335
2567
  countNamedPaths, // v0.70 — multi-path deny→hint downgrade
@@ -19,8 +19,9 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Pr
19
19
  // 2. file_path is under CWD (no escape to absolute paths outside the project)
20
20
  // 3. file_path is not at CWD root (top-level files = config / one-off scripts)
21
21
  // 4. .code-graph/index.db exists in CWD (project is indexed)
22
- // 5. ≥5 prior Reads to the SAME parent dir tracked in /tmp state
23
- // 6. Same-dir cooldown not active (5 min)
22
+ // 5. ≥5 DISTINCT files read in the SAME parent dir, tracked in /tmp state
23
+ // 6. The dir has not fired before while its state entry lives
24
+ // 7. The CLI delivers an overview for the dir (no advice-only line)
24
25
  //
25
26
  // State scoping: per-cwd (NOT per-session). Cost: two concurrent sessions in
26
27
  // the same project might share counters and over-trigger by ~1 hint each.
@@ -35,22 +36,17 @@ const path = require('path');
35
36
  const crypto = require('crypto');
36
37
  const { cgTmpDir } = require('./tmp-dir');
37
38
  const { recordRecommendation } = require('./recommendation-log');
38
- const { resolveProjectRoot } = require('./project-root');
39
+ const { resolveProjectRoot, indexBuildInProgress } = require('./project-root');
39
40
  const { runOverviewAnswer } = require('./cg-answer');
40
41
  const { emitPreToolAllowContext } = require('./hook-emit');
41
42
 
42
43
  // --- Configuration ---
43
44
 
44
- // Hint fires on the (FANOUT_THRESHOLD + 1)-th Read into the same dir.
45
+ // Hint fires on the (FANOUT_THRESHOLD + 1)-th DISTINCT file read in the same dir.
45
46
  // Set so that 4 reads stay quiet (legitimate "read a couple files to
46
- // understand X" pattern); 5+ reads is the fanout we want to catch.
47
+ // understand X" pattern); 5+ files is the fanout we want to catch.
47
48
  const FANOUT_THRESHOLD = 4;
48
49
 
49
- // Per-dir cooldown after firing a hint. Prevents spam if Claude keeps
50
- // reading the same dir after seeing the hint (e.g., still has 3 more
51
- // files queued from a prior plan).
52
- const COOLDOWN_MS = 5 * 60 * 1000;
53
-
54
50
  // Entries older than this are pruned on load. Long enough to survive
55
51
  // normal multi-step tasks (15-20 min typical), short enough that stale
56
52
  // per-cwd state doesn't accumulate across days.
@@ -104,11 +100,23 @@ function saveState(cwd, state) {
104
100
  } catch { /* ok */ }
105
101
  }
106
102
 
107
- function recordRead(state, dir, now = Date.now()) {
103
+ // Distinct files remembered per dir. Past this the count is already far over
104
+ // the threshold, so the list stops growing and so does the state file.
105
+ const MAX_FILES_PER_DIR = 32;
106
+
107
+ /// Count a read of `file` (when given) in `dir`. Re-reading one file does not
108
+ /// count again: five chunked reads of one big file are not a fanout, and they
109
+ /// fired an overview of its whole parent dir (hook audit 2026-09-28).
110
+ function recordRead(state, dir, now = Date.now(), file) {
108
111
  if (!state.by_dir[dir]) state.by_dir[dir] = { reads: 0, last_read_at: 0, last_hint_at: 0 };
109
112
  const e = state.by_dir[dir];
110
- e.reads += 1;
111
113
  e.last_read_at = now;
114
+ if (file) {
115
+ if (!Array.isArray(e.files)) e.files = [];
116
+ if (e.files.includes(file)) return;
117
+ if (e.files.length < MAX_FILES_PER_DIR) e.files.push(file);
118
+ }
119
+ e.reads += 1;
112
120
  }
113
121
 
114
122
  function shouldHint(state, dir, now = Date.now()) {
@@ -116,7 +124,10 @@ function shouldHint(state, dir, now = Date.now()) {
116
124
  const e = state.by_dir[dir];
117
125
  if (!e) return false;
118
126
  if (e.reads < FANOUT_THRESHOLD + 1) return false; // need >=5
119
- if (e.last_hint_at && (now - e.last_hint_at < COOLDOWN_MS)) return false;
127
+ // Once per dir while its entry lives (pruned STATE_TTL_MS after the last
128
+ // read). The old 5-minute re-fire re-sent the same overview: 31.7% of fanout
129
+ // hints in 2026-09 sessions repeated a text the session already had.
130
+ if (e.last_hint_at) return false;
120
131
  return true;
121
132
  }
122
133
 
@@ -125,18 +136,13 @@ function markHint(state, dir, now = Date.now()) {
125
136
  state.by_dir[dir].last_hint_at = now;
126
137
  }
127
138
 
128
- function buildHint(dir) {
129
- // Single-line, ~190-byte budget. Skip-clause matches pre-grep-guide voice.
130
- return `[code-graph] 5+ Reads into ${dir}/ — \`code-graph-mcp overview ${dir}/\` gives symbols+callers in one call (MCP: \`module_overview path=${dir}\`). Skip if you need raw file contents.`;
131
- }
132
-
133
139
  // v0.49 — the hint DELIVERS the overview instead of advising a tool call
134
140
  // (advice measured 0/40 transfer on 2026-06-12; delivered answers satisfied
135
141
  // 5/5 in place). Falls back to the advice-only line when the CLI is
136
142
  // unavailable or the dir has no overview.
137
143
  function buildHintWithAnswer(dir, answer) {
138
144
  const lines = [
139
- `[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index (saves the remaining file-by-file reads):`,
145
+ `[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index:`,
140
146
  answer.text,
141
147
  ];
142
148
  if (answer.truncated) {
@@ -167,9 +173,11 @@ function trackRead(root, rel, now = Date.now()) {
167
173
  if (!dir || dir === '.' || dir === '') return null; // top-level file: not fanout
168
174
 
169
175
  const state = loadState(root, now);
170
- recordRead(state, dir, now);
176
+ recordRead(state, dir, now, rel);
171
177
  let fired = false;
172
- if (shouldHint(state, dir, now)) {
178
+ // During the startup build the overview would list part of the dir as the
179
+ // whole module (D5); leave the hint unspent for the first read after it.
180
+ if (!indexBuildInProgress(root, now) && shouldHint(state, dir, now)) {
173
181
  markHint(state, dir, now);
174
182
  fired = true;
175
183
  }
@@ -184,9 +192,15 @@ function trackRead(root, rel, now = Date.now()) {
184
192
  return dir;
185
193
  }
186
194
 
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.
195
+ /// The fanout hint text for `dir` with the overview answer embedded, or null
196
+ /// when the CLI delivers none. `maxBytes` bounds that answer — the pre-grep sed
197
+ /// path splits one envelope's budget between several dirs.
198
+ ///
199
+ /// No overview, no hint. The advice-only line measured no effect twice: 0/40
200
+ /// transfer on 2026-06-12, and 18/672 follow-through against a 2.76% baseline in
201
+ /// 2026-09 sessions, where 572 of the 672 advised `overview tests/` — which
202
+ /// answers "No symbols found", since overview leaves test symbols out. The
203
+ /// unanswered hint is still recorded, so the funnel keeps its reasons.
190
204
  function buildFanoutHint(root, dir, { maxBytes } = {}) {
191
205
  let answer = { status: 'unavailable' };
192
206
  if (maxBytes === 0) {
@@ -212,11 +226,11 @@ function buildFanoutHint(root, dir, { maxBytes } = {}) {
212
226
  ...(answered ? {} : { reason: answer.status }),
213
227
  ...(answered || !answer.reason ? {} : { fallthrough_reason: answer.reason }),
214
228
  });
215
- return answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
229
+ return answered ? buildHintWithAnswer(dir, answer) : null;
216
230
  }
217
231
 
218
232
  /// trackRead + buildFanoutHint for one read. Returns the hint text when the
219
- /// hint fired, else null. The caller emits it.
233
+ /// hint fired with an overview, else null. The caller emits it.
220
234
  function trackReadAndMaybeHint(root, rel, now = Date.now()) {
221
235
  const dir = trackRead(root, rel, now);
222
236
  return dir === null ? null : buildFanoutHint(root, dir);
@@ -263,8 +277,8 @@ if (require.main === module) {
263
277
  module.exports = {
264
278
  isSourceFile, dirOf, cwdHash, statePath,
265
279
  loadState, saveState, recordRead, shouldHint, markHint,
266
- buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
280
+ buildHintWithAnswer, isSilenced, isAnswerDisabled,
267
281
  trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
268
282
  trackRead, buildFanoutHint, // the sed path's two halves: one envelope for every dir that fired
269
- FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
283
+ FANOUT_THRESHOLD, STATE_TTL_MS, SRC_EXT,
270
284
  };
@@ -124,4 +124,26 @@ function resolveProjectRoot(startDir, opts = {}) {
124
124
  return null;
125
125
  }
126
126
 
127
- module.exports = { resolveProjectRoot };
127
+ // True while the MCP server's startup index is still being written for `root`.
128
+ // The server writes `.code-graph/indexing-status.json` ({s, d, t}) and heartbeats
129
+ // it per batch and per finalize phase, then removes it. Until then the index is
130
+ // partial, and a structural answer from it is wrong, not just short: 0.3 s into
131
+ // a cold networkx build `impact edge_betweenness_centrality` said "0 callers,
132
+ // Risk UNKNOWN"; the finished index has 4 callers, MEDIUM (2026-09-29, D5).
133
+ // Same test as statusline.js, whose INDEXING_STALE_MS mirrors the Rust
134
+ // INDEXING_STATUS_STALE_SECS: an older file was left by a killed server.
135
+ const INDEXING_STALE_MS = 120000;
136
+
137
+ function indexBuildInProgress(root, now = Date.now()) {
138
+ if (!root) return false;
139
+ const file = path.join(root, '.code-graph', 'indexing-status.json');
140
+ try {
141
+ if (now - fs.statSync(file).mtimeMs >= INDEXING_STALE_MS) return false;
142
+ const p = JSON.parse(fs.readFileSync(file, 'utf8'));
143
+ return (p.s === 'indexing' || p.s === 'finalizing') && p.t > 0;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ module.exports = { resolveProjectRoot, indexBuildInProgress, INDEXING_STALE_MS };
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // Per-session edit log shared by the PreToolUse(Edit) hook (writer) and the
4
- // Stop hook (reader) — P1 #3.
3
+ // Per-session edit log shared by the PreToolUse hooks (writers: pre-edit-guide
4
+ // for Edit/Write, pre-grep-guide for `sed -i`/`perl -pi`) and the
5
+ // Stop hook (reader) — P1 #3, Q1.
5
6
  //
6
7
  // Two files per (project, session), both in the shared cgTmpDir() so the
7
8
  // SessionStart prune (24 h, mtime) and `uninstall`'s wholesale removal of that
@@ -84,6 +85,59 @@ function recordEdit(root, sessionId, { file, symbol = null }, now = Date.now())
84
85
  } catch { return false; }
85
86
  }
86
87
 
88
+ // Q1: an edit that names no definition (Write over an existing file, `sed -i`,
89
+ // `perl -pi`) baselines the file's definitions at once. Bounded so the hook
90
+ // stays inside its budget: a larger file is logged as a file only, and at
91
+ // most this many definitions — the first in the file — get a record.
92
+ const MAX_FILE_SCAN_BYTES = 256 * 1024;
93
+ const MAX_FILE_DEFINITIONS = 64;
94
+
95
+ /**
96
+ * Log an edit that names no definition: one file record, then one baselined
97
+ * record per definition of the file as it is now (before the edit). With
98
+ * `newText` (a Write's content) only definitions whose header the new text
99
+ * changes are recorded — the Stop hook would find the rest unchanged. Without
100
+ * it (`sed -i`: the result is unknown until the command runs) every
101
+ * definition is, and the Stop hook sorts them out. One append, best-effort.
102
+ * @param {string} root project root
103
+ * @param {string} sessionId
104
+ * @param {string} file root-relative
105
+ * @param {{newText?: string|null}} [opts]
106
+ * @returns {number} records written
107
+ */
108
+ function recordFileEdit(root, sessionId, file, { newText = null } = {}, now = Date.now()) {
109
+ const p = editsPath(root, sessionId);
110
+ if (!p || !file) return 0;
111
+ const recs = [{ ts: now, file, symbol: null }];
112
+ for (const [symbol, sigs] of fileBaselines(root, file, newText)) recs.push({ ts: now, file, symbol, sigs });
113
+ try {
114
+ fs.appendFileSync(p, recs.map((r) => JSON.stringify(r) + '\n').join(''));
115
+ return recs.length;
116
+ } catch { return 0; }
117
+ }
118
+
119
+ function fileBaselines(root, file, newText) {
120
+ try {
121
+ const abs = path.join(root, file);
122
+ if (fs.statSync(abs).size > MAX_FILE_SCAN_BYTES) return [];
123
+ const { allSignatures, extractSignatures, signatureChanged } = require('./stop-impact');
124
+ const ext = path.extname(file).toLowerCase();
125
+ const all = allSignatures(fs.readFileSync(abs, 'utf8'), ext);
126
+ if (!all) return [];
127
+ const out = [];
128
+ for (const [symbol, sigs] of all) {
129
+ if (out.length === MAX_FILE_DEFINITIONS) break;
130
+ if (sigs.length > MAX_BASELINE_SIGS) continue;
131
+ if (typeof newText === 'string') {
132
+ const after = extractSignatures(newText, symbol, ext);
133
+ if (!after || after.length === 0 || !signatureChanged(sigs, after, symbol)) continue;
134
+ }
135
+ out.push([symbol, sigs]);
136
+ }
137
+ return out;
138
+ } catch { return []; }
139
+ }
140
+
87
141
  /** Every well-formed record in the session log; a torn or foreign line is skipped. */
88
142
  function readEdits(root, sessionId) {
89
143
  const p = editsPath(root, sessionId);
@@ -137,5 +191,6 @@ function writeStopState(root, sessionId, state) {
137
191
  }
138
192
 
139
193
  module.exports = {
140
- sessionKey, editsPath, stopStatePath, recordEdit, readEdits, readStopState, writeStopState,
194
+ sessionKey, editsPath, stopStatePath, recordEdit, recordFileEdit, readEdits, readStopState, writeStopState,
195
+ MAX_FILE_DEFINITIONS,
141
196
  };