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.
@@ -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 (!id || !ID.test(id))
48
- fail('a review id is required: lowercase letters, digits, dots and dashes');
49
- if (!['scope', 'anchor', 'gate'].includes(action))
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
- fs.writeFileSync(paths.scope, `${JSON.stringify(scope, null, 2)}\n`);
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 = readJson(paths.scope, 'scope.json');
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
- fs.writeFileSync(
105
- paths.anchored,
106
- `${JSON.stringify({ schema: review.FINDINGS_SCHEMA, scopeSha256: scope.sha256, ...anchored }, null, 2)}\n`
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
- print(json, { action, id, file: paths.anchored, counts: anchored.counts }, [
110
- `${paths.anchored} — ${anchored.counts.located} located, ${anchored.counts.ambiguous} ambiguous, ${anchored.counts.unlocated} unlocated`,
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 = { clean: 0, findings: 1, incomplete: 2 }[verdict.status];
139
- return;
340
+ process.exitCode = review.GATE_EXIT[verdict.status];
341
+ return undefined;
140
342
  }
141
343
 
142
- fail(`unknown action "${action}" (scope, anchor, gate)`);
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(json, { action, specId: spec.id, runId: run.runId, file, summary: run.summary }, [
289
- `${file} — ${run.summary.passed} passed, ${run.summary.failed} failed, ${run.summary.blocked} blocked, ${run.summary.unanswered} to do`,
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 };
@@ -191,7 +191,7 @@ const DERIVED = {
191
191
  "product": {
192
192
  "code": "bmad-plus",
193
193
  "displayName": "BMAD+",
194
- "version": "0.19.0",
194
+ "version": "0.21.0",
195
195
  "derivedFrom": "BMAD-METHOD v6.6.0"
196
196
  },
197
197
  "packOrder": [
@@ -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 };