sigmap 8.5.0 → 8.7.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/CHANGELOG.md +19 -0
- package/README.md +32 -10
- package/gen-context.js +499 -7
- package/llms-full.txt +13 -8
- package/llms.txt +5 -5
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/config/defaults.js +3 -0
- package/src/graph/call-graph.js +401 -0
- package/src/mcp/server.js +1 -1
- package/src/verify/lib-index.js +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,25 @@ Format: [Semantic Versioning](https://semver.org/)
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [8.7.0] — 2026-07-05
|
|
14
|
+
|
|
15
|
+
Minor release — **method/caller-level call-graph (D4 v1): the graph goes from files to functions.** SigMap's dependency graph was file-level only (imports → `--impact`/`get_impact`). This adds **symbol-level** edges — *which function calls which function* — and the **method-level blast radius** of changing a single symbol. Deterministic, zero-dependency, JS/TS + Python. This is the plan's single hardest unbuilt gap and the biggest remaining lever on graph intelligence (v9.5 GR1).
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Method/caller-level call-graph (#429, PR #430):** new `src/graph/call-graph.js` builds symbol edges keyed by `relPath#symbol` for **JS/TS + Python**. Definitions are extracted with real body ranges (brace-matching for JS, indentation for Python) over **comment- and string-masked** source, so braces/calls inside literals never create phantom edges. Each call site is resolved with **high precision** — a call binds to a definition of that name in the **same file** first, then in a **directly-imported file** (reusing the existing file-level import graph); names with no repo definition produce **no edge**, avoiding global name-collision noise. Public API: `buildCallGraph(cwd)` → `{ forward, reverse, defs }`, `methodImpact(symbol)` (transitive callers = method blast radius), `methodCallees(symbol)` (what it transitively calls), plus formatters mirroring `src/graph/impact.js`.
|
|
19
|
+
- **`--callers` / `--callees` CLI (#429, PR #430):** `sigmap --callers <symbol>` prints the method-level blast radius (every function that transitively calls `<symbol>`); `sigmap --callees <symbol>` prints what it calls. Both accept a bare name or a `file#symbol` id, support `--json` and `--depth <n>` (0 = unlimited), and mirror the `--impact` command. Documented in `--help`.
|
|
20
|
+
|
|
21
|
+
## [8.6.0] — 2026-07-05
|
|
22
|
+
|
|
23
|
+
Minor release — **Phase 1 "bank the A": the grounding moat's supporting surface.** Three master-plan items land together: a self-contained, third-party-runnable benchmark harness (G1), installed dependency version pins in the generated context header (D8), and `verify` promoted to a documented flagship command (G2). All zero-dependency, deterministic, and in-boundary.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **Public benchmark harness (#425, PR #426):** new `public-benchmarks/` — a self-contained, third-party-runnable retrieval harness. `repos.csv` (18 real repos pinned to exact commits), `queries.json` (90 natural-language query → expected-file tasks), `run.sh` (shallow-clones the pinned repos, then scores), `score.mjs` (maps each repo with `gen-context.js`, ranks with the **shipped** identifier-aware BM25 ranker `src/retrieval/bm25.js`, reports hit@1/hit@5/MRR), and a methodology `README.md`. Deterministic — pinned commits + a byte-stable map + rank-only math → the same numbers on any machine; no LLM, no API keys, no external deps. Dev-only (excluded from the npm package). Turns the published retrieval numbers from a claim into a third-party-verifiable fact (v9.0 G1).
|
|
27
|
+
- **Version pins in the context header (#425, PR #426):** the generated header now carries a `## versions (installed direct deps)` block of sorted `name@version` pins for installed JS + Python direct dependencies, so agents reading `CLAUDE.md`/`AGENTS.md` ground against what is **actually installed here** (compounds with the G5/D5 verify moat). New `collectVersionPins(cwd)` in `src/verify/lib-index.js` (versions only — no symbol parsing, cheap enough to run per-build); gated by a new `versionPins` config key (default on). Byte-stable given a fixed installed tree (D8).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **`sigmap verify` — the grounding flagship (#425, PR #426):** `verify` is now a first-class alias of `verify-ai-output` (identical flags, output, and exit codes), with a dedicated flagship section near the top of the README and a `--help` entry. Positions the deterministic grounding guard — the one capability no agentic-grep loop or competitor offers — as the headline (v9.0 G2).
|
|
31
|
+
|
|
13
32
|
## [8.5.0] — 2026-07-05
|
|
14
33
|
|
|
15
34
|
Minor release — **deterministic query expansion (a vocabulary-mismatch recall aid).** The BM25 ranker now bridges common code-domain synonyms/abbreviations so a query for `authentication` can still surface a file whose signatures only say `auth`. Zero-dependency, deterministic. **Honest framing:** measured on the retrieval benchmark, this is **benchmark-neutral** (hit@5 unchanged within the harness's 86.7–87.8% noise band at the shipped weight) — not a hit@5 improvement. The benefit is for real users whose query vocabulary differs from the code, a case the curated benchmark doesn't exercise.
|
package/README.md
CHANGED
|
@@ -52,15 +52,15 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
|
|
|
52
52
|
|
|
53
53
|
**Deterministic and verifiable — the two things an agentic-grep loop can't give you:**
|
|
54
54
|
- **Deterministic** — no LLM calls, no agent loop; the same repo always produces a byte-identical map you can diff, cache, and gate in CI.
|
|
55
|
-
- **Auditable & grounded** — every file and symbol traces to a real line anchor; `sigmap verify
|
|
55
|
+
- **Auditable & grounded** — every file and symbol traces to a real line anchor; `sigmap verify` flags any AI claim that isn't.
|
|
56
56
|
- **Zero dependencies** — `npx sigmap` on any machine; no embeddings, no vector DB, no hosted service, fully offline.
|
|
57
57
|
|
|
58
58
|
**Proof it pays off** (full benchmark below):
|
|
59
59
|
<!--SM:whyMetrics-->
|
|
60
|
-
- **
|
|
60
|
+
- **87.8% hit@5** — right file found in top 5 results (vs 13.6% baseline)
|
|
61
61
|
- **97.0% token reduction** — average across 21 real repos
|
|
62
|
-
- **
|
|
63
|
-
- **1.
|
|
62
|
+
- **68.9% task success rate** — up from 10% without context
|
|
63
|
+
- **1.43 prompts per task** — down from 2.84 (49.6% fewer retries)
|
|
64
64
|
<!--/SM:whyMetrics-->
|
|
65
65
|
- **<!--SM:languages-->33<!--/SM:languages--> languages supported** — TypeScript, Python, Go, Rust, Java, R, and more
|
|
66
66
|
- **No vendor lock-in** — works with any AI assistant or local LLM
|
|
@@ -69,12 +69,34 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
|
|
|
69
69
|
|
|
70
70
|
---
|
|
71
71
|
|
|
72
|
+
## 🔒 `sigmap verify` — the grounding flagship
|
|
73
|
+
|
|
74
|
+
The one thing no agentic-grep loop, and no competitor, gives you: **prove an AI answer is anchored to real signatures and line numbers before you trust it.** Deterministic, offline, no LLM — SigMap indexes your repo *plus the libraries actually installed here* and flags every fabricated file, import, symbol, test, or npm script.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
sigmap verify answer.md # ✓ grounded, or a line-by-line list of fabrications
|
|
78
|
+
sigmap verify answer.md --json # machine-readable report; exits 1 if any issue (CI gate)
|
|
79
|
+
sigmap verify answer.md --report # standalone red/amber/green HTML report
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
[sigmap] ✗ answer.md — 2 issues found
|
|
84
|
+
fake-file: 1 fake-test-file: 0 fake-import: 0 fake-symbol: 1 fake-npm-script: 0
|
|
85
|
+
|
|
86
|
+
L12 [Fake file] src/auth/session-store.js does not exist
|
|
87
|
+
L27 [Fake symbol] authorize() — did you mean authenticate()?
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`verify` is the flagship; `verify-ai-output` remains as the full command name. Pair it with `sigmap verify-plan` (check a plan before execution) and the `verify_suggestion` MCP tool (verify AI code against repo + private + installed-library symbols mid-session).
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
72
94
|
## Replace this with SigMap
|
|
73
95
|
|
|
74
96
|
| Without SigMap | With SigMap |
|
|
75
97
|
|---|---|
|
|
76
98
|
| ❌ Non-reproducible agent guesses | ✅ Deterministic map — same input, same output, every time |
|
|
77
|
-
| ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->
|
|
99
|
+
| ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->88%<!--/SM:hitWhole--> of the time, every symbol on a real line anchor |
|
|
78
100
|
| ❌ Embeddings / vector DB required | ✅ Zero deps, no infra, fully offline |
|
|
79
101
|
|
|
80
102
|
---
|
|
@@ -98,13 +120,13 @@ Ask → Rank → Context → Validate → Judge → Learn
|
|
|
98
120
|
|
|
99
121
|
<!--SM:benchmarkBlock-->
|
|
100
122
|
```
|
|
101
|
-
Benchmark : sigmap-v8.
|
|
102
|
-
Date : 2026-07-
|
|
123
|
+
Benchmark : sigmap-v8.7-main (21 repositories, including R language)
|
|
124
|
+
Date : 2026-07-05
|
|
103
125
|
|
|
104
|
-
Hit@5 :
|
|
126
|
+
Hit@5 : 87.8% (baseline 13.6% — 6.5× lift)
|
|
105
127
|
Token reduction: 97.0% (across 21 repos)
|
|
106
|
-
Prompt reduction :
|
|
107
|
-
Task success :
|
|
128
|
+
Prompt reduction : 49.6% (2.84 → 1.43 prompts per task)
|
|
129
|
+
Task success : 68.9% (baseline 10%)
|
|
108
130
|
Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
|
|
109
131
|
```
|
|
110
132
|
<!--/SM:benchmarkBlock-->
|
package/gen-context.js
CHANGED
|
@@ -1498,6 +1498,9 @@ __factories["./src/config/defaults"] = function(module, exports) {
|
|
|
1498
1498
|
// Include a compact import dependency map at top of output
|
|
1499
1499
|
depMap: true,
|
|
1500
1500
|
|
|
1501
|
+
// Include a compact `name@version` list of installed direct deps (D8)
|
|
1502
|
+
versionPins: true,
|
|
1503
|
+
|
|
1501
1504
|
// Include TODO/FIXME/HACK/XXX comments as compact section
|
|
1502
1505
|
todos: true,
|
|
1503
1506
|
|
|
@@ -10450,6 +10453,411 @@ __factories["./src/graph/builder"] = function(module, exports) {
|
|
|
10450
10453
|
|
|
10451
10454
|
};
|
|
10452
10455
|
|
|
10456
|
+
// ── ./src/graph/call-graph ──
|
|
10457
|
+
__factories["./src/graph/call-graph"] = function(module, exports) {
|
|
10458
|
+
|
|
10459
|
+
/**
|
|
10460
|
+
* Method/caller-level call-graph (D4 v1).
|
|
10461
|
+
*
|
|
10462
|
+
* Builds symbol-level edges — which function calls which function — for JS/TS
|
|
10463
|
+
* and Python. Deterministic, zero-dependency, regex + brace/indent matching.
|
|
10464
|
+
* Call sites are resolved with high precision: a call resolves to a definition
|
|
10465
|
+
* of that name in the *same file* first, then in a *directly-imported* file
|
|
10466
|
+
* (via the existing file-level import graph). Names that resolve to no repo
|
|
10467
|
+
* definition produce no edge — over-approximation noise is avoided.
|
|
10468
|
+
*
|
|
10469
|
+
* Symbol IDs are `relPath#symbolName` (forward-slashed, relative to cwd).
|
|
10470
|
+
*
|
|
10471
|
+
* @module src/graph/call-graph
|
|
10472
|
+
*/
|
|
10473
|
+
|
|
10474
|
+
const fs = require('fs');
|
|
10475
|
+
const path = require('path');
|
|
10476
|
+
const { build } = __require('./src/graph/builder');
|
|
10477
|
+
|
|
10478
|
+
const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
10479
|
+
const PY_EXTS = new Set(['.py', '.pyw']);
|
|
10480
|
+
|
|
10481
|
+
// Tokens that look like `name(` calls or definition headers but are language
|
|
10482
|
+
// keywords, not user symbols — never treated as a call or a definition.
|
|
10483
|
+
const NON_CALL = new Set([
|
|
10484
|
+
'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'typeof',
|
|
10485
|
+
'await', 'new', 'super', 'else', 'do', 'with', 'yield', 'void', 'delete',
|
|
10486
|
+
'in', 'of', 'case', 'throw', 'print', 'and', 'or', 'not', 'assert',
|
|
10487
|
+
'lambda', 'class', 'def', 'elif', 'except', 'finally', 'raise', 'import',
|
|
10488
|
+
'from', 'global', 'nonlocal', 'del', 'pass', 'async', 'require', 'constructor',
|
|
10489
|
+
]);
|
|
10490
|
+
|
|
10491
|
+
function normalizePath(p) { return path.normalize(p).toLowerCase(); }
|
|
10492
|
+
function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
|
|
10493
|
+
function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
|
|
10494
|
+
|
|
10495
|
+
// ── Length- and newline-preserving maskers ──────────────────────────────────
|
|
10496
|
+
// Replace comment / string bodies with spaces so their braces, parens, and
|
|
10497
|
+
// call-looking tokens never confuse structure detection. Offsets stay aligned.
|
|
10498
|
+
|
|
10499
|
+
function maskJs(src) {
|
|
10500
|
+
const out = src.split('');
|
|
10501
|
+
const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
10502
|
+
let i = 0; const n = src.length;
|
|
10503
|
+
while (i < n) {
|
|
10504
|
+
const c = src[i], d = src[i + 1];
|
|
10505
|
+
if (c === '/' && d === '/') { let j = i + 2; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
|
|
10506
|
+
if (c === '/' && d === '*') { let j = i + 2; while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++; j = Math.min(n, j + 2); blank(i, j); i = j; continue; }
|
|
10507
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
10508
|
+
let j = i + 1;
|
|
10509
|
+
while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c) break; if (c !== '`' && src[j] === '\n') break; j++; }
|
|
10510
|
+
j = Math.min(n, j + 1); blank(i, j); i = j; continue;
|
|
10511
|
+
}
|
|
10512
|
+
i++;
|
|
10513
|
+
}
|
|
10514
|
+
return out.join('');
|
|
10515
|
+
}
|
|
10516
|
+
|
|
10517
|
+
function maskPy(src) {
|
|
10518
|
+
const out = src.split('');
|
|
10519
|
+
const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
10520
|
+
let i = 0; const n = src.length;
|
|
10521
|
+
while (i < n) {
|
|
10522
|
+
const c = src[i];
|
|
10523
|
+
if (c === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
|
|
10524
|
+
if (c === '"' || c === "'") {
|
|
10525
|
+
if (src.substr(i, 3) === c + c + c) {
|
|
10526
|
+
let j = i + 3; while (j < n && src.substr(j, 3) !== c + c + c) j++; j = Math.min(n, j + 3); blank(i, j); i = j; continue;
|
|
10527
|
+
}
|
|
10528
|
+
let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c || src[j] === '\n') break; j++; }
|
|
10529
|
+
j = Math.min(n, j + 1); blank(i, j); i = j; continue;
|
|
10530
|
+
}
|
|
10531
|
+
i++;
|
|
10532
|
+
}
|
|
10533
|
+
return out.join('');
|
|
10534
|
+
}
|
|
10535
|
+
|
|
10536
|
+
// ── Balanced-delimiter matchers (operate on masked source) ───────────────────
|
|
10537
|
+
function matchDelim(masked, openIdx, open, close) {
|
|
10538
|
+
let depth = 0;
|
|
10539
|
+
for (let i = openIdx; i < masked.length; i++) {
|
|
10540
|
+
if (masked[i] === open) depth++;
|
|
10541
|
+
else if (masked[i] === close) { depth--; if (depth === 0) return i; }
|
|
10542
|
+
}
|
|
10543
|
+
return masked.length - 1;
|
|
10544
|
+
}
|
|
10545
|
+
|
|
10546
|
+
function lineAt(src, idx) {
|
|
10547
|
+
let line = 1;
|
|
10548
|
+
const end = Math.min(idx, src.length);
|
|
10549
|
+
for (let i = 0; i < end; i++) if (src.charCodeAt(i) === 10) line++;
|
|
10550
|
+
return line;
|
|
10551
|
+
}
|
|
10552
|
+
|
|
10553
|
+
// ── Definition extraction ────────────────────────────────────────────────────
|
|
10554
|
+
// Each def: { name, line, bodyStart, bodyEnd } with char offsets into `masked`.
|
|
10555
|
+
|
|
10556
|
+
function jsDefs(masked) {
|
|
10557
|
+
const defs = [];
|
|
10558
|
+
const seen = new Set();
|
|
10559
|
+
const push = (name, headerIdx, bodyStart, bodyEnd) => {
|
|
10560
|
+
const key = name + ':' + bodyStart;
|
|
10561
|
+
if (NON_CALL.has(name) || seen.has(key)) return;
|
|
10562
|
+
seen.add(key);
|
|
10563
|
+
defs.push({ name, line: lineAt(masked, headerIdx), bodyStart, bodyEnd });
|
|
10564
|
+
};
|
|
10565
|
+
|
|
10566
|
+
// Locate the `{` body (or `=>` expression) that follows a param list `)`.
|
|
10567
|
+
const bodyAfterParams = (closeParen) => {
|
|
10568
|
+
let k = closeParen + 1;
|
|
10569
|
+
// skip a `=>`, return-type annotations, and whitespace up to `{` or a statement end
|
|
10570
|
+
while (k < masked.length && masked[k] !== '{' && masked[k] !== ';' && masked[k] !== '\n') k++;
|
|
10571
|
+
if (masked[k] === '{') { const end = matchDelim(masked, k, '{', '}'); return { bodyStart: k, bodyEnd: end }; }
|
|
10572
|
+
return null; // no braced body (interface/abstract/overload signature) — skip
|
|
10573
|
+
};
|
|
10574
|
+
|
|
10575
|
+
// 1) function declarations: (async) function name(...) { ... }
|
|
10576
|
+
const reFn = /\b(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g;
|
|
10577
|
+
let m;
|
|
10578
|
+
while ((m = reFn.exec(masked)) !== null) {
|
|
10579
|
+
const paren = masked.indexOf('(', m.index + m[0].length - 1);
|
|
10580
|
+
const close = matchDelim(masked, paren, '(', ')');
|
|
10581
|
+
const body = bodyAfterParams(close);
|
|
10582
|
+
if (body) push(m[1], m.index, body.bodyStart, body.bodyEnd);
|
|
10583
|
+
}
|
|
10584
|
+
|
|
10585
|
+
// 2) arrow / function expressions: const name = (...) => { } | = function(...) { }
|
|
10586
|
+
const reArrow = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b\s*\*?\s*[A-Za-z_$]*\s*)?\(/g;
|
|
10587
|
+
while ((m = reArrow.exec(masked)) !== null) {
|
|
10588
|
+
const paren = masked.indexOf('(', m.index + m[0].length - 1);
|
|
10589
|
+
const close = matchDelim(masked, paren, '(', ')');
|
|
10590
|
+
let k = close + 1;
|
|
10591
|
+
while (k < masked.length && /\s/.test(masked[k])) k++;
|
|
10592
|
+
if (masked[k] === '{') { push(m[1], m.index, k, matchDelim(masked, k, '{', '}')); continue; }
|
|
10593
|
+
if (masked[k] === '=' && masked[k + 1] === '>') {
|
|
10594
|
+
let j = k + 2; while (j < masked.length && /\s/.test(masked[j])) j++;
|
|
10595
|
+
if (masked[j] === '{') push(m[1], m.index, j, matchDelim(masked, j, '{', '}'));
|
|
10596
|
+
else { // single-expression arrow body → to end of statement
|
|
10597
|
+
let e = j; let d = 0;
|
|
10598
|
+
while (e < masked.length) { const ch = masked[e]; if (ch === '(' || ch === '[') d++; else if (ch === ')' || ch === ']') d--; else if ((ch === ';' || ch === '\n') && d <= 0) break; e++; }
|
|
10599
|
+
push(m[1], m.index, j, e);
|
|
10600
|
+
}
|
|
10601
|
+
}
|
|
10602
|
+
}
|
|
10603
|
+
|
|
10604
|
+
// 3) class methods: class X { name(...) { } }
|
|
10605
|
+
const reClass = /\bclass\s+[A-Za-z_$][\w$]*/g;
|
|
10606
|
+
while ((m = reClass.exec(masked)) !== null) {
|
|
10607
|
+
const brace = masked.indexOf('{', m.index);
|
|
10608
|
+
if (brace === -1) continue;
|
|
10609
|
+
const classEnd = matchDelim(masked, brace, '{', '}');
|
|
10610
|
+
const reMethod = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|static\s+|readonly\s+|abstract\s+|async\s+|get\s+|set\s+|\*\s*)*([A-Za-z_$][\w$]*)\s*\(/g;
|
|
10611
|
+
reMethod.lastIndex = brace;
|
|
10612
|
+
let mm;
|
|
10613
|
+
while ((mm = reMethod.exec(masked)) !== null && mm.index < classEnd) {
|
|
10614
|
+
const paren = masked.indexOf('(', mm.index + mm[0].length - 1);
|
|
10615
|
+
const close = matchDelim(masked, paren, '(', ')');
|
|
10616
|
+
const body = bodyAfterParams(close);
|
|
10617
|
+
if (body && body.bodyEnd <= classEnd) push(mm[1], mm.index, body.bodyStart, body.bodyEnd);
|
|
10618
|
+
}
|
|
10619
|
+
}
|
|
10620
|
+
|
|
10621
|
+
return defs;
|
|
10622
|
+
}
|
|
10623
|
+
|
|
10624
|
+
function pyDefs(masked) {
|
|
10625
|
+
const defs = [];
|
|
10626
|
+
const lines = masked.split('\n');
|
|
10627
|
+
// precompute char offset of each line start
|
|
10628
|
+
const offsets = [0];
|
|
10629
|
+
for (let i = 0; i < lines.length; i++) offsets.push(offsets[i] + lines[i].length + 1);
|
|
10630
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10631
|
+
const m = lines[i].match(/^([ \t]*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
|
|
10632
|
+
if (!m) continue;
|
|
10633
|
+
const indent = m[1].length;
|
|
10634
|
+
let j = i + 1;
|
|
10635
|
+
for (; j < lines.length; j++) {
|
|
10636
|
+
const ln = lines[j];
|
|
10637
|
+
if (!ln.trim()) continue; // blank
|
|
10638
|
+
const ind = ln.match(/^[ \t]*/)[0].length;
|
|
10639
|
+
if (ind <= indent) break; // dedent → block ends
|
|
10640
|
+
}
|
|
10641
|
+
defs.push({ name: m[2], line: i + 1, bodyStart: offsets[i], bodyEnd: offsets[j] || masked.length });
|
|
10642
|
+
}
|
|
10643
|
+
return defs;
|
|
10644
|
+
}
|
|
10645
|
+
|
|
10646
|
+
function extractDefs(filePath, src) {
|
|
10647
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
10648
|
+
if (JS_EXTS.has(ext)) return jsDefs(maskJs(src));
|
|
10649
|
+
if (PY_EXTS.has(ext)) return pyDefs(maskPy(src));
|
|
10650
|
+
return null; // unsupported language
|
|
10651
|
+
}
|
|
10652
|
+
|
|
10653
|
+
// Collect `name(` call tokens within [start,end) of masked source.
|
|
10654
|
+
function callsInRange(masked, start, end) {
|
|
10655
|
+
const slice = masked.slice(start, end);
|
|
10656
|
+
const names = new Set();
|
|
10657
|
+
const re = /([A-Za-z_$][\w$]*)\s*\(/g;
|
|
10658
|
+
let m;
|
|
10659
|
+
while ((m = re.exec(slice)) !== null) {
|
|
10660
|
+
// skip a `.name(` method access (can't resolve the receiver deterministically)
|
|
10661
|
+
const before = slice[m.index - 1];
|
|
10662
|
+
if (before === '.') continue;
|
|
10663
|
+
if (!NON_CALL.has(m[1])) names.add(m[1]);
|
|
10664
|
+
}
|
|
10665
|
+
return names;
|
|
10666
|
+
}
|
|
10667
|
+
|
|
10668
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
10669
|
+
|
|
10670
|
+
function _walk(dir, excludeSet, out, depth) {
|
|
10671
|
+
if (depth > 8) return;
|
|
10672
|
+
let entries;
|
|
10673
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
10674
|
+
for (const e of entries) {
|
|
10675
|
+
if (excludeSet.has(e.name) || e.name.startsWith('.')) continue;
|
|
10676
|
+
const full = path.join(dir, e.name);
|
|
10677
|
+
if (e.isDirectory()) _walk(full, excludeSet, out, depth + 1);
|
|
10678
|
+
else if (e.isFile()) {
|
|
10679
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
10680
|
+
if (JS_EXTS.has(ext) || PY_EXTS.has(ext)) out.push(full);
|
|
10681
|
+
}
|
|
10682
|
+
}
|
|
10683
|
+
}
|
|
10684
|
+
|
|
10685
|
+
/**
|
|
10686
|
+
* Build the method-level call-graph for a project.
|
|
10687
|
+
*
|
|
10688
|
+
* @param {string} cwd
|
|
10689
|
+
* @param {object} [opts]
|
|
10690
|
+
* @param {string[]} [opts.srcDirs=['src','app','lib']]
|
|
10691
|
+
* @param {string[]} [opts.exclude]
|
|
10692
|
+
* @param {string[]} [opts.files] explicit absolute file list (skips the walk)
|
|
10693
|
+
* @returns {{
|
|
10694
|
+
* forward: Map<string,string[]>, // callerId → calleeIds
|
|
10695
|
+
* reverse: Map<string,string[]>, // calleeId → callerIds
|
|
10696
|
+
* defs: Map<string,{file:string,name:string,line:number}>
|
|
10697
|
+
* }}
|
|
10698
|
+
*/
|
|
10699
|
+
function buildCallGraph(cwd, opts = {}) {
|
|
10700
|
+
const excludeSet = new Set(opts.exclude || ['node_modules', '.git', 'dist', 'build', 'coverage', 'vendor']);
|
|
10701
|
+
let files = opts.files ? opts.files.map((f) => path.resolve(f)) : [];
|
|
10702
|
+
if (!opts.files) {
|
|
10703
|
+
for (const sd of (opts.srcDirs || ['src', 'app', 'lib'])) {
|
|
10704
|
+
const abs = path.resolve(cwd, sd);
|
|
10705
|
+
if (fs.existsSync(abs)) _walk(abs, excludeSet, files, 0);
|
|
10706
|
+
}
|
|
10707
|
+
}
|
|
10708
|
+
|
|
10709
|
+
// File-level import graph (for precise call-site resolution). Keys normalized.
|
|
10710
|
+
let fileGraph;
|
|
10711
|
+
try { fileGraph = build(files, cwd); } catch (_) { fileGraph = { forward: new Map() }; }
|
|
10712
|
+
|
|
10713
|
+
// Per-file definitions + name→file lookups.
|
|
10714
|
+
const perFileDefs = new Map(); // absFile → def[]
|
|
10715
|
+
const defsByName = new Map(); // absFile → Map<name, symbolId[]>
|
|
10716
|
+
const normToAbs = new Map(); // normalized abs → abs
|
|
10717
|
+
const defs = new Map(); // symbolId → {file,name,line}
|
|
10718
|
+
|
|
10719
|
+
for (const f of files) {
|
|
10720
|
+
normToAbs.set(normalizePath(path.resolve(f)), path.resolve(f));
|
|
10721
|
+
let src;
|
|
10722
|
+
try { src = fs.readFileSync(f, 'utf8'); } catch (_) { continue; }
|
|
10723
|
+
const fileDefs = extractDefs(f, src);
|
|
10724
|
+
if (!fileDefs) continue;
|
|
10725
|
+
perFileDefs.set(f, fileDefs);
|
|
10726
|
+
const byName = new Map();
|
|
10727
|
+
for (const d of fileDefs) {
|
|
10728
|
+
const id = symId(cwd, f, d.name);
|
|
10729
|
+
defs.set(id, { file: toRel(cwd, f), name: d.name, line: d.line });
|
|
10730
|
+
if (!byName.has(d.name)) byName.set(d.name, []);
|
|
10731
|
+
byName.get(d.name).push(id);
|
|
10732
|
+
}
|
|
10733
|
+
defsByName.set(f, byName);
|
|
10734
|
+
}
|
|
10735
|
+
|
|
10736
|
+
const forward = new Map();
|
|
10737
|
+
const reverse = new Map();
|
|
10738
|
+
const addEdge = (from, to) => {
|
|
10739
|
+
if (from === to) return;
|
|
10740
|
+
if (!forward.has(from)) forward.set(from, new Set());
|
|
10741
|
+
forward.get(from).add(to);
|
|
10742
|
+
if (!reverse.has(to)) reverse.set(to, new Set());
|
|
10743
|
+
reverse.get(to).add(from);
|
|
10744
|
+
};
|
|
10745
|
+
|
|
10746
|
+
for (const [f, fileDefs] of perFileDefs.entries()) {
|
|
10747
|
+
const masked = JS_EXTS.has(path.extname(f).toLowerCase()) ? maskJs(fs.readFileSync(f, 'utf8')) : maskPy(fs.readFileSync(f, 'utf8'));
|
|
10748
|
+
// resolution scope: this file's defs, then directly-imported files' defs
|
|
10749
|
+
const importedAbs = (fileGraph.forward.get(normalizePath(path.resolve(f))) || [])
|
|
10750
|
+
.map((nf) => normToAbs.get(nf)).filter(Boolean);
|
|
10751
|
+
for (const d of fileDefs) {
|
|
10752
|
+
const callerId = symId(cwd, f, d.name);
|
|
10753
|
+
if (!forward.has(callerId)) forward.set(callerId, new Set()); // ensure node exists
|
|
10754
|
+
const callees = callsInRange(masked, d.bodyStart, d.bodyEnd);
|
|
10755
|
+
for (const nm of callees) {
|
|
10756
|
+
const local = (defsByName.get(f) || new Map()).get(nm);
|
|
10757
|
+
if (local && local.length) { for (const id of local) addEdge(callerId, id); continue; }
|
|
10758
|
+
for (const imp of importedAbs) {
|
|
10759
|
+
const ids = (defsByName.get(imp) || new Map()).get(nm);
|
|
10760
|
+
if (ids && ids.length) { for (const id of ids) addEdge(callerId, id); break; }
|
|
10761
|
+
}
|
|
10762
|
+
}
|
|
10763
|
+
}
|
|
10764
|
+
}
|
|
10765
|
+
|
|
10766
|
+
const toArr = (mapOfSets) => {
|
|
10767
|
+
const out = new Map();
|
|
10768
|
+
for (const [k, set] of mapOfSets.entries()) out.set(k, [...set]);
|
|
10769
|
+
return out;
|
|
10770
|
+
};
|
|
10771
|
+
return { forward: toArr(forward), reverse: toArr(reverse), defs };
|
|
10772
|
+
}
|
|
10773
|
+
|
|
10774
|
+
// Resolve a user-supplied symbol (bare name or full `file#name` id) to ids.
|
|
10775
|
+
function _resolveSymbol(symbol, defs) {
|
|
10776
|
+
if (defs.has(symbol)) return [symbol];
|
|
10777
|
+
const ids = [];
|
|
10778
|
+
for (const id of defs.keys()) if (id.slice(id.indexOf('#') + 1) === symbol) ids.push(id);
|
|
10779
|
+
return ids;
|
|
10780
|
+
}
|
|
10781
|
+
|
|
10782
|
+
// BFS over a graph map from seed ids up to maxDepth (0 = unlimited).
|
|
10783
|
+
function _bfs(seedIds, graph, maxDepth) {
|
|
10784
|
+
const direct = new Set();
|
|
10785
|
+
const transitive = new Set();
|
|
10786
|
+
const visited = new Set(seedIds);
|
|
10787
|
+
let frontier = [];
|
|
10788
|
+
for (const s of seedIds) for (const nb of (graph.get(s) || [])) if (!visited.has(nb)) { direct.add(nb); visited.add(nb); frontier.push(nb); }
|
|
10789
|
+
let depth = 1;
|
|
10790
|
+
while (frontier.length && (maxDepth === 0 || depth < maxDepth)) {
|
|
10791
|
+
const next = [];
|
|
10792
|
+
for (const node of frontier) for (const nb of (graph.get(node) || [])) if (!visited.has(nb)) { transitive.add(nb); visited.add(nb); next.push(nb); }
|
|
10793
|
+
frontier = next; depth++;
|
|
10794
|
+
}
|
|
10795
|
+
return { direct: [...direct], transitive: [...transitive] };
|
|
10796
|
+
}
|
|
10797
|
+
|
|
10798
|
+
/**
|
|
10799
|
+
* Method-level blast radius: everything that (transitively) calls `symbol`.
|
|
10800
|
+
*
|
|
10801
|
+
* @param {string} symbol bare name or `file#name`
|
|
10802
|
+
* @param {string} cwd
|
|
10803
|
+
* @param {object} [opts] { depth=0, ...buildCallGraph opts }
|
|
10804
|
+
* @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
|
|
10805
|
+
*/
|
|
10806
|
+
function methodImpact(symbol, cwd, opts = {}) {
|
|
10807
|
+
const graph = opts.graph || buildCallGraph(cwd, opts);
|
|
10808
|
+
const ids = _resolveSymbol(symbol, graph.defs);
|
|
10809
|
+
if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
|
|
10810
|
+
const { direct, transitive } = _bfs(ids, graph.reverse, opts.depth || 0);
|
|
10811
|
+
return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
|
|
10812
|
+
}
|
|
10813
|
+
|
|
10814
|
+
/**
|
|
10815
|
+
* What `symbol` (transitively) calls.
|
|
10816
|
+
* @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
|
|
10817
|
+
*/
|
|
10818
|
+
function methodCallees(symbol, cwd, opts = {}) {
|
|
10819
|
+
const graph = opts.graph || buildCallGraph(cwd, opts);
|
|
10820
|
+
const ids = _resolveSymbol(symbol, graph.defs);
|
|
10821
|
+
if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
|
|
10822
|
+
const { direct, transitive } = _bfs(ids, graph.forward, opts.depth || 0);
|
|
10823
|
+
return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
|
|
10824
|
+
}
|
|
10825
|
+
|
|
10826
|
+
// ── Formatters ───────────────────────────────────────────────────────────────
|
|
10827
|
+
function formatCallGraph(result, kind) {
|
|
10828
|
+
const verb = kind === 'callees' ? 'calls' : 'callers of';
|
|
10829
|
+
const lines = [`## ${kind === 'callees' ? 'Callees' : 'Callers'}: \`${result.symbol}\``, ''];
|
|
10830
|
+
if (result.unresolved) { lines.push('_symbol not found in the call-graph._'); return lines.join('\n'); }
|
|
10831
|
+
if (result.total === 0) {
|
|
10832
|
+
lines.push(kind === 'callees' ? '_calls no repo-defined symbols._' : '_no repo symbol calls this — zero method blast radius._');
|
|
10833
|
+
return lines.join('\n');
|
|
10834
|
+
}
|
|
10835
|
+
lines.push(`**Total ${verb}:** ${result.total}`, '');
|
|
10836
|
+
if (result.direct.length) { lines.push(`### Direct`); for (const id of result.direct) lines.push(`- \`${id}\``); lines.push(''); }
|
|
10837
|
+
if (result.transitive.length) { lines.push(`### Transitive`); for (const id of result.transitive) lines.push(`- \`${id}\``); lines.push(''); }
|
|
10838
|
+
return lines.join('\n');
|
|
10839
|
+
}
|
|
10840
|
+
|
|
10841
|
+
function formatCallGraphJSON(result, kind) {
|
|
10842
|
+
return {
|
|
10843
|
+
symbol: result.symbol,
|
|
10844
|
+
kind: kind === 'callees' ? 'callees' : 'callers',
|
|
10845
|
+
resolved: result.resolved,
|
|
10846
|
+
direct: result.direct,
|
|
10847
|
+
transitive: result.transitive,
|
|
10848
|
+
total: result.total,
|
|
10849
|
+
unresolved: result.unresolved,
|
|
10850
|
+
};
|
|
10851
|
+
}
|
|
10852
|
+
|
|
10853
|
+
module.exports = {
|
|
10854
|
+
buildCallGraph, methodImpact, methodCallees,
|
|
10855
|
+
formatCallGraph, formatCallGraphJSON,
|
|
10856
|
+
extractDefs, maskJs, maskPy,
|
|
10857
|
+
};
|
|
10858
|
+
|
|
10859
|
+
};
|
|
10860
|
+
|
|
10453
10861
|
// ── ./src/graph/impact ──
|
|
10454
10862
|
__factories["./src/graph/impact"] = function(module, exports) {
|
|
10455
10863
|
|
|
@@ -13108,7 +13516,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
13108
13516
|
|
|
13109
13517
|
const SERVER_INFO = {
|
|
13110
13518
|
name: 'sigmap',
|
|
13111
|
-
version: '8.
|
|
13519
|
+
version: '8.7.0',
|
|
13112
13520
|
description: 'SigMap MCP server — code signatures on demand',
|
|
13113
13521
|
};
|
|
13114
13522
|
|
|
@@ -17311,8 +17719,37 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
17311
17719
|
.map((l) => `${l.name}@${l.version}`);
|
|
17312
17720
|
}
|
|
17313
17721
|
|
|
17722
|
+
/**
|
|
17723
|
+
* D8: collect `name@version` pins for direct dependencies — versions only, no
|
|
17724
|
+
* symbol extraction, no cache. Cheap enough to run on every context build so
|
|
17725
|
+
* the generated header can ground against what is actually installed (JS +
|
|
17726
|
+
* Python). Deterministic: sorted, then capped.
|
|
17727
|
+
* @param {string} cwd
|
|
17728
|
+
* @param {object} [opts]
|
|
17729
|
+
* @param {number} [opts.limit=40] max pins returned (after sort)
|
|
17730
|
+
* @returns {{ pins: string[], total: number }} pins capped to limit; total = all resolved
|
|
17731
|
+
*/
|
|
17732
|
+
function collectVersionPins(cwd, opts = {}) {
|
|
17733
|
+
const limit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : 40;
|
|
17734
|
+
const pins = [];
|
|
17735
|
+
for (const dep of directDeps(cwd).slice(0, MAX_DEPS)) {
|
|
17736
|
+
const r = resolveEntry(cwd, dep);
|
|
17737
|
+
if (r && r.version) pins.push(`${dep}@${r.version}`);
|
|
17738
|
+
}
|
|
17739
|
+
const sitePkgs = findSitePackages(cwd);
|
|
17740
|
+
if (sitePkgs.length) {
|
|
17741
|
+
for (const dep of pythonDirectDeps(cwd).slice(0, MAX_DEPS)) {
|
|
17742
|
+
const r = resolvePyEntry(sitePkgs, dep);
|
|
17743
|
+
if (r && r.version) pins.push(`${dep}@${r.version}`);
|
|
17744
|
+
}
|
|
17745
|
+
}
|
|
17746
|
+
pins.sort();
|
|
17747
|
+
return { pins: limit ? pins.slice(0, limit) : pins, total: pins.length };
|
|
17748
|
+
}
|
|
17749
|
+
|
|
17314
17750
|
module.exports = {
|
|
17315
17751
|
buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
|
|
17752
|
+
collectVersionPins,
|
|
17316
17753
|
extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
|
|
17317
17754
|
};
|
|
17318
17755
|
|
|
@@ -17647,7 +18084,7 @@ function __tryGit(args, opts = {}) {
|
|
|
17647
18084
|
catch (_) { return ''; }
|
|
17648
18085
|
}
|
|
17649
18086
|
|
|
17650
|
-
const VERSION = '8.
|
|
18087
|
+
const VERSION = '8.7.0';
|
|
17651
18088
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
17652
18089
|
|
|
17653
18090
|
function requireSourceOrBundled(key) {
|
|
@@ -18250,6 +18687,23 @@ function formatOutput(fileEntries, cwd, routingEnabled, config, extras) {
|
|
|
18250
18687
|
lines.push('');
|
|
18251
18688
|
}
|
|
18252
18689
|
|
|
18690
|
+
// D8: installed direct-dependency version pins — grounds agents against what
|
|
18691
|
+
// is actually installed here (byte-stable given a fixed installed tree).
|
|
18692
|
+
if (!config || config.versionPins !== false) {
|
|
18693
|
+
try {
|
|
18694
|
+
const { collectVersionPins } = requireSourceOrBundled('./src/verify/lib-index');
|
|
18695
|
+
const { pins, total } = collectVersionPins(cwd);
|
|
18696
|
+
if (pins.length) {
|
|
18697
|
+
lines.push('## versions (installed direct deps)');
|
|
18698
|
+
lines.push('```');
|
|
18699
|
+
lines.push(...pins);
|
|
18700
|
+
if (total > pins.length) lines.push(`… +${total - pins.length} more`);
|
|
18701
|
+
lines.push('```');
|
|
18702
|
+
lines.push('');
|
|
18703
|
+
}
|
|
18704
|
+
} catch (_) { /* no/uninstalled deps → skip */ }
|
|
18705
|
+
}
|
|
18706
|
+
|
|
18253
18707
|
const todoLines = buildTodoSection(fileEntries, cwd, config || {});
|
|
18254
18708
|
if (todoLines.length) {
|
|
18255
18709
|
lines.push('## todos');
|
|
@@ -19442,9 +19896,13 @@ Usage:
|
|
|
19442
19896
|
${cmd} --impact <file> Show every file impacted by changing <file>
|
|
19443
19897
|
${cmd} --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
|
|
19444
19898
|
${cmd} --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
|
|
19445
|
-
${cmd}
|
|
19446
|
-
${cmd}
|
|
19447
|
-
${cmd}
|
|
19899
|
+
${cmd} --callers <symbol> Method-level blast radius — every function that (transitively) calls <symbol> (JS/TS + Python)
|
|
19900
|
+
${cmd} --callees <symbol> Every repo function that <symbol> (transitively) calls
|
|
19901
|
+
${cmd} --callers <symbol> --json --depth <n> Call-graph edges as JSON (depth 0 = unlimited)
|
|
19902
|
+
${cmd} verify <answer.md> Flagship grounding guard — flag fake files/tests/imports/symbols/npm-scripts in an AI answer (alias of verify-ai-output)
|
|
19903
|
+
${cmd} verify <answer.md> --json Grounding report as JSON (exits 1 if issues)
|
|
19904
|
+
${cmd} verify <answer.md> --report Write a standalone HTML report (red/amber/green)
|
|
19905
|
+
${cmd} verify-ai-output <answer.md> Full command name for ${cmd} verify
|
|
19448
19906
|
${cmd} conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
|
|
19449
19907
|
${cmd} scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
|
|
19450
19908
|
${cmd} verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
|
|
@@ -21304,7 +21762,10 @@ function main() {
|
|
|
21304
21762
|
process.exit(s.ok ? 0 : 1);
|
|
21305
21763
|
}
|
|
21306
21764
|
|
|
21307
|
-
|
|
21765
|
+
// `sigmap verify` is the flagship alias for `verify-ai-output` — the grounding
|
|
21766
|
+
// guard that proves an AI answer is anchored to real signatures + line anchors.
|
|
21767
|
+
if (args[0] === 'verify-ai-output' || args[0] === 'verify') {
|
|
21768
|
+
const cmdName = args[0];
|
|
21308
21769
|
const target = args[1];
|
|
21309
21770
|
const jsonOut = args.includes('--json');
|
|
21310
21771
|
const reportIdx = args.indexOf('--report');
|
|
@@ -21312,7 +21773,7 @@ function main() {
|
|
|
21312
21773
|
? (args[reportIdx + 1] && !args[reportIdx + 1].startsWith('--') ? args[reportIdx + 1] : 'sigmap-verify-report.html')
|
|
21313
21774
|
: null;
|
|
21314
21775
|
if (!target || target.startsWith('--')) {
|
|
21315
|
-
console.error(
|
|
21776
|
+
console.error(`[sigmap] Usage: sigmap ${cmdName} <answer.md> [--json] [--report [out.html]]`);
|
|
21316
21777
|
process.exit(1);
|
|
21317
21778
|
}
|
|
21318
21779
|
const absTarget = path.resolve(cwd, target);
|
|
@@ -22014,6 +22475,37 @@ function main() {
|
|
|
22014
22475
|
process.exit(0);
|
|
22015
22476
|
}
|
|
22016
22477
|
|
|
22478
|
+
// Method-level call-graph (D4): --callers <symbol> = method blast radius,
|
|
22479
|
+
// --callees <symbol> = what it calls. JS/TS + Python, deterministic.
|
|
22480
|
+
if (args.includes('--callers') || args.includes('--callees')) {
|
|
22481
|
+
try {
|
|
22482
|
+
const kind = args.includes('--callees') ? 'callees' : 'callers';
|
|
22483
|
+
const flag = kind === 'callees' ? '--callees' : '--callers';
|
|
22484
|
+
const symbol = (args[args.indexOf(flag) + 1] || '').trim();
|
|
22485
|
+
if (!symbol || symbol.startsWith('--')) {
|
|
22486
|
+
console.error(`[sigmap] ${flag} requires a symbol name (or file#symbol)`);
|
|
22487
|
+
console.error(` Example: node gen-context.js ${flag} extractFileDeps`);
|
|
22488
|
+
process.exit(1);
|
|
22489
|
+
}
|
|
22490
|
+
const { methodImpact, methodCallees, formatCallGraph, formatCallGraphJSON } = requireSourceOrBundled('./src/graph/call-graph');
|
|
22491
|
+
const depthIdx = args.indexOf('--depth');
|
|
22492
|
+
const depth = depthIdx >= 0 ? Math.max(0, parseInt(args[depthIdx + 1], 10) || 0) : 0;
|
|
22493
|
+
const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length) ? config.srcDirs : undefined;
|
|
22494
|
+
const result = kind === 'callees'
|
|
22495
|
+
? methodCallees(symbol, cwd, { depth, srcDirs })
|
|
22496
|
+
: methodImpact(symbol, cwd, { depth, srcDirs });
|
|
22497
|
+
if (args.includes('--json')) {
|
|
22498
|
+
process.stdout.write(JSON.stringify(formatCallGraphJSON(result, kind)) + '\n');
|
|
22499
|
+
} else {
|
|
22500
|
+
process.stdout.write(formatCallGraph(result, kind) + '\n');
|
|
22501
|
+
}
|
|
22502
|
+
} catch (err) {
|
|
22503
|
+
console.error(`[sigmap] call-graph error: ${err.message}`);
|
|
22504
|
+
process.exit(1);
|
|
22505
|
+
}
|
|
22506
|
+
process.exit(0);
|
|
22507
|
+
}
|
|
22508
|
+
|
|
22017
22509
|
if (args.includes('--report')) {
|
|
22018
22510
|
if (args.includes('--history')) {
|
|
22019
22511
|
try {
|
package/llms-full.txt
CHANGED
|
@@ -11,20 +11,20 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
|
|
|
11
11
|
effect), with no LLM calls, embeddings, or vector database. Works with Claude,
|
|
12
12
|
Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
|
|
13
13
|
|
|
14
|
-
# Version: 8.
|
|
14
|
+
# Version: 8.7.0 | Benchmark: sigmap-v8.7-main (2026-07-05)
|
|
15
15
|
# Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
|
|
16
16
|
# Regenerate: npm run generate:llms | Validate: npm run validate:llms
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
## Core metrics (benchmark: sigmap-v8.
|
|
20
|
+
## Core metrics (benchmark: sigmap-v8.7-main, 2026-07-05)
|
|
21
21
|
|
|
22
22
|
| Metric | Without SigMap | With SigMap |
|
|
23
23
|
|--------|----------------|-------------|
|
|
24
|
-
| Retrieval hit@5 | 13.6% (random) |
|
|
24
|
+
| Retrieval hit@5 | 13.6% (random) | 87.8% (6.5× lift) |
|
|
25
25
|
| Token reduction | — | 97.0% average |
|
|
26
|
-
| Task success proxy | 10% |
|
|
27
|
-
| Prompts per task | 2.84 | 1.
|
|
26
|
+
| Task success proxy | 10% | 68.9% |
|
|
27
|
+
| Prompts per task | 2.84 | 1.43 (49.6% fewer) |
|
|
28
28
|
| Supported languages | — | 33 |
|
|
29
29
|
| MCP tools | — | 18 |
|
|
30
30
|
| npm runtime dependencies | — | 0 |
|
|
@@ -99,9 +99,13 @@ sigmap weights --json Learned weights as JSON
|
|
|
99
99
|
sigmap --impact <file> Show every file impacted by changing <file>
|
|
100
100
|
sigmap --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
|
|
101
101
|
sigmap --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
|
|
102
|
-
sigmap
|
|
103
|
-
sigmap
|
|
104
|
-
sigmap
|
|
102
|
+
sigmap --callers <symbol> Method-level blast radius — every function that (transitively) calls <symbol> (JS/TS + Python)
|
|
103
|
+
sigmap --callees <symbol> Every repo function that <symbol> (transitively) calls
|
|
104
|
+
sigmap --callers <symbol> --json --depth <n> Call-graph edges as JSON (depth 0 = unlimited)
|
|
105
|
+
sigmap verify <answer.md> Flagship grounding guard — flag fake files/tests/imports/symbols/npm-scripts in an AI answer (alias of verify-ai-output)
|
|
106
|
+
sigmap verify <answer.md> --json Grounding report as JSON (exits 1 if issues)
|
|
107
|
+
sigmap verify <answer.md> --report Write a standalone HTML report (red/amber/green)
|
|
108
|
+
sigmap verify-ai-output <answer.md> Full command name for gen-context verify
|
|
105
109
|
sigmap conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
|
|
106
110
|
sigmap scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
|
|
107
111
|
sigmap verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
|
|
@@ -310,6 +314,7 @@ format = default
|
|
|
310
314
|
tracking = false
|
|
311
315
|
mcp = {"autoRegister":true}
|
|
312
316
|
depMap = true
|
|
317
|
+
versionPins = true
|
|
313
318
|
todos = true
|
|
314
319
|
changes = true
|
|
315
320
|
changesCommits = 10
|
package/llms.txt
CHANGED
|
@@ -11,7 +11,7 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
|
|
|
11
11
|
effect), with no LLM calls, embeddings, or vector database. Works with Claude,
|
|
12
12
|
Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
|
|
13
13
|
|
|
14
|
-
# Version: 8.
|
|
14
|
+
# Version: 8.7.0 | Benchmark: sigmap-v8.7-main (2026-07-05)
|
|
15
15
|
# Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
|
|
16
16
|
# Regenerate: npm run generate:llms | Validate: npm run validate:llms
|
|
17
17
|
|
|
@@ -23,12 +23,12 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
|
|
|
23
23
|
- No blast-radius awareness before editing a hub file — `--impact` shows every file a change touches.
|
|
24
24
|
- Pasted stack traces, CI logs, and JSON bloat the prompt — `squeeze` minimizes them and enriches the top frame from the symbol index.
|
|
25
25
|
|
|
26
|
-
## Core metrics (benchmark: sigmap-v8.
|
|
26
|
+
## Core metrics (benchmark: sigmap-v8.7-main, 2026-07-05)
|
|
27
27
|
|
|
28
|
-
- hit@5 retrieval:
|
|
28
|
+
- hit@5 retrieval: 87.8% vs 13.6% random baseline (6.5× lift)
|
|
29
29
|
- Token reduction: 97.0% average across benchmark repos
|
|
30
|
-
- Task success:
|
|
31
|
-
- Prompts per task: 1.
|
|
30
|
+
- Task success: 68.9% vs 10% without SigMap
|
|
31
|
+
- Prompts per task: 1.43 vs 2.84 baseline (49.6% fewer)
|
|
32
32
|
- Languages: 33 supported · MCP tools: 18
|
|
33
33
|
- Dependencies: zero npm runtime dependencies · fully offline
|
|
34
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sigmap",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.7.0",
|
|
4
4
|
"description": "97% token reduction for AI coding. Extracts function & class signatures with TF-IDF ranking to feed only the right files to Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP. Zero dependencies, runs offline via npx.",
|
|
5
5
|
"main": "packages/core/index.js",
|
|
6
6
|
"exports": {
|
package/src/config/defaults.js
CHANGED
|
@@ -114,6 +114,9 @@ const DEFAULTS = {
|
|
|
114
114
|
// Include a compact import dependency map at top of output
|
|
115
115
|
depMap: true,
|
|
116
116
|
|
|
117
|
+
// Include a compact `name@version` list of installed direct deps (D8)
|
|
118
|
+
versionPins: true,
|
|
119
|
+
|
|
117
120
|
// Include TODO/FIXME/HACK/XXX comments as compact section
|
|
118
121
|
todos: true,
|
|
119
122
|
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Method/caller-level call-graph (D4 v1).
|
|
5
|
+
*
|
|
6
|
+
* Builds symbol-level edges — which function calls which function — for JS/TS
|
|
7
|
+
* and Python. Deterministic, zero-dependency, regex + brace/indent matching.
|
|
8
|
+
* Call sites are resolved with high precision: a call resolves to a definition
|
|
9
|
+
* of that name in the *same file* first, then in a *directly-imported* file
|
|
10
|
+
* (via the existing file-level import graph). Names that resolve to no repo
|
|
11
|
+
* definition produce no edge — over-approximation noise is avoided.
|
|
12
|
+
*
|
|
13
|
+
* Symbol IDs are `relPath#symbolName` (forward-slashed, relative to cwd).
|
|
14
|
+
*
|
|
15
|
+
* @module src/graph/call-graph
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { build } = require('./builder');
|
|
21
|
+
|
|
22
|
+
const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
23
|
+
const PY_EXTS = new Set(['.py', '.pyw']);
|
|
24
|
+
|
|
25
|
+
// Tokens that look like `name(` calls or definition headers but are language
|
|
26
|
+
// keywords, not user symbols — never treated as a call or a definition.
|
|
27
|
+
const NON_CALL = new Set([
|
|
28
|
+
'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'typeof',
|
|
29
|
+
'await', 'new', 'super', 'else', 'do', 'with', 'yield', 'void', 'delete',
|
|
30
|
+
'in', 'of', 'case', 'throw', 'print', 'and', 'or', 'not', 'assert',
|
|
31
|
+
'lambda', 'class', 'def', 'elif', 'except', 'finally', 'raise', 'import',
|
|
32
|
+
'from', 'global', 'nonlocal', 'del', 'pass', 'async', 'require', 'constructor',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function normalizePath(p) { return path.normalize(p).toLowerCase(); }
|
|
36
|
+
function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
|
|
37
|
+
function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
|
|
38
|
+
|
|
39
|
+
// ── Length- and newline-preserving maskers ──────────────────────────────────
|
|
40
|
+
// Replace comment / string bodies with spaces so their braces, parens, and
|
|
41
|
+
// call-looking tokens never confuse structure detection. Offsets stay aligned.
|
|
42
|
+
|
|
43
|
+
function maskJs(src) {
|
|
44
|
+
const out = src.split('');
|
|
45
|
+
const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
46
|
+
let i = 0; const n = src.length;
|
|
47
|
+
while (i < n) {
|
|
48
|
+
const c = src[i], d = src[i + 1];
|
|
49
|
+
if (c === '/' && d === '/') { let j = i + 2; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
|
|
50
|
+
if (c === '/' && d === '*') { let j = i + 2; while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++; j = Math.min(n, j + 2); blank(i, j); i = j; continue; }
|
|
51
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
52
|
+
let j = i + 1;
|
|
53
|
+
while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c) break; if (c !== '`' && src[j] === '\n') break; j++; }
|
|
54
|
+
j = Math.min(n, j + 1); blank(i, j); i = j; continue;
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return out.join('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function maskPy(src) {
|
|
62
|
+
const out = src.split('');
|
|
63
|
+
const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
64
|
+
let i = 0; const n = src.length;
|
|
65
|
+
while (i < n) {
|
|
66
|
+
const c = src[i];
|
|
67
|
+
if (c === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
|
|
68
|
+
if (c === '"' || c === "'") {
|
|
69
|
+
if (src.substr(i, 3) === c + c + c) {
|
|
70
|
+
let j = i + 3; while (j < n && src.substr(j, 3) !== c + c + c) j++; j = Math.min(n, j + 3); blank(i, j); i = j; continue;
|
|
71
|
+
}
|
|
72
|
+
let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c || src[j] === '\n') break; j++; }
|
|
73
|
+
j = Math.min(n, j + 1); blank(i, j); i = j; continue;
|
|
74
|
+
}
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
return out.join('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Balanced-delimiter matchers (operate on masked source) ───────────────────
|
|
81
|
+
function matchDelim(masked, openIdx, open, close) {
|
|
82
|
+
let depth = 0;
|
|
83
|
+
for (let i = openIdx; i < masked.length; i++) {
|
|
84
|
+
if (masked[i] === open) depth++;
|
|
85
|
+
else if (masked[i] === close) { depth--; if (depth === 0) return i; }
|
|
86
|
+
}
|
|
87
|
+
return masked.length - 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function lineAt(src, idx) {
|
|
91
|
+
let line = 1;
|
|
92
|
+
const end = Math.min(idx, src.length);
|
|
93
|
+
for (let i = 0; i < end; i++) if (src.charCodeAt(i) === 10) line++;
|
|
94
|
+
return line;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Definition extraction ────────────────────────────────────────────────────
|
|
98
|
+
// Each def: { name, line, bodyStart, bodyEnd } with char offsets into `masked`.
|
|
99
|
+
|
|
100
|
+
function jsDefs(masked) {
|
|
101
|
+
const defs = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const push = (name, headerIdx, bodyStart, bodyEnd) => {
|
|
104
|
+
const key = name + ':' + bodyStart;
|
|
105
|
+
if (NON_CALL.has(name) || seen.has(key)) return;
|
|
106
|
+
seen.add(key);
|
|
107
|
+
defs.push({ name, line: lineAt(masked, headerIdx), bodyStart, bodyEnd });
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Locate the `{` body (or `=>` expression) that follows a param list `)`.
|
|
111
|
+
const bodyAfterParams = (closeParen) => {
|
|
112
|
+
let k = closeParen + 1;
|
|
113
|
+
// skip a `=>`, return-type annotations, and whitespace up to `{` or a statement end
|
|
114
|
+
while (k < masked.length && masked[k] !== '{' && masked[k] !== ';' && masked[k] !== '\n') k++;
|
|
115
|
+
if (masked[k] === '{') { const end = matchDelim(masked, k, '{', '}'); return { bodyStart: k, bodyEnd: end }; }
|
|
116
|
+
return null; // no braced body (interface/abstract/overload signature) — skip
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// 1) function declarations: (async) function name(...) { ... }
|
|
120
|
+
const reFn = /\b(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = reFn.exec(masked)) !== null) {
|
|
123
|
+
const paren = masked.indexOf('(', m.index + m[0].length - 1);
|
|
124
|
+
const close = matchDelim(masked, paren, '(', ')');
|
|
125
|
+
const body = bodyAfterParams(close);
|
|
126
|
+
if (body) push(m[1], m.index, body.bodyStart, body.bodyEnd);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2) arrow / function expressions: const name = (...) => { } | = function(...) { }
|
|
130
|
+
const reArrow = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b\s*\*?\s*[A-Za-z_$]*\s*)?\(/g;
|
|
131
|
+
while ((m = reArrow.exec(masked)) !== null) {
|
|
132
|
+
const paren = masked.indexOf('(', m.index + m[0].length - 1);
|
|
133
|
+
const close = matchDelim(masked, paren, '(', ')');
|
|
134
|
+
let k = close + 1;
|
|
135
|
+
while (k < masked.length && /\s/.test(masked[k])) k++;
|
|
136
|
+
if (masked[k] === '{') { push(m[1], m.index, k, matchDelim(masked, k, '{', '}')); continue; }
|
|
137
|
+
if (masked[k] === '=' && masked[k + 1] === '>') {
|
|
138
|
+
let j = k + 2; while (j < masked.length && /\s/.test(masked[j])) j++;
|
|
139
|
+
if (masked[j] === '{') push(m[1], m.index, j, matchDelim(masked, j, '{', '}'));
|
|
140
|
+
else { // single-expression arrow body → to end of statement
|
|
141
|
+
let e = j; let d = 0;
|
|
142
|
+
while (e < masked.length) { const ch = masked[e]; if (ch === '(' || ch === '[') d++; else if (ch === ')' || ch === ']') d--; else if ((ch === ';' || ch === '\n') && d <= 0) break; e++; }
|
|
143
|
+
push(m[1], m.index, j, e);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3) class methods: class X { name(...) { } }
|
|
149
|
+
const reClass = /\bclass\s+[A-Za-z_$][\w$]*/g;
|
|
150
|
+
while ((m = reClass.exec(masked)) !== null) {
|
|
151
|
+
const brace = masked.indexOf('{', m.index);
|
|
152
|
+
if (brace === -1) continue;
|
|
153
|
+
const classEnd = matchDelim(masked, brace, '{', '}');
|
|
154
|
+
const reMethod = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|static\s+|readonly\s+|abstract\s+|async\s+|get\s+|set\s+|\*\s*)*([A-Za-z_$][\w$]*)\s*\(/g;
|
|
155
|
+
reMethod.lastIndex = brace;
|
|
156
|
+
let mm;
|
|
157
|
+
while ((mm = reMethod.exec(masked)) !== null && mm.index < classEnd) {
|
|
158
|
+
const paren = masked.indexOf('(', mm.index + mm[0].length - 1);
|
|
159
|
+
const close = matchDelim(masked, paren, '(', ')');
|
|
160
|
+
const body = bodyAfterParams(close);
|
|
161
|
+
if (body && body.bodyEnd <= classEnd) push(mm[1], mm.index, body.bodyStart, body.bodyEnd);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return defs;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function pyDefs(masked) {
|
|
169
|
+
const defs = [];
|
|
170
|
+
const lines = masked.split('\n');
|
|
171
|
+
// precompute char offset of each line start
|
|
172
|
+
const offsets = [0];
|
|
173
|
+
for (let i = 0; i < lines.length; i++) offsets.push(offsets[i] + lines[i].length + 1);
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const m = lines[i].match(/^([ \t]*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
|
|
176
|
+
if (!m) continue;
|
|
177
|
+
const indent = m[1].length;
|
|
178
|
+
let j = i + 1;
|
|
179
|
+
for (; j < lines.length; j++) {
|
|
180
|
+
const ln = lines[j];
|
|
181
|
+
if (!ln.trim()) continue; // blank
|
|
182
|
+
const ind = ln.match(/^[ \t]*/)[0].length;
|
|
183
|
+
if (ind <= indent) break; // dedent → block ends
|
|
184
|
+
}
|
|
185
|
+
defs.push({ name: m[2], line: i + 1, bodyStart: offsets[i], bodyEnd: offsets[j] || masked.length });
|
|
186
|
+
}
|
|
187
|
+
return defs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractDefs(filePath, src) {
|
|
191
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
192
|
+
if (JS_EXTS.has(ext)) return jsDefs(maskJs(src));
|
|
193
|
+
if (PY_EXTS.has(ext)) return pyDefs(maskPy(src));
|
|
194
|
+
return null; // unsupported language
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Collect `name(` call tokens within [start,end) of masked source.
|
|
198
|
+
function callsInRange(masked, start, end) {
|
|
199
|
+
const slice = masked.slice(start, end);
|
|
200
|
+
const names = new Set();
|
|
201
|
+
const re = /([A-Za-z_$][\w$]*)\s*\(/g;
|
|
202
|
+
let m;
|
|
203
|
+
while ((m = re.exec(slice)) !== null) {
|
|
204
|
+
// skip a `.name(` method access (can't resolve the receiver deterministically)
|
|
205
|
+
const before = slice[m.index - 1];
|
|
206
|
+
if (before === '.') continue;
|
|
207
|
+
if (!NON_CALL.has(m[1])) names.add(m[1]);
|
|
208
|
+
}
|
|
209
|
+
return names;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function _walk(dir, excludeSet, out, depth) {
|
|
215
|
+
if (depth > 8) return;
|
|
216
|
+
let entries;
|
|
217
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
218
|
+
for (const e of entries) {
|
|
219
|
+
if (excludeSet.has(e.name) || e.name.startsWith('.')) continue;
|
|
220
|
+
const full = path.join(dir, e.name);
|
|
221
|
+
if (e.isDirectory()) _walk(full, excludeSet, out, depth + 1);
|
|
222
|
+
else if (e.isFile()) {
|
|
223
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
224
|
+
if (JS_EXTS.has(ext) || PY_EXTS.has(ext)) out.push(full);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build the method-level call-graph for a project.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} cwd
|
|
233
|
+
* @param {object} [opts]
|
|
234
|
+
* @param {string[]} [opts.srcDirs=['src','app','lib']]
|
|
235
|
+
* @param {string[]} [opts.exclude]
|
|
236
|
+
* @param {string[]} [opts.files] explicit absolute file list (skips the walk)
|
|
237
|
+
* @returns {{
|
|
238
|
+
* forward: Map<string,string[]>, // callerId → calleeIds
|
|
239
|
+
* reverse: Map<string,string[]>, // calleeId → callerIds
|
|
240
|
+
* defs: Map<string,{file:string,name:string,line:number}>
|
|
241
|
+
* }}
|
|
242
|
+
*/
|
|
243
|
+
function buildCallGraph(cwd, opts = {}) {
|
|
244
|
+
const excludeSet = new Set(opts.exclude || ['node_modules', '.git', 'dist', 'build', 'coverage', 'vendor']);
|
|
245
|
+
let files = opts.files ? opts.files.map((f) => path.resolve(f)) : [];
|
|
246
|
+
if (!opts.files) {
|
|
247
|
+
for (const sd of (opts.srcDirs || ['src', 'app', 'lib'])) {
|
|
248
|
+
const abs = path.resolve(cwd, sd);
|
|
249
|
+
if (fs.existsSync(abs)) _walk(abs, excludeSet, files, 0);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// File-level import graph (for precise call-site resolution). Keys normalized.
|
|
254
|
+
let fileGraph;
|
|
255
|
+
try { fileGraph = build(files, cwd); } catch (_) { fileGraph = { forward: new Map() }; }
|
|
256
|
+
|
|
257
|
+
// Per-file definitions + name→file lookups.
|
|
258
|
+
const perFileDefs = new Map(); // absFile → def[]
|
|
259
|
+
const defsByName = new Map(); // absFile → Map<name, symbolId[]>
|
|
260
|
+
const normToAbs = new Map(); // normalized abs → abs
|
|
261
|
+
const defs = new Map(); // symbolId → {file,name,line}
|
|
262
|
+
|
|
263
|
+
for (const f of files) {
|
|
264
|
+
normToAbs.set(normalizePath(path.resolve(f)), path.resolve(f));
|
|
265
|
+
let src;
|
|
266
|
+
try { src = fs.readFileSync(f, 'utf8'); } catch (_) { continue; }
|
|
267
|
+
const fileDefs = extractDefs(f, src);
|
|
268
|
+
if (!fileDefs) continue;
|
|
269
|
+
perFileDefs.set(f, fileDefs);
|
|
270
|
+
const byName = new Map();
|
|
271
|
+
for (const d of fileDefs) {
|
|
272
|
+
const id = symId(cwd, f, d.name);
|
|
273
|
+
defs.set(id, { file: toRel(cwd, f), name: d.name, line: d.line });
|
|
274
|
+
if (!byName.has(d.name)) byName.set(d.name, []);
|
|
275
|
+
byName.get(d.name).push(id);
|
|
276
|
+
}
|
|
277
|
+
defsByName.set(f, byName);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const forward = new Map();
|
|
281
|
+
const reverse = new Map();
|
|
282
|
+
const addEdge = (from, to) => {
|
|
283
|
+
if (from === to) return;
|
|
284
|
+
if (!forward.has(from)) forward.set(from, new Set());
|
|
285
|
+
forward.get(from).add(to);
|
|
286
|
+
if (!reverse.has(to)) reverse.set(to, new Set());
|
|
287
|
+
reverse.get(to).add(from);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
for (const [f, fileDefs] of perFileDefs.entries()) {
|
|
291
|
+
const masked = JS_EXTS.has(path.extname(f).toLowerCase()) ? maskJs(fs.readFileSync(f, 'utf8')) : maskPy(fs.readFileSync(f, 'utf8'));
|
|
292
|
+
// resolution scope: this file's defs, then directly-imported files' defs
|
|
293
|
+
const importedAbs = (fileGraph.forward.get(normalizePath(path.resolve(f))) || [])
|
|
294
|
+
.map((nf) => normToAbs.get(nf)).filter(Boolean);
|
|
295
|
+
for (const d of fileDefs) {
|
|
296
|
+
const callerId = symId(cwd, f, d.name);
|
|
297
|
+
if (!forward.has(callerId)) forward.set(callerId, new Set()); // ensure node exists
|
|
298
|
+
const callees = callsInRange(masked, d.bodyStart, d.bodyEnd);
|
|
299
|
+
for (const nm of callees) {
|
|
300
|
+
const local = (defsByName.get(f) || new Map()).get(nm);
|
|
301
|
+
if (local && local.length) { for (const id of local) addEdge(callerId, id); continue; }
|
|
302
|
+
for (const imp of importedAbs) {
|
|
303
|
+
const ids = (defsByName.get(imp) || new Map()).get(nm);
|
|
304
|
+
if (ids && ids.length) { for (const id of ids) addEdge(callerId, id); break; }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const toArr = (mapOfSets) => {
|
|
311
|
+
const out = new Map();
|
|
312
|
+
for (const [k, set] of mapOfSets.entries()) out.set(k, [...set]);
|
|
313
|
+
return out;
|
|
314
|
+
};
|
|
315
|
+
return { forward: toArr(forward), reverse: toArr(reverse), defs };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Resolve a user-supplied symbol (bare name or full `file#name` id) to ids.
|
|
319
|
+
function _resolveSymbol(symbol, defs) {
|
|
320
|
+
if (defs.has(symbol)) return [symbol];
|
|
321
|
+
const ids = [];
|
|
322
|
+
for (const id of defs.keys()) if (id.slice(id.indexOf('#') + 1) === symbol) ids.push(id);
|
|
323
|
+
return ids;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// BFS over a graph map from seed ids up to maxDepth (0 = unlimited).
|
|
327
|
+
function _bfs(seedIds, graph, maxDepth) {
|
|
328
|
+
const direct = new Set();
|
|
329
|
+
const transitive = new Set();
|
|
330
|
+
const visited = new Set(seedIds);
|
|
331
|
+
let frontier = [];
|
|
332
|
+
for (const s of seedIds) for (const nb of (graph.get(s) || [])) if (!visited.has(nb)) { direct.add(nb); visited.add(nb); frontier.push(nb); }
|
|
333
|
+
let depth = 1;
|
|
334
|
+
while (frontier.length && (maxDepth === 0 || depth < maxDepth)) {
|
|
335
|
+
const next = [];
|
|
336
|
+
for (const node of frontier) for (const nb of (graph.get(node) || [])) if (!visited.has(nb)) { transitive.add(nb); visited.add(nb); next.push(nb); }
|
|
337
|
+
frontier = next; depth++;
|
|
338
|
+
}
|
|
339
|
+
return { direct: [...direct], transitive: [...transitive] };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Method-level blast radius: everything that (transitively) calls `symbol`.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} symbol bare name or `file#name`
|
|
346
|
+
* @param {string} cwd
|
|
347
|
+
* @param {object} [opts] { depth=0, ...buildCallGraph opts }
|
|
348
|
+
* @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
|
|
349
|
+
*/
|
|
350
|
+
function methodImpact(symbol, cwd, opts = {}) {
|
|
351
|
+
const graph = opts.graph || buildCallGraph(cwd, opts);
|
|
352
|
+
const ids = _resolveSymbol(symbol, graph.defs);
|
|
353
|
+
if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
|
|
354
|
+
const { direct, transitive } = _bfs(ids, graph.reverse, opts.depth || 0);
|
|
355
|
+
return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* What `symbol` (transitively) calls.
|
|
360
|
+
* @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
|
|
361
|
+
*/
|
|
362
|
+
function methodCallees(symbol, cwd, opts = {}) {
|
|
363
|
+
const graph = opts.graph || buildCallGraph(cwd, opts);
|
|
364
|
+
const ids = _resolveSymbol(symbol, graph.defs);
|
|
365
|
+
if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
|
|
366
|
+
const { direct, transitive } = _bfs(ids, graph.forward, opts.depth || 0);
|
|
367
|
+
return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Formatters ───────────────────────────────────────────────────────────────
|
|
371
|
+
function formatCallGraph(result, kind) {
|
|
372
|
+
const verb = kind === 'callees' ? 'calls' : 'callers of';
|
|
373
|
+
const lines = [`## ${kind === 'callees' ? 'Callees' : 'Callers'}: \`${result.symbol}\``, ''];
|
|
374
|
+
if (result.unresolved) { lines.push('_symbol not found in the call-graph._'); return lines.join('\n'); }
|
|
375
|
+
if (result.total === 0) {
|
|
376
|
+
lines.push(kind === 'callees' ? '_calls no repo-defined symbols._' : '_no repo symbol calls this — zero method blast radius._');
|
|
377
|
+
return lines.join('\n');
|
|
378
|
+
}
|
|
379
|
+
lines.push(`**Total ${verb}:** ${result.total}`, '');
|
|
380
|
+
if (result.direct.length) { lines.push(`### Direct`); for (const id of result.direct) lines.push(`- \`${id}\``); lines.push(''); }
|
|
381
|
+
if (result.transitive.length) { lines.push(`### Transitive`); for (const id of result.transitive) lines.push(`- \`${id}\``); lines.push(''); }
|
|
382
|
+
return lines.join('\n');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function formatCallGraphJSON(result, kind) {
|
|
386
|
+
return {
|
|
387
|
+
symbol: result.symbol,
|
|
388
|
+
kind: kind === 'callees' ? 'callees' : 'callers',
|
|
389
|
+
resolved: result.resolved,
|
|
390
|
+
direct: result.direct,
|
|
391
|
+
transitive: result.transitive,
|
|
392
|
+
total: result.total,
|
|
393
|
+
unresolved: result.unresolved,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = {
|
|
398
|
+
buildCallGraph, methodImpact, methodCallees,
|
|
399
|
+
formatCallGraph, formatCallGraphJSON,
|
|
400
|
+
extractDefs, maskJs, maskPy,
|
|
401
|
+
};
|
package/src/mcp/server.js
CHANGED
package/src/verify/lib-index.js
CHANGED
|
@@ -298,7 +298,36 @@ function formatVersionPins(libraries) {
|
|
|
298
298
|
.map((l) => `${l.name}@${l.version}`);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* D8: collect `name@version` pins for direct dependencies — versions only, no
|
|
303
|
+
* symbol extraction, no cache. Cheap enough to run on every context build so
|
|
304
|
+
* the generated header can ground against what is actually installed (JS +
|
|
305
|
+
* Python). Deterministic: sorted, then capped.
|
|
306
|
+
* @param {string} cwd
|
|
307
|
+
* @param {object} [opts]
|
|
308
|
+
* @param {number} [opts.limit=40] max pins returned (after sort)
|
|
309
|
+
* @returns {{ pins: string[], total: number }} pins capped to limit; total = all resolved
|
|
310
|
+
*/
|
|
311
|
+
function collectVersionPins(cwd, opts = {}) {
|
|
312
|
+
const limit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : 40;
|
|
313
|
+
const pins = [];
|
|
314
|
+
for (const dep of directDeps(cwd).slice(0, MAX_DEPS)) {
|
|
315
|
+
const r = resolveEntry(cwd, dep);
|
|
316
|
+
if (r && r.version) pins.push(`${dep}@${r.version}`);
|
|
317
|
+
}
|
|
318
|
+
const sitePkgs = findSitePackages(cwd);
|
|
319
|
+
if (sitePkgs.length) {
|
|
320
|
+
for (const dep of pythonDirectDeps(cwd).slice(0, MAX_DEPS)) {
|
|
321
|
+
const r = resolvePyEntry(sitePkgs, dep);
|
|
322
|
+
if (r && r.version) pins.push(`${dep}@${r.version}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
pins.sort();
|
|
326
|
+
return { pins: limit ? pins.slice(0, limit) : pins, total: pins.length };
|
|
327
|
+
}
|
|
328
|
+
|
|
301
329
|
module.exports = {
|
|
302
330
|
buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
|
|
331
|
+
collectVersionPins,
|
|
303
332
|
extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
|
|
304
333
|
};
|