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.
@@ -0,0 +1,186 @@
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', 'group', '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 (typeof rule.id !== 'string' || !RULE_ID.test(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 (rule.group !== undefined && !(typeof rule.group === 'string' && RULE_ID.test(rule.group)))
69
+ throw new Error(`${at}: invalid group`);
70
+ if (
71
+ !Array.isArray(rule.globs) ||
72
+ !rule.globs.length ||
73
+ !rule.globs.every((g) => typeof g === 'string' && g)
74
+ )
75
+ throw new Error(`${at}: globs must be a non-empty list of patterns`);
76
+ for (const glob of rule.globs) compile(glob);
77
+ if (!/\.md$/i.test(String(rule.doc))) throw new Error(`${at}: doc must be a Markdown file`);
78
+ const text = readConfined(root, rule.doc, MAX_DOC_BYTES, at).trim();
79
+ if (!text) throw new Error(`${at}: ${rule.doc} is empty`);
80
+ return {
81
+ id: rule.id,
82
+ title: rule.title.trim(),
83
+ // A rule family reviewers can split by; a rule without one is a family of its own.
84
+ group: rule.group === undefined ? rule.id : rule.group,
85
+ globs: [...rule.globs],
86
+ layer,
87
+ source: rule.doc,
88
+ text,
89
+ };
90
+ });
91
+ return { rules: parsed, disable };
92
+ }
93
+
94
+ /**
95
+ * The effective rule set for a project: built-in rules, then the project's own. A project
96
+ * rule with a built-in id replaces it; `disable` removes rules by id and is reported.
97
+ */
98
+ function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
99
+ const builtin = parseLayer(builtinDir, 'index.yaml', 'builtin');
100
+ const ordered = new Map(builtin.rules.map((rule) => [rule.id, rule]));
101
+ let disabled = [];
102
+ const projectIndex = path.join(projectDir, PROJECT_FILE);
103
+ let project = null;
104
+ if (fs.existsSync(projectIndex)) {
105
+ project = parseLayer(path.dirname(projectIndex), path.basename(projectIndex), 'project');
106
+ for (const rule of project.rules) ordered.set(rule.id, rule);
107
+ const unknown = project.disable.filter((id) => !ordered.has(id));
108
+ if (unknown.length)
109
+ throw new Error(`project review rules: disable names unknown rule(s) ${unknown.join(', ')}`);
110
+ disabled = [...project.disable];
111
+ for (const id of disabled) ordered.delete(id);
112
+ }
113
+ const list = [...ordered.values()];
114
+ const fingerprint = list.map((rule) => [
115
+ rule.id,
116
+ rule.group,
117
+ rule.layer,
118
+ rule.globs,
119
+ sha256(rule.text),
120
+ ]);
121
+ return {
122
+ rules: list,
123
+ disabled,
124
+ projectFile: project ? PROJECT_FILE.split(path.sep).join('/') : null,
125
+ sha256: sha256(JSON.stringify({ rules: fingerprint, disabled })),
126
+ };
127
+ }
128
+
129
+ /** Ids of every rule that applies to a path, in rule order. */
130
+ function rulesFor(ruleset, file) {
131
+ return ruleset.rules.filter((rule) => matchesAny(file, rule.globs)).map((rule) => rule.id);
132
+ }
133
+
134
+ /** The rule families of a set of rule ids, each with its rules, in rule order. */
135
+ function groupsOf(ruleset, ids) {
136
+ const groups = new Map();
137
+ for (const rule of ruleset.rules) {
138
+ if (!ids.includes(rule.id)) continue;
139
+ groups.set(rule.group, [...(groups.get(rule.group) || []), rule.id]);
140
+ }
141
+ return [...groups].map(([id, rules]) => ({ id, rules }));
142
+ }
143
+
144
+ /**
145
+ * The reviewer's checklist for a scope: each applicable rule once, with the files it
146
+ * covers. Rules that match nothing in the scope are left out.
147
+ */
148
+ function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
149
+ const files = new Map();
150
+ for (const [file, ids] of Object.entries(byPath)) {
151
+ for (const id of ids) files.set(id, [...(files.get(id) || []), file]);
152
+ }
153
+ const sections = ruleset.rules
154
+ .filter((rule) => files.has(rule.id))
155
+ .map((rule) =>
156
+ [
157
+ `## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}`,
158
+ '',
159
+ `Group: \`${rule.group}\``,
160
+ '',
161
+ `Applies to: ${files
162
+ .get(rule.id)
163
+ .map((file) => `\`${file}\``)
164
+ .join(', ')}`,
165
+ '',
166
+ rule.text,
167
+ ].join('\n')
168
+ );
169
+ return [
170
+ `# ${title}`,
171
+ '',
172
+ `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.`,
173
+ '',
174
+ ...sections.flatMap((section) => [section, '']),
175
+ ].join('\n');
176
+ }
177
+
178
+ module.exports = {
179
+ SCHEMA,
180
+ BUILTIN_DIR,
181
+ PROJECT_FILE,
182
+ loadRuleset,
183
+ rulesFor,
184
+ groupsOf,
185
+ checklist,
186
+ };