bmad-plus 0.18.0 → 0.20.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.
@@ -0,0 +1,634 @@
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, which checklists apply, and whether every selected file was accounted
7
+ * for. Written for BMAD+ after studying open-code-review (docs/research/open-code-review-2026-09-25)
8
+ * for ideas only. No model call, no network, no write outside the review folder.
9
+ */
10
+ 'use strict';
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const crypto = require('node:crypto');
15
+ const { spawnSync } = require('node:child_process');
16
+ const { matchesAny } = require('./glob');
17
+ const { redactFields } = require('./redact');
18
+ const rules = require('./review-rules');
19
+
20
+ const SCOPE_SCHEMA = 'bmad-plus/review-scope/1';
21
+ const FINDINGS_SCHEMA = 'bmad-plus/review-findings/1';
22
+ const COVERAGE_SCHEMA = 'bmad-plus/review-coverage/1';
23
+ const DEFAULT_DIR = '_bmad-output/review';
24
+
25
+ const SEVERITIES = ['critical', 'high', 'medium', 'low'];
26
+ const CATEGORIES = [
27
+ 'correctness',
28
+ 'security',
29
+ 'data-loss',
30
+ 'concurrency',
31
+ 'performance',
32
+ 'error-handling',
33
+ 'compatibility',
34
+ 'test-gap',
35
+ 'maintainability',
36
+ 'documentation',
37
+ ];
38
+ const DISPOSITIONS = ['confirmed', 'refuted', 'unresolved'];
39
+ const CONFIDENCE = ['high', 'medium', 'low'];
40
+ const OUTCOMES = ['completed', 'failed', 'waived'];
41
+
42
+ /** Files whose content must never reach a review packet, whatever an include pattern says. */
43
+ const SECRET_PATTERNS = [
44
+ '**/.env',
45
+ '**/.env.*',
46
+ '**/*.pem',
47
+ '**/*.key',
48
+ '**/*.p12',
49
+ '**/*.pfx',
50
+ '**/id_rsa*',
51
+ '**/id_ed25519*',
52
+ '**/.credentials/**',
53
+ '**/credentials/**',
54
+ '**/secrets/**',
55
+ '**/*.keystore',
56
+ '**/.npmrc',
57
+ '**/.netrc',
58
+ ];
59
+ const GENERATED_PATTERNS = [
60
+ '**/node_modules/**',
61
+ '**/dist/**',
62
+ '**/build/**',
63
+ '**/coverage/**',
64
+ '**/vendor/**',
65
+ '**/*.min.js',
66
+ '**/*.min.css',
67
+ '**/*.map',
68
+ '**/package-lock.json',
69
+ '**/yarn.lock',
70
+ '**/pnpm-lock.yaml',
71
+ '**/Cargo.lock',
72
+ '**/go.sum',
73
+ '**/poetry.lock',
74
+ ];
75
+ const UNIT_LIMITS = { files: 8, lines: 400 };
76
+
77
+ /**
78
+ * How much reviewing a change deserves. A pass is a complete read of the scope; later
79
+ * passes look for what the earlier ones missed and stop early when a pass adds nothing.
80
+ */
81
+ const EFFORTS = {
82
+ low: { passes: 1, refute: false },
83
+ medium: { passes: 2, refute: true },
84
+ high: { passes: 3, refute: true },
85
+ };
86
+ const CHURN = { plan: 150, split: 400 };
87
+
88
+ /** The reviewing plan follows the effort chosen and the measured size of the change. */
89
+ function reviewPlan(effort, lines, units) {
90
+ const { passes, refute } = EFFORTS[effort];
91
+ return {
92
+ effort,
93
+ passes,
94
+ refute,
95
+ planFirst: lines >= CHURN.plan,
96
+ parallelUnits: units > 1 && lines >= CHURN.split,
97
+ };
98
+ }
99
+
100
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
101
+
102
+ function git(projectDir, args) {
103
+ const result = spawnSync('git', args, {
104
+ cwd: projectDir,
105
+ encoding: 'utf8',
106
+ maxBuffer: 64 * 1024 * 1024,
107
+ windowsHide: true,
108
+ shell: false,
109
+ });
110
+ if (result.error) throw new Error(`git is unavailable: ${result.error.message}`);
111
+ if (result.status !== 0)
112
+ throw new Error(`git ${args[0]} failed: ${(result.stderr || '').trim().split('\n')[0]}`);
113
+ return result.stdout;
114
+ }
115
+
116
+ /** A ref given on the command line is resolved to a commit or refused; it never reaches git as an option. */
117
+ function resolveCommit(projectDir, ref) {
118
+ if (typeof ref !== 'string' || !ref || ref.startsWith('-'))
119
+ throw new Error(`invalid ref "${ref}"`);
120
+ return git(projectDir, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
121
+ }
122
+
123
+ /** `git diff --numstat -z` entries: {path, oldPath?, added, removed, binary}. */
124
+ function parseNumstat(output) {
125
+ const parts = output.split('\0');
126
+ const entries = [];
127
+ for (let i = 0; i < parts.length; i++) {
128
+ const head = parts[i];
129
+ if (!head) continue;
130
+ const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(head);
131
+ if (!match) continue;
132
+ const [, added, removed, rest] = match;
133
+ const binary = added === '-' || removed === '-';
134
+ if (rest === '') {
135
+ // Rename or copy: old path and new path follow as separate fields.
136
+ const oldPath = parts[++i];
137
+ const newPath = parts[++i];
138
+ entries.push({ path: newPath, oldPath, added: +added || 0, removed: +removed || 0, binary });
139
+ } else {
140
+ entries.push({ path: rest, added: +added || 0, removed: +removed || 0, binary });
141
+ }
142
+ }
143
+ return entries;
144
+ }
145
+
146
+ function nameStatus(output) {
147
+ const parts = output.split('\0');
148
+ const status = new Map();
149
+ for (let i = 0; i < parts.length; i++) {
150
+ const code = parts[i];
151
+ if (!code) continue;
152
+ const kind = code[0];
153
+ if (kind === 'R' || kind === 'C') {
154
+ i += 2;
155
+ status.set(parts[i], kind === 'R' ? 'renamed' : 'copied');
156
+ } else {
157
+ i += 1;
158
+ status.set(
159
+ parts[i],
160
+ { A: 'added', D: 'deleted', M: 'modified', T: 'modified' }[kind] || 'modified'
161
+ );
162
+ }
163
+ }
164
+ return status;
165
+ }
166
+
167
+ /** Deterministic review units: files grouped by directory, cut at the file and line limits, in path order. */
168
+ function planUnits(selected, limits = UNIT_LIMITS) {
169
+ const units = [];
170
+ let current = null;
171
+ const flush = () => {
172
+ if (current && current.paths.length) units.push(current);
173
+ current = null;
174
+ };
175
+ for (const item of selected) {
176
+ const dir = path.posix.dirname(item.path);
177
+ const lines = item.added + item.removed;
178
+ if (
179
+ !current ||
180
+ current.dir !== dir ||
181
+ current.paths.length >= limits.files ||
182
+ (current.lines + lines > limits.lines && current.paths.length > 0)
183
+ ) {
184
+ flush();
185
+ current = { dir, paths: [], lines: 0 };
186
+ }
187
+ current.paths.push(item.path);
188
+ current.lines += lines;
189
+ }
190
+ flush();
191
+ return units.map((unit, index) => ({
192
+ id: `u${index + 1}`,
193
+ paths: unit.paths,
194
+ lines: unit.lines,
195
+ }));
196
+ }
197
+
198
+ /**
199
+ * The review scope: what changed between base and head (or the working tree), which files
200
+ * are selected, and why every other changed file is not. Secrets are excluded before any
201
+ * include pattern is considered.
202
+ */
203
+ function buildScope(projectDir, options = {}) {
204
+ const head = options.workspace ? null : resolveCommit(projectDir, options.head || 'HEAD');
205
+ const base = resolveCommit(projectDir, options.base || 'HEAD~1');
206
+ const mergeBase = git(projectDir, ['merge-base', base, head || 'HEAD']).trim();
207
+ const range = options.workspace ? [mergeBase] : [mergeBase, head];
208
+ const numstat = parseNumstat(
209
+ git(projectDir, ['diff', '--numstat', '-z', '--find-renames', ...range, '--'])
210
+ );
211
+ const statuses = nameStatus(
212
+ git(projectDir, ['diff', '--name-status', '-z', '--find-renames', ...range, '--'])
213
+ );
214
+ const changed = numstat.map((entry) => ({
215
+ ...entry,
216
+ status: statuses.get(entry.path) || 'modified',
217
+ }));
218
+ if (options.workspace) {
219
+ const untracked = git(projectDir, ['ls-files', '--others', '--exclude-standard', '-z'])
220
+ .split('\0')
221
+ .filter(Boolean);
222
+ for (const file of untracked) {
223
+ if (changed.some((entry) => entry.path === file)) continue;
224
+ let lines = 0;
225
+ let binary;
226
+ try {
227
+ const bytes = fs.readFileSync(path.join(projectDir, file));
228
+ binary = bytes.includes(0);
229
+ lines = binary ? 0 : bytes.toString('utf8').split('\n').length;
230
+ } catch {
231
+ binary = true;
232
+ }
233
+ changed.push({ path: file, added: lines, removed: 0, binary, status: 'added' });
234
+ }
235
+ }
236
+ changed.sort((a, b) => a.path.localeCompare(b.path));
237
+
238
+ const include = options.include || [];
239
+ const exclude = options.exclude || [];
240
+ const selected = [];
241
+ const excluded = [];
242
+ for (const entry of changed) {
243
+ const file = entry.path;
244
+ let reason = null;
245
+ if (matchesAny(file, SECRET_PATTERNS)) reason = 'secret';
246
+ else if (entry.binary) reason = 'binary';
247
+ else if (entry.status === 'deleted') reason = 'deleted';
248
+ else if (matchesAny(file, exclude)) reason = 'excluded-by-pattern';
249
+ else if (matchesAny(file, GENERATED_PATTERNS) && !matchesAny(file, include))
250
+ reason = 'generated-or-vendored';
251
+ else if (include.length && !matchesAny(file, include)) reason = 'not-included';
252
+ if (reason) excluded.push({ path: file, reason, status: entry.status });
253
+ else
254
+ selected.push({
255
+ path: file,
256
+ status: entry.status,
257
+ added: entry.added,
258
+ removed: entry.removed,
259
+ ...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
260
+ });
261
+ }
262
+ const effort = options.effort || 'medium';
263
+ if (!EFFORTS[effort]) throw new Error(`effort must be one of ${Object.keys(EFFORTS).join('|')}`);
264
+ const ruleset = options.ruleset || rules.loadRuleset(projectDir);
265
+ for (const item of selected) item.rules = rules.rulesFor(ruleset, item.path);
266
+ const identity = {
267
+ base,
268
+ head: head || 'WORKTREE',
269
+ mergeBase,
270
+ workspace: Boolean(options.workspace),
271
+ include,
272
+ exclude,
273
+ effort,
274
+ rulesSha256: ruleset.sha256,
275
+ };
276
+ const units = planUnits(selected, options.unitLimits).map((unit) => ({
277
+ ...unit,
278
+ rules: [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))],
279
+ }));
280
+ const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
281
+ const plan = reviewPlan(effort, lines, units.length);
282
+ const body = { identity, selected, excluded, units, plan };
283
+ return {
284
+ schema: SCOPE_SCHEMA,
285
+ id: options.id,
286
+ ...body,
287
+ totals: {
288
+ changed: changed.length,
289
+ selected: selected.length,
290
+ excluded: excluded.length,
291
+ lines,
292
+ },
293
+ sha256: sha256(JSON.stringify(body)),
294
+ };
295
+ }
296
+
297
+ // ── Findings ──────────────────────────────────────────────────────────────────
298
+
299
+ const FINDING_KEYS = [
300
+ 'id',
301
+ 'path',
302
+ 'existing_code',
303
+ 'content',
304
+ 'category',
305
+ 'severity',
306
+ 'confidence',
307
+ 'disposition',
308
+ 'trigger',
309
+ 'consequence',
310
+ 'evidence',
311
+ 'refutation',
312
+ 'fix',
313
+ 'rule',
314
+ ];
315
+
316
+ /** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
317
+ function validateFindings(doc, scope) {
318
+ const errors = [];
319
+ if (!doc || doc.schema !== FINDINGS_SCHEMA) errors.push(`schema must be "${FINDINGS_SCHEMA}"`);
320
+ if (scope && doc && doc.scopeSha256 !== scope.sha256)
321
+ errors.push(
322
+ 'scopeSha256 does not match the current scope — the findings answer another review'
323
+ );
324
+ const ids = new Set();
325
+ for (const [index, finding] of ((doc && doc.findings) || []).entries()) {
326
+ const at = `finding ${finding?.id ?? index}`;
327
+ for (const key of Object.keys(finding || {}))
328
+ if (!FINDING_KEYS.includes(key)) errors.push(`${at}: unknown key "${key}"`);
329
+ if (!finding.id || ids.has(finding.id)) errors.push(`${at}: id missing or duplicated`);
330
+ ids.add(finding.id);
331
+ if (typeof finding.path !== 'string' || !finding.path) errors.push(`${at}: path is required`);
332
+ if (typeof finding.existing_code !== 'string' || !finding.existing_code.trim())
333
+ errors.push(
334
+ `${at}: existing_code is required — quote the code the finding is about, verbatim`
335
+ );
336
+ if (typeof finding.content !== 'string' || !finding.content.trim())
337
+ errors.push(`${at}: content is required`);
338
+ if (!CATEGORIES.includes(finding.category))
339
+ errors.push(`${at}: category "${finding.category}" is not one of ${CATEGORIES.join('|')}`);
340
+ if (!SEVERITIES.includes(finding.severity))
341
+ errors.push(`${at}: severity "${finding.severity}" is not one of ${SEVERITIES.join('|')}`);
342
+ if (!CONFIDENCE.includes(finding.confidence))
343
+ errors.push(
344
+ `${at}: confidence "${finding.confidence}" is not one of ${CONFIDENCE.join('|')}`
345
+ );
346
+ if (!DISPOSITIONS.includes(finding.disposition))
347
+ errors.push(
348
+ `${at}: disposition "${finding.disposition}" is not one of ${DISPOSITIONS.join('|')}`
349
+ );
350
+ if (finding.disposition === 'refuted' && !String(finding.refutation || '').trim())
351
+ errors.push(`${at}: a refuted finding keeps its refutation — the quoted ground`);
352
+ if (finding.disposition === 'confirmed' && !String(finding.evidence || '').trim())
353
+ errors.push(`${at}: a confirmed finding needs evidence`);
354
+ const entry = scope && scope.selected.find((item) => item.path === finding.path);
355
+ if (scope && finding.path && !entry)
356
+ errors.push(`${at}: ${finding.path} is not in the review scope`);
357
+ // A finding may name the checklist rule that led to it; the rule must apply to that file.
358
+ if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
359
+ errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
360
+ }
361
+ return errors;
362
+ }
363
+
364
+ const normalizeLine = (line) => line.replace(/\r$/, '').replace(/\s+/g, ' ').trim();
365
+
366
+ /**
367
+ * Where a quoted snippet is in a file: every place its lines appear consecutively, compared
368
+ * after whitespace normalisation. The model's line numbers are never used.
369
+ */
370
+ function locate(fileText, snippet) {
371
+ const fileLines = fileText.split('\n').map(normalizeLine);
372
+ const wanted = snippet
373
+ .split('\n')
374
+ .map(normalizeLine)
375
+ .filter((line, index, all) => line || (index > 0 && index < all.length - 1));
376
+ while (wanted.length && !wanted[0]) wanted.shift();
377
+ while (wanted.length && !wanted[wanted.length - 1]) wanted.pop();
378
+ if (!wanted.length) return [];
379
+ const hits = [];
380
+ for (let i = 0; i + wanted.length <= fileLines.length; i++) {
381
+ let ok = true;
382
+ for (let j = 0; j < wanted.length; j++) {
383
+ if (fileLines[i + j] !== wanted[j]) {
384
+ ok = false;
385
+ break;
386
+ }
387
+ }
388
+ if (ok) hits.push({ lineStart: i + 1, lineEnd: i + wanted.length });
389
+ }
390
+ return hits;
391
+ }
392
+
393
+ function readAtHead(projectDir, scope, file) {
394
+ if (scope.identity.workspace) return fs.readFileSync(path.join(projectDir, file), 'utf8');
395
+ return git(projectDir, ['show', `${scope.identity.head}:${file}`]);
396
+ }
397
+
398
+ /** Every finding gets a location status: located (unique), ambiguous (several), unlocated (none). */
399
+ function anchorFindings(projectDir, scope, doc) {
400
+ const cache = new Map();
401
+ const anchored = doc.findings.map((finding) => {
402
+ let text = cache.get(finding.path);
403
+ if (text === undefined) {
404
+ try {
405
+ text = readAtHead(projectDir, scope, finding.path);
406
+ } catch {
407
+ text = null;
408
+ }
409
+ cache.set(finding.path, text);
410
+ }
411
+ if (text === null)
412
+ return { ...finding, location: { status: 'unlocated', reason: 'file unreadable at head' } };
413
+ const hits = locate(text, finding.existing_code);
414
+ if (hits.length === 1) return { ...finding, location: { status: 'located', ...hits[0] } };
415
+ if (hits.length > 1)
416
+ return { ...finding, location: { status: 'ambiguous', candidates: hits.slice(0, 10) } };
417
+ return {
418
+ ...finding,
419
+ location: { status: 'unlocated', reason: 'the quoted code is not in the file' },
420
+ };
421
+ });
422
+ const counts = { located: 0, ambiguous: 0, unlocated: 0, redactions: 0 };
423
+ for (const finding of anchored) {
424
+ counts[finding.location.status] += 1;
425
+ // Located first, redacted second: the anchor needs the quote, the written record must not carry a secret.
426
+ const redacted = redactFields(finding, REDACTED_FIELDS);
427
+ if (redacted) finding.redactions = redacted;
428
+ counts.redactions += redacted;
429
+ }
430
+ return { findings: anchored, counts };
431
+ }
432
+
433
+ const REDACTED_FIELDS = [
434
+ 'existing_code',
435
+ 'content',
436
+ 'trigger',
437
+ 'consequence',
438
+ 'evidence',
439
+ 'refutation',
440
+ 'fix',
441
+ ];
442
+
443
+ // ── Coverage and gate ─────────────────────────────────────────────────────────
444
+
445
+ function validateCoverage(doc, scope) {
446
+ const errors = [];
447
+ if (!doc || doc.schema !== COVERAGE_SCHEMA) errors.push(`schema must be "${COVERAGE_SCHEMA}"`);
448
+ if (doc && doc.scopeSha256 !== scope.sha256)
449
+ errors.push('scopeSha256 does not match the current scope');
450
+ const seen = new Set();
451
+ for (const item of (doc && doc.items) || []) {
452
+ if (!scope.selected.some((entry) => entry.path === item.path))
453
+ errors.push(`${item.path}: not in the review scope`);
454
+ if (seen.has(item.path)) errors.push(`${item.path}: listed twice`);
455
+ seen.add(item.path);
456
+ if (!OUTCOMES.includes(item.outcome))
457
+ errors.push(`${item.path}: outcome "${item.outcome}" is not one of ${OUTCOMES.join('|')}`);
458
+ if (
459
+ (item.outcome === 'waived' || item.outcome === 'failed') &&
460
+ !String(item.reason || '').trim()
461
+ )
462
+ errors.push(`${item.path}: a ${item.outcome} file needs a reason`);
463
+ }
464
+ return errors;
465
+ }
466
+
467
+ /**
468
+ * The review verdict. `incomplete` whenever a selected file was not accounted for, failed,
469
+ * or the evidence is invalid; otherwise `findings` or `clean` — the latter only means no
470
+ * confirmed or unresolved finding within a fully covered scope.
471
+ */
472
+ function reviewGate({ scope, findings, coverage, anchored }) {
473
+ const reasons = [];
474
+ if (!coverage) reasons.push('no coverage.json: which files were reviewed is not established');
475
+ else reasons.push(...validateCoverage(coverage, scope));
476
+ if (!findings) reasons.push('no findings.json: write it even when there is nothing to report');
477
+ else reasons.push(...validateFindings(findings, scope));
478
+ const covered = new Map(((coverage && coverage.items) || []).map((item) => [item.path, item]));
479
+ const missing = scope.selected.filter((item) => !covered.has(item.path)).map((item) => item.path);
480
+ const failed = [...covered.values()]
481
+ .filter((item) => item.outcome === 'failed')
482
+ .map((item) => item.path);
483
+ const waived = [...covered.values()]
484
+ .filter((item) => item.outcome === 'waived')
485
+ .map((item) => item.path);
486
+ if (missing.length)
487
+ reasons.push(
488
+ `${missing.length} selected file(s) never accounted for: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`
489
+ );
490
+ if (failed.length)
491
+ reasons.push(`${failed.length} file(s) whose review failed: ${failed.slice(0, 5).join(', ')}`);
492
+ const open = ((anchored && anchored.findings) || []).filter((f) => f.disposition !== 'refuted');
493
+ const unlocated = open.filter((f) => f.location.status !== 'located');
494
+ if (unlocated.length)
495
+ reasons.push(
496
+ `${unlocated.length} open finding(s) not anchored on the code at head: ${unlocated
497
+ .map((f) => f.id)
498
+ .slice(0, 5)
499
+ .join(', ')}`
500
+ );
501
+ const total = scope.selected.length;
502
+ const done = [...covered.values()].filter((item) => item.outcome === 'completed').length;
503
+ const coverageRate = total ? Math.round((done / total) * 1000) / 10 : 100;
504
+ const status = reasons.length ? 'incomplete' : open.length ? 'findings' : 'clean';
505
+ return {
506
+ status,
507
+ reasons,
508
+ coverage: {
509
+ selected: total,
510
+ completed: done,
511
+ waived: waived.length,
512
+ failed: failed.length,
513
+ missing: missing.length,
514
+ rate: coverageRate,
515
+ },
516
+ findings: {
517
+ open: open.length,
518
+ confirmed: open.filter((f) => f.disposition === 'confirmed').length,
519
+ unresolved: open.filter((f) => f.disposition === 'unresolved').length,
520
+ refuted: ((anchored && anchored.findings) || []).length - open.length,
521
+ bySeverity: Object.fromEntries(
522
+ SEVERITIES.map((s) => [s, open.filter((f) => f.severity === s).length])
523
+ ),
524
+ },
525
+ };
526
+ }
527
+
528
+ // ── Comparing two reviews ─────────────────────────────────────────────────────
529
+
530
+ const COMPARE_SCHEMA = 'bmad-plus/review-compare/1';
531
+
532
+ /** A finding's identity survives moved lines and reindentation: path, category, normalised quote. */
533
+ function findingKey(file, finding) {
534
+ const code = String(finding.existing_code || '')
535
+ .split('\n')
536
+ .map(normalizeLine)
537
+ .filter(Boolean)
538
+ .join('\n');
539
+ return JSON.stringify([file, finding.category, code]);
540
+ }
541
+
542
+ /**
543
+ * What became of the open findings of an earlier review. `resolved` needs the later review
544
+ * to have completed that file; otherwise the finding is `not_reviewed`, never assumed fixed.
545
+ * Renames recorded in the later scope carry findings to the new path.
546
+ */
547
+ function compareReviews(before, after) {
548
+ const renamed = new Map(
549
+ after.scope.selected.filter((item) => item.oldPath).map((item) => [item.oldPath, item.path])
550
+ );
551
+ const completed = new Set(
552
+ ((after.coverage && after.coverage.items) || [])
553
+ .filter((item) => item.outcome === 'completed')
554
+ .map((item) => item.path)
555
+ );
556
+ const isOpen = (finding) => finding.disposition !== 'refuted';
557
+ const afterByKey = new Map(after.findings.findings.map((f) => [findingKey(f.path, f), f]));
558
+ const beforeKeys = new Set();
559
+ const buckets = { new: [], persisting: [], resolved: [], refuted: [], not_reviewed: [] };
560
+
561
+ for (const finding of before.findings.findings.filter(isOpen)) {
562
+ const file = renamed.get(finding.path) || finding.path;
563
+ const key = findingKey(file, finding);
564
+ beforeKeys.add(key);
565
+ const match = afterByKey.get(key);
566
+ const entry = {
567
+ before: finding.id,
568
+ path: file,
569
+ category: finding.category,
570
+ severity: finding.severity,
571
+ };
572
+ if (match && isOpen(match)) buckets.persisting.push({ ...entry, after: match.id });
573
+ else if (match) buckets.refuted.push({ ...entry, after: match.id });
574
+ else if (completed.has(file)) buckets.resolved.push(entry);
575
+ else buckets.not_reviewed.push(entry);
576
+ }
577
+ for (const finding of after.findings.findings.filter(isOpen)) {
578
+ if (!beforeKeys.has(findingKey(finding.path, finding)))
579
+ buckets.new.push({
580
+ after: finding.id,
581
+ path: finding.path,
582
+ category: finding.category,
583
+ severity: finding.severity,
584
+ });
585
+ }
586
+ return {
587
+ schema: COMPARE_SCHEMA,
588
+ before: { id: before.scope.id, scopeSha256: before.scope.sha256 },
589
+ after: { id: after.scope.id, scopeSha256: after.scope.sha256 },
590
+ counts: Object.fromEntries(Object.entries(buckets).map(([name, list]) => [name, list.length])),
591
+ buckets,
592
+ };
593
+ }
594
+
595
+ function layout(projectDir, dir = DEFAULT_DIR, id) {
596
+ const root = path.resolve(projectDir, dir, id);
597
+ return {
598
+ root,
599
+ scope: path.join(root, 'scope.json'),
600
+ findings: path.join(root, 'findings.json'),
601
+ anchored: path.join(root, 'findings.anchored.json'),
602
+ coverage: path.join(root, 'coverage.json'),
603
+ checklist: path.join(root, 'checklist.md'),
604
+ compare: path.join(root, 'compare.json'),
605
+ };
606
+ }
607
+
608
+ module.exports = {
609
+ SCOPE_SCHEMA,
610
+ FINDINGS_SCHEMA,
611
+ COVERAGE_SCHEMA,
612
+ COMPARE_SCHEMA,
613
+ DEFAULT_DIR,
614
+ EFFORTS,
615
+ SEVERITIES,
616
+ CATEGORIES,
617
+ DISPOSITIONS,
618
+ CONFIDENCE,
619
+ OUTCOMES,
620
+ SECRET_PATTERNS,
621
+ GENERATED_PATTERNS,
622
+ parseNumstat,
623
+ planUnits,
624
+ buildScope,
625
+ validateFindings,
626
+ locate,
627
+ anchorFindings,
628
+ validateCoverage,
629
+ reviewGate,
630
+ reviewPlan,
631
+ findingKey,
632
+ compareReviews,
633
+ layout,
634
+ };
@@ -4,6 +4,7 @@
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const crypto = require('node:crypto');
7
+ const { redactFields } = require('./redact');
7
8
 
