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.
- package/CHANGELOG.md +33 -0
- package/README.md +8 -3
- package/gen-context.js +644 -211
- package/llms-full.txt +2 -2
- package/llms.txt +2 -2
- package/package.json +9 -3
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/conventions/extract.js +13 -3
- package/src/conventions/fix.js +4 -1
- package/src/extractors/javascript.js +4 -4
- package/src/extractors/typescript.js +5 -5
- package/src/graph/builder.js +124 -16
- package/src/graph/impact.js +2 -2
- package/src/health/scorer.js +172 -97
- package/src/judge/judge-engine.js +62 -2
- package/src/mcp/server.js +1 -1
- package/src/plan/planner.js +44 -15
- package/src/review/pr-evidence.js +2 -1
- package/src/review/review-pr.js +24 -2
- package/src/util/truncate.js +42 -0
package/src/health/scorer.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
50
|
-
strategy = cfg.strategy || 'full';
|
|
128
|
+
strategy = JSON.parse(fs.readFileSync(cfgPath, 'utf8')).strategy || 'full';
|
|
51
129
|
}
|
|
52
130
|
} catch (_) {}
|
|
53
131
|
|
|
54
|
-
// ──
|
|
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:
|
|
141
|
+
const { percentile, overBudgetStreak: calcStreak } = require('../format/dashboard');
|
|
58
142
|
const entries = readLog(cwd);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
// ──
|
|
164
|
+
// ── Freshness across ALL adapter outputs (freshest wins) ───────────────────
|
|
165
|
+
let daysSinceRegen = null;
|
|
81
166
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
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
|
-
// ──
|
|
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
|
|
95
|
-
strategyFreshnessDays = parseFloat(((Date.now() -
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
package/src/plan/planner.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
const
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
testsAffected:
|
|
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(`- ⚠️ **
|
|
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
|
}
|
package/src/review/review-pr.js
CHANGED
|
@@ -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
|
-
//
|
|
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 };
|