bmad-plus 0.19.0 → 0.21.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 +35 -0
- package/README.md +15 -15
- package/package.json +1 -1
- package/readme-international/README.de.md +15 -15
- package/readme-international/README.es.md +15 -15
- package/readme-international/README.fr.md +15 -15
- package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +39 -4
- package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +1 -0
- package/tools/cli/bmad-plus-cli.js +16 -4
- package/tools/cli/commands/review.js +258 -22
- package/tools/cli/commands/uat.js +16 -3
- package/tools/cli/lib/glob.js +73 -0
- package/tools/cli/lib/packs.js +1 -1
- package/tools/cli/lib/redact.js +116 -0
- package/tools/cli/lib/review-rules.js +186 -0
- package/tools/cli/lib/review.js +598 -30
- package/tools/cli/lib/uat.js +16 -0
- package/tools/cli/review-rules/ci-workflows.md +7 -0
- package/tools/cli/review-rules/configuration.md +5 -0
- package/tools/cli/review-rules/containers.md +6 -0
- package/tools/cli/review-rules/general.md +10 -0
- package/tools/cli/review-rules/index.yaml +45 -0
- package/tools/cli/review-rules/javascript-typescript.md +7 -0
- package/tools/cli/review-rules/python.md +6 -0
- package/tools/cli/review-rules/shell.md +6 -0
- package/tools/cli/review-rules/sql-and-migrations.md +6 -0
package/tools/cli/lib/review.js
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
* derives the verdict from coverage — never from how many findings were written.
|
|
4
4
|
*
|
|
5
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* disposition. No model call, no network, no write outside the review folder.
|
|
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.
|
|
10
9
|
*/
|
|
11
10
|
'use strict';
|
|
12
11
|
|
|
@@ -14,6 +13,9 @@ const fs = require('node:fs');
|
|
|
14
13
|
const path = require('node:path');
|
|
15
14
|
const crypto = require('node:crypto');
|
|
16
15
|
const { spawnSync } = require('node:child_process');
|
|
16
|
+
const { matchesAny } = require('./glob');
|
|
17
|
+
const { redact, redactFields } = require('./redact');
|
|
18
|
+
const rules = require('./review-rules');
|
|
17
19
|
|
|
18
20
|
const SCOPE_SCHEMA = 'bmad-plus/review-scope/1';
|
|
19
21
|
const FINDINGS_SCHEMA = 'bmad-plus/review-findings/1';
|
|
@@ -37,6 +39,17 @@ const DISPOSITIONS = ['confirmed', 'refuted', 'unresolved'];
|
|
|
37
39
|
const CONFIDENCE = ['high', 'medium', 'low'];
|
|
38
40
|
const OUTCOMES = ['completed', 'failed', 'waived'];
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Why a review session ended. Only `completed` lets the gate reach a verdict; every other
|
|
44
|
+
* reason keeps it incomplete, whatever the coverage says.
|
|
45
|
+
*/
|
|
46
|
+
const STOP_REASONS = ['completed', 'budget', 'time-limit', 'failure-streak', 'interrupted'];
|
|
47
|
+
const ATTEMPT_OUTCOMES = ['completed', 'failed'];
|
|
48
|
+
/** Consecutive failed attempts after which a unit is abandoned rather than retried again. */
|
|
49
|
+
const STRIKES = 3;
|
|
50
|
+
const RUN_KEYS = ['stop', 'detail', 'passes', 'attempts', 'tokens', 'durationMs'];
|
|
51
|
+
const ATTEMPT_KEYS = ['unit', 'group', 'outcome', 'reason'];
|
|
52
|
+
|
|
40
53
|
/** Files whose content must never reach a review packet, whatever an include pattern says. */
|
|
41
54
|
const SECRET_PATTERNS = [
|
|
42
55
|
'**/.env',
|
|
@@ -72,24 +85,30 @@ const GENERATED_PATTERNS = [
|
|
|
72
85
|
];
|
|
73
86
|
const UNIT_LIMITS = { files: 8, lines: 400 };
|
|
74
87
|
|
|
75
|
-
|
|
88
|
+
/**
|
|
89
|
+
* How much reviewing a change deserves. A pass is a complete read of the scope; later
|
|
90
|
+
* passes look for what the earlier ones missed and stop early when a pass adds nothing.
|
|
91
|
+
*/
|
|
92
|
+
const EFFORTS = {
|
|
93
|
+
low: { passes: 1, refute: false },
|
|
94
|
+
medium: { passes: 2, refute: true },
|
|
95
|
+
high: { passes: 3, refute: true },
|
|
96
|
+
};
|
|
97
|
+
const CHURN = { plan: 150, split: 400 };
|
|
76
98
|
|
|
77
|
-
/**
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
else if (c === '?') source += '[^/]';
|
|
88
|
-
else source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
89
|
-
}
|
|
90
|
-
return new RegExp(`^${source}$`);
|
|
99
|
+
/** The reviewing plan follows the effort chosen and the measured size of the change. */
|
|
100
|
+
function reviewPlan(effort, lines, units) {
|
|
101
|
+
const { passes, refute } = EFFORTS[effort];
|
|
102
|
+
return {
|
|
103
|
+
effort,
|
|
104
|
+
passes,
|
|
105
|
+
refute,
|
|
106
|
+
planFirst: lines >= CHURN.plan,
|
|
107
|
+
parallelUnits: units > 1 && lines >= CHURN.split,
|
|
108
|
+
};
|
|
91
109
|
}
|
|
92
|
-
|
|
110
|
+
|
|
111
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
93
112
|
|
|
94
113
|
function git(projectDir, args) {
|
|
95
114
|
const result = spawnSync('git', args, {
|
|
@@ -187,6 +206,14 @@ function planUnits(selected, limits = UNIT_LIMITS) {
|
|
|
187
206
|
}));
|
|
188
207
|
}
|
|
189
208
|
|
|
209
|
+
function contentDigest(projectDir, file) {
|
|
210
|
+
try {
|
|
211
|
+
return sha256(fs.readFileSync(path.join(projectDir, file)));
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
190
217
|
/**
|
|
191
218
|
* The review scope: what changed between base and head (or the working tree), which files
|
|
192
219
|
* are selected, and why every other changed file is not. Secrets are excluded before any
|
|
@@ -226,12 +253,24 @@ function buildScope(projectDir, options = {}) {
|
|
|
226
253
|
}
|
|
227
254
|
}
|
|
228
255
|
changed.sort((a, b) => a.path.localeCompare(b.path));
|
|
256
|
+
// The review's own evidence is not part of the change under review: writing findings must
|
|
257
|
+
// never alter the scope they answer.
|
|
258
|
+
const evidenceDir = path
|
|
259
|
+
.relative(projectDir, path.resolve(projectDir, options.outputDir || DEFAULT_DIR))
|
|
260
|
+
.split(path.sep)
|
|
261
|
+
.join('/');
|
|
262
|
+
const ownEvidence = (file) =>
|
|
263
|
+
evidenceDir !== '' &&
|
|
264
|
+
!evidenceDir.startsWith('..') &&
|
|
265
|
+
!path.isAbsolute(evidenceDir) &&
|
|
266
|
+
(file === evidenceDir || file.startsWith(`${evidenceDir}/`));
|
|
267
|
+
const candidates = changed.filter((entry) => !ownEvidence(entry.path));
|
|
229
268
|
|
|
230
269
|
const include = options.include || [];
|
|
231
270
|
const exclude = options.exclude || [];
|
|
232
271
|
const selected = [];
|
|
233
272
|
const excluded = [];
|
|
234
|
-
for (const entry of
|
|
273
|
+
for (const entry of candidates) {
|
|
235
274
|
const file = entry.path;
|
|
236
275
|
let reason = null;
|
|
237
276
|
if (matchesAny(file, SECRET_PATTERNS)) reason = 'secret';
|
|
@@ -251,23 +290,40 @@ function buildScope(projectDir, options = {}) {
|
|
|
251
290
|
...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
|
|
252
291
|
});
|
|
253
292
|
}
|
|
293
|
+
const effort = options.effort || 'medium';
|
|
294
|
+
if (!EFFORTS[effort]) throw new Error(`effort must be one of ${Object.keys(EFFORTS).join('|')}`);
|
|
295
|
+
const ruleset = options.ruleset || rules.loadRuleset(projectDir);
|
|
296
|
+
for (const item of selected) {
|
|
297
|
+
item.rules = rules.rulesFor(ruleset, item.path);
|
|
298
|
+
// A commit fixes the content under review; a working tree does not, so its bytes are sealed.
|
|
299
|
+
if (options.workspace) item.sha256 = contentDigest(projectDir, item.path);
|
|
300
|
+
}
|
|
254
301
|
const identity = {
|
|
255
302
|
base,
|
|
256
303
|
head: head || 'WORKTREE',
|
|
304
|
+
// The ref as given, so a continuation can tell whether it still names the reviewed commit.
|
|
305
|
+
headRef: options.workspace ? null : options.head || 'HEAD',
|
|
257
306
|
mergeBase,
|
|
258
307
|
workspace: Boolean(options.workspace),
|
|
259
308
|
include,
|
|
260
309
|
exclude,
|
|
310
|
+
effort,
|
|
311
|
+
rulesSha256: ruleset.sha256,
|
|
261
312
|
};
|
|
262
|
-
const units = planUnits(selected, options.unitLimits)
|
|
313
|
+
const units = planUnits(selected, options.unitLimits).map((unit) => {
|
|
314
|
+
const ids = [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))];
|
|
315
|
+
// Rule groups let parallel reviewers split one unit by rule family.
|
|
316
|
+
return { ...unit, rules: ids, groups: rules.groupsOf(ruleset, ids) };
|
|
317
|
+
});
|
|
263
318
|
const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
|
|
264
|
-
const
|
|
319
|
+
const plan = reviewPlan(effort, lines, units.length);
|
|
320
|
+
const body = { identity, selected, excluded, units, plan };
|
|
265
321
|
return {
|
|
266
322
|
schema: SCOPE_SCHEMA,
|
|
267
323
|
id: options.id,
|
|
268
324
|
...body,
|
|
269
325
|
totals: {
|
|
270
|
-
changed:
|
|
326
|
+
changed: candidates.length,
|
|
271
327
|
selected: selected.length,
|
|
272
328
|
excluded: excluded.length,
|
|
273
329
|
lines,
|
|
@@ -292,6 +348,7 @@ const FINDING_KEYS = [
|
|
|
292
348
|
'evidence',
|
|
293
349
|
'refutation',
|
|
294
350
|
'fix',
|
|
351
|
+
'rule',
|
|
295
352
|
];
|
|
296
353
|
|
|
297
354
|
/** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
|
|
@@ -332,8 +389,12 @@ function validateFindings(doc, scope) {
|
|
|
332
389
|
errors.push(`${at}: a refuted finding keeps its refutation — the quoted ground`);
|
|
333
390
|
if (finding.disposition === 'confirmed' && !String(finding.evidence || '').trim())
|
|
334
391
|
errors.push(`${at}: a confirmed finding needs evidence`);
|
|
335
|
-
|
|
392
|
+
const entry = scope && scope.selected.find((item) => item.path === finding.path);
|
|
393
|
+
if (scope && finding.path && !entry)
|
|
336
394
|
errors.push(`${at}: ${finding.path} is not in the review scope`);
|
|
395
|
+
// A finding may name the checklist rule that led to it; the rule must apply to that file.
|
|
396
|
+
if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
|
|
397
|
+
errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
|
|
337
398
|
}
|
|
338
399
|
return errors;
|
|
339
400
|
}
|
|
@@ -396,13 +457,171 @@ function anchorFindings(projectDir, scope, doc) {
|
|
|
396
457
|
location: { status: 'unlocated', reason: 'the quoted code is not in the file' },
|
|
397
458
|
};
|
|
398
459
|
});
|
|
399
|
-
const counts = { located: 0, ambiguous: 0, unlocated: 0 };
|
|
400
|
-
for (const finding of anchored)
|
|
460
|
+
const counts = { located: 0, ambiguous: 0, unlocated: 0, redactions: 0 };
|
|
461
|
+
for (const finding of anchored) {
|
|
462
|
+
counts[finding.location.status] += 1;
|
|
463
|
+
// Located first, redacted second: the anchor needs the quote, the written record must not carry a secret.
|
|
464
|
+
const redacted = redactFields(finding, REDACTED_FIELDS);
|
|
465
|
+
if (redacted) finding.redactions = redacted;
|
|
466
|
+
counts.redactions += redacted;
|
|
467
|
+
}
|
|
401
468
|
return { findings: anchored, counts };
|
|
402
469
|
}
|
|
403
470
|
|
|
471
|
+
const REDACTED_FIELDS = [
|
|
472
|
+
'existing_code',
|
|
473
|
+
'content',
|
|
474
|
+
'trigger',
|
|
475
|
+
'consequence',
|
|
476
|
+
'evidence',
|
|
477
|
+
'refutation',
|
|
478
|
+
'fix',
|
|
479
|
+
];
|
|
480
|
+
|
|
404
481
|
// ── Coverage and gate ─────────────────────────────────────────────────────────
|
|
405
482
|
|
|
483
|
+
const isCount = (value) => Number.isInteger(value) && value >= 0;
|
|
484
|
+
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
485
|
+
|
|
486
|
+
/** What an attempt worked on: a whole unit (`u2`) or one rule group of it (`u2/data`). */
|
|
487
|
+
const attemptKey = (attempt) => (attempt.group ? `${attempt.unit}/${attempt.group}` : attempt.unit);
|
|
488
|
+
const unitOfKey = (key) => key.split('/')[0];
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The three-strikes rule over an ordered attempt log: work whose attempts failed three
|
|
492
|
+
* times in a row is exhausted, and any later attempt of it is a violation. A whole unit
|
|
493
|
+
* and each of its groups overlap: exhausting one blocks the other.
|
|
494
|
+
*/
|
|
495
|
+
function strikeLedger(attempts) {
|
|
496
|
+
const streak = new Map();
|
|
497
|
+
const exhausted = new Set();
|
|
498
|
+
const violations = [];
|
|
499
|
+
const blocked = (attempt) =>
|
|
500
|
+
exhausted.has(attempt.unit) ||
|
|
501
|
+
(attempt.group
|
|
502
|
+
? exhausted.has(attemptKey(attempt))
|
|
503
|
+
: [...exhausted].some((key) => unitOfKey(key) === attempt.unit));
|
|
504
|
+
for (const [index, attempt] of attempts.entries()) {
|
|
505
|
+
const key = attemptKey(attempt);
|
|
506
|
+
if (blocked(attempt)) {
|
|
507
|
+
violations.push({ index, unit: key });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const failures = attempt.outcome === 'failed' ? (streak.get(key) || 0) + 1 : 0;
|
|
511
|
+
streak.set(key, failures);
|
|
512
|
+
if (failures >= STRIKES) exhausted.add(key);
|
|
513
|
+
}
|
|
514
|
+
return { exhausted, violations, units: new Set([...exhausted].map(unitOfKey)) };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const unitIndex = (scope) =>
|
|
518
|
+
new Map(scope.units.flatMap((unit) => unit.paths.map((file) => [file, unit.id])));
|
|
519
|
+
|
|
520
|
+
/** Units whose review completed: in one attempt, or in one completed attempt per rule group. */
|
|
521
|
+
function completedUnits(scope, attempts) {
|
|
522
|
+
const done = new Set(attempts.filter((a) => a.outcome === 'completed').map(attemptKey));
|
|
523
|
+
return new Set(
|
|
524
|
+
scope.units
|
|
525
|
+
.filter(
|
|
526
|
+
(unit) =>
|
|
527
|
+
done.has(unit.id) ||
|
|
528
|
+
(Array.isArray(unit.groups) &&
|
|
529
|
+
unit.groups.length > 0 &&
|
|
530
|
+
unit.groups.every((group) => done.has(`${unit.id}/${group.id}`)))
|
|
531
|
+
)
|
|
532
|
+
.map((unit) => unit.id)
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The run record of a review: why it stopped, what it observed (passes run, units
|
|
538
|
+
* attempted, and tokens or duration only when the host reports them — never estimated),
|
|
539
|
+
* and the ordered log of unit attempts. When the log is not empty it is the whole story:
|
|
540
|
+
* a file is completed only if an attempt of its unit completed.
|
|
541
|
+
*/
|
|
542
|
+
function validateRun(run, scope, items) {
|
|
543
|
+
if (!isRecord(run)) return ['run must be an object'];
|
|
544
|
+
const errors = [];
|
|
545
|
+
for (const key of Object.keys(run))
|
|
546
|
+
if (!RUN_KEYS.includes(key)) errors.push(`run: unknown key "${key}"`);
|
|
547
|
+
if (!STOP_REASONS.includes(run.stop))
|
|
548
|
+
errors.push(`run: stop "${run.stop}" is not one of ${STOP_REASONS.join('|')}`);
|
|
549
|
+
if (run.detail !== undefined && typeof run.detail !== 'string')
|
|
550
|
+
errors.push('run: detail is text');
|
|
551
|
+
else if (STOP_REASONS.includes(run.stop) && run.stop !== 'completed' && !run.detail?.trim())
|
|
552
|
+
errors.push(`run: a review stopped by ${run.stop} says why in detail`);
|
|
553
|
+
if (!isCount(run.passes)) errors.push('run: passes is the number of passes actually run');
|
|
554
|
+
else if (run.stop === 'completed' && run.passes < 1)
|
|
555
|
+
errors.push('run: a completed review ran at least one pass');
|
|
556
|
+
for (const key of ['tokens', 'durationMs'])
|
|
557
|
+
if (run[key] !== undefined && !isCount(run[key]))
|
|
558
|
+
errors.push(`run: ${key} is a non-negative integer reported by the host, or absent`);
|
|
559
|
+
if (!Array.isArray(run.attempts)) {
|
|
560
|
+
errors.push('run: attempts lists every unit attempt, in order (empty when none was logged)');
|
|
561
|
+
return errors;
|
|
562
|
+
}
|
|
563
|
+
const units = new Map(scope.units.map((unit) => [unit.id, unit]));
|
|
564
|
+
for (const [index, attempt] of run.attempts.entries()) {
|
|
565
|
+
const at = `run: attempt ${index + 1}`;
|
|
566
|
+
if (!isRecord(attempt)) {
|
|
567
|
+
errors.push(`${at}: must be an object`);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
for (const key of Object.keys(attempt))
|
|
571
|
+
if (!ATTEMPT_KEYS.includes(key)) errors.push(`${at}: unknown key "${key}"`);
|
|
572
|
+
const unit = units.get(attempt.unit);
|
|
573
|
+
if (!unit) errors.push(`${at}: unit "${attempt.unit}" is not in the scope`);
|
|
574
|
+
else if (
|
|
575
|
+
attempt.group !== undefined &&
|
|
576
|
+
!(unit.groups || []).some((group) => group.id === attempt.group)
|
|
577
|
+
)
|
|
578
|
+
errors.push(`${at}: group "${attempt.group}" is not a rule group of unit ${unit.id}`);
|
|
579
|
+
if (!ATTEMPT_OUTCOMES.includes(attempt.outcome))
|
|
580
|
+
errors.push(
|
|
581
|
+
`${at}: outcome "${attempt.outcome}" is not one of ${ATTEMPT_OUTCOMES.join('|')}`
|
|
582
|
+
);
|
|
583
|
+
if (attempt.reason !== undefined && typeof attempt.reason !== 'string')
|
|
584
|
+
errors.push(`${at}: reason is text`);
|
|
585
|
+
else if (attempt.outcome === 'failed' && !attempt.reason?.trim())
|
|
586
|
+
errors.push(`${at}: a failed attempt needs a reason`);
|
|
587
|
+
}
|
|
588
|
+
if (errors.length) return errors;
|
|
589
|
+
|
|
590
|
+
const { exhausted, violations } = strikeLedger(run.attempts);
|
|
591
|
+
for (const { index, unit } of violations)
|
|
592
|
+
errors.push(
|
|
593
|
+
`run: attempt ${index + 1} retries unit ${unit} after ${STRIKES} consecutive failures — it is abandoned in this review`
|
|
594
|
+
);
|
|
595
|
+
if (run.stop === 'failure-streak' && !exhausted.size)
|
|
596
|
+
errors.push(`run: failure-streak needs a unit with ${STRIKES} consecutive failed attempts`);
|
|
597
|
+
if (run.attempts.length) {
|
|
598
|
+
const unitOf = unitIndex(scope);
|
|
599
|
+
const done = completedUnits(scope, run.attempts);
|
|
600
|
+
for (const item of items)
|
|
601
|
+
if (item.outcome === 'completed' && unitOf.has(item.path) && !done.has(unitOf.get(item.path)))
|
|
602
|
+
errors.push(
|
|
603
|
+
`${item.path}: completed in coverage, but unit ${unitOf.get(item.path)} was not completed — as a whole or in every rule group`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
return errors;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** What the review actually consumed, as reported; null when no run was recorded. */
|
|
610
|
+
function observedUsage(scope, run) {
|
|
611
|
+
if (!isRecord(run)) return null;
|
|
612
|
+
const attempts = Array.isArray(run.attempts) ? run.attempts.filter(isRecord) : [];
|
|
613
|
+
return {
|
|
614
|
+
passes: isCount(run.passes) ? run.passes : null,
|
|
615
|
+
plannedPasses: scope.plan ? scope.plan.passes : null,
|
|
616
|
+
units: scope.units.length,
|
|
617
|
+
unitsAttempted: new Set(attempts.map((a) => a.unit)).size,
|
|
618
|
+
attempts: attempts.length,
|
|
619
|
+
failedAttempts: attempts.filter((a) => a.outcome === 'failed').length,
|
|
620
|
+
...(isCount(run.tokens) ? { tokens: run.tokens } : {}),
|
|
621
|
+
...(isCount(run.durationMs) ? { durationMs: run.durationMs } : {}),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
406
625
|
function validateCoverage(doc, scope) {
|
|
407
626
|
const errors = [];
|
|
408
627
|
if (!doc || doc.schema !== COVERAGE_SCHEMA) errors.push(`schema must be "${COVERAGE_SCHEMA}"`);
|
|
@@ -422,12 +641,13 @@ function validateCoverage(doc, scope) {
|
|
|
422
641
|
)
|
|
423
642
|
errors.push(`${item.path}: a ${item.outcome} file needs a reason`);
|
|
424
643
|
}
|
|
644
|
+
if (doc && doc.run !== undefined) errors.push(...validateRun(doc.run, scope, doc.items || []));
|
|
425
645
|
return errors;
|
|
426
646
|
}
|
|
427
647
|
|
|
428
648
|
/**
|
|
429
649
|
* 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
|
|
650
|
+
* the review stopped before the end, or the evidence is invalid; otherwise `findings` or `clean` — the latter only means no
|
|
431
651
|
* confirmed or unresolved finding within a fully covered scope.
|
|
432
652
|
*/
|
|
433
653
|
function reviewGate({ scope, findings, coverage, anchored }) {
|
|
@@ -450,6 +670,17 @@ function reviewGate({ scope, findings, coverage, anchored }) {
|
|
|
450
670
|
);
|
|
451
671
|
if (failed.length)
|
|
452
672
|
reasons.push(`${failed.length} file(s) whose review failed: ${failed.slice(0, 5).join(', ')}`);
|
|
673
|
+
const run = coverage && isRecord(coverage.run) ? coverage.run : null;
|
|
674
|
+
// A review that stopped early is never clean, even when every file happens to be listed.
|
|
675
|
+
if (run && STOP_REASONS.includes(run.stop) && run.stop !== 'completed')
|
|
676
|
+
reasons.push(
|
|
677
|
+
`the review stopped before the end (${run.stop}): ${typeof run.detail === 'string' ? run.detail.trim() : ''}`
|
|
678
|
+
);
|
|
679
|
+
if (run && Array.isArray(run.attempts)) {
|
|
680
|
+
const { exhausted } = strikeLedger(run.attempts.filter(isRecord));
|
|
681
|
+
for (const unit of exhausted)
|
|
682
|
+
reasons.push(`unit ${unit} abandoned after ${STRIKES} consecutive failed attempts`);
|
|
683
|
+
}
|
|
453
684
|
const open = ((anchored && anchored.findings) || []).filter((f) => f.disposition !== 'refuted');
|
|
454
685
|
const unlocated = open.filter((f) => f.location.status !== 'located');
|
|
455
686
|
if (unlocated.length)
|
|
@@ -465,7 +696,11 @@ function reviewGate({ scope, findings, coverage, anchored }) {
|
|
|
465
696
|
const status = reasons.length ? 'incomplete' : open.length ? 'findings' : 'clean';
|
|
466
697
|
return {
|
|
467
698
|
status,
|
|
468
|
-
|
|
699
|
+
// Reasons echo host-written text (a stop detail, an invalid value), and they reach the
|
|
700
|
+
// terminal, the CI log and the check document alike: redacted once, here.
|
|
701
|
+
reasons: reasons.map((reason) => redact(reason).text),
|
|
702
|
+
stop: run && STOP_REASONS.includes(run.stop) ? run.stop : null,
|
|
703
|
+
usage: observedUsage(scope, run),
|
|
469
704
|
coverage: {
|
|
470
705
|
selected: total,
|
|
471
706
|
completed: done,
|
|
@@ -486,6 +721,322 @@ function reviewGate({ scope, findings, coverage, anchored }) {
|
|
|
486
721
|
};
|
|
487
722
|
}
|
|
488
723
|
|
|
724
|
+
// ── Check result for CI ───────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
const CHECK_SCHEMA = 'bmad-plus/review-check/1';
|
|
727
|
+
const GATE_EXIT = { clean: 0, findings: 1, incomplete: 2 };
|
|
728
|
+
const ANNOTATION_LEVEL = { critical: 'failure', high: 'failure', medium: 'warning', low: 'notice' };
|
|
729
|
+
/** The GitHub check-runs API accepts at most 50 annotations per request. */
|
|
730
|
+
const MAX_ANNOTATIONS = 50;
|
|
731
|
+
const MAX_SUMMARY_ROWS = 50;
|
|
732
|
+
|
|
733
|
+
const cell = (value) =>
|
|
734
|
+
String(value)
|
|
735
|
+
.replace(/[|\\`]/g, '\\$&')
|
|
736
|
+
.replace(/\s+/g, ' ');
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The gate verdict as a stable document a CI step consumes without parsing prose: the
|
|
740
|
+
* disposition and its exit code, the reasons, the counts, the scope it answers, the open
|
|
741
|
+
* findings with their anchored lines, and a `github` block shaped like the check-runs API
|
|
742
|
+
* (`conclusion` and `output` with a Markdown summary and annotations). Free text is passed
|
|
743
|
+
* through the redaction floor; the document carries no timestamp, so the same evidence
|
|
744
|
+
* always yields the same bytes.
|
|
745
|
+
*/
|
|
746
|
+
function checkResult({ id, scope, verdict, anchored }) {
|
|
747
|
+
const rank = (severity) => SEVERITIES.indexOf(severity);
|
|
748
|
+
const open = ((anchored && anchored.findings) || [])
|
|
749
|
+
.filter((f) => f.disposition !== 'refuted')
|
|
750
|
+
.map((f) => ({
|
|
751
|
+
id: f.id,
|
|
752
|
+
path: f.path,
|
|
753
|
+
severity: f.severity,
|
|
754
|
+
category: f.category,
|
|
755
|
+
confidence: f.confidence,
|
|
756
|
+
disposition: f.disposition,
|
|
757
|
+
...(f.rule !== undefined ? { rule: f.rule } : {}),
|
|
758
|
+
anchor: f.location.status,
|
|
759
|
+
lineStart: f.location.status === 'located' ? f.location.lineStart : null,
|
|
760
|
+
lineEnd: f.location.status === 'located' ? f.location.lineEnd : null,
|
|
761
|
+
message: f.content,
|
|
762
|
+
}))
|
|
763
|
+
.sort(
|
|
764
|
+
(a, b) =>
|
|
765
|
+
rank(a.severity) - rank(b.severity) ||
|
|
766
|
+
a.path.localeCompare(b.path) ||
|
|
767
|
+
(a.lineStart || 0) - (b.lineStart || 0) ||
|
|
768
|
+
String(a.id).localeCompare(String(b.id))
|
|
769
|
+
);
|
|
770
|
+
const reasons = verdict.reasons.map((reason) => redact(reason).text);
|
|
771
|
+
const located = open.filter((f) => f.anchor === 'located');
|
|
772
|
+
const annotations = located.slice(0, MAX_ANNOTATIONS).map((f) => ({
|
|
773
|
+
path: f.path,
|
|
774
|
+
start_line: f.lineStart,
|
|
775
|
+
end_line: f.lineEnd,
|
|
776
|
+
annotation_level: ANNOTATION_LEVEL[f.severity],
|
|
777
|
+
title: `${f.severity} ${f.category} (${f.id})`,
|
|
778
|
+
message: f.message,
|
|
779
|
+
}));
|
|
780
|
+
const { coverage, findings } = verdict;
|
|
781
|
+
const summary = [
|
|
782
|
+
`**${verdict.status}**: ${coverage.completed}/${coverage.selected} selected file(s) completed, ${coverage.waived} waived, ${coverage.failed} failed, ${coverage.missing} missing; ${findings.open} open finding(s), ${findings.refuted} refuted.`,
|
|
783
|
+
`Scope \`${scope.sha256.slice(0, 12)}\` (${scope.identity.effort || 'medium'} effort)${verdict.stop ? `, stopped: ${verdict.stop}` : ''}.`,
|
|
784
|
+
...(reasons.length ? ['', ...reasons.map((reason) => `- ${cell(reason)}`)] : []),
|
|
785
|
+
...(open.length
|
|
786
|
+
? [
|
|
787
|
+
'',
|
|
788
|
+
'| Severity | Category | Finding | Location |',
|
|
789
|
+
'|---|---|---|---|',
|
|
790
|
+
...open
|
|
791
|
+
.slice(0, MAX_SUMMARY_ROWS)
|
|
792
|
+
.map(
|
|
793
|
+
(f) =>
|
|
794
|
+
`| ${f.severity} | ${f.category} | ${cell(f.id)} | ${cell(f.path)}${f.lineStart ? `:${f.lineStart}` : ` (${f.anchor})`} |`
|
|
795
|
+
),
|
|
796
|
+
...(open.length > MAX_SUMMARY_ROWS
|
|
797
|
+
? [`\n${open.length - MAX_SUMMARY_ROWS} more in the check document.`]
|
|
798
|
+
: []),
|
|
799
|
+
]
|
|
800
|
+
: []),
|
|
801
|
+
...(verdict.status === 'clean'
|
|
802
|
+
? [
|
|
803
|
+
'',
|
|
804
|
+
'Clean means no open finding within a fully covered scope; it does not prove the code correct.',
|
|
805
|
+
]
|
|
806
|
+
: []),
|
|
807
|
+
].join('\n');
|
|
808
|
+
const body = {
|
|
809
|
+
id,
|
|
810
|
+
scopeSha256: scope.sha256,
|
|
811
|
+
identity: {
|
|
812
|
+
base: scope.identity.base,
|
|
813
|
+
head: scope.identity.head,
|
|
814
|
+
mergeBase: scope.identity.mergeBase,
|
|
815
|
+
workspace: scope.identity.workspace,
|
|
816
|
+
effort: scope.identity.effort || null,
|
|
817
|
+
rulesSha256: scope.identity.rulesSha256 || null,
|
|
818
|
+
},
|
|
819
|
+
status: verdict.status,
|
|
820
|
+
exitCode: GATE_EXIT[verdict.status],
|
|
821
|
+
reasons,
|
|
822
|
+
stop: verdict.stop,
|
|
823
|
+
usage: verdict.usage,
|
|
824
|
+
coverage,
|
|
825
|
+
findings,
|
|
826
|
+
open,
|
|
827
|
+
github: {
|
|
828
|
+
name: `bmad-plus review ${id}`,
|
|
829
|
+
conclusion: verdict.status === 'clean' ? 'success' : 'failure',
|
|
830
|
+
output: {
|
|
831
|
+
title: `Review ${verdict.status}: ${findings.open} open finding(s), ${coverage.completed}/${coverage.selected} file(s) completed`,
|
|
832
|
+
summary,
|
|
833
|
+
annotations,
|
|
834
|
+
},
|
|
835
|
+
annotationsOmitted: located.length - annotations.length,
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
return { schema: CHECK_SCHEMA, ...body, sha256: sha256(JSON.stringify(body)) };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── Continuing an interrupted review ──────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
const CONTINUE_SCHEMA = 'bmad-plus/review-continue/1';
|
|
844
|
+
const short = (sha) => String(sha).slice(0, 12);
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Why a sealed scope no longer describes the code: its head ref names another commit, the
|
|
848
|
+
* review rules changed, or the change itself did (for a working tree, any selected byte).
|
|
849
|
+
* Empty when the scope rebuilt now is exactly the scope that was sealed.
|
|
850
|
+
*/
|
|
851
|
+
function scopeDrift(projectDir, scope, options = {}) {
|
|
852
|
+
const { identity } = scope;
|
|
853
|
+
// Since continuation, every scope carries headRef (null for a working tree); an older one,
|
|
854
|
+
// commit or working tree, lacks the key along with the per-file digests it relies on.
|
|
855
|
+
if (!('headRef' in identity) || (!identity.workspace && !identity.headRef))
|
|
856
|
+
return ['the scope was sealed without its head ref, before continuation existed'];
|
|
857
|
+
const drift = [];
|
|
858
|
+
if (!identity.workspace) {
|
|
859
|
+
let now = null;
|
|
860
|
+
try {
|
|
861
|
+
now = resolveCommit(projectDir, identity.headRef);
|
|
862
|
+
} catch {
|
|
863
|
+
// Reported just below: a ref that no longer resolves has moved too.
|
|
864
|
+
}
|
|
865
|
+
if (now !== identity.head)
|
|
866
|
+
drift.push(
|
|
867
|
+
now
|
|
868
|
+
? `${identity.headRef} moved from ${short(identity.head)} to ${short(now)}`
|
|
869
|
+
: `${identity.headRef} no longer resolves to a commit`
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
let rebuilt;
|
|
873
|
+
try {
|
|
874
|
+
rebuilt = buildScope(projectDir, {
|
|
875
|
+
id: scope.id,
|
|
876
|
+
base: identity.base,
|
|
877
|
+
head: identity.workspace ? undefined : identity.headRef,
|
|
878
|
+
workspace: identity.workspace,
|
|
879
|
+
include: identity.include,
|
|
880
|
+
exclude: identity.exclude,
|
|
881
|
+
effort: identity.effort,
|
|
882
|
+
ruleset: options.ruleset,
|
|
883
|
+
outputDir: options.outputDir,
|
|
884
|
+
});
|
|
885
|
+
} catch (error) {
|
|
886
|
+
return [...drift, `the scope cannot be rebuilt: ${error.message}`];
|
|
887
|
+
}
|
|
888
|
+
if (rebuilt.sha256 === scope.sha256 || drift.length) return drift;
|
|
889
|
+
if (rebuilt.identity.rulesSha256 !== identity.rulesSha256) drift.push('the review rules changed');
|
|
890
|
+
// Rules are compared above; here only what each file is and how it changed.
|
|
891
|
+
const describe = (list) =>
|
|
892
|
+
new Map(list.map((item) => [item.path, JSON.stringify({ ...item, rules: undefined })]));
|
|
893
|
+
const sealed = describe(scope.selected);
|
|
894
|
+
const current = describe(rebuilt.selected);
|
|
895
|
+
const moved = [...new Set([...sealed.keys(), ...current.keys()])]
|
|
896
|
+
.filter((file) => sealed.get(file) !== current.get(file))
|
|
897
|
+
.sort();
|
|
898
|
+
if (moved.length)
|
|
899
|
+
drift.push(
|
|
900
|
+
`${moved.length} file(s) changed since the scope was sealed: ${moved.slice(0, 5).join(', ')}${moved.length > 5 ? '…' : ''}`
|
|
901
|
+
);
|
|
902
|
+
if (!drift.length) drift.push('the scope rebuilt now differs from the sealed one');
|
|
903
|
+
return drift;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* What an interrupted review still owes against its unchanged scope: selected files never
|
|
908
|
+
* accounted for or whose review failed, grouped back into their units; files of units
|
|
909
|
+
* abandoned under the three-strikes rule, which this review will not retry; open findings
|
|
910
|
+
* to requote; and the passes the plan still asks for when the run stopped early.
|
|
911
|
+
*/
|
|
912
|
+
function remainingWork({ scope, coverage, anchored, findingErrors = [] }) {
|
|
913
|
+
const accounted = new Map(((coverage && coverage.items) || []).map((item) => [item.path, item]));
|
|
914
|
+
const run = coverage && isRecord(coverage.run) ? coverage.run : null;
|
|
915
|
+
const attempts = run && Array.isArray(run.attempts) ? run.attempts.filter(isRecord) : [];
|
|
916
|
+
const { units: exhausted } = strikeLedger(attempts);
|
|
917
|
+
const done = new Set(attempts.filter((a) => a.outcome === 'completed').map(attemptKey));
|
|
918
|
+
const unitOf = unitIndex(scope);
|
|
919
|
+
const files = [];
|
|
920
|
+
const abandoned = [];
|
|
921
|
+
for (const item of scope.selected) {
|
|
922
|
+
const entry = accounted.get(item.path);
|
|
923
|
+
if (entry && entry.outcome !== 'failed') continue;
|
|
924
|
+
const record = {
|
|
925
|
+
path: item.path,
|
|
926
|
+
unit: unitOf.get(item.path),
|
|
927
|
+
state: entry ? 'failed' : 'missing',
|
|
928
|
+
};
|
|
929
|
+
(exhausted.has(record.unit) ? abandoned : files).push(record);
|
|
930
|
+
}
|
|
931
|
+
const pending = new Set(files.map((file) => file.path));
|
|
932
|
+
const units = scope.units
|
|
933
|
+
.map((unit) => ({
|
|
934
|
+
id: unit.id,
|
|
935
|
+
paths: unit.paths.filter((file) => pending.has(file)),
|
|
936
|
+
rules: unit.rules,
|
|
937
|
+
// Rule groups another reviewer already completed are not handed out again.
|
|
938
|
+
...(unit.groups
|
|
939
|
+
? { groups: unit.groups.filter((group) => !done.has(`${unit.id}/${group.id}`)) }
|
|
940
|
+
: {}),
|
|
941
|
+
}))
|
|
942
|
+
.filter((unit) => unit.paths.length);
|
|
943
|
+
const requote = ((anchored && anchored.findings) || [])
|
|
944
|
+
.filter((f) => f.disposition !== 'refuted' && f.location.status !== 'located')
|
|
945
|
+
.map((f) => ({ id: f.id, path: f.path, status: f.location.status }));
|
|
946
|
+
const usage = observedUsage(scope, run);
|
|
947
|
+
const stopped = run && run.stop !== 'completed';
|
|
948
|
+
const passes =
|
|
949
|
+
stopped && usage.passes !== null && usage.plannedPasses !== null
|
|
950
|
+
? Math.max(0, usage.plannedPasses - usage.passes)
|
|
951
|
+
: 0;
|
|
952
|
+
return {
|
|
953
|
+
schema: CONTINUE_SCHEMA,
|
|
954
|
+
id: scope.id,
|
|
955
|
+
scopeSha256: scope.sha256,
|
|
956
|
+
previous: run ? { stop: STOP_REASONS.includes(run.stop) ? run.stop : null, usage } : null,
|
|
957
|
+
counts: {
|
|
958
|
+
files: files.length,
|
|
959
|
+
units: units.length,
|
|
960
|
+
abandoned: abandoned.length,
|
|
961
|
+
requote: requote.length,
|
|
962
|
+
findingErrors: findingErrors.length,
|
|
963
|
+
passes,
|
|
964
|
+
},
|
|
965
|
+
units,
|
|
966
|
+
files,
|
|
967
|
+
abandoned,
|
|
968
|
+
requote,
|
|
969
|
+
findingErrors,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ── Comparing two reviews ─────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
const COMPARE_SCHEMA = 'bmad-plus/review-compare/1';
|
|
976
|
+
|
|
977
|
+
/** A finding's identity survives moved lines and reindentation: path, category, normalised quote. */
|
|
978
|
+
function findingKey(file, finding) {
|
|
979
|
+
const code = String(finding.existing_code || '')
|
|
980
|
+
.split('\n')
|
|
981
|
+
.map(normalizeLine)
|
|
982
|
+
.filter(Boolean)
|
|
983
|
+
.join('\n');
|
|
984
|
+
return JSON.stringify([file, finding.category, code]);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* What became of the open findings of an earlier review. `resolved` needs the later review
|
|
989
|
+
* to have completed that file; otherwise the finding is `not_reviewed`, never assumed fixed.
|
|
990
|
+
* Renames recorded in the later scope carry findings to the new path.
|
|
991
|
+
*/
|
|
992
|
+
function compareReviews(before, after) {
|
|
993
|
+
const renamed = new Map(
|
|
994
|
+
after.scope.selected.filter((item) => item.oldPath).map((item) => [item.oldPath, item.path])
|
|
995
|
+
);
|
|
996
|
+
const completed = new Set(
|
|
997
|
+
((after.coverage && after.coverage.items) || [])
|
|
998
|
+
.filter((item) => item.outcome === 'completed')
|
|
999
|
+
.map((item) => item.path)
|
|
1000
|
+
);
|
|
1001
|
+
const isOpen = (finding) => finding.disposition !== 'refuted';
|
|
1002
|
+
const afterByKey = new Map(after.findings.findings.map((f) => [findingKey(f.path, f), f]));
|
|
1003
|
+
const beforeKeys = new Set();
|
|
1004
|
+
const buckets = { new: [], persisting: [], resolved: [], refuted: [], not_reviewed: [] };
|
|
1005
|
+
|
|
1006
|
+
for (const finding of before.findings.findings.filter(isOpen)) {
|
|
1007
|
+
const file = renamed.get(finding.path) || finding.path;
|
|
1008
|
+
const key = findingKey(file, finding);
|
|
1009
|
+
beforeKeys.add(key);
|
|
1010
|
+
const match = afterByKey.get(key);
|
|
1011
|
+
const entry = {
|
|
1012
|
+
before: finding.id,
|
|
1013
|
+
path: file,
|
|
1014
|
+
category: finding.category,
|
|
1015
|
+
severity: finding.severity,
|
|
1016
|
+
};
|
|
1017
|
+
if (match && isOpen(match)) buckets.persisting.push({ ...entry, after: match.id });
|
|
1018
|
+
else if (match) buckets.refuted.push({ ...entry, after: match.id });
|
|
1019
|
+
else if (completed.has(file)) buckets.resolved.push(entry);
|
|
1020
|
+
else buckets.not_reviewed.push(entry);
|
|
1021
|
+
}
|
|
1022
|
+
for (const finding of after.findings.findings.filter(isOpen)) {
|
|
1023
|
+
if (!beforeKeys.has(findingKey(finding.path, finding)))
|
|
1024
|
+
buckets.new.push({
|
|
1025
|
+
after: finding.id,
|
|
1026
|
+
path: finding.path,
|
|
1027
|
+
category: finding.category,
|
|
1028
|
+
severity: finding.severity,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
return {
|
|
1032
|
+
schema: COMPARE_SCHEMA,
|
|
1033
|
+
before: { id: before.scope.id, scopeSha256: before.scope.sha256 },
|
|
1034
|
+
after: { id: after.scope.id, scopeSha256: after.scope.sha256 },
|
|
1035
|
+
counts: Object.fromEntries(Object.entries(buckets).map(([name, list]) => [name, list.length])),
|
|
1036
|
+
buckets,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
489
1040
|
function layout(projectDir, dir = DEFAULT_DIR, id) {
|
|
490
1041
|
const root = path.resolve(projectDir, dir, id);
|
|
491
1042
|
return {
|
|
@@ -494,6 +1045,9 @@ function layout(projectDir, dir = DEFAULT_DIR, id) {
|
|
|
494
1045
|
findings: path.join(root, 'findings.json'),
|
|
495
1046
|
anchored: path.join(root, 'findings.anchored.json'),
|
|
496
1047
|
coverage: path.join(root, 'coverage.json'),
|
|
1048
|
+
checklist: path.join(root, 'checklist.md'),
|
|
1049
|
+
compare: path.join(root, 'compare.json'),
|
|
1050
|
+
continue: path.join(root, 'continue.json'),
|
|
497
1051
|
};
|
|
498
1052
|
}
|
|
499
1053
|
|
|
@@ -501,15 +1055,18 @@ module.exports = {
|
|
|
501
1055
|
SCOPE_SCHEMA,
|
|
502
1056
|
FINDINGS_SCHEMA,
|
|
503
1057
|
COVERAGE_SCHEMA,
|
|
1058
|
+
COMPARE_SCHEMA,
|
|
504
1059
|
DEFAULT_DIR,
|
|
1060
|
+
EFFORTS,
|
|
505
1061
|
SEVERITIES,
|
|
506
1062
|
CATEGORIES,
|
|
507
1063
|
DISPOSITIONS,
|
|
508
1064
|
CONFIDENCE,
|
|
509
1065
|
OUTCOMES,
|
|
1066
|
+
STOP_REASONS,
|
|
1067
|
+
STRIKES,
|
|
510
1068
|
SECRET_PATTERNS,
|
|
511
1069
|
GENERATED_PATTERNS,
|
|
512
|
-
globToRegExp,
|
|
513
1070
|
parseNumstat,
|
|
514
1071
|
planUnits,
|
|
515
1072
|
buildScope,
|
|
@@ -517,6 +1074,17 @@ module.exports = {
|
|
|
517
1074
|
locate,
|
|
518
1075
|
anchorFindings,
|
|
519
1076
|
validateCoverage,
|
|
1077
|
+
validateRun,
|
|
1078
|
+
strikeLedger,
|
|
520
1079
|
reviewGate,
|
|
1080
|
+
CHECK_SCHEMA,
|
|
1081
|
+
GATE_EXIT,
|
|
1082
|
+
checkResult,
|
|
1083
|
+
CONTINUE_SCHEMA,
|
|
1084
|
+
scopeDrift,
|
|
1085
|
+
remainingWork,
|
|
1086
|
+
reviewPlan,
|
|
1087
|
+
findingKey,
|
|
1088
|
+
compareReviews,
|
|
521
1089
|
layout,
|
|
522
1090
|
};
|