sigmap 8.2.0 → 8.4.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 +14 -0
- package/README.md +1 -1
- package/gen-context.js +316 -23
- package/llms-full.txt +4 -3
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/mcp/server.js +1 -1
- package/src/review/pr-evidence.js +139 -0
- package/src/verify/lib-index.js +160 -20
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,20 @@ Format: [Semantic Versioning](https://semver.org/)
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [8.4.0] — 2026-07-05
|
|
14
|
+
|
|
15
|
+
Minor release — **PR Evidence Report (v9.0 G3): a branded, deterministic review artifact.** SigMap already had the pieces — `review-pr` findings and `get_diff_context` — but no single Markdown comment an agent or CI could post on a PR. This adds it: one report that answers *"what changed, what it touches, and what to test"*, with no LLM.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **PR Evidence Report (#417, PR #418):** new `src/review/pr-evidence.js` — `buildPrEvidence(changedFiles, cwd)` folds together, per changed file, its extracted **signatures**, **blast radius** (direct/transitive importers, impacted tests + routes), cross-language **related tests**, a **risk label**, and the **`review-pr` findings** (scope drift, god-node edits, missing tests, security-sensitive files). `formatPrEvidenceMarkdown` renders the branded **"🔍 PR Evidence Report"** — with **no wall-clock timestamp**, so it's byte-stable given a fixed tree (diff-friendly as a comment). Exposed via `sigmap review-pr --markdown` (alias `--evidence`); honors `--staged`/`--base`; the exit code reflects the review pass/fail so CI can both post the comment and gate on it. Reuses shipped zero-dep modules only; git stays behind the shell-free `git()` util.
|
|
19
|
+
|
|
20
|
+
## [8.3.0] — 2026-07-05
|
|
21
|
+
|
|
22
|
+
Minor release — **Python site-packages grounding: the moat now spans both major ecosystems.** v8.1/v8.2 built local-library grounding for JS/TS (`node_modules` `.d.ts`); this extends it to **Python**, so `verify-ai-output` and the `verify_suggestion` MCP tool ground AI-suggested Python code against the libraries actually installed in the project's venv — with pinned versions (D8). Zero-dependency, no Python runtime, deterministic.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Python site-packages grounding (#413, PR #414):** `buildLibraryIndex` (`src/verify/lib-index.js`) gains a Python pass alongside the JS/TS one. It reads direct deps from `requirements.txt` / `pyproject.toml` (PEP 621 `[project].dependencies` + Poetry), discovers the venv `site-packages` (`.venv|venv|env` → `lib/python*/site-packages`, or `Lib/site-packages` on Windows) **without spawning Python**, resolves each dep's installed module + version (`*.dist-info`, D8) with PEP 503 import-name normalization, and extracts exported names from the package's `__init__.py`/`.pyi` (`__all__`, top-level `def`/`class`, public assignments, and `from … import` re-exports). Both ecosystems merge into one symbol index — genuine installed-Python-library calls stop being false-flagged as `fake-symbol`. Byte-stable given a fixed installed tree; cached via `src/cache/sig-cache.js`; graceful on missing venv / unresolved deps.
|
|
26
|
+
|
|
13
27
|
## [8.2.0] — 2026-07-04
|
|
14
28
|
|
|
15
29
|
Minor release — **`verify_suggestion` MCP tool: the grounding moat, made consumable by agents.** v8.1.0 built local-library grounding inside the `verify-ai-output` CLI; this exposes it as the **18th MCP tool**, so a coding agent can verify its own generated code against the repo **and the libraries actually installed** in `node_modules` — *before it writes* — and get back the flagged issues plus the pinned versions it verified against (D8).
|
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ Ask → Rank → Context → Validate → Judge → Learn
|
|
|
98
98
|
|
|
99
99
|
<!--SM:benchmarkBlock-->
|
|
100
100
|
```
|
|
101
|
-
Benchmark : sigmap-v8.
|
|
101
|
+
Benchmark : sigmap-v8.4-main (21 repositories, including R language)
|
|
102
102
|
Date : 2026-07-04
|
|
103
103
|
|
|
104
104
|
Hit@5 : 86.7% (baseline 13.6% — 6.4× lift)
|
package/gen-context.js
CHANGED
|
@@ -13108,7 +13108,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
13108
13108
|
|
|
13109
13109
|
const SERVER_INFO = {
|
|
13110
13110
|
name: 'sigmap',
|
|
13111
|
-
version: '8.
|
|
13111
|
+
version: '8.4.0',
|
|
13112
13112
|
description: 'SigMap MCP server — code signatures on demand',
|
|
13113
13113
|
};
|
|
13114
13114
|
|
|
@@ -14610,6 +14610,149 @@ __factories["./src/retrieval/tokenizer"] = function(module, exports) {
|
|
|
14610
14610
|
|
|
14611
14611
|
};
|
|
14612
14612
|
|
|
14613
|
+
// ── ./src/review/pr-evidence ──
|
|
14614
|
+
__factories["./src/review/pr-evidence"] = function(module, exports) {
|
|
14615
|
+
|
|
14616
|
+
/**
|
|
14617
|
+
* PR Evidence Report (v9.0 G3).
|
|
14618
|
+
*
|
|
14619
|
+
* A single, branded, deterministic Markdown artifact for code review: for each
|
|
14620
|
+
* changed file it folds together the signature context, blast radius (direct /
|
|
14621
|
+
* transitive importers, impacted tests + routes), cross-language related tests,
|
|
14622
|
+
* a risk label, and the `review-pr` findings (scope drift, god-node edits,
|
|
14623
|
+
* missing tests, security-sensitive files). Posted as a PR comment, it answers
|
|
14624
|
+
* "what changed, what it touches, and what to test" — without an LLM.
|
|
14625
|
+
*
|
|
14626
|
+
* Built entirely from shipped zero-dep modules (reviewPr, graph/impact,
|
|
14627
|
+
* evidence/pack, extractors/dispatch). Carries NO wall-clock timestamp, so the
|
|
14628
|
+
* report is byte-stable given a fixed tree — diff-friendly as a comment.
|
|
14629
|
+
*/
|
|
14630
|
+
|
|
14631
|
+
const fs = require('fs');
|
|
14632
|
+
const path = require('path');
|
|
14633
|
+
const { reviewPr } = __require('./src/review/review-pr');
|
|
14634
|
+
|
|
14635
|
+
/**
|
|
14636
|
+
* Build the structured PR evidence for a changed-file list.
|
|
14637
|
+
* @param {Array<{path:string,status?:string}>|string[]} changedFiles
|
|
14638
|
+
* @param {string} cwd
|
|
14639
|
+
* @param {object} [opts]
|
|
14640
|
+
* @param {number} [opts.depth=2] blast-radius BFS depth
|
|
14641
|
+
* @param {string} [opts.scope] label for the diff scope (e.g. "vs main")
|
|
14642
|
+
* @returns {{ scope:string, files:object[], review:object }}
|
|
14643
|
+
*/
|
|
14644
|
+
function buildPrEvidence(changedFiles, cwd, opts = {}) {
|
|
14645
|
+
const files = (changedFiles || []).map((f) =>
|
|
14646
|
+
typeof f === 'string' ? { path: f, status: 'M' } : { path: f.path, status: f.status || 'M' });
|
|
14647
|
+
|
|
14648
|
+
const review = reviewPr(files, cwd, opts);
|
|
14649
|
+
|
|
14650
|
+
let riskLabelFor = () => 'source';
|
|
14651
|
+
let findRelatedTests = () => [];
|
|
14652
|
+
try { ({ riskLabelFor, findRelatedTests } = __require('./src/evidence/pack')); } catch (_) { /* defaults */ }
|
|
14653
|
+
const { extractFile, langFor } = __require('./src/extractors/dispatch');
|
|
14654
|
+
|
|
14655
|
+
let allFiles = [];
|
|
14656
|
+
try { const { buildSigIndex } = __require('./src/retrieval/ranker'); allFiles = [...buildSigIndex(cwd).keys()]; } catch (_) { /* no index */ }
|
|
14657
|
+
|
|
14658
|
+
const depth = Number.isFinite(opts.depth) ? opts.depth : 2;
|
|
14659
|
+
const srcPaths = files.filter((f) => f.status !== 'D' && langFor(f.path)).map((f) => f.path);
|
|
14660
|
+
let impactByFile = new Map();
|
|
14661
|
+
try {
|
|
14662
|
+
const { analyzeImpact } = __require('./src/graph/impact');
|
|
14663
|
+
impactByFile = new Map(analyzeImpact(srcPaths, cwd, { depth }).map((r) => [r.file, r.impact]));
|
|
14664
|
+
} catch (_) { /* graph optional */ }
|
|
14665
|
+
|
|
14666
|
+
const fileReports = files.map((f) => {
|
|
14667
|
+
const deleted = f.status === 'D';
|
|
14668
|
+
let signatures = [];
|
|
14669
|
+
if (!deleted && langFor(f.path)) {
|
|
14670
|
+
try { signatures = extractFile(f.path, fs.readFileSync(path.resolve(cwd, f.path), 'utf8')); } catch (_) { /* unreadable */ }
|
|
14671
|
+
}
|
|
14672
|
+
const impact = impactByFile.get(f.path) || null;
|
|
14673
|
+
return {
|
|
14674
|
+
path: f.path,
|
|
14675
|
+
status: f.status,
|
|
14676
|
+
riskLabel: riskLabelFor(f.path),
|
|
14677
|
+
signatures,
|
|
14678
|
+
blast: impact ? {
|
|
14679
|
+
total: impact.totalImpact,
|
|
14680
|
+
direct: impact.direct || [],
|
|
14681
|
+
transitive: (impact.transitive || []).length,
|
|
14682
|
+
tests: impact.tests || [],
|
|
14683
|
+
routes: impact.routes || [],
|
|
14684
|
+
} : null,
|
|
14685
|
+
relatedTests: deleted ? [] : findRelatedTests(f.path, allFiles),
|
|
14686
|
+
};
|
|
14687
|
+
});
|
|
14688
|
+
|
|
14689
|
+
return { scope: opts.scope || 'diff', files: fileReports, review };
|
|
14690
|
+
}
|
|
14691
|
+
|
|
14692
|
+
const STATUS_LABEL = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied' };
|
|
14693
|
+
|
|
14694
|
+
/** Render the branded, deterministic "PR Evidence Report" Markdown. */
|
|
14695
|
+
function formatPrEvidenceMarkdown(evidence, opts = {}) {
|
|
14696
|
+
const L = [];
|
|
14697
|
+
const s = evidence.review.summary;
|
|
14698
|
+
const maxSigs = Number.isFinite(opts.maxSignatures) ? opts.maxSignatures : 30;
|
|
14699
|
+
|
|
14700
|
+
L.push('## 🔍 PR Evidence Report');
|
|
14701
|
+
L.push('');
|
|
14702
|
+
L.push(
|
|
14703
|
+
`**${s.filesChanged} file(s) changed** — ${s.sourceChanged} source, ${s.testsChanged} test · ` +
|
|
14704
|
+
(s.ok ? '✅ no review findings' : `⚠️ ${s.findings} finding(s)`) +
|
|
14705
|
+
` · scope: ${evidence.scope}`
|
|
14706
|
+
);
|
|
14707
|
+
L.push('');
|
|
14708
|
+
|
|
14709
|
+
if (!s.ok) {
|
|
14710
|
+
L.push('### Review findings');
|
|
14711
|
+
for (const f of evidence.review.findings) {
|
|
14712
|
+
if (f.type === 'missing-tests') L.push(`- ⚠️ **missing tests** — \`${f.file}\` changed with no matching test`);
|
|
14713
|
+
else if (f.type === 'security-file') L.push(`- ⚠️ **security-sensitive file** — \`${f.file}\``);
|
|
14714
|
+
else if (f.type === 'god-node') L.push(`- ⚠️ **god node** — \`${f.file}\` → ${f.count} dependents (high blast radius)`);
|
|
14715
|
+
else if (f.type === 'scope-drift') L.push(`- ⚠️ **scope drift** — ${f.count} top-level dirs touched (${f.dirs.join(', ')})`);
|
|
14716
|
+
}
|
|
14717
|
+
L.push('');
|
|
14718
|
+
}
|
|
14719
|
+
|
|
14720
|
+
L.push('### Changed files');
|
|
14721
|
+
for (const f of evidence.files) {
|
|
14722
|
+
const st = STATUS_LABEL[f.status] || f.status;
|
|
14723
|
+
L.push(`#### \`${f.path}\` _(${st} · risk: ${f.riskLabel})_`);
|
|
14724
|
+
if (f.status === 'D') { L.push('_deleted_', ''); continue; }
|
|
14725
|
+
|
|
14726
|
+
if (f.blast) {
|
|
14727
|
+
L.push(
|
|
14728
|
+
`**Blast radius:** ${f.blast.total} file(s) impacted — ${f.blast.direct.length} direct, ${f.blast.transitive} transitive` +
|
|
14729
|
+
(f.blast.tests.length ? `, ${f.blast.tests.length} test(s)` : '') +
|
|
14730
|
+
(f.blast.routes.length ? `, ${f.blast.routes.length} route(s)` : '')
|
|
14731
|
+
);
|
|
14732
|
+
if (f.blast.tests.length) L.push(`Tests to run: ${f.blast.tests.slice(0, 8).map((t) => '`' + t + '`').join(', ')}`);
|
|
14733
|
+
} else {
|
|
14734
|
+
L.push('**Blast radius:** _(not in dependency graph — new or leaf file)_');
|
|
14735
|
+
}
|
|
14736
|
+
if (f.relatedTests.length) L.push(`Related tests: ${f.relatedTests.slice(0, 8).map((t) => '`' + t + '`').join(', ')}`);
|
|
14737
|
+
|
|
14738
|
+
if (f.signatures.length) {
|
|
14739
|
+
L.push('```');
|
|
14740
|
+
for (const sig of f.signatures.slice(0, maxSigs)) L.push(sig);
|
|
14741
|
+
if (f.signatures.length > maxSigs) L.push(`… +${f.signatures.length - maxSigs} more`);
|
|
14742
|
+
L.push('```');
|
|
14743
|
+
}
|
|
14744
|
+
L.push('');
|
|
14745
|
+
}
|
|
14746
|
+
|
|
14747
|
+
L.push('---');
|
|
14748
|
+
L.push('_Deterministic PR Evidence Report — generated by [SigMap](https://sigmap.io). No LLM; byte-stable given a fixed tree._');
|
|
14749
|
+
return L.join('\n');
|
|
14750
|
+
}
|
|
14751
|
+
|
|
14752
|
+
module.exports = { buildPrEvidence, formatPrEvidenceMarkdown };
|
|
14753
|
+
|
|
14754
|
+
};
|
|
14755
|
+
|
|
14613
14756
|
// ── ./src/review/review-pr ──
|
|
14614
14757
|
__factories["./src/review/review-pr"] = function(module, exports) {
|
|
14615
14758
|
|
|
@@ -16805,12 +16948,17 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
16805
16948
|
* in `node_modules` and verify AI suggestions against repo + private +
|
|
16806
16949
|
* installed-lib symbols. This module builds the installed-lib half.
|
|
16807
16950
|
*
|
|
16808
|
-
*
|
|
16809
|
-
*
|
|
16810
|
-
*
|
|
16811
|
-
*
|
|
16812
|
-
*
|
|
16813
|
-
*
|
|
16951
|
+
* Two ecosystems, one index:
|
|
16952
|
+
* - **JS/TS** — each **direct** dependency in `package.json` resolved under
|
|
16953
|
+
* `node_modules/<dep>`; exports read from its TypeScript declaration entry
|
|
16954
|
+
* (`types`/`typings`, else `index.d.ts`).
|
|
16955
|
+
* - **Python** — each direct dependency in `requirements.txt`/`pyproject.toml`
|
|
16956
|
+
* resolved in the project's venv `site-packages`; exports read from the
|
|
16957
|
+
* package's `__init__.py`/`.pyi`. No Python runtime is spawned (North-Star #1).
|
|
16958
|
+
*
|
|
16959
|
+
* Pure, zero-dependency, deterministic: byte-stable given a fixed installed
|
|
16960
|
+
* tree. Bounded (per-file read cap + dep cap) and cached via
|
|
16961
|
+
* `src/cache/sig-cache.js` so repeat builds are near-free.
|
|
16814
16962
|
*/
|
|
16815
16963
|
|
|
16816
16964
|
const fs = require('fs');
|
|
@@ -16820,6 +16968,7 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
16820
16968
|
const MAX_DTS_BYTES = 512 * 1024; // per-file read cap
|
|
16821
16969
|
const MAX_DEPS = 1000; // dep count cap
|
|
16822
16970
|
const DEP_KEYS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
16971
|
+
const VENV_DIRS = ['.venv', 'venv', 'env', '.env'];
|
|
16823
16972
|
|
|
16824
16973
|
/**
|
|
16825
16974
|
* Extract exported symbol names from a `.d.ts` declaration file. Deterministic,
|
|
@@ -16895,6 +17044,130 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
16895
17044
|
return { version, dtsPath: null }; // installed but untyped
|
|
16896
17045
|
}
|
|
16897
17046
|
|
|
17047
|
+
// ── Python ──────────────────────────────────────────────────────────────────
|
|
17048
|
+
|
|
17049
|
+
/**
|
|
17050
|
+
* Extract exported symbol names from a Python module's `__init__.py`/`.pyi`.
|
|
17051
|
+
* Deterministic, regex-based, top-level only: `__all__`, `def`/`class`, public
|
|
17052
|
+
* module-level assignments, and `from … import …` re-exports (a package's
|
|
17053
|
+
* public API is largely re-exports). Private names (leading `_`) are skipped
|
|
17054
|
+
* unless listed in `__all__`.
|
|
17055
|
+
* @param {string} src
|
|
17056
|
+
* @returns {string[]} sorted unique exported names
|
|
17057
|
+
*/
|
|
17058
|
+
function extractPyExports(src) {
|
|
17059
|
+
const names = new Set();
|
|
17060
|
+
if (!src) return [];
|
|
17061
|
+
|
|
17062
|
+
// __all__ = [ 'a', 'b', ... ] (authoritative when present; keeps privates)
|
|
17063
|
+
const allMatch = src.match(/^__all__\s*[:+]?=\s*[\[(]([\s\S]*?)[\])]/m);
|
|
17064
|
+
if (allMatch) {
|
|
17065
|
+
for (const m of allMatch[1].matchAll(/['"]([A-Za-z_]\w*)['"]/g)) names.add(m[1]);
|
|
17066
|
+
}
|
|
17067
|
+
|
|
17068
|
+
// top-level def / class (column 0)
|
|
17069
|
+
for (const m of src.matchAll(/^(?:async\s+)?def\s+([A-Za-z_]\w*)/gm)) if (!m[1].startsWith('_')) names.add(m[1]);
|
|
17070
|
+
for (const m of src.matchAll(/^class\s+([A-Za-z_]\w*)/gm)) if (!m[1].startsWith('_')) names.add(m[1]);
|
|
17071
|
+
|
|
17072
|
+
// top-level public assignments: NAME = … / NAME: type = … (not ==, +=, etc.)
|
|
17073
|
+
for (const m of src.matchAll(/^([A-Za-z_]\w*)\s*(?::[^=\n]+)?=(?!=)/gm)) {
|
|
17074
|
+
if (!m[1].startsWith('_')) names.add(m[1]);
|
|
17075
|
+
}
|
|
17076
|
+
|
|
17077
|
+
// re-exports: from .mod import Name, Other as Alias
|
|
17078
|
+
for (const m of src.matchAll(/^from\s+[^\n]+?\s+import\s+([^\n#]+)/gm)) {
|
|
17079
|
+
for (const part of m[1].split(',')) {
|
|
17080
|
+
const name = part.trim().replace(/[()]/g, '').split(/\s+as\s+/).pop().trim();
|
|
17081
|
+
if (/^[A-Za-z_]\w*$/.test(name) && !name.startsWith('_')) names.add(name);
|
|
17082
|
+
}
|
|
17083
|
+
}
|
|
17084
|
+
|
|
17085
|
+
return [...names].sort();
|
|
17086
|
+
}
|
|
17087
|
+
|
|
17088
|
+
/** Read direct Python dependency names from requirements.txt + pyproject.toml. */
|
|
17089
|
+
function pythonDirectDeps(cwd) {
|
|
17090
|
+
const names = new Set();
|
|
17091
|
+
try {
|
|
17092
|
+
const req = fs.readFileSync(path.join(cwd, 'requirements.txt'), 'utf8');
|
|
17093
|
+
for (const line of req.split('\n')) {
|
|
17094
|
+
const t = line.trim();
|
|
17095
|
+
if (!t || t.startsWith('#') || t.startsWith('-')) continue;
|
|
17096
|
+
const m = t.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/);
|
|
17097
|
+
if (m) names.add(m[1]);
|
|
17098
|
+
}
|
|
17099
|
+
} catch (_) { /* none */ }
|
|
17100
|
+
try {
|
|
17101
|
+
const py = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf8');
|
|
17102
|
+
// PEP 621: [project] dependencies = ["foo>=1", "bar"]
|
|
17103
|
+
const projDeps = py.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
|
17104
|
+
if (projDeps) for (const m of projDeps[1].matchAll(/['"]([A-Za-z0-9][A-Za-z0-9._-]*)/g)) names.add(m[1]);
|
|
17105
|
+
// Poetry: [tool.poetry.dependencies]\n foo = "^1"
|
|
17106
|
+
const poetry = py.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
|
|
17107
|
+
if (poetry) for (const m of poetry[1].matchAll(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/gm)) {
|
|
17108
|
+
if (m[1] !== 'python') names.add(m[1]);
|
|
17109
|
+
}
|
|
17110
|
+
} catch (_) { /* none */ }
|
|
17111
|
+
return [...names].sort();
|
|
17112
|
+
}
|
|
17113
|
+
|
|
17114
|
+
/** Locate the project's venv `site-packages` directories (no Python runtime). */
|
|
17115
|
+
function findSitePackages(cwd) {
|
|
17116
|
+
const out = [];
|
|
17117
|
+
for (const v of VENV_DIRS) {
|
|
17118
|
+
const base = path.join(cwd, v);
|
|
17119
|
+
const libDir = path.join(base, 'lib'); // POSIX: <venv>/lib/pythonX.Y/site-packages
|
|
17120
|
+
let pyDirs = [];
|
|
17121
|
+
try { pyDirs = fs.readdirSync(libDir).filter((d) => /^python\d/.test(d)).sort(); } catch (_) { /* none */ }
|
|
17122
|
+
for (const py of pyDirs) {
|
|
17123
|
+
const sp = path.join(libDir, py, 'site-packages');
|
|
17124
|
+
try { if (fs.statSync(sp).isDirectory()) out.push(sp); } catch (_) { /* next */ }
|
|
17125
|
+
}
|
|
17126
|
+
const winSp = path.join(base, 'Lib', 'site-packages'); // Windows
|
|
17127
|
+
try { if (fs.statSync(winSp).isDirectory()) out.push(winSp); } catch (_) { /* next */ }
|
|
17128
|
+
}
|
|
17129
|
+
return out;
|
|
17130
|
+
}
|
|
17131
|
+
|
|
17132
|
+
/** PEP 503 name normalization (case-insensitive, `-`/`_`/`.` collapsed). */
|
|
17133
|
+
function normalizePy(name) {
|
|
17134
|
+
return String(name).toLowerCase().replace(/[-_.]+/g, '-');
|
|
17135
|
+
}
|
|
17136
|
+
|
|
17137
|
+
/** Find an installed distribution's version from its `*.dist-info`/`*.egg-info`. */
|
|
17138
|
+
function findPyVersion(sitePkgsDir, dep) {
|
|
17139
|
+
const norm = normalizePy(dep);
|
|
17140
|
+
let entries;
|
|
17141
|
+
try { entries = fs.readdirSync(sitePkgsDir); } catch (_) { return null; }
|
|
17142
|
+
for (const e of entries.sort()) {
|
|
17143
|
+
const m = e.match(/^(.+?)-(\d[^-]*)\.(?:dist-info|egg-info)$/);
|
|
17144
|
+
if (m && normalizePy(m[1]) === norm) return m[2];
|
|
17145
|
+
}
|
|
17146
|
+
return null;
|
|
17147
|
+
}
|
|
17148
|
+
|
|
17149
|
+
/**
|
|
17150
|
+
* Resolve a Python dependency to its installed module entry file + version.
|
|
17151
|
+
* @returns {{ version: string|null, sourcePath: string|null }|null} null if not installed
|
|
17152
|
+
*/
|
|
17153
|
+
function resolvePyEntry(sitePkgsDirs, dep) {
|
|
17154
|
+
const candidates = [...new Set([dep, dep.replace(/-/g, '_'), dep.toLowerCase(), dep.toLowerCase().replace(/-/g, '_')])];
|
|
17155
|
+
for (const sp of sitePkgsDirs) {
|
|
17156
|
+
const version = findPyVersion(sp, dep);
|
|
17157
|
+
for (const cand of candidates) {
|
|
17158
|
+
for (const entry of ['__init__.pyi', '__init__.py']) { // package
|
|
17159
|
+
const p = path.join(sp, cand, entry);
|
|
17160
|
+
try { if (fs.statSync(p).isFile()) return { version, sourcePath: p }; } catch (_) { /* next */ }
|
|
17161
|
+
}
|
|
17162
|
+
for (const ext of ['.pyi', '.py']) { // single-module
|
|
17163
|
+
const p = path.join(sp, cand + ext);
|
|
17164
|
+
try { if (fs.statSync(p).isFile()) return { version, sourcePath: p }; } catch (_) { /* next */ }
|
|
17165
|
+
}
|
|
17166
|
+
}
|
|
17167
|
+
}
|
|
17168
|
+
return null;
|
|
17169
|
+
}
|
|
17170
|
+
|
|
16898
17171
|
/**
|
|
16899
17172
|
* Build the installed-library signature index for `cwd`.
|
|
16900
17173
|
*
|
|
@@ -16907,17 +17180,24 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
16907
17180
|
function buildLibraryIndex(cwd, opts = {}) {
|
|
16908
17181
|
const version = opts.version || '0';
|
|
16909
17182
|
const useCache = opts.cache !== false;
|
|
16910
|
-
const deps = directDeps(cwd).slice(0, MAX_DEPS);
|
|
16911
17183
|
|
|
16912
|
-
|
|
16913
|
-
|
|
17184
|
+
// Collect entries from both ecosystems; each carries its extractor kind.
|
|
17185
|
+
const entries = []; // { name, version, sourcePath, kind: 'dts'|'py' }
|
|
17186
|
+
for (const dep of directDeps(cwd).slice(0, MAX_DEPS)) {
|
|
16914
17187
|
const r = resolveEntry(cwd, dep);
|
|
16915
|
-
if (r) entries.push({ dep, version: r.version,
|
|
17188
|
+
if (r) entries.push({ name: dep, version: r.version, sourcePath: r.dtsPath, kind: 'dts' });
|
|
17189
|
+
}
|
|
17190
|
+
const sitePkgs = findSitePackages(cwd);
|
|
17191
|
+
if (sitePkgs.length) {
|
|
17192
|
+
for (const dep of pythonDirectDeps(cwd).slice(0, MAX_DEPS)) {
|
|
17193
|
+
const r = resolvePyEntry(sitePkgs, dep);
|
|
17194
|
+
if (r) entries.push({ name: dep, version: r.version, sourcePath: r.sourcePath, kind: 'py' });
|
|
17195
|
+
}
|
|
16916
17196
|
}
|
|
16917
17197
|
|
|
16918
17198
|
const cache = useCache ? loadCache(cwd, version) : new Map();
|
|
16919
|
-
const
|
|
16920
|
-
const { unchanged } = getChangedFiles(
|
|
17199
|
+
const files = entries.filter((e) => e.sourcePath).map((e) => e.sourcePath);
|
|
17200
|
+
const { unchanged } = getChangedFiles(files, cache);
|
|
16921
17201
|
const unchangedSet = new Set(unchanged);
|
|
16922
17202
|
|
|
16923
17203
|
const symbols = new Set();
|
|
@@ -16926,20 +17206,20 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
16926
17206
|
|
|
16927
17207
|
for (const e of entries) {
|
|
16928
17208
|
let names;
|
|
16929
|
-
if (!e.
|
|
17209
|
+
if (!e.sourcePath) {
|
|
16930
17210
|
names = [];
|
|
16931
|
-
} else if (unchangedSet.has(e.
|
|
16932
|
-
names = cache.get(e.
|
|
17211
|
+
} else if (unchangedSet.has(e.sourcePath) && cache.get(e.sourcePath)) {
|
|
17212
|
+
names = cache.get(e.sourcePath).sigs || [];
|
|
16933
17213
|
} else {
|
|
16934
17214
|
let src = '';
|
|
16935
17215
|
try {
|
|
16936
|
-
if (fs.statSync(e.
|
|
17216
|
+
if (fs.statSync(e.sourcePath).size <= MAX_DTS_BYTES) src = fs.readFileSync(e.sourcePath, 'utf8');
|
|
16937
17217
|
} catch (_) { /* unreadable → empty */ }
|
|
16938
|
-
names = extractDtsExports(src);
|
|
16939
|
-
fresh.push({ file: e.
|
|
17218
|
+
names = e.kind === 'py' ? extractPyExports(src) : extractDtsExports(src);
|
|
17219
|
+
fresh.push({ file: e.sourcePath, sigs: names });
|
|
16940
17220
|
}
|
|
16941
17221
|
for (const n of names) symbols.add(n);
|
|
16942
|
-
libraries.push({ name: e.
|
|
17222
|
+
libraries.push({ name: e.name, version: e.version, symbols: names.length, typed: !!e.sourcePath });
|
|
16943
17223
|
}
|
|
16944
17224
|
|
|
16945
17225
|
if (useCache && fresh.length) {
|
|
@@ -16958,7 +17238,10 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
|
|
|
16958
17238
|
.map((l) => `${l.name}@${l.version}`);
|
|
16959
17239
|
}
|
|
16960
17240
|
|
|
16961
|
-
module.exports = {
|
|
17241
|
+
module.exports = {
|
|
17242
|
+
buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
|
|
17243
|
+
extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
|
|
17244
|
+
};
|
|
16962
17245
|
|
|
16963
17246
|
};
|
|
16964
17247
|
|
|
@@ -17291,7 +17574,7 @@ function __tryGit(args, opts = {}) {
|
|
|
17291
17574
|
catch (_) { return ''; }
|
|
17292
17575
|
}
|
|
17293
17576
|
|
|
17294
|
-
const VERSION = '8.
|
|
17577
|
+
const VERSION = '8.4.0';
|
|
17295
17578
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
17296
17579
|
|
|
17297
17580
|
function requireSourceOrBundled(key) {
|
|
@@ -19092,7 +19375,8 @@ Usage:
|
|
|
19092
19375
|
${cmd} conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
|
|
19093
19376
|
${cmd} scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
|
|
19094
19377
|
${cmd} verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
|
|
19095
|
-
${cmd} review-pr Audit a diff — scope drift, god-node edits, missing tests, security files (--staged, --json)
|
|
19378
|
+
${cmd} review-pr Audit a diff — scope drift, god-node edits, missing tests, security files (--staged, --base, --json, --markdown)
|
|
19379
|
+
${cmd} review-pr --markdown PR Evidence Report — branded Markdown (signatures + blast radius + tests) to post as a PR comment
|
|
19096
19380
|
${cmd} create "<task>" Grounded-creation pipeline: scaffold → verify-plan → verify-ai-output → review-pr (--staged)
|
|
19097
19381
|
${cmd} squeeze <file|-> Minimize a pasted stacktrace/CI-log/JSON blob (--json for stats)
|
|
19098
19382
|
${cmd} ask "<query>" --squeeze Auto-accept input minimization (no prompt; for scripts/CI)
|
|
@@ -21033,6 +21317,15 @@ function main() {
|
|
|
21033
21317
|
return { path: file, status };
|
|
21034
21318
|
});
|
|
21035
21319
|
|
|
21320
|
+
// --markdown / --evidence: emit the branded, deterministic PR Evidence Report.
|
|
21321
|
+
if (args.includes('--markdown') || args.includes('--evidence')) {
|
|
21322
|
+
const { buildPrEvidence, formatPrEvidenceMarkdown } = requireSourceOrBundled('./src/review/pr-evidence');
|
|
21323
|
+
const scope = staged ? 'staged' : (baseArg ? `vs ${baseArg}` : 'branch');
|
|
21324
|
+
const ev = buildPrEvidence(changedFiles, cwd, { scope });
|
|
21325
|
+
process.stdout.write(formatPrEvidenceMarkdown(ev) + '\n');
|
|
21326
|
+
process.exit(ev.review.summary.ok ? 0 : 1);
|
|
21327
|
+
}
|
|
21328
|
+
|
|
21036
21329
|
const { reviewPr } = requireSourceOrBundled('./src/review/review-pr');
|
|
21037
21330
|
const result = reviewPr(changedFiles, cwd, {});
|
|
21038
21331
|
|
package/llms-full.txt
CHANGED
|
@@ -11,13 +11,13 @@ 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.4.0 | Benchmark: sigmap-v8.4-main (2026-07-04)
|
|
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.4-main, 2026-07-04)
|
|
21
21
|
|
|
22
22
|
| Metric | Without SigMap | With SigMap |
|
|
23
23
|
|--------|----------------|-------------|
|
|
@@ -105,7 +105,8 @@ sigmap verify-ai-output <answer.md> --report Write a standalone HTML report (re
|
|
|
105
105
|
sigmap conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
|
|
106
106
|
sigmap scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
|
|
107
107
|
sigmap verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
|
|
108
|
-
sigmap review-pr Audit a diff — scope drift, god-node edits, missing tests, security files (--staged, --json)
|
|
108
|
+
sigmap review-pr Audit a diff — scope drift, god-node edits, missing tests, security files (--staged, --base, --json, --markdown)
|
|
109
|
+
sigmap review-pr --markdown PR Evidence Report — branded Markdown (signatures + blast radius + tests) to post as a PR comment
|
|
109
110
|
sigmap create "<task>" Grounded-creation pipeline: scaffold → verify-plan → verify-ai-output → review-pr (--staged)
|
|
110
111
|
sigmap squeeze <file|-> Minimize a pasted stacktrace/CI-log/JSON blob (--json for stats)
|
|
111
112
|
sigmap ask "<query>" --squeeze Auto-accept input minimization (no prompt; for scripts/CI)
|
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.4.0 | Benchmark: sigmap-v8.4-main (2026-07-04)
|
|
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,7 +23,7 @@ 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.4-main, 2026-07-04)
|
|
27
27
|
|
|
28
28
|
- hit@5 retrieval: 86.7% vs 13.6% random baseline (6.4× lift)
|
|
29
29
|
- Token reduction: 97.0% average across benchmark repos
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sigmap",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.4.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/mcp/server.js
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR Evidence Report (v9.0 G3).
|
|
5
|
+
*
|
|
6
|
+
* A single, branded, deterministic Markdown artifact for code review: for each
|
|
7
|
+
* changed file it folds together the signature context, blast radius (direct /
|
|
8
|
+
* transitive importers, impacted tests + routes), cross-language related tests,
|
|
9
|
+
* a risk label, and the `review-pr` findings (scope drift, god-node edits,
|
|
10
|
+
* missing tests, security-sensitive files). Posted as a PR comment, it answers
|
|
11
|
+
* "what changed, what it touches, and what to test" — without an LLM.
|
|
12
|
+
*
|
|
13
|
+
* Built entirely from shipped zero-dep modules (reviewPr, graph/impact,
|
|
14
|
+
* evidence/pack, extractors/dispatch). Carries NO wall-clock timestamp, so the
|
|
15
|
+
* report is byte-stable given a fixed tree — diff-friendly as a comment.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { reviewPr } = require('./review-pr');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the structured PR evidence for a changed-file list.
|
|
24
|
+
* @param {Array<{path:string,status?:string}>|string[]} changedFiles
|
|
25
|
+
* @param {string} cwd
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {number} [opts.depth=2] blast-radius BFS depth
|
|
28
|
+
* @param {string} [opts.scope] label for the diff scope (e.g. "vs main")
|
|
29
|
+
* @returns {{ scope:string, files:object[], review:object }}
|
|
30
|
+
*/
|
|
31
|
+
function buildPrEvidence(changedFiles, cwd, opts = {}) {
|
|
32
|
+
const files = (changedFiles || []).map((f) =>
|
|
33
|
+
typeof f === 'string' ? { path: f, status: 'M' } : { path: f.path, status: f.status || 'M' });
|
|
34
|
+
|
|
35
|
+
const review = reviewPr(files, cwd, opts);
|
|
36
|
+
|
|
37
|
+
let riskLabelFor = () => 'source';
|
|
38
|
+
let findRelatedTests = () => [];
|
|
39
|
+
try { ({ riskLabelFor, findRelatedTests } = require('../evidence/pack')); } catch (_) { /* defaults */ }
|
|
40
|
+
const { extractFile, langFor } = require('../extractors/dispatch');
|
|
41
|
+
|
|
42
|
+
let allFiles = [];
|
|
43
|
+
try { const { buildSigIndex } = require('../retrieval/ranker'); allFiles = [...buildSigIndex(cwd).keys()]; } catch (_) { /* no index */ }
|
|
44
|
+
|
|
45
|
+
const depth = Number.isFinite(opts.depth) ? opts.depth : 2;
|
|
46
|
+
const srcPaths = files.filter((f) => f.status !== 'D' && langFor(f.path)).map((f) => f.path);
|
|
47
|
+
let impactByFile = new Map();
|
|
48
|
+
try {
|
|
49
|
+
const { analyzeImpact } = require('../graph/impact');
|
|
50
|
+
impactByFile = new Map(analyzeImpact(srcPaths, cwd, { depth }).map((r) => [r.file, r.impact]));
|
|
51
|
+
} catch (_) { /* graph optional */ }
|
|
52
|
+
|
|
53
|
+
const fileReports = files.map((f) => {
|
|
54
|
+
const deleted = f.status === 'D';
|
|
55
|
+
let signatures = [];
|
|
56
|
+
if (!deleted && langFor(f.path)) {
|
|
57
|
+
try { signatures = extractFile(f.path, fs.readFileSync(path.resolve(cwd, f.path), 'utf8')); } catch (_) { /* unreadable */ }
|
|
58
|
+
}
|
|
59
|
+
const impact = impactByFile.get(f.path) || null;
|
|
60
|
+
return {
|
|
61
|
+
path: f.path,
|
|
62
|
+
status: f.status,
|
|
63
|
+
riskLabel: riskLabelFor(f.path),
|
|
64
|
+
signatures,
|
|
65
|
+
blast: impact ? {
|
|
66
|
+
total: impact.totalImpact,
|
|
67
|
+
direct: impact.direct || [],
|
|
68
|
+
transitive: (impact.transitive || []).length,
|
|
69
|
+
tests: impact.tests || [],
|
|
70
|
+
routes: impact.routes || [],
|
|
71
|
+
} : null,
|
|
72
|
+
relatedTests: deleted ? [] : findRelatedTests(f.path, allFiles),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { scope: opts.scope || 'diff', files: fileReports, review };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const STATUS_LABEL = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied' };
|
|
80
|
+
|
|
81
|
+
/** Render the branded, deterministic "PR Evidence Report" Markdown. */
|
|
82
|
+
function formatPrEvidenceMarkdown(evidence, opts = {}) {
|
|
83
|
+
const L = [];
|
|
84
|
+
const s = evidence.review.summary;
|
|
85
|
+
const maxSigs = Number.isFinite(opts.maxSignatures) ? opts.maxSignatures : 30;
|
|
86
|
+
|
|
87
|
+
L.push('## 🔍 PR Evidence Report');
|
|
88
|
+
L.push('');
|
|
89
|
+
L.push(
|
|
90
|
+
`**${s.filesChanged} file(s) changed** — ${s.sourceChanged} source, ${s.testsChanged} test · ` +
|
|
91
|
+
(s.ok ? '✅ no review findings' : `⚠️ ${s.findings} finding(s)`) +
|
|
92
|
+
` · scope: ${evidence.scope}`
|
|
93
|
+
);
|
|
94
|
+
L.push('');
|
|
95
|
+
|
|
96
|
+
if (!s.ok) {
|
|
97
|
+
L.push('### Review findings');
|
|
98
|
+
for (const f of evidence.review.findings) {
|
|
99
|
+
if (f.type === 'missing-tests') L.push(`- ⚠️ **missing tests** — \`${f.file}\` changed with no matching test`);
|
|
100
|
+
else if (f.type === 'security-file') L.push(`- ⚠️ **security-sensitive file** — \`${f.file}\``);
|
|
101
|
+
else if (f.type === 'god-node') L.push(`- ⚠️ **god node** — \`${f.file}\` → ${f.count} dependents (high blast radius)`);
|
|
102
|
+
else if (f.type === 'scope-drift') L.push(`- ⚠️ **scope drift** — ${f.count} top-level dirs touched (${f.dirs.join(', ')})`);
|
|
103
|
+
}
|
|
104
|
+
L.push('');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
L.push('### Changed files');
|
|
108
|
+
for (const f of evidence.files) {
|
|
109
|
+
const st = STATUS_LABEL[f.status] || f.status;
|
|
110
|
+
L.push(`#### \`${f.path}\` _(${st} · risk: ${f.riskLabel})_`);
|
|
111
|
+
if (f.status === 'D') { L.push('_deleted_', ''); continue; }
|
|
112
|
+
|
|
113
|
+
if (f.blast) {
|
|
114
|
+
L.push(
|
|
115
|
+
`**Blast radius:** ${f.blast.total} file(s) impacted — ${f.blast.direct.length} direct, ${f.blast.transitive} transitive` +
|
|
116
|
+
(f.blast.tests.length ? `, ${f.blast.tests.length} test(s)` : '') +
|
|
117
|
+
(f.blast.routes.length ? `, ${f.blast.routes.length} route(s)` : '')
|
|
118
|
+
);
|
|
119
|
+
if (f.blast.tests.length) L.push(`Tests to run: ${f.blast.tests.slice(0, 8).map((t) => '`' + t + '`').join(', ')}`);
|
|
120
|
+
} else {
|
|
121
|
+
L.push('**Blast radius:** _(not in dependency graph — new or leaf file)_');
|
|
122
|
+
}
|
|
123
|
+
if (f.relatedTests.length) L.push(`Related tests: ${f.relatedTests.slice(0, 8).map((t) => '`' + t + '`').join(', ')}`);
|
|
124
|
+
|
|
125
|
+
if (f.signatures.length) {
|
|
126
|
+
L.push('```');
|
|
127
|
+
for (const sig of f.signatures.slice(0, maxSigs)) L.push(sig);
|
|
128
|
+
if (f.signatures.length > maxSigs) L.push(`… +${f.signatures.length - maxSigs} more`);
|
|
129
|
+
L.push('```');
|
|
130
|
+
}
|
|
131
|
+
L.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
L.push('---');
|
|
135
|
+
L.push('_Deterministic PR Evidence Report — generated by [SigMap](https://sigmap.io). No LLM; byte-stable given a fixed tree._');
|
|
136
|
+
return L.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { buildPrEvidence, formatPrEvidenceMarkdown };
|
package/src/verify/lib-index.js
CHANGED
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
* in `node_modules` and verify AI suggestions against repo + private +
|
|
9
9
|
* installed-lib symbols. This module builds the installed-lib half.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* Two ecosystems, one index:
|
|
12
|
+
* - **JS/TS** — each **direct** dependency in `package.json` resolved under
|
|
13
|
+
* `node_modules/<dep>`; exports read from its TypeScript declaration entry
|
|
14
|
+
* (`types`/`typings`, else `index.d.ts`).
|
|
15
|
+
* - **Python** — each direct dependency in `requirements.txt`/`pyproject.toml`
|
|
16
|
+
* resolved in the project's venv `site-packages`; exports read from the
|
|
17
|
+
* package's `__init__.py`/`.pyi`. No Python runtime is spawned (North-Star #1).
|
|
18
|
+
*
|
|
19
|
+
* Pure, zero-dependency, deterministic: byte-stable given a fixed installed
|
|
20
|
+
* tree. Bounded (per-file read cap + dep cap) and cached via
|
|
21
|
+
* `src/cache/sig-cache.js` so repeat builds are near-free.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
const fs = require('fs');
|
|
@@ -23,6 +28,7 @@ const { loadCache, saveCache, getChangedFiles, updateCacheEntries } = require('.
|
|
|
23
28
|
const MAX_DTS_BYTES = 512 * 1024; // per-file read cap
|
|
24
29
|
const MAX_DEPS = 1000; // dep count cap
|
|
25
30
|
const DEP_KEYS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
31
|
+
const VENV_DIRS = ['.venv', 'venv', 'env', '.env'];
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* Extract exported symbol names from a `.d.ts` declaration file. Deterministic,
|
|
@@ -98,6 +104,130 @@ function resolveEntry(cwd, dep) {
|
|
|
98
104
|
return { version, dtsPath: null }; // installed but untyped
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
// ── Python ──────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract exported symbol names from a Python module's `__init__.py`/`.pyi`.
|
|
111
|
+
* Deterministic, regex-based, top-level only: `__all__`, `def`/`class`, public
|
|
112
|
+
* module-level assignments, and `from … import …` re-exports (a package's
|
|
113
|
+
* public API is largely re-exports). Private names (leading `_`) are skipped
|
|
114
|
+
* unless listed in `__all__`.
|
|
115
|
+
* @param {string} src
|
|
116
|
+
* @returns {string[]} sorted unique exported names
|
|
117
|
+
*/
|
|
118
|
+
function extractPyExports(src) {
|
|
119
|
+
const names = new Set();
|
|
120
|
+
if (!src) return [];
|
|
121
|
+
|
|
122
|
+
// __all__ = [ 'a', 'b', ... ] (authoritative when present; keeps privates)
|
|
123
|
+
const allMatch = src.match(/^__all__\s*[:+]?=\s*[\[(]([\s\S]*?)[\])]/m);
|
|
124
|
+
if (allMatch) {
|
|
125
|
+
for (const m of allMatch[1].matchAll(/['"]([A-Za-z_]\w*)['"]/g)) names.add(m[1]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// top-level def / class (column 0)
|
|
129
|
+
for (const m of src.matchAll(/^(?:async\s+)?def\s+([A-Za-z_]\w*)/gm)) if (!m[1].startsWith('_')) names.add(m[1]);
|
|
130
|
+
for (const m of src.matchAll(/^class\s+([A-Za-z_]\w*)/gm)) if (!m[1].startsWith('_')) names.add(m[1]);
|
|
131
|
+
|
|
132
|
+
// top-level public assignments: NAME = … / NAME: type = … (not ==, +=, etc.)
|
|
133
|
+
for (const m of src.matchAll(/^([A-Za-z_]\w*)\s*(?::[^=\n]+)?=(?!=)/gm)) {
|
|
134
|
+
if (!m[1].startsWith('_')) names.add(m[1]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// re-exports: from .mod import Name, Other as Alias
|
|
138
|
+
for (const m of src.matchAll(/^from\s+[^\n]+?\s+import\s+([^\n#]+)/gm)) {
|
|
139
|
+
for (const part of m[1].split(',')) {
|
|
140
|
+
const name = part.trim().replace(/[()]/g, '').split(/\s+as\s+/).pop().trim();
|
|
141
|
+
if (/^[A-Za-z_]\w*$/.test(name) && !name.startsWith('_')) names.add(name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [...names].sort();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read direct Python dependency names from requirements.txt + pyproject.toml. */
|
|
149
|
+
function pythonDirectDeps(cwd) {
|
|
150
|
+
const names = new Set();
|
|
151
|
+
try {
|
|
152
|
+
const req = fs.readFileSync(path.join(cwd, 'requirements.txt'), 'utf8');
|
|
153
|
+
for (const line of req.split('\n')) {
|
|
154
|
+
const t = line.trim();
|
|
155
|
+
if (!t || t.startsWith('#') || t.startsWith('-')) continue;
|
|
156
|
+
const m = t.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/);
|
|
157
|
+
if (m) names.add(m[1]);
|
|
158
|
+
}
|
|
159
|
+
} catch (_) { /* none */ }
|
|
160
|
+
try {
|
|
161
|
+
const py = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf8');
|
|
162
|
+
// PEP 621: [project] dependencies = ["foo>=1", "bar"]
|
|
163
|
+
const projDeps = py.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
|
164
|
+
if (projDeps) for (const m of projDeps[1].matchAll(/['"]([A-Za-z0-9][A-Za-z0-9._-]*)/g)) names.add(m[1]);
|
|
165
|
+
// Poetry: [tool.poetry.dependencies]\n foo = "^1"
|
|
166
|
+
const poetry = py.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
|
|
167
|
+
if (poetry) for (const m of poetry[1].matchAll(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/gm)) {
|
|
168
|
+
if (m[1] !== 'python') names.add(m[1]);
|
|
169
|
+
}
|
|
170
|
+
} catch (_) { /* none */ }
|
|
171
|
+
return [...names].sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Locate the project's venv `site-packages` directories (no Python runtime). */
|
|
175
|
+
function findSitePackages(cwd) {
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const v of VENV_DIRS) {
|
|
178
|
+
const base = path.join(cwd, v);
|
|
179
|
+
const libDir = path.join(base, 'lib'); // POSIX: <venv>/lib/pythonX.Y/site-packages
|
|
180
|
+
let pyDirs = [];
|
|
181
|
+
try { pyDirs = fs.readdirSync(libDir).filter((d) => /^python\d/.test(d)).sort(); } catch (_) { /* none */ }
|
|
182
|
+
for (const py of pyDirs) {
|
|
183
|
+
const sp = path.join(libDir, py, 'site-packages');
|
|
184
|
+
try { if (fs.statSync(sp).isDirectory()) out.push(sp); } catch (_) { /* next */ }
|
|
185
|
+
}
|
|
186
|
+
const winSp = path.join(base, 'Lib', 'site-packages'); // Windows
|
|
187
|
+
try { if (fs.statSync(winSp).isDirectory()) out.push(winSp); } catch (_) { /* next */ }
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** PEP 503 name normalization (case-insensitive, `-`/`_`/`.` collapsed). */
|
|
193
|
+
function normalizePy(name) {
|
|
194
|
+
return String(name).toLowerCase().replace(/[-_.]+/g, '-');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Find an installed distribution's version from its `*.dist-info`/`*.egg-info`. */
|
|
198
|
+
function findPyVersion(sitePkgsDir, dep) {
|
|
199
|
+
const norm = normalizePy(dep);
|
|
200
|
+
let entries;
|
|
201
|
+
try { entries = fs.readdirSync(sitePkgsDir); } catch (_) { return null; }
|
|
202
|
+
for (const e of entries.sort()) {
|
|
203
|
+
const m = e.match(/^(.+?)-(\d[^-]*)\.(?:dist-info|egg-info)$/);
|
|
204
|
+
if (m && normalizePy(m[1]) === norm) return m[2];
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a Python dependency to its installed module entry file + version.
|
|
211
|
+
* @returns {{ version: string|null, sourcePath: string|null }|null} null if not installed
|
|
212
|
+
*/
|
|
213
|
+
function resolvePyEntry(sitePkgsDirs, dep) {
|
|
214
|
+
const candidates = [...new Set([dep, dep.replace(/-/g, '_'), dep.toLowerCase(), dep.toLowerCase().replace(/-/g, '_')])];
|
|
215
|
+
for (const sp of sitePkgsDirs) {
|
|
216
|
+
const version = findPyVersion(sp, dep);
|
|
217
|
+
for (const cand of candidates) {
|
|
218
|
+
for (const entry of ['__init__.pyi', '__init__.py']) { // package
|
|
219
|
+
const p = path.join(sp, cand, entry);
|
|
220
|
+
try { if (fs.statSync(p).isFile()) return { version, sourcePath: p }; } catch (_) { /* next */ }
|
|
221
|
+
}
|
|
222
|
+
for (const ext of ['.pyi', '.py']) { // single-module
|
|
223
|
+
const p = path.join(sp, cand + ext);
|
|
224
|
+
try { if (fs.statSync(p).isFile()) return { version, sourcePath: p }; } catch (_) { /* next */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
101
231
|
/**
|
|
102
232
|
* Build the installed-library signature index for `cwd`.
|
|
103
233
|
*
|
|
@@ -110,17 +240,24 @@ function resolveEntry(cwd, dep) {
|
|
|
110
240
|
function buildLibraryIndex(cwd, opts = {}) {
|
|
111
241
|
const version = opts.version || '0';
|
|
112
242
|
const useCache = opts.cache !== false;
|
|
113
|
-
const deps = directDeps(cwd).slice(0, MAX_DEPS);
|
|
114
243
|
|
|
115
|
-
|
|
116
|
-
|
|
244
|
+
// Collect entries from both ecosystems; each carries its extractor kind.
|
|
245
|
+
const entries = []; // { name, version, sourcePath, kind: 'dts'|'py' }
|
|
246
|
+
for (const dep of directDeps(cwd).slice(0, MAX_DEPS)) {
|
|
117
247
|
const r = resolveEntry(cwd, dep);
|
|
118
|
-
if (r) entries.push({ dep, version: r.version,
|
|
248
|
+
if (r) entries.push({ name: dep, version: r.version, sourcePath: r.dtsPath, kind: 'dts' });
|
|
249
|
+
}
|
|
250
|
+
const sitePkgs = findSitePackages(cwd);
|
|
251
|
+
if (sitePkgs.length) {
|
|
252
|
+
for (const dep of pythonDirectDeps(cwd).slice(0, MAX_DEPS)) {
|
|
253
|
+
const r = resolvePyEntry(sitePkgs, dep);
|
|
254
|
+
if (r) entries.push({ name: dep, version: r.version, sourcePath: r.sourcePath, kind: 'py' });
|
|
255
|
+
}
|
|
119
256
|
}
|
|
120
257
|
|
|
121
258
|
const cache = useCache ? loadCache(cwd, version) : new Map();
|
|
122
|
-
const
|
|
123
|
-
const { unchanged } = getChangedFiles(
|
|
259
|
+
const files = entries.filter((e) => e.sourcePath).map((e) => e.sourcePath);
|
|
260
|
+
const { unchanged } = getChangedFiles(files, cache);
|
|
124
261
|
const unchangedSet = new Set(unchanged);
|
|
125
262
|
|
|
126
263
|
const symbols = new Set();
|
|
@@ -129,20 +266,20 @@ function buildLibraryIndex(cwd, opts = {}) {
|
|
|
129
266
|
|
|
130
267
|
for (const e of entries) {
|
|
131
268
|
let names;
|
|
132
|
-
if (!e.
|
|
269
|
+
if (!e.sourcePath) {
|
|
133
270
|
names = [];
|
|
134
|
-
} else if (unchangedSet.has(e.
|
|
135
|
-
names = cache.get(e.
|
|
271
|
+
} else if (unchangedSet.has(e.sourcePath) && cache.get(e.sourcePath)) {
|
|
272
|
+
names = cache.get(e.sourcePath).sigs || [];
|
|
136
273
|
} else {
|
|
137
274
|
let src = '';
|
|
138
275
|
try {
|
|
139
|
-
if (fs.statSync(e.
|
|
276
|
+
if (fs.statSync(e.sourcePath).size <= MAX_DTS_BYTES) src = fs.readFileSync(e.sourcePath, 'utf8');
|
|
140
277
|
} catch (_) { /* unreadable → empty */ }
|
|
141
|
-
names = extractDtsExports(src);
|
|
142
|
-
fresh.push({ file: e.
|
|
278
|
+
names = e.kind === 'py' ? extractPyExports(src) : extractDtsExports(src);
|
|
279
|
+
fresh.push({ file: e.sourcePath, sigs: names });
|
|
143
280
|
}
|
|
144
281
|
for (const n of names) symbols.add(n);
|
|
145
|
-
libraries.push({ name: e.
|
|
282
|
+
libraries.push({ name: e.name, version: e.version, symbols: names.length, typed: !!e.sourcePath });
|
|
146
283
|
}
|
|
147
284
|
|
|
148
285
|
if (useCache && fresh.length) {
|
|
@@ -161,4 +298,7 @@ function formatVersionPins(libraries) {
|
|
|
161
298
|
.map((l) => `${l.name}@${l.version}`);
|
|
162
299
|
}
|
|
163
300
|
|
|
164
|
-
module.exports = {
|
|
301
|
+
module.exports = {
|
|
302
|
+
buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
|
|
303
|
+
extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
|
|
304
|
+
};
|