8
9
  const SPEC_SCHEMA = 'bmad-plus/uat-spec/2';
9
10
  const RESULTS_SCHEMA = 'bmad-plus/uat-results/2';
@@ -90,7 +91,7 @@ function readJson(file) {
90
91
  const stat = fs.lstatSync(file);
91
92
  if (stat.isSymbolicLink()) throw new Error(`${file} is a symbolic link; refused.`);
92
93
  if (stat.size > MAX_FILE) throw new Error(`${file} exceeds ${MAX_FILE} bytes.`);
93
- return JSON.parse(fs.readFileSync(file, 'utf8'));
94
+ return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); // PowerShell 5.1 writes a BOM
94
95
  }
95
96
 
96
97
  // ── Spec: legacy adapter, validation, lint ────────────────────────────────────
@@ -714,6 +715,20 @@ function normalizeRun(doc, spec) {
714
715
  return run;
715
716
  }
716
717
 
718
+ /**
719
+ * Tester notes are free text, and testers paste terminal output into them: credentials go
720
+ * before the run is written. Returns the number of replacements, recorded on the run.
721
+ */
722
+ function redactRun(run) {
723
+ let count = redactFields(run, ['overallNote']);
724
+ for (const step of Object.values(run.steps || {})) {
725
+ count += redactFields(step, ['note']);
726
+ for (const expect of Object.values(step.expect || {})) count += redactFields(expect, ['note']);
727
+ }
728
+ if (count) run.redactions = (run.redactions || 0) + count;
729
+ return count;
730
+ }
731
+
717
732
  function readRuns(dir, spec) {
718
733
  const runs = [];
719
734
  const seen = new Set();
@@ -1013,6 +1028,7 @@ module.exports = {
1013
1028
  pageGuarantees,
1014
1029
  languageCode,
1015
1030
  normalizeRun,
1031
+ redactRun,
1016
1032
  readRuns,
1017
1033
  stepState,
1018
1034
  failures,
@@ -0,0 +1,7 @@
1
+ - **Untrusted triggers.** `pull_request_target`, `workflow_run` or `issue_comment` jobs that check out or execute code from a pull request with secrets or a write token available.
2
+ - **Script injection.** Expressions such as `${{ github.event.* }}`, branch names, titles or commit messages interpolated directly into `run:`; pass them through `env:` and quote them.
3
+ - **Permissions.** No top-level `permissions:` (defaults may be broad), or `write` scopes a job does not need; `id-token: write` on jobs that do not publish.
4
+ - **Pinning.** Third-party actions referenced by a moving tag or branch instead of a full commit SHA; container images without a digest.
5
+ - **Secrets.** Secrets echoed, written to artifacts or caches, or exposed to steps that run third-party code; `ACTIONS_STEP_DEBUG` left on.
6
+ - **Runners.** Self-hosted runners on public repositories; a job that assumes a tool preinstalled on GitHub-hosted images (`node`, `python`, `jq`) before setting it up.
7
+ - **Gates.** A step that should block marked `continue-on-error`; a required check that can be skipped by a path filter; a release job that does not depend on the test jobs.
@@ -0,0 +1,5 @@
1
+ - **Secrets.** A real credential, token, private URL or personal data committed in a configuration file or an example file; an example file whose placeholder looks real.
2
+ - **Dependencies.** A new dependency: is it maintained, necessary, and from the expected publisher (typosquatting)? A version range widened to accept a major; a lockfile out of step with its manifest; install scripts from a new package.
3
+ - **Defaults.** A security-relevant setting weakened (TLS verification, CORS origins, cookie flags, debug mode, log level exposing data) or a default that now differs between environments.
4
+ - **Consistency.** A key renamed in one environment file and not the others; a value duplicated where one source of truth exists; a feature flag left on.
5
+ - **Parsing.** YAML values that change type silently (`no`, `on`, `0755`, unquoted dates and versions); JSON with duplicate keys.