sigmap 8.9.0 → 8.10.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.
@@ -1,152 +1,227 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * SigMap health scorer.
4
+ * SigMap health scorer (v8.11 — auditable composite).
5
5
  *
6
- * Computes a composite 0-100 health score for the current project by combining:
7
- * 1. Days since context file was last regenerated (staleness penalty 30 pts)
8
- * 2. Average token reduction percentage (low-reduction penalty 20 pts)
9
- * 3. Over-budget run rate (budget penalty 20 pts)
6
+ * Computes a 0-100 health score for a project. Every deduction is recorded in a
7
+ * `components[]` breakdown so the number is auditable (which signal cost what),
8
+ * and purely-informational metrics live under `diagnostics` rather than being
9
+ * dressed up as if they affect the grade.
10
10
  *
11
- * Strategy-aware: thresholds adjust based on the active strategy so that
12
- * hot-cold (90% reduction intentional) is not penalized as 'low reduction'.
11
+ * Scored signals (each appears in `components` only when it fires):
12
+ * 1. context never generated — no adapter output exists though source does (45 pts)
13
+ * 2. staleness — freshest adapter output older than 7 days (≤30 pts)
14
+ * 3. low token reduction — avg reduction under threshold (full strategy) (20 pts)
15
+ * 4. cold-context staleness — hot-cold context-cold.md older than 1 day (≤10 pts)
16
+ * 5. over-budget rate — >20% of runs exceeded the token budget (20 pts)
17
+ * 6. sustained over-budget — ≥3 consecutive over-budget runs (5 pts)
13
18
  *
14
- * Grade scale: A 90 | B 75 | C 60 | D < 60
19
+ * Diagnostics (informational, NOT scored): p50/p95 token count, and
20
+ * languageCoverage (share of SigMap's supported languages present in the repo —
21
+ * this is language diversity, not extractor quality, and was previously
22
+ * mislabeled "extractorCoverage").
15
23
  *
16
- * Never throws returns graceful result with nulls for unavailable metrics.
24
+ * Freshness looks at the freshest of ANY adapter output (not just Copilot), so a
25
+ * Claude/Codex/Cursor user is not falsely flagged as "never generated".
17
26
  *
18
- * @param {string} cwd - Working directory (root of the project)
19
- * @returns {{
20
- * score: number,
21
- * grade: 'A'|'B'|'C'|'D',
22
- * strategy: string,
23
- * tokenReductionPct: number|null,
24
- * daysSinceRegen: number|null,
25
- * strategyFreshnessDays: number|null,
26
- * totalRuns: number,
27
- * overBudgetRuns: number,
28
- * }}
27
+ * Grade scale: A 90 | B 75 | C ≥ 60 | D < 60. Never throws.
28
+ *
29
+ * @param {string} cwd
30
+ * @returns {object} { score, grade, components, strategy, tokenReductionPct,
31
+ * daysSinceRegen, strategyFreshnessDays, totalRuns, overBudgetRuns,
32
+ * overBudgetStreak, languageCoverage, extractorCoverage, diagnostics }
29
33
  */
