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
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const review = require('../lib/review');
|
|
7
|
+
const reviewRules = require('../lib/review-rules');
|
|
7
8
|
|
|
8
9
|
const ID = /^[a-z0-9][a-z0-9.-]{0,80}$/;
|
|
10
|
+
const ACTIONS = ['scope', 'anchor', 'gate', 'continue', 'compare', 'rules'];
|
|
9
11
|
|
|
10
12
|
function fail(message) {
|
|
11
13
|
throw new Error(message);
|
|
@@ -22,14 +24,177 @@ function readJson(file, what) {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function writeJson(file, value) {
|
|
28
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A reader of the file sees the previous version or the new one, never half of it. */
|
|
32
|
+
function writeAtomically(file, text) {
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
35
|
+
fs.writeFileSync(temporary, text, { flag: 'wx' });
|
|
36
|
+
try {
|
|
37
|
+
fs.renameSync(temporary, file);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
fs.rmSync(temporary, { force: true });
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const inside = (dir, file) => {
|
|
45
|
+
const relative = path.relative(dir, file);
|
|
46
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where `--emit-check` writes: a JSON file, resolved against the project, never the evidence
|
|
51
|
+
* itself. A working-tree scope selects untracked files, so its check stays in the review
|
|
52
|
+
* folder or out of the project: anywhere else it would enter the change it reports on.
|
|
53
|
+
*/
|
|
54
|
+
function checkTarget(projectDir, value, paths, scope) {
|
|
55
|
+
const file = path.resolve(projectDir, value);
|
|
56
|
+
if (!/\.json$/i.test(file)) fail('--emit-check writes a .json file');
|
|
57
|
+
const evidence = Object.entries(paths).filter(([key]) => key !== 'root');
|
|
58
|
+
if (evidence.some(([, taken]) => path.relative(taken, file) === ''))
|
|
59
|
+
fail('--emit-check must not overwrite the review evidence');
|
|
60
|
+
if (
|
|
61
|
+
scope.identity.workspace &&
|
|
62
|
+
inside(projectDir, file) &&
|
|
63
|
+
!inside(path.dirname(paths.root), file)
|
|
64
|
+
)
|
|
65
|
+
fail(
|
|
66
|
+
`--emit-check: a working-tree review writes its check in ${path.relative(projectDir, path.dirname(paths.root)) || '.'} or outside the project, where it cannot enter the reviewed change`
|
|
67
|
+
);
|
|
68
|
+
if (fs.existsSync(file) && !fs.statSync(file).isFile())
|
|
69
|
+
fail(`--emit-check: ${file} is not a file`);
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
25
73
|
function print(json, payload, lines) {
|
|
26
74
|
if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
|
|
27
75
|
else for (const line of lines) console.log(line);
|
|
28
76
|
}
|
|
29
77
|
|
|
78
|
+
function requireId(value, what = 'a review id') {
|
|
79
|
+
if (!value || !ID.test(value))
|
|
80
|
+
fail(`${what} is required: lowercase letters, digits, dots and dashes`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadReview(projectDir, dir, id) {
|
|
85
|
+
const paths = review.layout(projectDir, dir, id);
|
|
86
|
+
const scope = readJson(paths.scope, `${id}/scope.json`);
|
|
87
|
+
if (!scope) fail(`no scope at ${paths.scope} — run "bmad-plus review scope ${id}" first`);
|
|
88
|
+
return { paths, scope };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One line of observed usage: what the host reported, nothing estimated. */
|
|
92
|
+
function describeUsage(stop, usage) {
|
|
93
|
+
const parts = [
|
|
94
|
+
`stopped: ${stop || 'unstated'}`,
|
|
95
|
+
`${usage.passes ?? '?'}/${usage.plannedPasses ?? '?'} pass(es)`,
|
|
96
|
+
`${usage.unitsAttempted}/${usage.units} unit(s) attempted in ${usage.attempts} attempt(s), ${usage.failedAttempts} failed`,
|
|
97
|
+
];
|
|
98
|
+
if (usage.tokens !== undefined) parts.push(`${usage.tokens} tokens`);
|
|
99
|
+
if (usage.durationMs !== undefined) parts.push(`${Math.round(usage.durationMs / 1000)} s`);
|
|
100
|
+
return parts.join(', ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `review continue <id>`: what an interrupted review still owes, against the same sealed
|
|
105
|
+
* scope. Refused when the code or the rules moved — a new scope and a compare answer that.
|
|
106
|
+
*/
|
|
107
|
+
function continueAction(projectDir, dir, id, paths, scope, json) {
|
|
108
|
+
const drift = review.scopeDrift(projectDir, scope, {
|
|
109
|
+
ruleset: reviewRules.loadRuleset(projectDir),
|
|
110
|
+
outputDir: dir,
|
|
111
|
+
});
|
|
112
|
+
if (drift.length) {
|
|
113
|
+
const next = [
|
|
114
|
+
`bmad-plus review scope <new-id> (same options as ${id})`,
|
|
115
|
+
`bmad-plus review compare <new-id> --since ${id}`,
|
|
116
|
+
];
|
|
117
|
+
print(json, { action: 'continue', id, status: 'moved', drift, next }, [
|
|
118
|
+
`${id} cannot continue: the sealed scope no longer describes the code.`,
|
|
119
|
+
...drift.map((reason) => ` - ${reason}`),
|
|
120
|
+
` Seal a new scope and compare: ${next.join(', then ')}.`,
|
|
121
|
+
]);
|
|
122
|
+
process.exitCode = 3;
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const coverage = readJson(paths.coverage, 'coverage.json');
|
|
126
|
+
const coverageErrors = coverage ? review.validateCoverage(coverage, scope) : [];
|
|
127
|
+
if (coverageErrors.length) {
|
|
128
|
+
print(json, { action: 'continue', id, status: 'error', errors: coverageErrors }, [
|
|
129
|
+
`${id}: coverage.json must be valid before the review continues`,
|
|
130
|
+
...coverageErrors.map((e) => ` error ${e}`),
|
|
131
|
+
]);
|
|
132
|
+
process.exitCode = 3;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const findings = readJson(paths.findings, 'findings.json');
|
|
136
|
+
const findingErrors = findings ? review.validateFindings(findings, scope) : [];
|
|
137
|
+
const anchored =
|
|
138
|
+
findings && !findingErrors.length ? review.anchorFindings(projectDir, scope, findings) : null;
|
|
139
|
+
const packet = review.remainingWork({ scope, coverage, anchored, findingErrors });
|
|
140
|
+
writeJson(paths.continue, packet);
|
|
141
|
+
const c = packet.counts;
|
|
142
|
+
const nothing = !c.files && !c.requote && !c.findingErrors && !c.passes;
|
|
143
|
+
print(json, { action: 'continue', id, status: 'ready', file: paths.continue, counts: c }, [
|
|
144
|
+
`${paths.continue} — same scope ${scope.sha256.slice(0, 12)}`,
|
|
145
|
+
`${c.files} file(s) in ${c.units} unit(s) to review, ${c.requote} finding(s) to requote, ${c.passes} pass(es) left`,
|
|
146
|
+
...packet.units.map((unit) => ` ${unit.id.padEnd(5)} ${unit.paths.join(', ')}`),
|
|
147
|
+
...packet.abandoned.map(
|
|
148
|
+
(file) => ` abandoned ${file.path} (${file.unit}): three failed attempts; it stays failed`
|
|
149
|
+
),
|
|
150
|
+
...packet.requote.map((f) => ` requote ${f.id} ${f.path} (${f.status})`),
|
|
151
|
+
...packet.findingErrors.map((e) => ` fix ${e}`),
|
|
152
|
+
nothing
|
|
153
|
+
? `Nothing remains: once coverage.json records the run as completed, run bmad-plus review gate ${id}.`
|
|
154
|
+
: 'Add to the same coverage.json and findings.json; set run.stop when this session ends.',
|
|
155
|
+
]);
|
|
156
|
+
process.exitCode = 0;
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `review rules [path]`: the effective rule set, or the rules one path would get. */
|
|
161
|
+
function rulesAction(projectDir, target, json) {
|
|
162
|
+
const ruleset = reviewRules.loadRuleset(projectDir);
|
|
163
|
+
const file = target ? target.split(path.sep).join('/').replace(/^\.\//, '') : null;
|
|
164
|
+
const ids = file ? reviewRules.rulesFor(ruleset, file) : ruleset.rules.map((rule) => rule.id);
|
|
165
|
+
const listed = ruleset.rules.filter((rule) => ids.includes(rule.id));
|
|
166
|
+
print(
|
|
167
|
+
json,
|
|
168
|
+
{
|
|
169
|
+
action: 'rules',
|
|
170
|
+
path: file,
|
|
171
|
+
sha256: ruleset.sha256,
|
|
172
|
+
projectFile: ruleset.projectFile,
|
|
173
|
+
disabled: ruleset.disabled,
|
|
174
|
+
rules: listed.map(({ id, title, group, layer, globs, source }) => ({
|
|
175
|
+
id,
|
|
176
|
+
title,
|
|
177
|
+
group,
|
|
178
|
+
layer,
|
|
179
|
+
globs,
|
|
180
|
+
source,
|
|
181
|
+
})),
|
|
182
|
+
},
|
|
183
|
+
[
|
|
184
|
+
`rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ? ` (built-in + ${ruleset.projectFile})` : ' (built-in)'}`,
|
|
185
|
+
...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
|
|
186
|
+
...listed.map(
|
|
187
|
+
(rule) =>
|
|
188
|
+
` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}`
|
|
189
|
+
),
|
|
190
|
+
...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
|
|
191
|
+
]
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
30
195
|
module.exports = {
|
|
31
196
|
command: 'review <action> [id]',
|
|
32
|
-
description: 'Code review evidence: scope, anchor, gate',
|
|
197
|
+
description: 'Code review evidence: scope, anchor, gate, continue, compare, rules',
|
|
33
198
|
options: [
|
|
34
199
|
['-d, --directory <path>', 'Project directory'],
|
|
35
200
|
['--dir <path>', 'Review folder inside the project', review.DEFAULT_DIR],
|
|
@@ -38,19 +203,25 @@ module.exports = {
|
|
|
38
203
|
['--workspace', 'Review the working tree, untracked files included, against the base'],
|
|
39
204
|
['--include <glob>', 'Only review matching paths (repeatable)', (v, all) => [...all, v], []],
|
|
40
205
|
['--exclude <glob>', 'Never review matching paths (repeatable)', (v, all) => [...all, v], []],
|
|
206
|
+
[
|
|
207
|
+
'--effort <level>',
|
|
208
|
+
`Review depth: ${Object.keys(review.EFFORTS).join(', ')} (default medium)`,
|
|
209
|
+
],
|
|
210
|
+
['--since <id>', 'compare: the earlier review to compare against'],
|
|
211
|
+
['--emit-check <file>', 'gate: also write the verdict as a JSON check result for CI'],
|
|
41
212
|
['--json', 'Machine-readable output'],
|
|
42
213
|
],
|
|
43
214
|
action: (action, id, options = {}) => {
|
|
44
215
|
const projectDir = path.resolve(options.directory || process.cwd());
|
|
216
|
+
const dir = options.dir || review.DEFAULT_DIR;
|
|
45
217
|
const json = Boolean(options.json);
|
|
46
218
|
try {
|
|
47
|
-
if (!
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
fail(`unknown action "${action}" (scope, anchor, gate)`);
|
|
51
|
-
const paths = review.layout(projectDir, options.dir || review.DEFAULT_DIR, id);
|
|
219
|
+
if (!ACTIONS.includes(action)) fail(`unknown action "${action}" (${ACTIONS.join(', ')})`);
|
|
220
|
+
if (action === 'rules') return rulesAction(projectDir, id, json);
|
|
221
|
+
requireId(id);
|
|
52
222
|
|
|
53
223
|
if (action === 'scope') {
|
|
224
|
+
const ruleset = reviewRules.loadRuleset(projectDir);
|
|
54
225
|
const scope = review.buildScope(projectDir, {
|
|
55
226
|
id,
|
|
56
227
|
base: options.base,
|
|
@@ -58,34 +229,48 @@ module.exports = {
|
|
|
58
229
|
workspace: Boolean(options.workspace),
|
|
59
230
|
include: options.include,
|
|
60
231
|
exclude: options.exclude,
|
|
232
|
+
effort: options.effort,
|
|
233
|
+
outputDir: dir,
|
|
234
|
+
ruleset,
|
|
61
235
|
});
|
|
236
|
+
const paths = review.layout(projectDir, dir, id);
|
|
62
237
|
fs.mkdirSync(paths.root, { recursive: true });
|
|
63
|
-
|
|
238
|
+
writeJson(paths.scope, scope);
|
|
239
|
+
const byPath = Object.fromEntries(scope.selected.map((item) => [item.path, item.rules]));
|
|
240
|
+
fs.writeFileSync(
|
|
241
|
+
paths.checklist,
|
|
242
|
+
reviewRules.checklist(ruleset, byPath, { title: `Review checklist — ${id}` })
|
|
243
|
+
);
|
|
64
244
|
const reasons = {};
|
|
65
245
|
for (const item of scope.excluded) reasons[item.reason] = (reasons[item.reason] || 0) + 1;
|
|
246
|
+
const { plan } = scope;
|
|
66
247
|
print(
|
|
67
248
|
json,
|
|
68
249
|
{
|
|
69
250
|
action,
|
|
70
251
|
id,
|
|
71
252
|
file: paths.scope,
|
|
253
|
+
checklist: paths.checklist,
|
|
72
254
|
totals: scope.totals,
|
|
73
255
|
units: scope.units.length,
|
|
74
256
|
excluded: reasons,
|
|
257
|
+
plan,
|
|
258
|
+
rulesSha256: scope.identity.rulesSha256,
|
|
75
259
|
sha256: scope.sha256,
|
|
76
260
|
},
|
|
77
261
|
[
|
|
78
262
|
`${paths.scope}`,
|
|
79
263
|
`${scope.totals.selected} file(s) to review, ${scope.totals.lines} changed lines, ${scope.units.length} unit(s) — scope ${scope.sha256.slice(0, 12)}`,
|
|
80
264
|
...Object.entries(reasons).map(([reason, n]) => ` excluded ${n} × ${reason}`),
|
|
265
|
+
`checklist: ${paths.checklist}`,
|
|
266
|
+
`plan: ${plan.effort} effort, ${plan.passes} pass(es)${plan.refute ? ', refutation pass' : ''}${plan.planFirst ? ', plan before reading' : ''}${plan.parallelUnits ? ', units can be reviewed in parallel' : ''}`,
|
|
81
267
|
'Every selected file must end in coverage.json as completed, failed or waived (with a reason).',
|
|
82
268
|
]
|
|
83
269
|
);
|
|
84
|
-
return;
|
|
270
|
+
return undefined;
|
|
85
271
|
}
|
|
86
272
|
|
|
87
|
-
const scope =
|
|
88
|
-
if (!scope) fail(`no scope at ${paths.scope} — run "bmad-plus review scope ${id}" first`);
|
|
273
|
+
const { paths, scope } = loadReview(projectDir, dir, id);
|
|
89
274
|
|
|
90
275
|
if (action === 'anchor') {
|
|
91
276
|
const findings = readJson(paths.findings, 'findings.json');
|
|
@@ -98,23 +283,30 @@ module.exports = {
|
|
|
98
283
|
errors.map((e) => ` error ${e}`)
|
|
99
284
|
);
|
|
100
285
|
process.exitCode = 1;
|
|
101
|
-
return;
|
|
286
|
+
return undefined;
|
|
102
287
|
}
|
|
103
288
|
const anchored = review.anchorFindings(projectDir, scope, findings);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
289
|
+
writeJson(paths.anchored, {
|
|
290
|
+
schema: review.FINDINGS_SCHEMA,
|
|
291
|
+
scopeSha256: scope.sha256,
|
|
292
|
+
...anchored,
|
|
293
|
+
});
|
|
108
294
|
const loose = anchored.findings.filter((f) => f.location.status !== 'located');
|
|
109
|
-
|
|
110
|
-
|
|
295
|
+
const { counts } = anchored;
|
|
296
|
+
print(json, { action, id, file: paths.anchored, counts }, [
|
|
297
|
+
`${paths.anchored} — ${counts.located} located, ${counts.ambiguous} ambiguous, ${counts.unlocated} unlocated`,
|
|
111
298
|
...loose.map(
|
|
112
299
|
(f) =>
|
|
113
300
|
` ${f.location.status.padEnd(9)} ${f.id} ${f.path}: quote a longer, exact excerpt`
|
|
114
301
|
),
|
|
302
|
+
...(counts.redactions
|
|
303
|
+
? [
|
|
304
|
+
` ${counts.redactions} credential-like value(s) replaced by [REDACTED] in the written record`,
|
|
305
|
+
]
|
|
306
|
+
: []),
|
|
115
307
|
]);
|
|
116
308
|
process.exitCode = loose.length ? 1 : 0;
|
|
117
|
-
return;
|
|
309
|
+
return undefined;
|
|
118
310
|
}
|
|
119
311
|
|
|
120
312
|
if (action === 'gate') {
|
|
@@ -124,22 +316,65 @@ module.exports = {
|
|
|
124
316
|
if (findings && !review.validateFindings(findings, scope).length)
|
|
125
317
|
anchored = review.anchorFindings(projectDir, scope, findings);
|
|
126
318
|
const verdict = review.reviewGate({ scope, findings, coverage, anchored });
|
|
319
|
+
let check = null;
|
|
320
|
+
if (options.emitCheck) {
|
|
321
|
+
check = checkTarget(projectDir, options.emitCheck, paths, scope);
|
|
322
|
+
writeAtomically(
|
|
323
|
+
check,
|
|
324
|
+
`${JSON.stringify(review.checkResult({ id, scope, verdict, anchored }), null, 2)}\n`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
127
327
|
print(
|
|
128
328
|
json,
|
|
129
|
-
{ action, id, ...verdict },
|
|
329
|
+
{ action, id, ...verdict, check },
|
|
130
330
|
[
|
|
131
331
|
`${id}: ${verdict.status} — coverage ${verdict.coverage.completed}/${verdict.coverage.selected} completed (${verdict.coverage.waived} waived), ${verdict.findings.open} open finding(s), ${verdict.findings.refuted} refuted`,
|
|
132
332
|
...verdict.reasons.map((reason) => ` - ${reason}`),
|
|
333
|
+
verdict.usage ? ` run: ${describeUsage(verdict.stop, verdict.usage)}` : '',
|
|
334
|
+
check ? ` check: ${check}` : '',
|
|
133
335
|
verdict.status === 'clean'
|
|
134
336
|
? ' clean: no open finding within a fully covered scope. It does not prove the code correct.'
|
|
135
337
|
: '',
|
|
136
338
|
].filter(Boolean)
|
|
137
339
|
);
|
|
138
|
-
process.exitCode =
|
|
139
|
-
return;
|
|
340
|
+
process.exitCode = review.GATE_EXIT[verdict.status];
|
|
341
|
+
return undefined;
|
|
140
342
|
}
|
|
141
343
|
|
|
142
|
-
|
|
344
|
+
if (action === 'continue') return continueAction(projectDir, dir, id, paths, scope, json);
|
|
345
|
+
|
|
346
|
+
// compare: this review against an earlier one.
|
|
347
|
+
const sinceId = requireId(options.since, '--since <earlier review id>');
|
|
348
|
+
if (sinceId === id) fail('--since must name a different review');
|
|
349
|
+
const earlier = loadReview(projectDir, dir, sinceId);
|
|
350
|
+
const load = (entry, label) => {
|
|
351
|
+
const findings = readJson(entry.paths.findings, `${label}/findings.json`);
|
|
352
|
+
if (!findings) fail(`no findings at ${entry.paths.findings}`);
|
|
353
|
+
const errors = review.validateFindings(findings, entry.scope);
|
|
354
|
+
if (errors.length) fail(`${label}/findings.json is invalid: ${errors[0]}`);
|
|
355
|
+
return findings;
|
|
356
|
+
};
|
|
357
|
+
const result = review.compareReviews(
|
|
358
|
+
{ scope: earlier.scope, findings: load(earlier, sinceId) },
|
|
359
|
+
{
|
|
360
|
+
scope,
|
|
361
|
+
findings: load({ paths, scope }, id),
|
|
362
|
+
coverage: readJson(paths.coverage, `${id}/coverage.json`),
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
writeJson(paths.compare, result);
|
|
366
|
+
const c = result.counts;
|
|
367
|
+
print(
|
|
368
|
+
json,
|
|
369
|
+
{ action, id, since: sinceId, file: paths.compare, counts: c },
|
|
370
|
+
[
|
|
371
|
+
`${id} since ${sinceId}: ${c.new} new, ${c.persisting} persisting, ${c.resolved} resolved, ${c.refuted} refuted, ${c.not_reviewed} not reviewed`,
|
|
372
|
+
c.not_reviewed
|
|
373
|
+
? ' not reviewed: earlier findings on files this review did not complete — they are not known to be fixed.'
|
|
374
|
+
: '',
|
|
375
|
+
].filter(Boolean)
|
|
376
|
+
);
|
|
377
|
+
return undefined;
|
|
143
378
|
} catch (error) {
|
|
144
379
|
if (json)
|
|
145
380
|
console.log(
|
|
@@ -147,6 +382,7 @@ module.exports = {
|
|
|
147
382
|
);
|
|
148
383
|
else console.error(`review: ${error.message}`);
|
|
149
384
|
process.exitCode = 3;
|
|
385
|
+
return undefined;
|
|
150
386
|
}
|
|
151
387
|
},
|
|
152
388
|
};
|
|
@@ -145,6 +145,7 @@ function serve(spec, paths, options) {
|
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
uat.redactRun(run);
|
|
148
149
|
fs.writeFileSync(file, JSON.stringify(run, null, 2));
|
|
149
150
|
const s = run.summary;
|
|
150
151
|
console.log(
|
|
@@ -284,10 +285,22 @@ module.exports = {
|
|
|
284
285
|
const dir = path.join(paths.results, spec.id);
|
|
285
286
|
fs.mkdirSync(dir, { recursive: true });
|
|
286
287
|
const file = path.join(dir, `${run.runId}.json`);
|
|
288
|
+
uat.redactRun(run);
|
|
287
289
|
fs.writeFileSync(file, JSON.stringify(run, null, 2));
|
|
288
|
-
print(
|
|
289
|
-
|
|
290
|
-
|
|
290
|
+
print(
|
|
291
|
+
json,
|
|
292
|
+
{
|
|
293
|
+
action,
|
|
294
|
+
specId: spec.id,
|
|
295
|
+
runId: run.runId,
|
|
296
|
+
file,
|
|
297
|
+
summary: run.summary,
|
|
298
|
+
redactions: run.redactions || 0,
|
|
299
|
+
},
|
|
300
|
+
[
|
|
301
|
+
`${file} — ${run.summary.passed} passed, ${run.summary.failed} failed, ${run.summary.blocked} blocked, ${run.summary.unanswered} to do`,
|
|
302
|
+
]
|
|
303
|
+
);
|
|
291
304
|
return;
|
|
292
305
|
}
|
|
293
306
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path patterns for review scopes and rules. Deliberately small and dependency-free:
|
|
3
|
+
* `**` spans directories, `*` stays within one segment, `?` is one character, and
|
|
4
|
+
* `{a,b}` alternates — every group, nested groups included. Paths use `/`.
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const MAX_EXPANSIONS = 256;
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
|
|
11
|
+
/** `src/{a,b{c,d}}/*.js` → four patterns. An unbalanced brace is an error, never a literal. */
|
|
12
|
+
function expandBraces(pattern) {
|
|
13
|
+
const open = pattern.indexOf('{');
|
|
14
|
+
if (open < 0) {
|
|
15
|
+
if (pattern.includes('}')) throw new Error(`unbalanced "}" in pattern "${pattern}"`);
|
|
16
|
+
return [pattern];
|
|
17
|
+
}
|
|
18
|
+
let depth = 0;
|
|
19
|
+
let close = -1;
|
|
20
|
+
const commas = [];
|
|
21
|
+
for (let i = open; i < pattern.length; i++) {
|
|
22
|
+
const c = pattern[i];
|
|
23
|
+
if (c === '{') depth++;
|
|
24
|
+
else if (c === '}' && --depth === 0) {
|
|
25
|
+
close = i;
|
|
26
|
+
break;
|
|
27
|
+
} else if (c === ',' && depth === 1) commas.push(i);
|
|
28
|
+
}
|
|
29
|
+
if (close < 0) throw new Error(`unbalanced "{" in pattern "${pattern}"`);
|
|
30
|
+
const head = pattern.slice(0, open);
|
|
31
|
+
const tail = pattern.slice(close + 1);
|
|
32
|
+
const bounds = [open, ...commas, close];
|
|
33
|
+
const results = [];
|
|
34
|
+
for (let i = 0; i < bounds.length - 1; i++) {
|
|
35
|
+
const option = pattern.slice(bounds[i] + 1, bounds[i + 1]);
|
|
36
|
+
for (const expanded of expandBraces(head + option + tail)) {
|
|
37
|
+
results.push(expanded);
|
|
38
|
+
if (results.length > MAX_EXPANSIONS)
|
|
39
|
+
throw new Error(`pattern "${pattern}" expands to more than ${MAX_EXPANSIONS} forms`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toRegExp(glob) {
|
|
46
|
+
let source = '';
|
|
47
|
+
for (let i = 0; i < glob.length; i++) {
|
|
48
|
+
const c = glob[i];
|
|
49
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
50
|
+
const slash = glob[i + 2] === '/';
|
|
51
|
+
source += slash ? '(?:.*/)?' : '.*';
|
|
52
|
+
i += slash ? 2 : 1;
|
|
53
|
+
} else if (c === '*') source += '[^/]*';
|
|
54
|
+
else if (c === '?') source += '[^/]';
|
|
55
|
+
else source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
}
|
|
57
|
+
return new RegExp(`^${source}$`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Compiled once per pattern: the same patterns are tested against every changed file. */
|
|
61
|
+
function compile(pattern) {
|
|
62
|
+
let compiled = cache.get(pattern);
|
|
63
|
+
if (!compiled) {
|
|
64
|
+
compiled = expandBraces(pattern).map(toRegExp);
|
|
65
|
+
cache.set(pattern, compiled);
|
|
66
|
+
}
|
|
67
|
+
return compiled;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const matches = (file, pattern) => compile(pattern).some((re) => re.test(file));
|
|
71
|
+
const matchesAny = (file, patterns) => patterns.some((pattern) => matches(file, pattern));
|
|
72
|
+
|
|
73
|
+
module.exports = { expandBraces, compile, matches, matchesAny };
|
package/tools/cli/lib/packs.js
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction floor for free text that BMAD+ writes to disk or prints: reviewer notes,
|
|
3
|
+
* tester notes, finding descriptions. It removes credentials that commonly end up pasted
|
|
4
|
+
* into such text; it is a floor, not a scanner, and never a reason to paste secrets.
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const MARK = '[REDACTED]';
|
|
9
|
+
const MAX_LENGTH = 20000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A value that names where a secret comes from rather than holding it: an environment or
|
|
13
|
+
* secret-store reference, a template placeholder, or an UPPER_SNAKE variable name (an
|
|
14
|
+
* underscore is required, so an all-capitals random key is still masked).
|
|
15
|
+
*/
|
|
16
|
+
const REFERENCE =
|
|
17
|
+
/^(?:process\.env\b|os\.environ\b|os\.getenv\b|getenv\b|env\.|secrets\.|vars\.|\$|%|<|\{\{|[A-Z][A-Z0-9]*_[A-Z0-9_]*$|\[REDACTED\]$)/;
|
|
18
|
+
|
|
19
|
+
/** Ordered: whole blocks first, then structured forms, then well-known token shapes. */
|
|
20
|
+
const RULES = [
|
|
21
|
+
{
|
|
22
|
+
kind: 'private-key',
|
|
23
|
+
pattern:
|
|
24
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
|
|
25
|
+
replace: () => `-----${MARK} PRIVATE KEY-----`,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
kind: 'url-credentials',
|
|
29
|
+
pattern: /\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s/?#@:]+:[^\s/?#@]+@/gi,
|
|
30
|
+
replace: (_, scheme) => `${scheme}${MARK}@`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: 'authorization',
|
|
34
|
+
pattern:
|
|
35
|
+
/\b((?:authorization|proxy-authorization)\s*[:=]\s*)(bearer|basic|token|digest)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
36
|
+
replace: (_, lead, scheme) => `${lead}${scheme} ${MARK}`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
kind: 'bearer',
|
|
40
|
+
pattern: /\b(bearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi,
|
|
41
|
+
replace: (_, lead) => `${lead}${MARK}`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: 'assignment',
|
|
45
|
+
// name = value where the name says it holds a credential; the name stays readable.
|
|
46
|
+
pattern:
|
|
47
|
+
/\b([A-Za-z0-9_.-]*(?:passw(?:or)?d|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?key|credentials?)[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(["']?)([^\s"',;]{6,})\2/gi,
|
|
48
|
+
replace: (whole, lead, quote, value) =>
|
|
49
|
+
REFERENCE.test(value) ? whole : `${lead}${quote}${MARK}${quote}`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
kind: 'known-token',
|
|
53
|
+
pattern: new RegExp(
|
|
54
|
+
[
|
|
55
|
+
'gh[pousr]_[A-Za-z0-9]{30,}',
|
|
56
|
+
'github_pat_[A-Za-z0-9_]{40,}',
|
|
57
|
+
'glpat-[A-Za-z0-9_-]{20,}',
|
|
58
|
+
'npm_[A-Za-z0-9]{30,}',
|
|
59
|
+
'sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}',
|
|
60
|
+
'xox[abprs]-[A-Za-z0-9-]{10,}',
|
|
61
|
+
'AKIA[0-9A-Z]{16}',
|
|
62
|
+
'AIza[0-9A-Za-z_-]{35}',
|
|
63
|
+
'eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}',
|
|
64
|
+
]
|
|
65
|
+
.map((source) => `\\b${source}`)
|
|
66
|
+
.join('|'),
|
|
67
|
+
'g'
|
|
68
|
+
),
|
|
69
|
+
replace: () => MARK,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Control characters other than tab, line feed and carriage return are dropped. */
|
|
74
|
+
function stripControl(text) {
|
|
75
|
+
let out = '';
|
|
76
|
+
for (const char of text) {
|
|
77
|
+
const code = char.codePointAt(0);
|
|
78
|
+
if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) out += char;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns the text with credentials replaced by a marker, and what was replaced by kind.
|
|
85
|
+
* Non-strings pass through untouched so callers can map over mixed records.
|
|
86
|
+
*/
|
|
87
|
+
function redact(value, { maxLength = MAX_LENGTH } = {}) {
|
|
88
|
+
if (typeof value !== 'string') return { text: value, found: {} };
|
|
89
|
+
let text = stripControl(value);
|
|
90
|
+
const found = {};
|
|
91
|
+
for (const rule of RULES) {
|
|
92
|
+
text = text.replace(rule.pattern, (...match) => {
|
|
93
|
+
found[rule.kind] = (found[rule.kind] || 0) + 1;
|
|
94
|
+
return rule.replace(...match);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (text.length > maxLength) {
|
|
98
|
+
text = `${text.slice(0, maxLength)} […${text.length - maxLength} characters cut]`;
|
|
99
|
+
found.truncated = 1;
|
|
100
|
+
}
|
|
101
|
+
return { text, found };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Redacts the listed string fields of a record in place; returns the number of replacements. */
|
|
105
|
+
function redactFields(record, fields, options) {
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const field of fields) {
|
|
108
|
+
if (typeof record[field] !== 'string') continue;
|
|
109
|
+
const { text, found } = redact(record[field], options);
|
|
110
|
+
record[field] = text;
|
|
111
|
+
count += Object.entries(found).reduce((n, [kind, k]) => (kind === 'truncated' ? n : n + k), 0);
|
|
112
|
+
}
|
|
113
|
+
return count;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { MARK, redact, redactFields };
|