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 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.2-main (21 repositories, including R language)
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.2.0',
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
- * For each **direct** dependency declared in `package.json`, it locates the
16809
- * package under `node_modules/<dep>`, reads its version (D8 version pinning),
16810
- * and extracts the exported symbol names from its TypeScript declaration entry
16811
- * (`types`/`typings`, else `index.d.ts`). Pure, zero-dependency, deterministic:
16812
- * byte-stable given a fixed installed tree. Bounded (per-file read cap + dep
16813
- * cap) and cached via `src/cache/sig-cache.js` so repeat builds are near-free.
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
- const entries = [];
16913
- for (const dep of deps) {
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, dtsPath: r.dtsPath });
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 dtsFiles = entries.filter((e) => e.dtsPath).map((e) => e.dtsPath);
16920
- const { unchanged } = getChangedFiles(dtsFiles, cache);
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.dtsPath) {
17209
+ if (!e.sourcePath) {
16930
17210
  names = [];
16931
- } else if (unchangedSet.has(e.dtsPath) && cache.get(e.dtsPath)) {
16932
- names = cache.get(e.dtsPath).sigs || [];
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.dtsPath).size <= MAX_DTS_BYTES) src = fs.readFileSync(e.dtsPath, 'utf8');
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.dtsPath, sigs: names });
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.dep, version: e.version, symbols: names.length, typed: !!e.dtsPath });
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 = { buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins };
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.2.0';
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.2.0 | Benchmark: sigmap-v8.2-main (2026-07-04)
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.2-main, 2026-07-04)
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.2.0 | Benchmark: sigmap-v8.2-main (2026-07-04)
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.2-main, 2026-07-04)
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.2.0",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.2.0",
3
+ "version": "8.4.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.2.0",
3
+ "version": "8.4.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.2.0',
21
+ version: '8.4.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -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 };
@@ -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
- * For each **direct** dependency declared in `package.json`, it locates the
12
- * package under `node_modules/<dep>`, reads its version (D8 version pinning),
13
- * and extracts the exported symbol names from its TypeScript declaration entry
14
- * (`types`/`typings`, else `index.d.ts`). Pure, zero-dependency, deterministic:
15
- * byte-stable given a fixed installed tree. Bounded (per-file read cap + dep
16
- * cap) and cached via `src/cache/sig-cache.js` so repeat builds are near-free.
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
- const entries = [];
116
- for (const dep of deps) {
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, dtsPath: r.dtsPath });
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 dtsFiles = entries.filter((e) => e.dtsPath).map((e) => e.dtsPath);
123
- const { unchanged } = getChangedFiles(dtsFiles, cache);
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.dtsPath) {
269
+ if (!e.sourcePath) {
133
270
  names = [];
134
- } else if (unchangedSet.has(e.dtsPath) && cache.get(e.dtsPath)) {
135
- names = cache.get(e.dtsPath).sigs || [];
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.dtsPath).size <= MAX_DTS_BYTES) src = fs.readFileSync(e.dtsPath, 'utf8');
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.dtsPath, sigs: names });
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.dep, version: e.version, symbols: names.length, typed: !!e.dtsPath });
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 = { buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins };
301
+ module.exports = {
302
+ buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
303
+ extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
304
+ };