30
- function score(cwd) {
31
- const fs = require('fs');
32
- const path = require('path');
33
34
 
34
- let tokenReductionPct = null;
35
- let daysSinceRegen = null;
36
- let strategyFreshnessDays = null;
37
- let overBudgetRuns = 0;
38
- let totalRuns = 0;
39
- let p50TokenCount = 0;
40
- let p95TokenCount = 0;
41
- let overBudgetStreak = 0;
42
- let extractorCoverage = 0;
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+
38
+ // Every path a SigMap adapter may write context to (freshness looks at the
39
+ // freshest existing one). Mirrors the ranker's adapter-output probe order.
40
+ const CONTEXT_FILES = [
41
+ ['.github', 'copilot-instructions.md'],
42
+ ['CLAUDE.md'],
43
+ ['AGENTS.md'],
44
+ ['.cursorrules'],
45
+ ['.windsurfrules'],
46
+ ['.github', 'openai-context.md'],
47
+ ['.github', 'gemini-context.md'],
48
+ ['llm-full.txt'],
49
+ ['llm.txt'],
50
+ ];
51
+
52
+ function gradeFor(points) {
53
+ if (points >= 90) return 'A';
54
+ if (points >= 75) return 'B';
55
+ if (points >= 60) return 'C';
56
+ return 'D';
57
+ }
58
+
59
+ /**
60
+ * Pure scoring core. Given gathered signals, return the score, grade, and the
61
+ * labeled list of deductions. No IO — unit-testable in isolation.
62
+ *
63
+ * @param {object} s gathered signals
64
+ * @returns {{ score:number, grade:'A'|'B'|'C'|'D', components:Array }}
65
+ */
66
+ function composeHealth(s) {
67
+ const components = [];
68
+ const add = (id, label, penalty, detail) => {
69
+ const p = Math.round(penalty);
70
+ if (p > 0) components.push({ id, label, penalty: p, detail });
71
+ };
43
72
 
44
- // ── Detect active strategy ────────────────────────────────────────────────
73
+ // 1. Context never generated — a project with source files but no context of
74
+ // any kind is not "healthy"; it hasn't been set up. Gated on hasSource so
75
+ // an empty/new directory is not penalised for having nothing to index.
76
+ if (s.daysSinceRegen === null && s.hasSource) {
77
+ add('not-generated', 'context never generated', 45,
78
+ 'no adapter output found — run `sigmap` to generate context');
79
+ }
80
+
81
+ // 2. Staleness — freshest adapter output older than the 7-day window.
82
+ if (s.daysSinceRegen !== null && s.daysSinceRegen > 7) {
83
+ add('staleness', 'context stale', Math.min(30, Math.floor((s.daysSinceRegen - 7) * 4)),
84
+ `${s.daysSinceRegen}d since last regen (>7d)`);
85
+ }
86
+
87
+ // 3. Low token reduction — only meaningful for the 'full' strategy; hot-cold
88
+ // and per-module intentionally produce small/partial outputs.
89
+ const reductionThreshold = s.strategy === 'full' ? 60 : 0;
90
+ if (s.tokenReductionPct !== null && s.tokenReductionPct < reductionThreshold) {
91
+ add('low-reduction', 'low token reduction', 20,
92
+ `${s.tokenReductionPct}% avg reduction (<${reductionThreshold}%)`);
93
+ }
94
+
95
+ // 4. Cold-context staleness (hot-cold only).
96
+ if (s.strategy === 'hot-cold' && s.strategyFreshnessDays !== null && s.strategyFreshnessDays > 1) {
97
+ add('cold-freshness', 'cold context stale', Math.min(10, Math.floor(s.strategyFreshnessDays - 1) * 3),
98
+ `context-cold.md ${s.strategyFreshnessDays}d old`);
99
+ }
100
+
101
+ // 5. Over-budget rate.
102
+ if (s.overBudgetRuns > 0 && s.totalRuns > 0) {
103
+ const rate = (s.overBudgetRuns / s.totalRuns) * 100;
104
+ if (rate > 20) add('over-budget', 'runs over budget', 20,
105
+ `${Math.round(rate)}% of runs exceeded budget (>20%)`);
106
+ }
107
+
108
+ // 6. Sustained over-budget streak — previously computed but never scored.
109
+ if (s.overBudgetStreak >= 3) {
110
+ add('over-budget-streak', 'sustained over-budget', 5,
111
+ `${s.overBudgetStreak} consecutive over-budget runs`);
112
+ }
113
+
114
+ const penalty = components.reduce((sum, c) => sum + c.penalty, 0);
115
+ const score = Math.max(0, Math.min(100, 100 - penalty));
116
+ return { score, grade: gradeFor(score), components };
117
+ }
118
+
119
+ /**
120
+ * Gather health signals from disk and score them. Never throws.
121
+ * @param {string} cwd
122
+ */
123
+ function score(cwd) {
45
124
  let strategy = 'full';
46
125
  try {
47
126
  const cfgPath = path.join(cwd, 'gen-context.config.json');
48
127
  if (fs.existsSync(cfgPath)) {
49
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
50
- strategy = cfg.strategy || 'full';
128
+ strategy = JSON.parse(fs.readFileSync(cfgPath, 'utf8')).strategy || 'full';
51
129
  }
52
130
  } catch (_) {}
53
131
 
54
- // ── Read usage log via tracking logger ──────────────────────────────────
132
+ // ── Usage-log signals (only present when tracking has recorded runs) ────────
133
+ let tokenReductionPct = null;
134
+ let overBudgetRuns = 0;
135
+ let totalRuns = 0;
136
+ let p50TokenCount = 0;
137
+ let p95TokenCount = 0;
138
+ let overBudgetStreak = 0;
55
139
  try {
56
140
  const { readLog, summarize } = require('../tracking/logger');
57
- const { percentile, overBudgetStreak: calcOverBudgetStreak } = require('../format/dashboard');
141
+ const { percentile, overBudgetStreak: calcStreak } = require('../format/dashboard');
58
142
  const entries = readLog(cwd);
59
- const s = summarize(entries);
60
- // Only set tokenReductionPct when there is actual history; a brand-new/
61
- // untracked project should not be penalised for "0% reduction".
62
- if (s.totalRuns > 0) tokenReductionPct = s.avgReductionPct;
63
- overBudgetRuns = s.overBudgetRuns;
64
- totalRuns = s.totalRuns;
143
+ const sum = summarize(entries);
144
+ if (sum.totalRuns > 0) tokenReductionPct = sum.avgReductionPct;
145
+ overBudgetRuns = sum.overBudgetRuns;
146
+ totalRuns = sum.totalRuns;
65
147
  const finals = entries.map((e) => Number(e.finalTokens)).filter(Number.isFinite);
66
148
  p50TokenCount = Math.round(percentile(finals, 50));
67
149
  p95TokenCount = Math.round(percentile(finals, 95));
68
- overBudgetStreak = calcOverBudgetStreak(entries);
69
- } catch (_) {
70
- // No usage log yet — proceed with nulls
71
- }
150
+ overBudgetStreak = calcStreak(entries);
151
+ } catch (_) {}
72
152
 
153
+ // ── Language coverage (DIAGNOSTIC — share of supported languages present,
154
+ // i.e. diversity, not extractor quality). Also yields hasSource. ──────────
155
+ let languageCoverage = null;
156
+ let hasSource = false;
73
157
  try {
74
158
  const { computeExtractorCoverage } = require('../format/dashboard');
75
- extractorCoverage = computeExtractorCoverage(cwd).pct;
76
- } catch (_) {
77
- extractorCoverage = 0;
78
- }
159
+ const cov = computeExtractorCoverage(cwd);
160
+ languageCoverage = { covered: cov.covered, supported: cov.supported, pct: cov.pct };
161
+ hasSource = Object.values(cov.perLanguage || {}).some((n) => n > 0);
162
+ } catch (_) {}
79
163
 
