@sdsrs/code-graph 0.68.0 → 0.70.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.
|
@@ -65,10 +65,20 @@ const SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`);
|
|
|
65
65
|
const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`);
|
|
66
66
|
const PIPE_INTO_GREP = /\|\s*(?:grep|rg|ag)\b/;
|
|
67
67
|
const CG_INVOKED = /\bcode-graph-mcp\b/;
|
|
68
|
-
//
|
|
69
|
-
// path
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// File argument(s) that end in a config/lockfile/data extension. If, after removing
|
|
69
|
+
// ALL of them, no source-tree path remains, the grep is searching config/data not code.
|
|
70
|
+
// v0.69 floor-hardening: (a) extended the extension list (ini/conf/xml/log/csv) and
|
|
71
|
+
// (b) made the strip GLOBAL so multiple data files (`grep X src/a.json src/b.json`) all
|
|
72
|
+
// peel off — previously only the first did, leaving the 2nd's `src/`-prefixed path to
|
|
73
|
+
// false-match SRC_PATH and fire. cg has no structural answer for these, so a deny is
|
|
74
|
+
// friction-without-value that teaches CODE_GRAPH_NO_BLOCK_GREP bypass (2026-06-23 reach
|
|
75
|
+
// audit: the unreached ~75% of greps are genuinely non-foldable — keep precision).
|
|
76
|
+
const NON_SOURCE_EXTS =
|
|
77
|
+
'toml|md|json|yml|yaml|lock|txt|cfg|env|gitignore|properties|ini|conf|xml|log|csv';
|
|
78
|
+
const CONFIG_TARGET_ONLY = new RegExp(`(?:^|\\s)[^\\s|<>]*\\.(?:${NON_SOURCE_EXTS})(?:\\s|$)`, 'i');
|
|
79
|
+
// Global + trailing-lookahead variant for the strip: lookahead (not consume) so adjacent
|
|
80
|
+
// data-file tokens both match; global so every one is peeled before the SRC_PATH re-check.
|
|
81
|
+
const CONFIG_TARGET_STRIP = new RegExp(`(?:^|\\s)[^\\s|<>]*\\.(?:${NON_SOURCE_EXTS})(?=\\s|$)`, 'gi');
|
|
72
82
|
|
|
73
83
|
function shouldHint(cmd) {
|
|
74
84
|
if (!cmd || typeof cmd !== 'string') return false;
|
|
@@ -79,7 +89,7 @@ function shouldHint(cmd) {
|
|
|
79
89
|
if (!SRC_PATH.test(cmd)) return false; // not against indexed source tree
|
|
80
90
|
// If a config file appears AND no source path remains after stripping it, skip.
|
|
81
91
|
if (CONFIG_TARGET_ONLY.test(cmd)) {
|
|
82
|
-
const stripped = cmd.replace(
|
|
92
|
+
const stripped = cmd.replace(CONFIG_TARGET_STRIP, ' ');
|
|
83
93
|
if (!SRC_PATH.test(stripped)) return false;
|
|
84
94
|
}
|
|
85
95
|
return true;
|
|
@@ -156,6 +166,12 @@ function classifyBlock(cmd) {
|
|
|
156
166
|
if (symbols.length === 0) return null; // context read without named decls
|
|
157
167
|
return { mode: 'show', symbols: symbols.slice(0, 3) };
|
|
158
168
|
}
|
|
169
|
+
// v0.70 — only DENY when the inline grep answer can cover the SAME scope. It scopes to one
|
|
170
|
+
// path (extractSearchPath = first src-prefixed token), so a grep naming ≥2 file paths gets a
|
|
171
|
+
// first-path-only answer (the rest silently dropped) — an incomplete substitute that
|
|
172
|
+
// rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade to hint: the model's complete
|
|
173
|
+
// grep runs and the hint still nudges. (show mode above is symbol-scoped, not path → unaffected.)
|
|
174
|
+
if (countNamedPaths(cmd, patterns) >= 2) return null;
|
|
159
175
|
return { mode: 'grep' };
|
|
160
176
|
}
|
|
161
177
|
|
|
@@ -293,6 +309,37 @@ function extractSearchPath(cmd) {
|
|
|
293
309
|
return undefined;
|
|
294
310
|
}
|
|
295
311
|
|
|
312
|
+
// v0.70 — count the explicit file/dir path arguments a grep names (excluding flags and
|
|
313
|
+
// the quoted search pattern). The deny's inline answer scopes to ONE path
|
|
314
|
+
// (extractSearchPath returns only the first source-prefixed token), so a grep naming ≥2
|
|
315
|
+
// paths gets an answer covering only the first — an incomplete substitute that rationally
|
|
316
|
+
// drives CODE_GRAPH_NO_BLOCK_GREP bypass (2026-06-23: the dominant observed bypass was a
|
|
317
|
+
// multi-file named grep whose deny silently dropped the other files). classifyBlock uses
|
|
318
|
+
// this to downgrade those denies to a hint (which still nudges) so the complete grep runs.
|
|
319
|
+
function countNamedPaths(cmd, patterns) {
|
|
320
|
+
if (!cmd || typeof cmd !== 'string') return 0;
|
|
321
|
+
const pats = new Set(patterns || []);
|
|
322
|
+
// Only the grep's OWN path args count. Stop at the first top-level command separator so a
|
|
323
|
+
// path in a compound tail (`grep X src/a.py | sed … src/b.py`) is NOT mistaken for a second
|
|
324
|
+
// grep target — that would wrongly downgrade a complete single-file grep to a hint.
|
|
325
|
+
let seg = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
|
|
326
|
+
let quote = null;
|
|
327
|
+
for (let i = 0; i < seg.length; i++) {
|
|
328
|
+
const c = seg[i];
|
|
329
|
+
if (quote) { if (c === quote) quote = null; continue; }
|
|
330
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
331
|
+
if (c === ';' || c === '|' || c === '&' || c === '>' || c === '<' || c === '\n') { seg = seg.slice(0, i); break; }
|
|
332
|
+
}
|
|
333
|
+
let n = 0;
|
|
334
|
+
for (const raw of seg.split(/\s+/)) {
|
|
335
|
+
const tok = raw.replace(/^["']|["']$/g, '');
|
|
336
|
+
if (!tok || tok.startsWith('-')) continue; // a flag
|
|
337
|
+
if (pats.has(tok)) continue; // the search pattern, not a path
|
|
338
|
+
if (tok.includes('/') || /\.[A-Za-z0-9]{1,6}$/.test(tok)) n++; // dir-sep or file extension
|
|
339
|
+
}
|
|
340
|
+
return n;
|
|
341
|
+
}
|
|
342
|
+
|
|
296
343
|
// v0.47.0 — the pattern that justified the block: first identifier-like one.
|
|
297
344
|
function pickBlockPattern(cmd) {
|
|
298
345
|
return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p));
|
|
@@ -597,6 +644,7 @@ module.exports = {
|
|
|
597
644
|
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
598
645
|
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
599
646
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
647
|
+
countNamedPaths, // v0.70 — multi-path deny→hint downgrade
|
|
600
648
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
601
649
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|
|
602
650
|
resolveProjectRoot, // v0.48 — subdir-cwd dark fix
|
|
@@ -5,6 +5,7 @@ const {
|
|
|
5
5
|
shouldHint,
|
|
6
6
|
shouldBlock,
|
|
7
7
|
classifyBlock,
|
|
8
|
+
countNamedPaths,
|
|
8
9
|
extractDeclSymbols,
|
|
9
10
|
translateBreToRg,
|
|
10
11
|
buildShowDenyReason,
|
|
@@ -108,6 +109,42 @@ test('shouldHint: grep on a markdown changelog', () => {
|
|
|
108
109
|
assert.equal(shouldHint('grep "v0.24" CHANGELOG.md'), false);
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
// ── Floor (v0.69 hardening): non-foldable greps must NEVER deny/hint ──
|
|
113
|
+
// cg has no structural answer for these → a deny is friction-without-value that teaches
|
|
114
|
+
// CODE_GRAPH_NO_BLOCK_GREP bypass. 2026-06-23 reach audit: foldability (~24%) ≈
|
|
115
|
+
// interception (24%), so the floor (precision) is the lever — not reach expansion.
|
|
116
|
+
|
|
117
|
+
test('floor: grep on an external / non-indexed dir (/tmp clone) never fires', () => {
|
|
118
|
+
assert.equal(shouldHint('grep -rn "FooBar" /tmp/openwolf-analysis'), false);
|
|
119
|
+
assert.equal(shouldBlock('grep -rn "FooBar" /tmp/openwolf-analysis'), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('floor: external path with an embedded src/ segment never fires', () => {
|
|
123
|
+
// SRC_PATH only matches a prefix at ^|\s|quote — `/tmp/clone/src/` is not a project path.
|
|
124
|
+
assert.equal(shouldHint('grep -rn "FooBar" /tmp/clone/src/'), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('floor: a non-source data file (.log) under src/ never fires', () => {
|
|
128
|
+
assert.equal(shouldHint('grep "ErrorHandler" src/fixtures/app.log'), false);
|
|
129
|
+
assert.equal(shouldBlock('grep "ErrorHandler" src/fixtures/app.log'), false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('floor: ini/conf/xml/csv data files under src/ never fire', () => {
|
|
133
|
+
assert.equal(shouldHint('grep "FooBar" src/config.ini'), false);
|
|
134
|
+
assert.equal(shouldHint('grep "FooBar" src/app.conf'), false);
|
|
135
|
+
assert.equal(shouldHint('grep "FooBar" src/data.xml'), false);
|
|
136
|
+
assert.equal(shouldHint('grep "FooBar" src/rows.csv'), false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('floor: multiple config files under a src prefix all peel off → skip', () => {
|
|
140
|
+
// global strip (v0.69): pre-fix only the first .json peeled, the 2nd false-matched SRC_PATH.
|
|
141
|
+
assert.equal(shouldHint('grep "FooBar" src/a.json src/b.json'), false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('floor: mixed target (data file + real source file) STILL fires (no foldable miss)', () => {
|
|
145
|
+
assert.equal(shouldHint('grep -rn "FooBar" src/app.log src/handler.rs'), true);
|
|
146
|
+
});
|
|
147
|
+
|
|
111
148
|
// ── Should NOT fire: not search tools ───────────────────────────────
|
|
112
149
|
|
|
113
150
|
test('shouldHint: ls src/', () => {
|
|
@@ -336,6 +373,45 @@ test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)
|
|
|
336
373
|
assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
|
|
337
374
|
});
|
|
338
375
|
|
|
376
|
+
// ── B (v0.70): deny only when the inline answer covers the FULL scope ──
|
|
377
|
+
// The deny scopes to ONE path (extractSearchPath = first src-prefixed token). A grep naming
|
|
378
|
+
// ≥2 file paths would get a first-path-only answer (the rest silently dropped) — an incomplete
|
|
379
|
+
// substitute that rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade those to HINT;
|
|
380
|
+
// single file / directory greps (which the answer fully covers) still deny.
|
|
381
|
+
|
|
382
|
+
test('B: ≥2 named files downgrade deny→hint (deny would drop all but the first)', () => {
|
|
383
|
+
const cmd = 'grep -n "CLAUDE_MEM_DIR" scripts/setup.sh hook-shared.mjs';
|
|
384
|
+
assert.equal(shouldHint(cmd), true); // still nudges
|
|
385
|
+
assert.equal(shouldBlock(cmd), false); // but does NOT deny (answer can't cover hook-shared.mjs)
|
|
386
|
+
assert.equal(classifyBlock(cmd), null);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('B: two source files also downgrade (deny would cover only the first)', () => {
|
|
390
|
+
assert.equal(shouldBlock('grep -rn "set_hook" src/main.rs src/lib.rs'), false);
|
|
391
|
+
assert.equal(shouldHint('grep -rn "set_hook" src/main.rs src/lib.rs'), true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('B: single file still DENIES (inline answer fully covers it)', () => {
|
|
395
|
+
assert.deepEqual(classifyBlock('grep -n "handleMessage" src/server.mjs'), { mode: 'grep' });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('B: single directory (recursive) still DENIES (cg grep covers the whole dir)', () => {
|
|
399
|
+
assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('B: --include on a single dir still DENIES (one path, fully scoped)', () => {
|
|
403
|
+
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('countNamedPaths: counts paths, excludes flags and the quoted pattern', () => {
|
|
407
|
+
assert.equal(countNamedPaths('grep -n "Foo" src/a.rs src/b.rs', ['Foo']), 2);
|
|
408
|
+
assert.equal(countNamedPaths('grep -rn "Foo" src/', ['Foo']), 1);
|
|
409
|
+
// a path-shaped pattern is the pattern, not a second path token
|
|
410
|
+
assert.equal(countNamedPaths('grep "config.json" src/app.rs', ['config.json']), 1);
|
|
411
|
+
// a path in a compound tail (sed/pipe target) is NOT a 2nd grep target → stays 1 (deny)
|
|
412
|
+
assert.equal(countNamedPaths("grep -n \"Foo\" src/foo.rs | head; sed -n '1,5p' src/bar.rs", ['Foo']), 1);
|
|
413
|
+
});
|
|
414
|
+
|
|
339
415
|
test('shouldBlock: -L / -v inverted intents → hint only', () => {
|
|
340
416
|
assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
|
|
341
417
|
assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.70.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.70.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.70.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.70.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.70.0"
|
|
43
43
|
}
|
|
44
44
|
}
|