bmad-plus 0.19.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.
@@ -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', 'compare', 'rules'];
9
11
 
10
12
  function fail(message) {
11
13
  throw new Error(message);
@@ -22,14 +24,64 @@ 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
+
25
31
  function print(json, payload, lines) {
26
32
  if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
27
33
  else for (const line of lines) console.log(line);
28
34
  }
29
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
+
30
82
  module.exports = {
31
83
  command: 'review <action> [id]',
32
- description: 'Code review evidence: scope, anchor, gate',
84
+ description: 'Code review evidence: scope, anchor, gate, compare, rules',
33
85
  options: [
34
86
  ['-d, --directory <path>', 'Project directory'],
35
87
  ['--dir <path>', 'Review folder inside the project', review.DEFAULT_DIR],
@@ -38,19 +90,24 @@ module.exports = {
38
90
  ['--workspace', 'Review the working tree, untracked files included, against the base'],
39
91
  ['--include <glob>', 'Only review matching paths (repeatable)', (v, all) => [...all, v], []],
40
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'],
41
98
  ['--json', 'Machine-readable output'],
42
99
  ],
43
100
  action: (action, id, options = {}) => {
44
101
  const projectDir = path.resolve(options.directory || process.cwd());
102
+ const dir = options.dir || review.DEFAULT_DIR;
45
103
  const json = Boolean(options.json);
46
104
  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);
105
+ if (!ACTIONS.includes(action)) fail(`unknown action "${action}" (${ACTIONS.join(', ')})`);
106
+ if (action === 'rules') return rulesAction(projectDir, id, json);
107
+ requireId(id);
52
108
 
53
109
  if (action === 'scope') {
110
+ const ruleset = reviewRules.loadRuleset(projectDir);
54
111
  const scope = review.buildScope(projectDir, {
55
112
  id,
56
113
  base: options.base,
@@ -58,34 +115,47 @@ module.exports = {
58
115
  workspace: Boolean(options.workspace),
59
116
  include: options.include,
60
117
  exclude: options.exclude,
118
+ effort: options.effort,
119
+ ruleset,
61
120
  });
121
+ const paths = review.layout(projectDir, dir, id);
62
122
  fs.mkdirSync(paths.root, { recursive: true });
63
- fs.writeFileSync(paths.scope, `${JSON.stringify(scope, null, 2)}\n`);
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
+ );
64
129
  const reasons = {};
65
130
  for (const item of scope.excluded) reasons[item.reason] = (reasons[item.reason] || 0) + 1;
131
+ const { plan } = scope;
66
132
  print(
67
133
  json,
68
134
  {
69
135
  action,
70
136
  id,
71
137
  file: paths.scope,
138
+ checklist: paths.checklist,
72
139
  totals: scope.totals,
73
140
  units: scope.units.length,
74
141
  excluded: reasons,
142
+ plan,
143
+ rulesSha256: scope.identity.rulesSha256,
75
144
  sha256: scope.sha256,
76
145
  },
77
146
  [
78
147
  `${paths.scope}`,
79
148
  `${scope.totals.selected} file(s) to review, ${scope.totals.lines} changed lines, ${scope.units.length} unit(s) — scope ${scope.sha256.slice(0, 12)}`,
80
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' : ''}`,
81
152
  'Every selected file must end in coverage.json as completed, failed or waived (with a reason).',
82
153
  ]
83
154
  );
84
- return;
155
+ return undefined;
85
156
  }
86
157
 
87
- const scope = readJson(paths.scope, 'scope.json');
88
- if (!scope) fail(`no scope at ${paths.scope} — run "bmad-plus review scope ${id}" first`);
158
+ const { paths, scope } = loadReview(projectDir, dir, id);
89
159
 
90
160
  if (action === 'anchor') {
91
161
  const findings = readJson(paths.findings, 'findings.json');
@@ -98,23 +168,30 @@ module.exports = {
98
168
  errors.map((e) => ` error ${e}`)
99
169
  );
100
170
  process.exitCode = 1;
101
- return;
171
+ return undefined;
102
172
  }
103
173
  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
- );
174
+ writeJson(paths.anchored, {
175
+ schema: review.FINDINGS_SCHEMA,
176
+ scopeSha256: scope.sha256,
177
+ ...anchored,
178
+ });
108
179
  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`,
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`,
111
183
  ...loose.map(
112
184
  (f) =>
113
185
  ` ${f.location.status.padEnd(9)} ${f.id} ${f.path}: quote a longer, exact excerpt`
114
186
  ),
187
+ ...(counts.redactions
188
+ ? [
189
+ ` ${counts.redactions} credential-like value(s) replaced by [REDACTED] in the written record`,
190
+ ]
191
+ : []),
115
192
  ]);
116
193
  process.exitCode = loose.length ? 1 : 0;
117
- return;
194
+ return undefined;
118
195
  }
119
196
 
120
197
  if (action === 'gate') {
@@ -136,10 +213,41 @@ module.exports = {
136
213
  ].filter(Boolean)
137
214
  );
138
215
  process.exitCode = { clean: 0, findings: 1, incomplete: 2 }[verdict.status];
139
- return;
216
+ return undefined;
140
217
  }
141
218
 
142
- fail(`unknown action "${action}" (scope, anchor, gate)`);
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;
143
251
  } catch (error) {
144
252
  if (json)
145
253
  console.log(
@@ -147,6 +255,7 @@ module.exports = {
147
255
  );
148
256
  else console.error(`review: ${error.message}`);
149
257
  process.exitCode = 3;
258
+ return undefined;
150
259
  }
151
260
  },
152
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(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.20.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 };
@@ -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 };