80
- // ── Days since primary context file was last regenerated ─────────────────
164
+ // ── Freshness across ALL adapter outputs (freshest wins) ───────────────────
165
+ let daysSinceRegen = null;
81
166
  try {
82
- const ctxFile = path.join(cwd, '.github', 'copilot-instructions.md');
83
- if (fs.existsSync(ctxFile)) {
84
- const mtime = fs.statSync(ctxFile).mtimeMs;
85
- daysSinceRegen = parseFloat(((Date.now() - mtime) / (1000 * 60 * 60 * 24)).toFixed(1));
167
+ let newest = null;
168
+ for (const parts of CONTEXT_FILES) {
169
+ const p = path.join(cwd, ...parts);
170
+ try {
171
+ if (fs.existsSync(p)) {
172
+ const m = fs.statSync(p).mtimeMs;
173
+ if (newest === null || m > newest) newest = m;
174
+ }
175
+ } catch (_) {}
176
+ }
177
+ if (newest !== null) {
178
+ daysSinceRegen = parseFloat(((Date.now() - newest) / (1000 * 60 * 60 * 24)).toFixed(1));
86
179
  }
87
180
  } catch (_) {}
88
181
 
89
- // ── Strategy freshness: context-cold.md age (hot-cold only) ─────────────
182
+ // ── Cold-context freshness (hot-cold strategy only) ────────────────────────
183
+ let strategyFreshnessDays = null;
90
184
  if (strategy === 'hot-cold') {
91
185
  try {
92
186
  const coldFile = path.join(cwd, '.github', 'context-cold.md');
93
187
  if (fs.existsSync(coldFile)) {
94
- const mtime = fs.statSync(coldFile).mtimeMs;
95
- strategyFreshnessDays = parseFloat(((Date.now() - mtime) / (1000 * 60 * 60 * 24)).toFixed(1));
188
+ const m = fs.statSync(coldFile).mtimeMs;
189
+ strategyFreshnessDays = parseFloat(((Date.now() - m) / (1000 * 60 * 60 * 24)).toFixed(1));
96
190
  }
97
191
  } catch (_) {}
98
192
  }
99
193
 
100
- // ── Compute composite score ───────────────────────────────────────────────
101
- let points = 100;
102
-
103
- // Staleness penalty: -4 pts per day over the 7-day freshness window (max -30)
104
- if (daysSinceRegen !== null && daysSinceRegen > 7) {
105
- points -= Math.min(30, Math.floor((daysSinceRegen - 7) * 4));
106
- }
107
-
108
- // Low-reduction penalty — threshold depends on strategy:
109
- // - hot-cold: primary output is intentionally tiny; measure cold freshness instead
110
- // - per-module: per-file budgets; global reduction < 60% expected, no penalty
111
- // - full: standard 60% threshold
112
- const reductionThreshold = (strategy === 'full') ? 60 : 0; // disable for hot-cold/per-module
113
- if (tokenReductionPct !== null && tokenReductionPct < reductionThreshold) {
114
- points -= 20;
115
- }
116
-
117
- // hot-cold strategy freshness penalty: context-cold.md older than 1 day (-10 pts)
118
- if (strategy === 'hot-cold' && strategyFreshnessDays !== null && strategyFreshnessDays > 1) {
119
- points -= Math.min(10, Math.floor(strategyFreshnessDays - 1) * 3);
120
- }
121
-
122
- // Over-budget penalty: more than 20% of runs exceeded the token budget (-20)
123
- if (overBudgetRuns > 0 && totalRuns > 0) {
124
- const overBudgetRate = (overBudgetRuns / totalRuns) * 100;
125
- if (overBudgetRate > 20) points -= 20;
126
- }
127
-
128
- points = Math.max(0, Math.min(100, Math.round(points)));
129
-
130
- let grade;
131
- if (points >= 90) grade = 'A';
132
- else if (points >= 75) grade = 'B';
133
- else if (points >= 60) grade = 'C';
134
- else grade = 'D';
194
+ const { score: points, grade, components } = composeHealth({
195
+ strategy,
196
+ daysSinceRegen,
197
+ strategyFreshnessDays,
198
+ tokenReductionPct,
199
+ overBudgetRuns,
200
+ totalRuns,
201
+ overBudgetStreak,
202
+ hasSource,
203
+ });
135
204
 
136
205
  return {
137
206
  score: points,
138
207
  grade,
208
+ components,
139
209
  strategy,
140
210
  tokenReductionPct,
141
211
  daysSinceRegen,
142
212
  strategyFreshnessDays,
143
213
  totalRuns,
144
214
  overBudgetRuns,
215
+ overBudgetStreak,
216
+ languageCoverage,
217
+ // Back-compat top-level fields (also surfaced, honestly grouped, under
218
+ // `diagnostics`). `extractorCoverage` keeps its old name but its value is
219
+ // language-diversity pct (never extractor quality) — prefer `languageCoverage`.
145
220
  p50TokenCount,
146
221
  p95TokenCount,
147
- overBudgetStreak,
148
- extractorCoverage,
222
+ extractorCoverage: languageCoverage ? languageCoverage.pct : 0,
223
+ diagnostics: { p50TokenCount, p95TokenCount, languageCoverage },
149
224
  };
150
225
  }
