sigmap 8.3.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,13 @@ 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
+
13
20
  ## [8.3.0] — 2026-07-05
14
21
 
15
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.
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.3-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.3.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
 
@@ -17431,7 +17574,7 @@ function __tryGit(args, opts = {}) {
17431
17574
  catch (_) { return ''; }
17432
17575
  }
17433
17576
 
17434
- const VERSION = '8.3.0';
17577
+ const VERSION = '8.4.0';
17435
17578
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
17436
17579
 
17437
17580
  function requireSourceOrBundled(key) {
@@ -19232,7 +19375,8 @@ Usage:
19232
19375
  ${cmd} conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
19233
19376
  ${cmd} scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
19234
19377
  ${cmd} verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
19235
- ${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
19236
19380
  ${cmd} create "<task>" Grounded-creation pipeline: scaffold → verify-plan → verify-ai-output → review-pr (--staged)
19237
19381
  ${cmd} squeeze <file|-> Minimize a pasted stacktrace/CI-log/JSON blob (--json for stats)
19238
19382
  ${cmd} ask "<query>" --squeeze Auto-accept input minimization (no prompt; for scripts/CI)
@@ -21173,6 +21317,15 @@ function main() {
21173
21317
  return { path: file, status };
21174
21318
  });
21175
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
+
21176
21329
  const { reviewPr } = requireSourceOrBundled('./src/review/review-pr');
21177
21330
  const result = reviewPr(changedFiles, cwd, {});
21178
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.3.0 | Benchmark: sigmap-v8.3-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.3-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.3.0 | Benchmark: sigmap-v8.3-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.3-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.3.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.3.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.3.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.3.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 };