bmad-plus 0.17.1 → 0.19.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 +30 -1
- package/README.md +16 -18
- package/package.json +3 -1
- package/readme-international/README.de.md +16 -19
- package/readme-international/README.es.md +16 -19
- package/readme-international/README.fr.md +16 -19
- package/src/bmad-plus/agents/agent-quality/SKILL.md +3 -2
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +15 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +25 -4
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +61 -1
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +11 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +455 -137
- package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +230 -20
- package/tools/cli/bmad-plus-cli.js +1 -0
- package/tools/cli/commands/review.js +152 -0
- package/tools/cli/commands/uat.js +50 -5
- package/tools/cli/commands/update-check.js +5 -1
- package/tools/cli/commands/update.js +9 -3
- package/tools/cli/lib/install-manifest.js +19 -1
- package/tools/cli/lib/packs.js +1 -1
- package/tools/cli/lib/review.js +522 -0
- package/tools/cli/lib/uat.js +140 -2
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code review evidence: a sealed scope, findings anchored on quoted code, and a gate that
|
|
3
|
+
* derives the verdict from coverage — never from how many findings were written.
|
|
4
|
+
*
|
|
5
|
+
* The host agent reviews; this module only establishes what must be reviewed, where each
|
|
6
|
+
* finding really is, and whether every selected file was accounted for. Design taken from
|
|
7
|
+
* the open-code-review study (docs/research/open-code-review-2026-09-25): deterministic
|
|
8
|
+
* selection with named exclusion reasons, verbatim-snippet anchoring, coverage-derived
|
|
9
|
+
* disposition. No model call, no network, no write outside the review folder.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const SCOPE_SCHEMA = 'bmad-plus/review-scope/1';
|
|
19
|
+
const FINDINGS_SCHEMA = 'bmad-plus/review-findings/1';
|
|
20
|
+
const COVERAGE_SCHEMA = 'bmad-plus/review-coverage/1';
|
|
21
|
+
const DEFAULT_DIR = '_bmad-output/review';
|
|
22
|
+
|
|
23
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
24
|
+
const CATEGORIES = [
|
|
25
|
+
'correctness',
|
|
26
|
+
'security',
|
|
27
|
+
'data-loss',
|
|
28
|
+
'concurrency',
|
|
29
|
+
'performance',
|
|
30
|
+
'error-handling',
|
|
31
|
+
'compatibility',
|
|
32
|
+
'test-gap',
|
|
33
|
+
'maintainability',
|
|
34
|
+
'documentation',
|
|
35
|
+
];
|
|
36
|
+
const DISPOSITIONS = ['confirmed', 'refuted', 'unresolved'];
|
|
37
|
+
const CONFIDENCE = ['high', 'medium', 'low'];
|
|
38
|
+
const OUTCOMES = ['completed', 'failed', 'waived'];
|
|
39
|
+
|
|
40
|
+
/** Files whose content must never reach a review packet, whatever an include pattern says. */
|
|
41
|
+
const SECRET_PATTERNS = [
|
|
42
|
+
'**/.env',
|
|
43
|
+
'**/.env.*',
|
|
44
|
+
'**/*.pem',
|
|
45
|
+
'**/*.key',
|
|
46
|
+
'**/*.p12',
|
|
47
|
+
'**/*.pfx',
|
|
48
|
+
'**/id_rsa*',
|
|
49
|
+
'**/id_ed25519*',
|
|
50
|
+
'**/.credentials/**',
|
|
51
|
+
'**/credentials/**',
|
|
52
|
+
'**/secrets/**',
|
|
53
|
+
'**/*.keystore',
|
|
54
|
+
'**/.npmrc',
|
|
55
|
+
'**/.netrc',
|
|
56
|
+
];
|
|
57
|
+
const GENERATED_PATTERNS = [
|
|
58
|
+
'**/node_modules/**',
|
|
59
|
+
'**/dist/**',
|
|
60
|
+
'**/build/**',
|
|
61
|
+
'**/coverage/**',
|
|
62
|
+
'**/vendor/**',
|
|
63
|
+
'**/*.min.js',
|
|
64
|
+
'**/*.min.css',
|
|
65
|
+
'**/*.map',
|
|
66
|
+
'**/package-lock.json',
|
|
67
|
+
'**/yarn.lock',
|
|
68
|
+
'**/pnpm-lock.yaml',
|
|
69
|
+
'**/Cargo.lock',
|
|
70
|
+
'**/go.sum',
|
|
71
|
+
'**/poetry.lock',
|
|
72
|
+
];
|
|
73
|
+
const UNIT_LIMITS = { files: 8, lines: 400 };
|
|
74
|
+
|
|
75
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
76
|
+
|
|
77
|
+
/** A small glob: `**` spans directories, `*` stays within one, `?` is one character. Case-sensitive. */
|
|
78
|
+
function globToRegExp(glob) {
|
|
79
|
+
let source = '';
|
|
80
|
+
for (let i = 0; i < glob.length; i++) {
|
|
81
|
+
const c = glob[i];
|
|
82
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
83
|
+
const slash = glob[i + 2] === '/';
|
|
84
|
+
source += slash ? '(?:.*/)?' : '.*';
|
|
85
|
+
i += slash ? 2 : 1;
|
|
86
|
+
} else if (c === '*') source += '[^/]*';
|
|
87
|
+
else if (c === '?') source += '[^/]';
|
|
88
|
+
else source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
89
|
+
}
|
|
90
|
+
return new RegExp(`^${source}$`);
|
|
91
|
+
}
|
|
92
|
+
const matchesAny = (file, patterns) => patterns.some((glob) => globToRegExp(glob).test(file));
|
|
93
|
+
|
|
94
|
+
function git(projectDir, args) {
|
|
95
|
+
const result = spawnSync('git', args, {
|
|
96
|
+
cwd: projectDir,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
shell: false,
|
|
101
|
+
});
|
|
102
|
+
if (result.error) throw new Error(`git is unavailable: ${result.error.message}`);
|
|
103
|
+
if (result.status !== 0)
|
|
104
|
+
throw new Error(`git ${args[0]} failed: ${(result.stderr || '').trim().split('\n')[0]}`);
|
|
105
|
+
return result.stdout;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A ref given on the command line is resolved to a commit or refused; it never reaches git as an option. */
|
|
109
|
+
function resolveCommit(projectDir, ref) {
|
|
110
|
+
if (typeof ref !== 'string' || !ref || ref.startsWith('-'))
|
|
111
|
+
throw new Error(`invalid ref "${ref}"`);
|
|
112
|
+
return git(projectDir, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `git diff --numstat -z` entries: {path, oldPath?, added, removed, binary}. */
|
|
116
|
+
function parseNumstat(output) {
|
|
117
|
+
const parts = output.split('\0');
|
|
118
|
+
const entries = [];
|
|
119
|
+
for (let i = 0; i < parts.length; i++) {
|
|
120
|
+
const head = parts[i];
|
|
121
|
+
if (!head) continue;
|
|
122
|
+
const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(head);
|
|
123
|
+
if (!match) continue;
|
|
124
|
+
const [, added, removed, rest] = match;
|
|
125
|
+
const binary = added === '-' || removed === '-';
|
|
126
|
+
if (rest === '') {
|
|
127
|
+
// Rename or copy: old path and new path follow as separate fields.
|
|
128
|
+
const oldPath = parts[++i];
|
|
129
|
+
const newPath = parts[++i];
|
|
130
|
+
entries.push({ path: newPath, oldPath, added: +added || 0, removed: +removed || 0, binary });
|
|
131
|
+
} else {
|
|
132
|
+
entries.push({ path: rest, added: +added || 0, removed: +removed || 0, binary });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return entries;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function nameStatus(output) {
|
|
139
|
+
const parts = output.split('\0');
|
|
140
|
+
const status = new Map();
|
|
141
|
+
for (let i = 0; i < parts.length; i++) {
|
|
142
|
+
const code = parts[i];
|
|
143
|
+
if (!code) continue;
|
|
144
|
+
const kind = code[0];
|
|
145
|
+
if (kind === 'R' || kind === 'C') {
|
|
146
|
+
i += 2;
|
|
147
|
+
status.set(parts[i], kind === 'R' ? 'renamed' : 'copied');
|
|
148
|
+
} else {
|
|
149
|
+
i += 1;
|
|
150
|
+
status.set(
|
|
151
|
+
parts[i],
|
|
152
|
+
{ A: 'added', D: 'deleted', M: 'modified', T: 'modified' }[kind] || 'modified'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return status;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Deterministic review units: files grouped by directory, cut at the file and line limits, in path order. */
|
|
160
|
+
function planUnits(selected, limits = UNIT_LIMITS) {
|
|
161
|
+
const units = [];
|
|
162
|
+
let current = null;
|
|
163
|
+
const flush = () => {
|
|
164
|
+
if (current && current.paths.length) units.push(current);
|
|
165
|
+
current = null;
|
|
166
|
+
};
|
|
167
|
+
for (const item of selected) {
|
|
168
|
+
const dir = path.posix.dirname(item.path);
|
|
169
|
+
const lines = item.added + item.removed;
|
|
170
|
+
if (
|
|
171
|
+
!current ||
|
|
172
|
+
current.dir !== dir ||
|
|
173
|
+
current.paths.length >= limits.files ||
|
|
174
|
+
(current.lines + lines > limits.lines && current.paths.length > 0)
|
|
175
|
+
) {
|
|
176
|
+
flush();
|
|
177
|
+
current = { dir, paths: [], lines: 0 };
|
|
178
|
+
}
|
|
179
|
+
current.paths.push(item.path);
|
|
180
|
+
current.lines += lines;
|
|
181
|
+
}
|
|
182
|
+
flush();
|
|
183
|
+
return units.map((unit, index) => ({
|
|
184
|
+
id: `u${index + 1}`,
|
|
185
|
+
paths: unit.paths,
|
|
186
|
+
lines: unit.lines,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The review scope: what changed between base and head (or the working tree), which files
|
|
192
|
+
* are selected, and why every other changed file is not. Secrets are excluded before any
|
|
193
|
+
* include pattern is considered.
|
|
194
|
+
*/
|
|
195
|
+
function buildScope(projectDir, options = {}) {
|
|
196
|
+
const head = options.workspace ? null : resolveCommit(projectDir, options.head || 'HEAD');
|
|
197
|
+
const base = resolveCommit(projectDir, options.base || 'HEAD~1');
|
|
198
|
+
const mergeBase = git(projectDir, ['merge-base', base, head || 'HEAD']).trim();
|
|
199
|
+
const range = options.workspace ? [mergeBase] : [mergeBase, head];
|
|
200
|
+
const numstat = parseNumstat(
|
|
201
|
+
git(projectDir, ['diff', '--numstat', '-z', '--find-renames', ...range, '--'])
|
|
202
|
+
);
|
|
203
|
+
const statuses = nameStatus(
|
|
204
|
+
git(projectDir, ['diff', '--name-status', '-z', '--find-renames', ...range, '--'])
|
|
205
|
+
);
|
|
206
|
+
const changed = numstat.map((entry) => ({
|
|
207
|
+
...entry,
|
|
208
|
+
status: statuses.get(entry.path) || 'modified',
|
|
209
|
+
}));
|
|
210
|
+
if (options.workspace) {
|
|
211
|
+
const untracked = git(projectDir, ['ls-files', '--others', '--exclude-standard', '-z'])
|
|
212
|
+
.split('\0')
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
for (const file of untracked) {
|
|
215
|
+
if (changed.some((entry) => entry.path === file)) continue;
|
|
216
|
+
let lines = 0;
|
|
217
|
+
let binary;
|
|
218
|
+
try {
|
|
219
|
+
const bytes = fs.readFileSync(path.join(projectDir, file));
|
|
220
|
+
binary = bytes.includes(0);
|
|
221
|
+
lines = binary ? 0 : bytes.toString('utf8').split('\n').length;
|
|
222
|
+
} catch {
|
|
223
|
+
binary = true;
|
|
224
|
+
}
|
|
225
|
+
changed.push({ path: file, added: lines, removed: 0, binary, status: 'added' });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
changed.sort((a, b) => a.path.localeCompare(b.path));
|
|
229
|
+
|
|
230
|
+
const include = options.include || [];
|
|
231
|
+
const exclude = options.exclude || [];
|
|
232
|
+
const selected = [];
|
|
233
|
+
const excluded = [];
|
|
234
|
+
for (const entry of changed) {
|
|
235
|
+
const file = entry.path;
|
|
236
|
+
let reason = null;
|
|
237
|
+
if (matchesAny(file, SECRET_PATTERNS)) reason = 'secret';
|
|
238
|
+
else if (entry.binary) reason = 'binary';
|
|
239
|
+
else if (entry.status === 'deleted') reason = 'deleted';
|
|
240
|
+
else if (matchesAny(file, exclude)) reason = 'excluded-by-pattern';
|
|
241
|
+
else if (matchesAny(file, GENERATED_PATTERNS) && !matchesAny(file, include))
|
|
242
|
+
reason = 'generated-or-vendored';
|
|
243
|
+
else if (include.length && !matchesAny(file, include)) reason = 'not-included';
|
|
244
|
+
if (reason) excluded.push({ path: file, reason, status: entry.status });
|
|
245
|
+
else
|
|
246
|
+
selected.push({
|
|
247
|
+
path: file,
|
|
248
|
+
status: entry.status,
|
|
249
|
+
added: entry.added,
|
|
250
|
+
removed: entry.removed,
|
|
251
|
+
...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const identity = {
|
|
255
|
+
base,
|
|
256
|
+
head: head || 'WORKTREE',
|
|
257
|
+
mergeBase,
|
|
258
|
+
workspace: Boolean(options.workspace),
|
|
259
|
+
include,
|
|
260
|
+
exclude,
|
|
261
|
+
};
|
|
262
|
+
const units = planUnits(selected, options.unitLimits);
|
|
263
|
+
const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
|
|
264
|
+
const body = { identity, selected, excluded, units };
|
|
265
|
+
return {
|
|
266
|
+
schema: SCOPE_SCHEMA,
|
|
267
|
+
id: options.id,
|
|
268
|
+
...body,
|
|
269
|
+
totals: {
|
|
270
|
+
changed: changed.length,
|
|
271
|
+
selected: selected.length,
|
|
272
|
+
excluded: excluded.length,
|
|
273
|
+
lines,
|
|
274
|
+
},
|
|
275
|
+
sha256: sha256(JSON.stringify(body)),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Findings ──────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
const FINDING_KEYS = [
|
|
282
|
+
'id',
|
|
283
|
+
'path',
|
|
284
|
+
'existing_code',
|
|
285
|
+
'content',
|
|
286
|
+
'category',
|
|
287
|
+
'severity',
|
|
288
|
+
'confidence',
|
|
289
|
+
'disposition',
|
|
290
|
+
'trigger',
|
|
291
|
+
'consequence',
|
|
292
|
+
'evidence',
|
|
293
|
+
'refutation',
|
|
294
|
+
'fix',
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
/** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
|
|
298
|
+
function validateFindings(doc, scope) {
|
|
299
|
+
const errors = [];
|
|
300
|
+
if (!doc || doc.schema !== FINDINGS_SCHEMA) errors.push(`schema must be "${FINDINGS_SCHEMA}"`);
|
|
301
|
+
if (scope && doc && doc.scopeSha256 !== scope.sha256)
|
|
302
|
+
errors.push(
|
|
303
|
+
'scopeSha256 does not match the current scope — the findings answer another review'
|
|
304
|
+
);
|
|
305
|
+
const ids = new Set();
|
|
306
|
+
for (const [index, finding] of ((doc && doc.findings) || []).entries()) {
|
|
307
|
+
const at = `finding ${finding?.id ?? index}`;
|
|
308
|
+
for (const key of Object.keys(finding || {}))
|
|
309
|
+
if (!FINDING_KEYS.includes(key)) errors.push(`${at}: unknown key "${key}"`);
|
|
310
|
+
if (!finding.id || ids.has(finding.id)) errors.push(`${at}: id missing or duplicated`);
|
|
311
|
+
ids.add(finding.id);
|
|
312
|
+
if (typeof finding.path !== 'string' || !finding.path) errors.push(`${at}: path is required`);
|
|
313
|
+
if (typeof finding.existing_code !== 'string' || !finding.existing_code.trim())
|
|
314
|
+
errors.push(
|
|
315
|
+
`${at}: existing_code is required — quote the code the finding is about, verbatim`
|
|
316
|
+
);
|
|
317
|
+
if (typeof finding.content !== 'string' || !finding.content.trim())
|
|
318
|
+
errors.push(`${at}: content is required`);
|
|
319
|
+
if (!CATEGORIES.includes(finding.category))
|
|
320
|
+
errors.push(`${at}: category "${finding.category}" is not one of ${CATEGORIES.join('|')}`);
|
|
321
|
+
if (!SEVERITIES.includes(finding.severity))
|
|
322
|
+
errors.push(`${at}: severity "${finding.severity}" is not one of ${SEVERITIES.join('|')}`);
|
|
323
|
+
if (!CONFIDENCE.includes(finding.confidence))
|
|
324
|
+
errors.push(
|
|
325
|
+
`${at}: confidence "${finding.confidence}" is not one of ${CONFIDENCE.join('|')}`
|
|
326
|
+
);
|
|
327
|
+
if (!DISPOSITIONS.includes(finding.disposition))
|
|
328
|
+
errors.push(
|
|
329
|
+
`${at}: disposition "${finding.disposition}" is not one of ${DISPOSITIONS.join('|')}`
|
|
330
|
+
);
|
|
331
|
+
if (finding.disposition === 'refuted' && !String(finding.refutation || '').trim())
|
|
332
|
+
errors.push(`${at}: a refuted finding keeps its refutation — the quoted ground`);
|
|
333
|
+
if (finding.disposition === 'confirmed' && !String(finding.evidence || '').trim())
|
|
334
|
+
errors.push(`${at}: a confirmed finding needs evidence`);
|
|
335
|
+
if (scope && finding.path && !scope.selected.some((item) => item.path === finding.path))
|
|
336
|
+
errors.push(`${at}: ${finding.path} is not in the review scope`);
|
|
337
|
+
}
|
|
338
|
+
return errors;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const normalizeLine = (line) => line.replace(/\r$/, '').replace(/\s+/g, ' ').trim();
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Where a quoted snippet is in a file: every place its lines appear consecutively, compared
|
|
345
|
+
* after whitespace normalisation. The model's line numbers are never used.
|
|
346
|
+
*/
|
|
347
|
+
function locate(fileText, snippet) {
|
|
348
|
+
const fileLines = fileText.split('\n').map(normalizeLine);
|
|
349
|
+
const wanted = snippet
|
|
350
|
+
.split('\n')
|
|
351
|
+
.map(normalizeLine)
|
|
352
|
+
.filter((line, index, all) => line || (index > 0 && index < all.length - 1));
|
|
353
|
+
while (wanted.length && !wanted[0]) wanted.shift();
|
|
354
|
+
while (wanted.length && !wanted[wanted.length - 1]) wanted.pop();
|
|
355
|
+
if (!wanted.length) return [];
|
|
356
|
+
const hits = [];
|
|
357
|
+
for (let i = 0; i + wanted.length <= fileLines.length; i++) {
|
|
358
|
+
let ok = true;
|
|
359
|
+
for (let j = 0; j < wanted.length; j++) {
|
|
360
|
+
if (fileLines[i + j] !== wanted[j]) {
|
|
361
|
+
ok = false;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (ok) hits.push({ lineStart: i + 1, lineEnd: i + wanted.length });
|
|
366
|
+
}
|
|
367
|
+
return hits;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function readAtHead(projectDir, scope, file) {
|
|
371
|
+
if (scope.identity.workspace) return fs.readFileSync(path.join(projectDir, file), 'utf8');
|
|
372
|
+
return git(projectDir, ['show', `${scope.identity.head}:${file}`]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Every finding gets a location status: located (unique), ambiguous (several), unlocated (none). */
|
|
376
|
+
function anchorFindings(projectDir, scope, doc) {
|
|
377
|
+
const cache = new Map();
|
|
378
|
+
const anchored = doc.findings.map((finding) => {
|
|
379
|
+
let text = cache.get(finding.path);
|
|
380
|
+
if (text === undefined) {
|
|
381
|
+
try {
|
|
382
|
+
text = readAtHead(projectDir, scope, finding.path);
|
|
383
|
+
} catch {
|
|
384
|
+
text = null;
|
|
385
|
+
}
|
|
386
|
+
cache.set(finding.path, text);
|
|
387
|
+
}
|
|
388
|
+
if (text === null)
|
|
389
|
+
return { ...finding, location: { status: 'unlocated', reason: 'file unreadable at head' } };
|
|
390
|
+
const hits = locate(text, finding.existing_code);
|
|
391
|
+
if (hits.length === 1) return { ...finding, location: { status: 'located', ...hits[0] } };
|
|
392
|
+
if (hits.length > 1)
|
|
393
|
+
return { ...finding, location: { status: 'ambiguous', candidates: hits.slice(0, 10) } };
|
|
394
|
+
return {
|
|
395
|
+
...finding,
|
|
396
|
+
location: { status: 'unlocated', reason: 'the quoted code is not in the file' },
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
const counts = { located: 0, ambiguous: 0, unlocated: 0 };
|
|
400
|
+
for (const finding of anchored) counts[finding.location.status] += 1;
|
|
401
|
+
return { findings: anchored, counts };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Coverage and gate ─────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
function validateCoverage(doc, scope) {
|
|
407
|
+
const errors = [];
|
|
408
|
+
if (!doc || doc.schema !== COVERAGE_SCHEMA) errors.push(`schema must be "${COVERAGE_SCHEMA}"`);
|
|
409
|
+
if (doc && doc.scopeSha256 !== scope.sha256)
|
|
410
|
+
errors.push('scopeSha256 does not match the current scope');
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
for (const item of (doc && doc.items) || []) {
|
|
413
|
+
if (!scope.selected.some((entry) => entry.path === item.path))
|
|
414
|
+
errors.push(`${item.path}: not in the review scope`);
|
|
415
|
+
if (seen.has(item.path)) errors.push(`${item.path}: listed twice`);
|
|
416
|
+
seen.add(item.path);
|
|
417
|
+
if (!OUTCOMES.includes(item.outcome))
|
|
418
|
+
errors.push(`${item.path}: outcome "${item.outcome}" is not one of ${OUTCOMES.join('|')}`);
|
|
419
|
+
if (
|
|
420
|
+
(item.outcome === 'waived' || item.outcome === 'failed') &&
|
|
421
|
+
!String(item.reason || '').trim()
|
|
422
|
+
)
|
|
423
|
+
errors.push(`${item.path}: a ${item.outcome} file needs a reason`);
|
|
424
|
+
}
|
|
425
|
+
return errors;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The review verdict. `incomplete` whenever a selected file was not accounted for, failed,
|
|
430
|
+
* or the evidence is invalid; otherwise `findings` or `clean` — the latter only means no
|
|
431
|
+
* confirmed or unresolved finding within a fully covered scope.
|
|
432
|
+
*/
|
|
433
|
+
function reviewGate({ scope, findings, coverage, anchored }) {
|
|
434
|
+
const reasons = [];
|
|
435
|
+
if (!coverage) reasons.push('no coverage.json: which files were reviewed is not established');
|
|
436
|
+
else reasons.push(...validateCoverage(coverage, scope));
|
|
437
|
+
if (!findings) reasons.push('no findings.json: write it even when there is nothing to report');
|
|
438
|
+
else reasons.push(...validateFindings(findings, scope));
|
|
439
|
+
const covered = new Map(((coverage && coverage.items) || []).map((item) => [item.path, item]));
|
|
440
|
+
const missing = scope.selected.filter((item) => !covered.has(item.path)).map((item) => item.path);
|
|
441
|
+
const failed = [...covered.values()]
|
|
442
|
+
.filter((item) => item.outcome === 'failed')
|
|
443
|
+
.map((item) => item.path);
|
|
444
|
+
const waived = [...covered.values()]
|
|
445
|
+
.filter((item) => item.outcome === 'waived')
|
|
446
|
+
.map((item) => item.path);
|
|
447
|
+
if (missing.length)
|
|
448
|
+
reasons.push(
|
|
449
|
+
`${missing.length} selected file(s) never accounted for: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`
|
|
450
|
+
);
|
|
451
|
+
if (failed.length)
|
|
452
|
+
reasons.push(`${failed.length} file(s) whose review failed: ${failed.slice(0, 5).join(', ')}`);
|
|
453
|
+
const open = ((anchored && anchored.findings) || []).filter((f) => f.disposition !== 'refuted');
|
|
454
|
+
const unlocated = open.filter((f) => f.location.status !== 'located');
|
|
455
|
+
if (unlocated.length)
|
|
456
|
+
reasons.push(
|
|
457
|
+
`${unlocated.length} open finding(s) not anchored on the code at head: ${unlocated
|
|
458
|
+
.map((f) => f.id)
|
|
459
|
+
.slice(0, 5)
|
|
460
|
+
.join(', ')}`
|
|
461
|
+
);
|
|
462
|
+
const total = scope.selected.length;
|
|
463
|
+
const done = [...covered.values()].filter((item) => item.outcome === 'completed').length;
|
|
464
|
+
const coverageRate = total ? Math.round((done / total) * 1000) / 10 : 100;
|
|
465
|
+
const status = reasons.length ? 'incomplete' : open.length ? 'findings' : 'clean';
|
|
466
|
+
return {
|
|
467
|
+
status,
|
|
468
|
+
reasons,
|
|
469
|
+
coverage: {
|
|
470
|
+
selected: total,
|
|
471
|
+
completed: done,
|
|
472
|
+
waived: waived.length,
|
|
473
|
+
failed: failed.length,
|
|
474
|
+
missing: missing.length,
|
|
475
|
+
rate: coverageRate,
|
|
476
|
+
},
|
|
477
|
+
findings: {
|
|
478
|
+
open: open.length,
|
|
479
|
+
confirmed: open.filter((f) => f.disposition === 'confirmed').length,
|
|
480
|
+
unresolved: open.filter((f) => f.disposition === 'unresolved').length,
|
|
481
|
+
refuted: ((anchored && anchored.findings) || []).length - open.length,
|
|
482
|
+
bySeverity: Object.fromEntries(
|
|
483
|
+
SEVERITIES.map((s) => [s, open.filter((f) => f.severity === s).length])
|
|
484
|
+
),
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function layout(projectDir, dir = DEFAULT_DIR, id) {
|
|
490
|
+
const root = path.resolve(projectDir, dir, id);
|
|
491
|
+
return {
|
|
492
|
+
root,
|
|
493
|
+
scope: path.join(root, 'scope.json'),
|
|
494
|
+
findings: path.join(root, 'findings.json'),
|
|
495
|
+
anchored: path.join(root, 'findings.anchored.json'),
|
|
496
|
+
coverage: path.join(root, 'coverage.json'),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
module.exports = {
|
|
501
|
+
SCOPE_SCHEMA,
|
|
502
|
+
FINDINGS_SCHEMA,
|
|
503
|
+
COVERAGE_SCHEMA,
|
|
504
|
+
DEFAULT_DIR,
|
|
505
|
+
SEVERITIES,
|
|
506
|
+
CATEGORIES,
|
|
507
|
+
DISPOSITIONS,
|
|
508
|
+
CONFIDENCE,
|
|
509
|
+
OUTCOMES,
|
|
510
|
+
SECRET_PATTERNS,
|
|
511
|
+
GENERATED_PATTERNS,
|
|
512
|
+
globToRegExp,
|
|
513
|
+
parseNumstat,
|
|
514
|
+
planUnits,
|
|
515
|
+
buildScope,
|
|
516
|
+
validateFindings,
|
|
517
|
+
locate,
|
|
518
|
+
anchorFindings,
|
|
519
|
+
validateCoverage,
|
|
520
|
+
reviewGate,
|
|
521
|
+
layout,
|
|
522
|
+
};
|
package/tools/cli/lib/uat.js
CHANGED
|
@@ -90,7 +90,7 @@ function readJson(file) {
|
|
|
90
90
|
const stat = fs.lstatSync(file);
|
|
91
91
|
if (stat.isSymbolicLink()) throw new Error(`${file} is a symbolic link; refused.`);
|
|
92
92
|
if (stat.size > MAX_FILE) throw new Error(`${file} exceeds ${MAX_FILE} bytes.`);
|
|
93
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
93
|
+
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); // PowerShell 5.1 writes a BOM
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// ── Spec: legacy adapter, validation, lint ────────────────────────────────────
|
|
@@ -472,8 +472,144 @@ function languageCode(value, strings = STRINGS) {
|
|
|
472
472
|
return Object.keys(strings).find((code) => code.toLowerCase().split('-')[0] === base) || null;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
/**
|
|
476
|
+
* What every built page guarantees a tester, by construction. Each entry names a
|
|
477
|
+
* behaviour the DOM suite exercises (tests/unit/uat-page.test.js) and the literal
|
|
478
|
+
* the template must carry for it. A template that lost one does not build: the
|
|
479
|
+
* FormaPro incident of 2026-09-25 (answers typed before any run existed, then lost
|
|
480
|
+
* on reload, with a "saved" status nobody had verified) came from exactly such a loss.
|
|
481
|
+
*/
|
|
482
|
+
const PAGE_GUARANTEES = [
|
|
483
|
+
{
|
|
484
|
+
id: 'hidden-before-start',
|
|
485
|
+
what: 'no answer field is shown before a run exists, whatever a class says about display',
|
|
486
|
+
// The rule must also be the LAST rule of the sheet: a later !important display rule would beat it.
|
|
487
|
+
markers: [
|
|
488
|
+
'[hidden] { display: none !important; }\n</style>',
|
|
489
|
+
'<main class="steps" id="steps" hidden>',
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
id: 'no-answer-before-run',
|
|
494
|
+
what: 'a tick or a note before the run starts is refused and explained, never discarded in silence',
|
|
495
|
+
markers: [
|
|
496
|
+
'function requireRun(',
|
|
497
|
+
'T.startFirst',
|
|
498
|
+
'!requireRun(event)',
|
|
499
|
+
'if (!requireRun()) return;',
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
id: 'verified-local-write',
|
|
504
|
+
what: 'a write to this browser counts only once it reads back; a refused write returns false',
|
|
505
|
+
markers: ['return localStorage.getItem(k) === v;', 'function saveLocal()'],
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: 'honest-save-status',
|
|
509
|
+
what: 'the status line reports the verified outcome, and names a failed save',
|
|
510
|
+
markers: ['function showSaveState(', 'T.saveFailed', 'T.remoteFailed', 'T.saving'],
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: 'save-on-every-change',
|
|
514
|
+
what: 'every tick and every keystroke in a note is saved at once, without waiting for blur, and a save never moves the cursor',
|
|
515
|
+
markers: [
|
|
516
|
+
'addEventListener("change"',
|
|
517
|
+
'addEventListener("input"',
|
|
518
|
+
'document.activeElement !== field',
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
id: 'restore-before-capabilities',
|
|
523
|
+
what: 'the browser copy is restored synchronously, before any optional capability answers',
|
|
524
|
+
markers: ['function restoreLocal()'],
|
|
525
|
+
order: ['restoreLocal();', 'probe()'],
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
id: 'newer-copy-wins',
|
|
529
|
+
what: 'an older remote copy never overwrites a newer local one; a copy without a date is the oldest of all',
|
|
530
|
+
markers: ['function connectDb()', 'remoteNewer', 'function at(', 'function adopt('],
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: 'revision-guard',
|
|
534
|
+
what: 'a run that answered another revision is offered, explained and carried by an explicit rule, never poured in blindly',
|
|
535
|
+
markers: ['function sameRevision(', 'function carry(', 'T.otherRevision', 'T.continueHere'],
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: 'other-tab-notice',
|
|
539
|
+
what: 'two tabs on one run converge on the latest change, and the page says so',
|
|
540
|
+
markers: ['addEventListener("storage"', 'T.otherTab'],
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: 'unreadable-draft-kept',
|
|
544
|
+
what: 'a saved run that cannot be read is reported and exportable, never deleted',
|
|
545
|
+
markers: ['id="draft-problem"', 'T.draftUnreadable', 'id="btn-save-raw"'],
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
id: 'progress-accessible',
|
|
549
|
+
what: 'the share of lines answered is exposed with a progressbar role and value; answered is not passed',
|
|
550
|
+
markers: ['role="progressbar"', 'aria-valuenow', 'T.percentAnswered'],
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: 'storage-explained',
|
|
554
|
+
what: 'the page says where answers live and what can make them disappear',
|
|
555
|
+
markers: ['id="storage-note"', 'id="mode-note"'],
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
id: 'finished-is-not-accepted',
|
|
559
|
+
what: 'finishing keeps the date and says it is not an acceptance',
|
|
560
|
+
markers: ['T.finishedNote', 'run.finishedAt = new Date().toISOString();'],
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'unload-guard',
|
|
564
|
+
what: 'leaving with an unsaved run is questioned; pending remote writes are flushed',
|
|
565
|
+
markers: ['addEventListener("beforeunload"', 'addEventListener("pagehide"'],
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: 'utf8-and-escaped-diacritics',
|
|
569
|
+
what: 'the page declares UTF-8 and writes the combining-mark range as escapes',
|
|
570
|
+
markers: ['<meta charset="utf-8">', '\\u0300-\\u036f'],
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
id: 'export-is-the-run',
|
|
574
|
+
what: 'the exported JSON is the run as answered, and nothing is exported before a run exists',
|
|
575
|
+
markers: ['download(runId + ".json", JSON.stringify(run, null, 2))'],
|
|
576
|
+
},
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Which guarantees a template carries: every marker present, in the required order where one
|
|
581
|
+
* is set. Checked on the TEMPLATE, never on the built page: a recipe's own text must not be
|
|
582
|
+
* able to satisfy or defeat a marker.
|
|
583
|
+
*/
|
|
584
|
+
function pageGuarantees(template) {
|
|
585
|
+
const missing = [];
|
|
586
|
+
for (const guarantee of PAGE_GUARANTEES) {
|
|
587
|
+
const lost = guarantee.markers.filter((marker) => !template.includes(marker));
|
|
588
|
+
if (!lost.length && guarantee.order) {
|
|
589
|
+
const positions = guarantee.order.map((marker) => template.indexOf(marker));
|
|
590
|
+
if (positions.some((at) => at < 0) || positions.some((at, i) => i && at < positions[i - 1]))
|
|
591
|
+
lost.push(`order ${guarantee.order.join(' → ')}`);
|
|
592
|
+
}
|
|
593
|
+
if (lost.length) missing.push({ id: guarantee.id, what: guarantee.what, lost });
|
|
594
|
+
}
|
|
595
|
+
const carried = PAGE_GUARANTEES.map((g) => g.id).filter(
|
|
596
|
+
(id) => !missing.some((entry) => entry.id === id)
|
|
597
|
+
);
|
|
598
|
+
return { ok: missing.length === 0, carried, missing };
|
|
599
|
+
}
|
|
600
|
+
|
|
475
601
|
function buildPage(spec, options = {}) {
|
|
476
602
|
const template = options.template || fs.readFileSync(TEMPLATE, 'utf8');
|
|
603
|
+
// A page missing one guarantee is not a page: refusing here is what keeps a tester's
|
|
604
|
+
// answers safe in an installation that has no test suite of its own.
|
|
605
|
+
const guarantees = pageGuarantees(template);
|
|
606
|
+
if (!guarantees.ok) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`the page template (${options.template ? 'the template given' : TEMPLATE}) lost a guarantee — not built: ${guarantees.missing
|
|
609
|
+
.map((entry) => `${entry.id}: ${entry.what} — missing ${entry.lost.join(', ')}`)
|
|
610
|
+
.join('; ')}`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
477
613
|
const hash = specHash(spec);
|
|
478
614
|
const json = canonical(spec).replace(/<\//g, '<\\/');
|
|
479
615
|
const strings = options.strings || STRINGS;
|
|
@@ -502,7 +638,7 @@ function buildPage(spec, options = {}) {
|
|
|
502
638
|
cause: error,
|
|
503
639
|
});
|
|
504
640
|
}
|
|
505
|
-
return { html, sha256: hash, language };
|
|
641
|
+
return { html, sha256: hash, language, guarantees: guarantees.carried };
|
|
506
642
|
}
|
|
507
643
|
|
|
508
644
|
// ── Runs: legacy adapter, normalisation, reading ──────────────────────────────
|
|
@@ -873,6 +1009,8 @@ module.exports = {
|
|
|
873
1009
|
lintSpec,
|
|
874
1010
|
screenLabels,
|
|
875
1011
|
buildPage,
|
|
1012
|
+
PAGE_GUARANTEES,
|
|
1013
|
+
pageGuarantees,
|
|
876
1014
|
languageCode,
|
|
877
1015
|
normalizeRun,
|
|
878
1016
|
readRuns,
|