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.
- package/CHANGELOG.md +26 -0
- package/README.md +15 -17
- package/package.json +1 -1
- package/readme-international/README.de.md +15 -17
- package/readme-international/README.es.md +15 -17
- package/readme-international/README.fr.md +15 -17
- package/src/bmad-plus/agents/agent-quality/SKILL.md +3 -2
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +15 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +41 -4
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +1 -0
- package/tools/cli/bmad-plus-cli.js +17 -4
- package/tools/cli/commands/review.js +261 -0
- 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 +156 -0
- package/tools/cli/lib/review.js +634 -0
- package/tools/cli/lib/uat.js +17 -1
- 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 +36 -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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/** Code review evidence: seal the scope, anchor the findings, derive the verdict from coverage. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const review = require('../lib/review');
|
|
7
|
+
const reviewRules = require('../lib/review-rules');
|
|
8
|
+
|
|
9
|
+
const ID = /^[a-z0-9][a-z0-9.-]{0,80}$/;
|
|
10
|
+
const ACTIONS = ['scope', 'anchor', 'gate', 'compare', 'rules'];
|
|
11
|
+
|
|
12
|
+
function fail(message) {
|
|
13
|
+
throw new Error(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readJson(file, what) {
|
|
17
|
+
if (!fs.existsSync(file)) return null;
|
|
18
|
+
try {
|
|
19
|
+
// PowerShell 5.1 writes UTF-8 with a byte-order mark; JSON.parse refuses it.
|
|
20
|
+
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
fail(`${what} is not valid JSON (${error.message})`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeJson(file, value) {
|
|
28
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function print(json, payload, lines) {
|
|
32
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
|
|
33
|
+
else for (const line of lines) console.log(line);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireId(value, what = 'a review id') {
|
|
37
|
+
if (!value || !ID.test(value))
|
|
38
|
+
fail(`${what} is required: lowercase letters, digits, dots and dashes`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadReview(projectDir, dir, id) {
|
|
43
|
+
const paths = review.layout(projectDir, dir, id);
|
|
44
|
+
const scope = readJson(paths.scope, `${id}/scope.json`);
|
|
45
|
+
if (!scope) fail(`no scope at ${paths.scope} — run "bmad-plus review scope ${id}" first`);
|
|
46
|
+
return { paths, scope };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `review rules [path]`: the effective rule set, or the rules one path would get. */
|
|
50
|
+
function rulesAction(projectDir, target, json) {
|
|
51
|
+
const ruleset = reviewRules.loadRuleset(projectDir);
|
|
52
|
+
const file = target ? target.split(path.sep).join('/').replace(/^\.\//, '') : null;
|
|
53
|
+
const ids = file ? reviewRules.rulesFor(ruleset, file) : ruleset.rules.map((rule) => rule.id);
|
|
54
|
+
const listed = ruleset.rules.filter((rule) => ids.includes(rule.id));
|
|
55
|
+
print(
|
|
56
|
+
json,
|
|
57
|
+
{
|
|
58
|
+
action: 'rules',
|
|
59
|
+
path: file,
|
|
60
|
+
sha256: ruleset.sha256,
|
|
61
|
+
projectFile: ruleset.projectFile,
|
|
62
|
+
disabled: ruleset.disabled,
|
|
63
|
+
rules: listed.map(({ id, title, layer, globs, source }) => ({
|
|
64
|
+
id,
|
|
65
|
+
title,
|
|
66
|
+
layer,
|
|
67
|
+
globs,
|
|
68
|
+
source,
|
|
69
|
+
})),
|
|
70
|
+
},
|
|
71
|
+
[
|
|
72
|
+
`rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ? ` (built-in + ${ruleset.projectFile})` : ' (built-in)'}`,
|
|
73
|
+
...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
|
|
74
|
+
...listed.map(
|
|
75
|
+
(rule) => ` ${rule.id.padEnd(24)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}`
|
|
76
|
+
),
|
|
77
|
+
...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
|
|
78
|
+
]
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
command: 'review <action> [id]',
|
|
84
|
+
description: 'Code review evidence: scope, anchor, gate, compare, rules',
|
|
85
|
+
options: [
|
|
86
|
+
['-d, --directory <path>', 'Project directory'],
|
|
87
|
+
['--dir <path>', 'Review folder inside the project', review.DEFAULT_DIR],
|
|
88
|
+
['--base <ref>', 'Base commit or branch (default HEAD~1)'],
|
|
89
|
+
['--head <ref>', 'Head commit or branch (default HEAD)'],
|
|
90
|
+
['--workspace', 'Review the working tree, untracked files included, against the base'],
|
|
91
|
+
['--include <glob>', 'Only review matching paths (repeatable)', (v, all) => [...all, v], []],
|
|
92
|
+
['--exclude <glob>', 'Never review matching paths (repeatable)', (v, all) => [...all, v], []],
|
|
93
|
+
[
|
|
94
|
+
'--effort <level>',
|
|
95
|
+
`Review depth: ${Object.keys(review.EFFORTS).join(', ')} (default medium)`,
|
|
96
|
+
],
|
|
97
|
+
['--since <id>', 'compare: the earlier review to compare against'],
|
|
98
|
+
['--json', 'Machine-readable output'],
|
|
99
|
+
],
|
|
100
|
+
action: (action, id, options = {}) => {
|
|
101
|
+
const projectDir = path.resolve(options.directory || process.cwd());
|
|
102
|
+
const dir = options.dir || review.DEFAULT_DIR;
|
|
103
|
+
const json = Boolean(options.json);
|
|
104
|
+
try {
|
|
105
|
+
if (!ACTIONS.includes(action)) fail(`unknown action "${action}" (${ACTIONS.join(', ')})`);
|
|
106
|
+
if (action === 'rules') return rulesAction(projectDir, id, json);
|
|
107
|
+
requireId(id);
|
|
108
|
+
|
|
109
|
+
if (action === 'scope') {
|
|
110
|
+
const ruleset = reviewRules.loadRuleset(projectDir);
|
|
111
|
+
const scope = review.buildScope(projectDir, {
|
|
112
|
+
id,
|
|
113
|
+
base: options.base,
|
|
114
|
+
head: options.head,
|
|
115
|
+
workspace: Boolean(options.workspace),
|
|
116
|
+
include: options.include,
|
|
117
|
+
exclude: options.exclude,
|
|
118
|
+
effort: options.effort,
|
|
119
|
+
ruleset,
|
|
120
|
+
});
|
|
121
|
+
const paths = review.layout(projectDir, dir, id);
|
|
122
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
123
|
+
writeJson(paths.scope, scope);
|
|
124
|
+
const byPath = Object.fromEntries(scope.selected.map((item) => [item.path, item.rules]));
|
|
125
|
+
fs.writeFileSync(
|
|
126
|
+
paths.checklist,
|
|
127
|
+
reviewRules.checklist(ruleset, byPath, { title: `Review checklist — ${id}` })
|
|
128
|
+
);
|
|
129
|
+
const reasons = {};
|
|
130
|
+
for (const item of scope.excluded) reasons[item.reason] = (reasons[item.reason] || 0) + 1;
|
|
131
|
+
const { plan } = scope;
|
|
132
|
+
print(
|
|
133
|
+
json,
|
|
134
|
+
{
|
|
135
|
+
action,
|
|
136
|
+
id,
|
|
137
|
+
file: paths.scope,
|
|
138
|
+
checklist: paths.checklist,
|
|
139
|
+
totals: scope.totals,
|
|
140
|
+
units: scope.units.length,
|
|
141
|
+
excluded: reasons,
|
|
142
|
+
plan,
|
|
143
|
+
rulesSha256: scope.identity.rulesSha256,
|
|
144
|
+
sha256: scope.sha256,
|
|
145
|
+
},
|
|
146
|
+
[
|
|
147
|
+
`${paths.scope}`,
|
|
148
|
+
`${scope.totals.selected} file(s) to review, ${scope.totals.lines} changed lines, ${scope.units.length} unit(s) — scope ${scope.sha256.slice(0, 12)}`,
|
|
149
|
+
...Object.entries(reasons).map(([reason, n]) => ` excluded ${n} × ${reason}`),
|
|
150
|
+
`checklist: ${paths.checklist}`,
|
|
151
|
+
`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' : ''}`,
|
|
152
|
+
'Every selected file must end in coverage.json as completed, failed or waived (with a reason).',
|
|
153
|
+
]
|
|
154
|
+
);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { paths, scope } = loadReview(projectDir, dir, id);
|
|
159
|
+
|
|
160
|
+
if (action === 'anchor') {
|
|
161
|
+
const findings = readJson(paths.findings, 'findings.json');
|
|
162
|
+
if (!findings) fail(`no findings at ${paths.findings}`);
|
|
163
|
+
const errors = review.validateFindings(findings, scope);
|
|
164
|
+
if (errors.length) {
|
|
165
|
+
print(
|
|
166
|
+
json,
|
|
167
|
+
{ action, id, status: 'error', errors },
|
|
168
|
+
errors.map((e) => ` error ${e}`)
|
|
169
|
+
);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const anchored = review.anchorFindings(projectDir, scope, findings);
|
|
174
|
+
writeJson(paths.anchored, {
|
|
175
|
+
schema: review.FINDINGS_SCHEMA,
|
|
176
|
+
scopeSha256: scope.sha256,
|
|
177
|
+
...anchored,
|
|
178
|
+
});
|
|
179
|
+
const loose = anchored.findings.filter((f) => f.location.status !== 'located');
|
|
180
|
+
const { counts } = anchored;
|
|
181
|
+
print(json, { action, id, file: paths.anchored, counts }, [
|
|
182
|
+
`${paths.anchored} — ${counts.located} located, ${counts.ambiguous} ambiguous, ${counts.unlocated} unlocated`,
|
|
183
|
+
...loose.map(
|
|
184
|
+
(f) =>
|
|
185
|
+
` ${f.location.status.padEnd(9)} ${f.id} ${f.path}: quote a longer, exact excerpt`
|
|
186
|
+
),
|
|
187
|
+
...(counts.redactions
|
|
188
|
+
? [
|
|
189
|
+
` ${counts.redactions} credential-like value(s) replaced by [REDACTED] in the written record`,
|
|
190
|
+
]
|
|
191
|
+
: []),
|
|
192
|
+
]);
|
|
193
|
+
process.exitCode = loose.length ? 1 : 0;
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (action === 'gate') {
|
|
198
|
+
const findings = readJson(paths.findings, 'findings.json');
|
|
199
|
+
const coverage = readJson(paths.coverage, 'coverage.json');
|
|
200
|
+
let anchored = null;
|
|
201
|
+
if (findings && !review.validateFindings(findings, scope).length)
|
|
202
|
+
anchored = review.anchorFindings(projectDir, scope, findings);
|
|
203
|
+
const verdict = review.reviewGate({ scope, findings, coverage, anchored });
|
|
204
|
+
print(
|
|
205
|
+
json,
|
|
206
|
+
{ action, id, ...verdict },
|
|
207
|
+
[
|
|
208
|
+
`${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`,
|
|
209
|
+
...verdict.reasons.map((reason) => ` - ${reason}`),
|
|
210
|
+
verdict.status === 'clean'
|
|
211
|
+
? ' clean: no open finding within a fully covered scope. It does not prove the code correct.'
|
|
212
|
+
: '',
|
|
213
|
+
].filter(Boolean)
|
|
214
|
+
);
|
|
215
|
+
process.exitCode = { clean: 0, findings: 1, incomplete: 2 }[verdict.status];
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// compare: this review against an earlier one.
|
|
220
|
+
const sinceId = requireId(options.since, '--since <earlier review id>');
|
|
221
|
+
if (sinceId === id) fail('--since must name a different review');
|
|
222
|
+
const earlier = loadReview(projectDir, dir, sinceId);
|
|
223
|
+
const load = (entry, label) => {
|
|
224
|
+
const findings = readJson(entry.paths.findings, `${label}/findings.json`);
|
|
225
|
+
if (!findings) fail(`no findings at ${entry.paths.findings}`);
|
|
226
|
+
const errors = review.validateFindings(findings, entry.scope);
|
|
227
|
+
if (errors.length) fail(`${label}/findings.json is invalid: ${errors[0]}`);
|
|
228
|
+
return findings;
|
|
229
|
+
};
|
|
230
|
+
const result = review.compareReviews(
|
|
231
|
+
{ scope: earlier.scope, findings: load(earlier, sinceId) },
|
|
232
|
+
{
|
|
233
|
+
scope,
|
|
234
|
+
findings: load({ paths, scope }, id),
|
|
235
|
+
coverage: readJson(paths.coverage, `${id}/coverage.json`),
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
writeJson(paths.compare, result);
|
|
239
|
+
const c = result.counts;
|
|
240
|
+
print(
|
|
241
|
+
json,
|
|
242
|
+
{ action, id, since: sinceId, file: paths.compare, counts: c },
|
|
243
|
+
[
|
|
244
|
+
`${id} since ${sinceId}: ${c.new} new, ${c.persisting} persisting, ${c.resolved} resolved, ${c.refuted} refuted, ${c.not_reviewed} not reviewed`,
|
|
245
|
+
c.not_reviewed
|
|
246
|
+
? ' not reviewed: earlier findings on files this review did not complete — they are not known to be fixed.'
|
|
247
|
+
: '',
|
|
248
|
+
].filter(Boolean)
|
|
249
|
+
);
|
|
250
|
+
return undefined;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (json)
|
|
253
|
+
console.log(
|
|
254
|
+
JSON.stringify({ schemaVersion: 1, status: 'error', message: error.message }, null, 2)
|
|
255
|
+
);
|
|
256
|
+
else console.error(`review: ${error.message}`);
|
|
257
|
+
process.exitCode = 3;
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
};
|
|
@@ -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 };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review checklists chosen by path. Built-in rules ship with the CLI; a project adds,
|
|
3
|
+
* replaces or disables rules in `_bmad/review-rules.yaml`. Every rule whose patterns match
|
|
4
|
+
* a file applies to it — rules add up, so a broad project rule never silences the built-in
|
|
5
|
+
* ones by accident. The resolved set is hashed into the review scope.
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const yaml = require('js-yaml');
|
|
13
|
+
const { compile, matchesAny } = require('./glob');
|
|
14
|
+
|
|
15
|
+
const SCHEMA = 'bmad-plus/review-rules/1';
|
|
16
|
+
const BUILTIN_DIR = path.join(__dirname, '..', 'review-rules');
|
|
17
|
+
const PROJECT_FILE = path.join('_bmad', 'review-rules.yaml');
|
|
18
|
+
const RULE_ID = /^[a-z0-9][a-z0-9-]{0,60}$/;
|
|
19
|
+
const RULE_KEYS = ['id', 'title', 'globs', 'doc'];
|
|
20
|
+
const MAX_DOC_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_INDEX_BYTES = 256 * 1024;
|
|
22
|
+
|
|
23
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
24
|
+
|
|
25
|
+
/** A file this module reads must be a regular file inside `root`, of bounded size. */
|
|
26
|
+
function readConfined(root, relative, maxBytes, what) {
|
|
27
|
+
if (typeof relative !== 'string' || !relative || path.isAbsolute(relative))
|
|
28
|
+
throw new Error(`${what}: expected a relative path, got "${relative}"`);
|
|
29
|
+
const realRoot = fs.realpathSync(root);
|
|
30
|
+
const file = path.resolve(realRoot, relative);
|
|
31
|
+
if (file !== realRoot && !file.startsWith(realRoot + path.sep))
|
|
32
|
+
throw new Error(`${what}: "${relative}" leaves ${root}`);
|
|
33
|
+
const stat = fs.lstatSync(file);
|
|
34
|
+
if (!stat.isFile()) throw new Error(`${what}: "${relative}" is not a regular file`);
|
|
35
|
+
if (stat.size > maxBytes) throw new Error(`${what}: "${relative}" exceeds ${maxBytes} bytes`);
|
|
36
|
+
return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseLayer(root, indexName, layer) {
|
|
40
|
+
const where = `${layer} review rules (${path.join(root, indexName)})`;
|
|
41
|
+
let doc;
|
|
42
|
+
try {
|
|
43
|
+
doc = yaml.load(readConfined(root, indexName, MAX_INDEX_BYTES, where));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`${where}: ${error.message}`, { cause: error });
|
|
46
|
+
}
|
|
47
|
+
if (!doc || doc.schema !== SCHEMA) throw new Error(`${where}: schema must be "${SCHEMA}"`);
|
|
48
|
+
const extra = Object.keys(doc).filter((key) => !['schema', 'rules', 'disable'].includes(key));
|
|
49
|
+
if (extra.length) throw new Error(`${where}: unknown key(s) ${extra.join(', ')}`);
|
|
50
|
+
if (layer === 'builtin' && doc.disable)
|
|
51
|
+
throw new Error(`${where}: built-in rules cannot disable`);
|
|
52
|
+
const disable = doc.disable || [];
|
|
53
|
+
if (!Array.isArray(disable) || !disable.every((id) => typeof id === 'string'))
|
|
54
|
+
throw new Error(`${where}: disable must be a list of rule ids`);
|
|
55
|
+
const rules = doc.rules || [];
|
|
56
|
+
if (!Array.isArray(rules)) throw new Error(`${where}: rules must be a list`);
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const parsed = rules.map((rule, index) => {
|
|
59
|
+
const at = `${where}: rule ${rule?.id ?? index}`;
|
|
60
|
+
if (!rule || typeof rule !== 'object') throw new Error(`${at}: must be a mapping`);
|
|
61
|
+
const unknown = Object.keys(rule).filter((key) => !RULE_KEYS.includes(key));
|
|
62
|
+
if (unknown.length) throw new Error(`${at}: unknown key(s) ${unknown.join(', ')}`);
|
|
63
|
+
if (!RULE_ID.test(String(rule.id))) throw new Error(`${at}: invalid id`);
|
|
64
|
+
if (seen.has(rule.id)) throw new Error(`${at}: duplicate id`);
|
|
65
|
+
seen.add(rule.id);
|
|
66
|
+
if (typeof rule.title !== 'string' || !rule.title.trim())
|
|
67
|
+
throw new Error(`${at}: title is required`);
|
|
68
|
+
if (
|
|
69
|
+
!Array.isArray(rule.globs) ||
|
|
70
|
+
!rule.globs.length ||
|
|
71
|
+
!rule.globs.every((g) => typeof g === 'string' && g)
|
|
72
|
+
)
|
|
73
|
+
throw new Error(`${at}: globs must be a non-empty list of patterns`);
|
|
74
|
+
for (const glob of rule.globs) compile(glob);
|
|
75
|
+
if (!/\.md$/i.test(String(rule.doc))) throw new Error(`${at}: doc must be a Markdown file`);
|
|
76
|
+
const text = readConfined(root, rule.doc, MAX_DOC_BYTES, at).trim();
|
|
77
|
+
if (!text) throw new Error(`${at}: ${rule.doc} is empty`);
|
|
78
|
+
return {
|
|
79
|
+
id: rule.id,
|
|
80
|
+
title: rule.title.trim(),
|
|
81
|
+
globs: [...rule.globs],
|
|
82
|
+
layer,
|
|
83
|
+
source: rule.doc,
|
|
84
|
+
text,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
return { rules: parsed, disable };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The effective rule set for a project: built-in rules, then the project's own. A project
|
|
92
|
+
* rule with a built-in id replaces it; `disable` removes rules by id and is reported.
|
|
93
|
+
*/
|
|
94
|
+
function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
|
|
95
|
+
const builtin = parseLayer(builtinDir, 'index.yaml', 'builtin');
|
|
96
|
+
const ordered = new Map(builtin.rules.map((rule) => [rule.id, rule]));
|
|
97
|
+
let disabled = [];
|
|
98
|
+
const projectIndex = path.join(projectDir, PROJECT_FILE);
|
|
99
|
+
let project = null;
|
|
100
|
+
if (fs.existsSync(projectIndex)) {
|
|
101
|
+
project = parseLayer(path.dirname(projectIndex), path.basename(projectIndex), 'project');
|
|
102
|
+
for (const rule of project.rules) ordered.set(rule.id, rule);
|
|
103
|
+
const unknown = project.disable.filter((id) => !ordered.has(id));
|
|
104
|
+
if (unknown.length)
|
|
105
|
+
throw new Error(`project review rules: disable names unknown rule(s) ${unknown.join(', ')}`);
|
|
106
|
+
disabled = [...project.disable];
|
|
107
|
+
for (const id of disabled) ordered.delete(id);
|
|
108
|
+
}
|
|
109
|
+
const list = [...ordered.values()];
|
|
110
|
+
const fingerprint = list.map((rule) => [rule.id, rule.layer, rule.globs, sha256(rule.text)]);
|
|
111
|
+
return {
|
|
112
|
+
rules: list,
|
|
113
|
+
disabled,
|
|
114
|
+
projectFile: project ? PROJECT_FILE.split(path.sep).join('/') : null,
|
|
115
|
+
sha256: sha256(JSON.stringify({ rules: fingerprint, disabled })),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Ids of every rule that applies to a path, in rule order. */
|
|
120
|
+
function rulesFor(ruleset, file) {
|
|
121
|
+
return ruleset.rules.filter((rule) => matchesAny(file, rule.globs)).map((rule) => rule.id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The reviewer's checklist for a scope: each applicable rule once, with the files it
|
|
126
|
+
* covers. Rules that match nothing in the scope are left out.
|
|
127
|
+
*/
|
|
128
|
+
function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
|
|
129
|
+
const files = new Map();
|
|
130
|
+
for (const [file, ids] of Object.entries(byPath)) {
|
|
131
|
+
for (const id of ids) files.set(id, [...(files.get(id) || []), file]);
|
|
132
|
+
}
|
|
133
|
+
const sections = ruleset.rules
|
|
134
|
+
.filter((rule) => files.has(rule.id))
|
|
135
|
+
.map((rule) =>
|
|
136
|
+
[
|
|
137
|
+
`## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}`,
|
|
138
|
+
'',
|
|
139
|
+
`Applies to: ${files
|
|
140
|
+
.get(rule.id)
|
|
141
|
+
.map((file) => `\`${file}\``)
|
|
142
|
+
.join(', ')}`,
|
|
143
|
+
'',
|
|
144
|
+
rule.text,
|
|
145
|
+
].join('\n')
|
|
146
|
+
);
|
|
147
|
+
return [
|
|
148
|
+
`# ${title}`,
|
|
149
|
+
'',
|
|
150
|
+
`Rule set ${ruleset.sha256.slice(0, 12)}. Report a finding only with its quoted code; a rule is a prompt to look, not a finding.`,
|
|
151
|
+
'',
|
|
152
|
+
...sections.flatMap((section) => [section, '']),
|
|
153
|
+
].join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { SCHEMA, BUILTIN_DIR, PROJECT_FILE, loadRuleset, rulesFor, checklist };
|