151
226
 
152
- module.exports = { score };
227
+ module.exports = { score, composeHealth };
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { boostFiles, normalizeFile, penalizeFiles } = require('../learning/weights');
6
+ const parsers = require('../verify/parsers');
6
7
 
7
8
  const STOP = new Set([
8
9
  'the','a','an','in','on','at','to','of','for','and','or','but',
@@ -25,6 +26,57 @@ function groundedness(response, context) {
25
26
  return parseFloat((matched.length / respTokens.length).toFixed(3));
26
27
  }
27
28
 
29
+ /**
30
+ * Claim-level grounding (v8.10) — the structural half of the judge.
31
+ *
32
+ * `groundedness` above measures lexical *word* overlap: "does the answer reuse
33
+ * context vocabulary?" That is a weak proxy — an answer can echo context words
34
+ * while asserting a symbol, file, or import the context never mentions (a
35
+ * hallucination), and still score high. This function extracts the answer's
36
+ * *concrete, checkable claims* — the same high-precision claims the hallucination
37
+ * guard checks (backtick-wrapped `foo()` calls, `path/to/file.ext` references,
38
+ * and `import … from 'mod'` statements) — and verifies each one appears in the
39
+ * provided context. A claim the context never grounds is a hallucination signal
40
+ * that pure word-overlap cannot see.
41
+ *
42
+ * Deterministic, offline, zero-dependency. Reuses `src/verify/parsers`.
43
+ *
44
+ * @param {string} response
45
+ * @param {string} context
46
+ * @returns {{ total: number, grounded: number, ungrounded: Array<{kind:string, value:string}> }}
47
+ */
48
+ function claimGrounding(response, context) {
49
+ if (!response || !context) return { total: 0, grounded: 0, ungrounded: [] };
50
+ const ctxLower = context.toLowerCase();
51
+
52
+ const raw = [];
53
+ for (const s of parsers.extractSymbols(response)) raw.push({ kind: 'symbol', value: s.name });
54
+ for (const f of parsers.extractFilePaths(response)) raw.push({ kind: 'file', value: f.path });
55
+ for (const i of parsers.extractImports(response)) raw.push({ kind: 'import', value: i.module });
56
+
57
+ const seen = new Set();
58
+ const claims = raw.filter((c) => {
59
+ const key = `${c.kind}::${c.value}`;
60
+ if (seen.has(key)) return false;
61
+ seen.add(key);
62
+ return true;
63
+ });
64
+
65
+ const ungrounded = [];
66
+ let grounded = 0;
67
+ for (const c of claims) {
68
+ // A file claim is grounded if its basename appears in context (the answer
69
+ // may cite a different directory than the map records). Symbols and modules
70
+ // are matched on the token itself.
71
+ const needle = c.value.toLowerCase();
72
+ const base = c.kind === 'file' ? (c.value.split('/').pop() || c.value).toLowerCase() : needle;
73
+ if (ctxLower.includes(base) || ctxLower.includes(needle)) grounded++;
74
+ else ungrounded.push({ kind: c.kind, value: c.value });
75
+ }
76
+
77
+ return { total: claims.length, grounded, ungrounded };
78
+ }
79
+
28
80
  const GENERIC_MARKERS = [
29
81
  'however, based on my knowledge',
30
82
  'generally speaking',
@@ -76,8 +128,16 @@ function judge(response, context, opts = {}) {
76
128
  }
77
129
  }
78
130
 
131
+ // Structural claim grounding: any concrete symbol/file/import the answer
132
+ // states that the context never mentions is a hallucination the lexical
133
+ // score above cannot detect. Each ungrounded claim fails the verdict.
134
+ const claims = claimGrounding(response, context);
135
+ for (const c of claims.ungrounded) {
136
+ reasons.push(`${c.kind} claim not grounded in context: ${c.value}${c.kind === 'symbol' ? '()' : ''}`);
137
+ }
138
+
79
139
  const verdict = score >= threshold && reasons.length === 0 ? 'pass' : 'fail';
80
- const result = { score, verdict, reasons };
140
+ const result = { score, verdict, reasons, claims };
81
141
 
82
142
  if (opts.learn) {
83
143
  const learning = {
@@ -119,4 +179,4 @@ function judge(response, context, opts = {}) {
119
179
  return result;
120
180
  }
121
181
 
122
- module.exports = { groundedness, judge };
182
+ module.exports = { groundedness, claimGrounding, judge };
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.9.0',
21
+ version: '8.10.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -9,7 +9,7 @@ const { buildTestIndex, isTested } = require('../extractors/coverage');
9
9
 
10
10
  module.exports = { createPlan };
11
11
 
12
- function createPlan(goal, cwd, config) {
12
+ function createPlan(goal, cwd, config = {}) {
13
13
  // Step 1: Detect intent and rank files for the goal
14
14
  const intent = detectIntent(goal);
15
15
  const sigIndex = buildSigIndex(cwd);
@@ -23,30 +23,59 @@ function createPlan(goal, cwd, config) {
23
23
  const highConf = ranked.filter(r => r.confidence === 'high').slice(0, 5);
24
24
  const medConf = ranked.filter(r => r.confidence === 'medium').slice(0, 5);
25
25
 
26
- // Step 3: Compute impact radius for highest-confidence file
26
+ // Step 3: Impact radius union the reverse-dependency blast radius of EVERY
27
+ // high-confidence file (not just the top one), bounded to 3 hops. Note the
28
+ // dependency graph resolves relative imports only, so this is a *lower bound*
29
+ // on real coupling (aliased/bare/dynamic imports are invisible). Previously
30
+ // this passed `{ maxDepth: 3 }`, which getImpact ignores — it reads `depth`,
31
+ // so the traversal silently ran unbounded (depth 0). Fixed to `depth: 3`.
27
32
  let impact = null;
28
33
  if (highConf.length > 0) {
29
- const entryFile = highConf[0].file;
30
34
  try {
31
35
  const graph = buildFromCwd(cwd);
32
- impact = getImpact(entryFile, graph, { maxDepth: 3, cwd });
36
+ // getImpact normalizes graph paths to lowercase, so on a case-varying
37
+ // filesystem (e.g. macOS `/Users`) its returned paths climb out of cwd.
38
+ // Re-anchor every impacted path to a clean, case-insensitive repo-relative
39
+ // form so dedup against the entry set works and output is readable.
40
+ const clean = (f) => {
41
+ const abs = path.resolve(cwd, f);
42
+ return abs.toLowerCase().startsWith(cwd.toLowerCase())
43
+ ? abs.slice(cwd.length).replace(/^[/\\]/, '')
44
+ : path.relative(cwd, abs);
45
+ };
46
+ const entrySet = new Set(highConf.map(r => r.file));
47
+ const direct = new Set();
48
+ const transitive = new Set();
49
+ for (const r of highConf) {
50
+ const imp = getImpact(r.file, graph, { depth: 3, cwd });
51
+ for (const f of (imp.direct || [])) direct.add(clean(f));
52
+ for (const f of (imp.transitive || [])) transitive.add(clean(f));
53
+ }
54
+ // The files we plan to change are not their own blast radius; and a file
55
+ // reached directly from one entry outranks a transitive reach from another.
56
+ for (const e of entrySet) { direct.delete(e); transitive.delete(e); }
57
+ for (const f of direct) transitive.delete(f);
58
+ impact = { direct: [...direct], transitive: [...transitive] };
33
59
  } catch (_) {
34
60
  // Graph build failed, continue without impact
35
61
  }
36
62
  }
37
63
 
38
- // Step 4: Identify likely-affected tests
39
- let testedFiles = [];
64
+ // Step 4: Flag which files-to-inspect have detectable test coverage. The test
65
+ // index maps test-*name tokens*, not test files, so `isTested` can only tell
66
+ // us a source file is covered — it cannot name the test file. We therefore
67
+ // report the covered SOURCE files honestly rather than pretending to list the
68
+ // tests to run.
69
+ let coveredFiles = [];
40
70
  try {
41
71
  const testIndex = buildTestIndex(cwd, config.testDirs || ['test', 'tests', '__tests__', 'spec']);
42
- testedFiles = highConf.filter(r => {
43
- const sigs = r.sigs || [];
44
- const fnNames = sigs.map(s => {
72
+ coveredFiles = highConf.filter(r => {
73
+ const fnNames = (r.sigs || []).map(s => {
45
74
  const m = s.match(/(?:function|def|fn)\s+(\w+)/);
46
75
  return m ? m[1] : null;
47
76
  }).filter(Boolean);
48
77
  return fnNames.some(fn => isTested(fn, testIndex));
49
- });
78
+ }).map(r => r.file);
50
79
  } catch (_) {
51
80
  // Coverage index failed, continue without test info
52
81
  }
@@ -56,10 +85,10 @@ function createPlan(goal, cwd, config) {
56
85
  intent,
57
86
  inspectFirst: highConf.map(r => r.file),
58
87
  likelyToChange: medConf.map(r => r.file),
59
- impactRadius: impact ? {
60
- direct: [...(impact.direct || [])],
61
- transitive: [...(impact.transitive || [])],
62
- } : null,
63
- testsAffected: testedFiles.map(r => r.file),
88
+ impactRadius: impact,
89
+ coveredFiles,
90
+ // `testsAffected` retained for backward compatibility; it is the set of
91
+ // covered source files, NOT the test files (which the index cannot name).
92
+ testsAffected: coveredFiles,
64
93
  };
65
94
  }
@@ -97,7 +97,8 @@ function formatPrEvidenceMarkdown(evidence, opts = {}) {
97
97
  L.push('### Review findings');
98
98
  for (const f of evidence.review.findings) {
99
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}\``);
100
+ else if (f.type === 'security-file') L.push(`- ⚠️ **sensitive path touched** (path heuristic, not a content scan) — \`${f.file}\``);
101
+ else if (f.type === 'secret-detected') L.push(`- 🔑 **secret detected** (${f.secret}) — \`${f.file}\``);
101
102
  else if (f.type === 'god-node') L.push(`- ⚠️ **god node** — \`${f.file}\` → ${f.count} dependents (high blast radius)`);
102
103
  else if (f.type === 'scope-drift') L.push(`- ⚠️ **scope drift** — ${f.count} top-level dirs touched (${f.dirs.join(', ')})`);
103
104
  }
@@ -9,8 +9,10 @@
9
9
  * zero-dependency, bundle-safe; reuses the impact graph for blast radius.
10
10
  */
11
11
 
12
+ const fs = require('fs');
12
13
  const path = require('path');
13
14
  const { analyzeImpact } = require('../graph/impact');
15
+ const { PATTERNS } = require('../security/patterns');
14
16
 
15
17
  const SECURITY_PATTERNS = [
16
18
  /(^|\/)\.env(\.|$)/i,
@@ -61,10 +63,30 @@ function reviewPr(changedFiles, cwd, opts = {}) {
61
63
  if (!covered) findings.push({ type: 'missing-tests', file: s, severity: 'warn' });
62
64
  }
63
65
 
64
- // 2. Security-sensitive files.
66
+ // 2a. Sensitive-path heuristic — flags files whose PATH looks security-relevant
67
+ // (.env, auth/, lockfiles, workflows, key material). This is a path heuristic,
68
+ // NOT a content scan: it flags touching the path regardless of what changed,
69
+ // and cannot see a secret hidden in an innocently-named file. `basis` records
70
+ // that honestly so consumers don't mistake it for a content check.
65
71
  for (const f of live) {
66
72
  if (SECURITY_PATTERNS.some((re) => re.test(f.path))) {
67
- findings.push({ type: 'security-file', file: f.path, severity: 'warn' });
73
+ findings.push({ type: 'security-file', file: f.path, severity: 'warn', basis: 'path-heuristic' });
74
+ }
75
+ }
76
+
77
+ // 2b. Real secret scan — read each changed file's CONTENT and match known
78
+ // secret patterns. This is the actual security check (content, not filename):
79
+ // it catches a hardcoded key in a file the path heuristic would never flag.
80
+ const readFile = opts.readFile || ((p) => fs.readFileSync(path.resolve(cwd, p), 'utf8'));
81
+ for (const f of live) {
82
+ let content;
83
+ try { content = readFile(f.path); } catch (_) { continue; } // absent/unreadable → skip
84
+ if (typeof content !== 'string' || content.length > 2_000_000) continue; // skip huge/binary
85
+ for (const pat of PATTERNS) {
86
+ if (pat.regex.test(content)) {
87
+ findings.push({ type: 'secret-detected', file: f.path, secret: pat.name, severity: 'high', basis: 'content-scan' });
88
+ break; // one hit is enough to flag the file
89
+ }
68
90
  }
69
91
  }
70
92
 
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Visible truncation for extractor caps (v8.11).
5
+ *
6
+ * Extractors cap per-file signatures and per-class members to protect the token
7
+ * budget. Historically that truncation was SILENT — the tail of a large file
8
+ * simply vanished with no trace, so "5 of 40 methods extracted" looked identical
9
+ * to "fully extracted". These helpers keep the cap but append a visible marker
10
+ * so the loss is always disclosed.
11
+ *
12
+ * Zero-dependency, bundle-safe.
13
+ */
14
+
15
+ /**
16
+ * Cap a string array, appending a `… +N more <label>` marker when items drop.
17
+ * @param {string[]} items
18
+ * @param {number} limit
19
+ * @param {string} label e.g. 'signatures'
20
+ * @returns {string[]}
21
+ */
22
+ function capWithNotice(items, limit, label) {
23
+ if (!Array.isArray(items) || items.length <= limit) return items;
24
+ const dropped = items.length - limit;
25
+ return items.slice(0, limit).concat(`… +${dropped} more ${label}`);
26
+ }
27
+
28
+ /**
29
+ * Cap an array of member objects ({ text, ... }), appending a marker member
30
+ * when items drop so the class block discloses the omission.
31
+ * @param {Array<{text:string}>} members
32
+ * @param {number} limit
33
+ * @param {string} [label='methods']
34
+ * @returns {Array<{text:string}>}
35
+ */
36
+ function capMembersWithNotice(members, limit, label = 'methods') {
37
+ if (!Array.isArray(members) || members.length <= limit) return members;
38
+ const dropped = members.length - limit;
39
+ return members.slice(0, limit).concat({ text: `… +${dropped} more ${label}`, start: 0, end: 0 });
40
+ }
41
+
42
+ module.exports = { capWithNotice, capMembersWithNotice };