bmad-plus 0.21.0 → 0.22.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 +20 -0
- package/README.md +13 -13
- package/SECURITY.md +62 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +1 -1
- package/readme-international/README.de.md +13 -13
- package/readme-international/README.es.md +13 -13
- package/readme-international/README.fr.md +13 -13
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +3 -1
- package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
- package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
- package/src/bmad-plus/packs/pack-shield/README.md +12 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
- package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
- package/tools/build/generate-adapters.js +7 -0
- package/tools/build/generate.js +14 -0
- package/tools/cli/bmad-plus-cli.js +2 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/review.js +10 -3
- package/tools/cli/lib/ai-register.js +393 -0
- package/tools/cli/lib/assurance.js +822 -0
- package/tools/cli/lib/control-refs.js +132 -0
- package/tools/cli/lib/installation-health.js +17 -0
- package/tools/cli/lib/packs.js +60 -2
- package/tools/cli/lib/page-origins.js +582 -0
- package/tools/cli/lib/review-rules.js +92 -24
- package/tools/cli/lib/review.js +28 -1
- package/tools/cli/lib/uat.js +22 -5
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Review checklists chosen by path. Built-in rules ship with the CLI;
|
|
3
|
-
*
|
|
4
|
-
* a file applies to it — rules
|
|
5
|
-
*
|
|
2
|
+
* Review checklists chosen by path. Built-in rules ship with the CLI; an installed pack may
|
|
3
|
+
* add its own (Shield's compliance rules); a project adds, replaces or disables rules in
|
|
4
|
+
* `_bmad/review-rules.yaml`. Every rule whose patterns match a file applies to it — rules
|
|
5
|
+
* add up, so a broad project rule never silences the built-in ones by accident. A rule may
|
|
6
|
+
* name the compliance controls it examines. The resolved set is hashed into the review scope.
|
|
6
7
|
*/
|
|
7
8
|
'use strict';
|
|
8
9
|
|
|
@@ -11,12 +12,16 @@ const path = require('node:path');
|
|
|
11
12
|
const crypto = require('node:crypto');
|
|
12
13
|
const yaml = require('js-yaml');
|
|
13
14
|
const { compile, matchesAny } = require('./glob');
|
|
15
|
+
const { parseControls } = require('./control-refs');
|
|
16
|
+
const { PACKS } = require('./packs');
|
|
14
17
|
|
|
15
18
|
const SCHEMA = 'bmad-plus/review-rules/1';
|
|
16
19
|
const BUILTIN_DIR = path.join(__dirname, '..', 'review-rules');
|
|
17
20
|
const PROJECT_FILE = path.join('_bmad', 'review-rules.yaml');
|
|
21
|
+
/** Where an installed pack keeps its rules, below its own folder in `.agents/skills`. */
|
|
22
|
+
const PACK_RULES_DIR = 'review-rules';
|
|
18
23
|
const RULE_ID = /^[a-z0-9][a-z0-9-]{0,60}$/;
|
|
19
|
-
const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc'];
|
|
24
|
+
const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc', 'controls'];
|
|
20
25
|
const MAX_DOC_BYTES = 64 * 1024;
|
|
21
26
|
const MAX_INDEX_BYTES = 256 * 1024;
|
|
22
27
|
|
|
@@ -47,8 +52,8 @@ function parseLayer(root, indexName, layer) {
|
|
|
47
52
|
if (!doc || doc.schema !== SCHEMA) throw new Error(`${where}: schema must be "${SCHEMA}"`);
|
|
48
53
|
const extra = Object.keys(doc).filter((key) => !['schema', 'rules', 'disable'].includes(key));
|
|
49
54
|
if (extra.length) throw new Error(`${where}: unknown key(s) ${extra.join(', ')}`);
|
|
50
|
-
if (layer
|
|
51
|
-
throw new Error(`${where}:
|
|
55
|
+
if (layer !== 'project' && doc.disable)
|
|
56
|
+
throw new Error(`${where}: only a project's rules can disable`);
|
|
52
57
|
const disable = doc.disable || [];
|
|
53
58
|
if (!Array.isArray(disable) || !disable.every((id) => typeof id === 'string'))
|
|
54
59
|
throw new Error(`${where}: disable must be a list of rule ids`);
|
|
@@ -74,6 +79,7 @@ function parseLayer(root, indexName, layer) {
|
|
|
74
79
|
)
|
|
75
80
|
throw new Error(`${at}: globs must be a non-empty list of patterns`);
|
|
76
81
|
for (const glob of rule.globs) compile(glob);
|
|
82
|
+
const controls = rule.controls === undefined ? [] : parseControls(rule.controls, at);
|
|
77
83
|
if (!/\.md$/i.test(String(rule.doc))) throw new Error(`${at}: doc must be a Markdown file`);
|
|
78
84
|
const text = readConfined(root, rule.doc, MAX_DOC_BYTES, at).trim();
|
|
79
85
|
if (!text) throw new Error(`${at}: ${rule.doc} is empty`);
|
|
@@ -83,6 +89,7 @@ function parseLayer(root, indexName, layer) {
|
|
|
83
89
|
// A rule family reviewers can split by; a rule without one is a family of its own.
|
|
84
90
|
group: rule.group === undefined ? rule.id : rule.group,
|
|
85
91
|
globs: [...rule.globs],
|
|
92
|
+
controls,
|
|
86
93
|
layer,
|
|
87
94
|
source: rule.doc,
|
|
88
95
|
text,
|
|
@@ -91,13 +98,35 @@ function parseLayer(root, indexName, layer) {
|
|
|
91
98
|
return { rules: parsed, disable };
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/** Rule indexes of the packs installed in the project, in pack order. */
|
|
102
|
+
function installedPackLayers(projectDir) {
|
|
103
|
+
const layers = [];
|
|
104
|
+
for (const [pack, { packDir }] of Object.entries(PACKS)) {
|
|
105
|
+
if (!packDir) continue;
|
|
106
|
+
const relative = ['.agents', 'skills', packDir, PACK_RULES_DIR, 'index.yaml'];
|
|
107
|
+
if (fs.existsSync(path.join(projectDir, ...relative)))
|
|
108
|
+
layers.push({ pack, file: relative.join('/') });
|
|
109
|
+
}
|
|
110
|
+
return layers;
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
/**
|
|
95
|
-
* The effective rule set for a project: built-in rules, then
|
|
96
|
-
*
|
|
114
|
+
* The effective rule set for a project: built-in rules, then those of installed packs, then
|
|
115
|
+
* the project's own. A pack only adds; a project rule with an existing id replaces it, and
|
|
116
|
+
* `disable` removes rules by id and is reported.
|
|
97
117
|
*/
|
|
98
118
|
function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
|
|
99
119
|
const builtin = parseLayer(builtinDir, 'index.yaml', 'builtin');
|
|
100
120
|
const ordered = new Map(builtin.rules.map((rule) => [rule.id, rule]));
|
|
121
|
+
const packFiles = installedPackLayers(projectDir);
|
|
122
|
+
for (const { pack, file } of packFiles) {
|
|
123
|
+
const layer = parseLayer(path.join(projectDir, path.dirname(file)), 'index.yaml', 'pack');
|
|
124
|
+
for (const rule of layer.rules) {
|
|
125
|
+
if (ordered.has(rule.id))
|
|
126
|
+
throw new Error(`${pack} review rules: rule ${rule.id} already exists; a pack only adds`);
|
|
127
|
+
ordered.set(rule.id, { ...rule, pack });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
101
130
|
let disabled = [];
|
|
102
131
|
const projectIndex = path.join(projectDir, PROJECT_FILE);
|
|
103
132
|
let project = null;
|
|
@@ -111,16 +140,20 @@ function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
|
|
|
111
140
|
for (const id of disabled) ordered.delete(id);
|
|
112
141
|
}
|
|
113
142
|
const list = [...ordered.values()];
|
|
143
|
+
// Controls join the fingerprint only when a rule names some, so a rule set without any
|
|
144
|
+
// keeps the hash it had before rules could carry controls.
|
|
114
145
|
const fingerprint = list.map((rule) => [
|
|
115
146
|
rule.id,
|
|
116
147
|
rule.group,
|
|
117
148
|
rule.layer,
|
|
118
149
|
rule.globs,
|
|
119
150
|
sha256(rule.text),
|
|
151
|
+
...(rule.controls.length ? [rule.controls] : []),
|
|
120
152
|
]);
|
|
121
153
|
return {
|
|
122
154
|
rules: list,
|
|
123
155
|
disabled,
|
|
156
|
+
packFiles: packFiles.map(({ file }) => file),
|
|
124
157
|
projectFile: project ? PROJECT_FILE.split(path.sep).join('/') : null,
|
|
125
158
|
sha256: sha256(JSON.stringify({ rules: fingerprint, disabled })),
|
|
126
159
|
};
|
|
@@ -142,35 +175,68 @@ function groupsOf(ruleset, ids) {
|
|
|
142
175
|
}
|
|
143
176
|
|
|
144
177
|
/**
|
|
145
|
-
* The
|
|
146
|
-
*
|
|
178
|
+
* The compliance controls a set of rule ids examines, each with the rules that name it, in
|
|
179
|
+
* rule order. A control touched is a prompt to look at its requirement, not a finding.
|
|
180
|
+
*/
|
|
181
|
+
function controlsOf(ruleset, ids) {
|
|
182
|
+
const controls = new Map();
|
|
183
|
+
for (const rule of ruleset.rules) {
|
|
184
|
+
if (!ids.includes(rule.id)) continue;
|
|
185
|
+
for (const control of rule.controls)
|
|
186
|
+
controls.set(control, [...(controls.get(control) || []), rule.id]);
|
|
187
|
+
}
|
|
188
|
+
return [...controls].map(([id, rules]) => ({ id, rules }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The reviewer's checklist for a scope: the controls the change touches, then each
|
|
193
|
+
* applicable rule once, with the files it covers. Rules that match nothing are left out.
|
|
147
194
|
*/
|
|
148
195
|
function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
|
|
149
196
|
const files = new Map();
|
|
150
197
|
for (const [file, ids] of Object.entries(byPath)) {
|
|
151
198
|
for (const id of ids) files.set(id, [...(files.get(id) || []), file]);
|
|
152
199
|
}
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
200
|
+
const applied = ruleset.rules.filter((rule) => files.has(rule.id));
|
|
201
|
+
const controls = controlsOf(
|
|
202
|
+
ruleset,
|
|
203
|
+
applied.map((rule) => rule.id)
|
|
204
|
+
);
|
|
205
|
+
const touched = controls.length
|
|
206
|
+
? [
|
|
207
|
+
'## Controls this change touches',
|
|
158
208
|
'',
|
|
159
|
-
|
|
209
|
+
...controls.map(
|
|
210
|
+
({ id, rules }) => `- \`${id}\` — ${rules.map((rule) => `\`${rule}\``).join(', ')}`
|
|
211
|
+
),
|
|
160
212
|
'',
|
|
161
|
-
`
|
|
162
|
-
.get(rule.id)
|
|
163
|
-
.map((file) => `\`${file}\``)
|
|
164
|
-
.join(', ')}`,
|
|
213
|
+
'List in the `controls` of a finding the control it breaks; a control listed here is not a finding.',
|
|
165
214
|
'',
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
]
|
|
216
|
+
: [];
|
|
217
|
+
const sections = applied.map((rule) =>
|
|
218
|
+
[
|
|
219
|
+
`## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}${rule.pack ? ` (${rule.pack} rule)` : ''}`,
|
|
220
|
+
'',
|
|
221
|
+
`Group: \`${rule.group}\``,
|
|
222
|
+
...(rule.controls.length
|
|
223
|
+
? ['', `Controls: ${rule.controls.map((control) => `\`${control}\``).join(', ')}`]
|
|
224
|
+
: []),
|
|
225
|
+
'',
|
|
226
|
+
`Applies to: ${files
|
|
227
|
+
.get(rule.id)
|
|
228
|
+
.map((file) => `\`${file}\``)
|
|
229
|
+
.join(', ')}`,
|
|
230
|
+
'',
|
|
231
|
+
rule.text,
|
|
232
|
+
].join('\n')
|
|
233
|
+
);
|
|
169
234
|
return [
|
|
170
235
|
`# ${title}`,
|
|
171
236
|
'',
|
|
172
237
|
`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
238
|
'',
|
|
239
|
+
...touched,
|
|
174
240
|
...sections.flatMap((section) => [section, '']),
|
|
175
241
|
].join('\n');
|
|
176
242
|
}
|
|
@@ -179,8 +245,10 @@ module.exports = {
|
|
|
179
245
|
SCHEMA,
|
|
180
246
|
BUILTIN_DIR,
|
|
181
247
|
PROJECT_FILE,
|
|
248
|
+
PACK_RULES_DIR,
|
|
182
249
|
loadRuleset,
|
|
183
250
|
rulesFor,
|
|
184
251
|
groupsOf,
|
|
252
|
+
controlsOf,
|
|
185
253
|
checklist,
|
|
186
254
|
};
|
package/tools/cli/lib/review.js
CHANGED
|
@@ -295,6 +295,9 @@ function buildScope(projectDir, options = {}) {
|
|
|
295
295
|
const ruleset = options.ruleset || rules.loadRuleset(projectDir);
|
|
296
296
|
for (const item of selected) {
|
|
297
297
|
item.rules = rules.rulesFor(ruleset, item.path);
|
|
298
|
+
// Kept only when a rule names controls, so a scope without any keeps its digest.
|
|
299
|
+
const controls = rules.controlsOf(ruleset, item.rules).map(({ id }) => id);
|
|
300
|
+
if (controls.length) item.controls = controls;
|
|
298
301
|
// A commit fixes the content under review; a working tree does not, so its bytes are sealed.
|
|
299
302
|
if (options.workspace) item.sha256 = contentDigest(projectDir, item.path);
|
|
300
303
|
}
|
|
@@ -312,8 +315,14 @@ function buildScope(projectDir, options = {}) {
|
|
|
312
315
|
};
|
|
313
316
|
const units = planUnits(selected, options.unitLimits).map((unit) => {
|
|
314
317
|
const ids = [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))];
|
|
318
|
+
const controls = rules.controlsOf(ruleset, ids).map(({ id }) => id);
|
|
315
319
|
// Rule groups let parallel reviewers split one unit by rule family.
|
|
316
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
...unit,
|
|
322
|
+
rules: ids,
|
|
323
|
+
groups: rules.groupsOf(ruleset, ids),
|
|
324
|
+
...(controls.length ? { controls } : {}),
|
|
325
|
+
};
|
|
317
326
|
});
|
|
318
327
|
const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
|
|
319
328
|
const plan = reviewPlan(effort, lines, units.length);
|
|
@@ -349,6 +358,7 @@ const FINDING_KEYS = [
|
|
|
349
358
|
'refutation',
|
|
350
359
|
'fix',
|
|
351
360
|
'rule',
|
|
361
|
+
'controls',
|
|
352
362
|
];
|
|
353
363
|
|
|
354
364
|
/** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
|
|
@@ -395,6 +405,21 @@ function validateFindings(doc, scope) {
|
|
|
395
405
|
// A finding may name the checklist rule that led to it; the rule must apply to that file.
|
|
396
406
|
if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
|
|
397
407
|
errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
|
|
408
|
+
// It may name the controls it breaks: only those the rules for that file examine.
|
|
409
|
+
if (finding.controls !== undefined) {
|
|
410
|
+
if (
|
|
411
|
+
!Array.isArray(finding.controls) ||
|
|
412
|
+
!finding.controls.length ||
|
|
413
|
+
!finding.controls.every((control) => typeof control === 'string')
|
|
414
|
+
)
|
|
415
|
+
errors.push(`${at}: controls must be a non-empty list of control ids`);
|
|
416
|
+
else if (entry)
|
|
417
|
+
for (const control of finding.controls)
|
|
418
|
+
if (!(entry.controls || []).includes(control))
|
|
419
|
+
errors.push(
|
|
420
|
+
`${at}: control "${control}" is not examined by the rules for ${finding.path}`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
398
423
|
}
|
|
399
424
|
return errors;
|
|
400
425
|
}
|
|
@@ -755,6 +780,7 @@ function checkResult({ id, scope, verdict, anchored }) {
|
|
|
755
780
|
confidence: f.confidence,
|
|
756
781
|
disposition: f.disposition,
|
|
757
782
|
...(f.rule !== undefined ? { rule: f.rule } : {}),
|
|
783
|
+
...(f.controls !== undefined ? { controls: f.controls } : {}),
|
|
758
784
|
anchor: f.location.status,
|
|
759
785
|
lineStart: f.location.status === 'located' ? f.location.lineStart : null,
|
|
760
786
|
lineEnd: f.location.status === 'located' ? f.location.lineEnd : null,
|
|
@@ -934,6 +960,7 @@ function remainingWork({ scope, coverage, anchored, findingErrors = [] }) {
|
|
|
934
960
|
id: unit.id,
|
|
935
961
|
paths: unit.paths.filter((file) => pending.has(file)),
|
|
936
962
|
rules: unit.rules,
|
|
963
|
+
...(unit.controls ? { controls: unit.controls } : {}),
|
|
937
964
|
// Rule groups another reviewer already completed are not handed out again.
|
|
938
965
|
...(unit.groups
|
|
939
966
|
? { groups: unit.groups.filter((group) => !done.has(`${unit.id}/${group.id}`)) }
|
package/tools/cli/lib/uat.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require('node:fs');
|
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const crypto = require('node:crypto');
|
|
7
7
|
const { redactFields } = require('./redact');
|
|
8
|
+
const { foreignAddresses } = require('./page-origins');
|
|
8
9
|
|
|
9
10
|
const SPEC_SCHEMA = 'bmad-plus/uat-spec/2';
|
|
10
11
|
const RESULTS_SCHEMA = 'bmad-plus/uat-results/2';
|
|
@@ -176,6 +177,9 @@ function checkMarkup(value, where, errors) {
|
|
|
176
177
|
rest = rest.replace(match[0], '');
|
|
177
178
|
}
|
|
178
179
|
if (rest.includes('<')) errors.push(`${where}: a "<" is left outside any allowed tag`);
|
|
180
|
+
// The page renders this text as markup: it is read as the template is, so the page loads
|
|
181
|
+
// nothing from another origin whatever the allowed tags come to be.
|
|
182
|
+
for (const found of foreignAddresses(value)) errors.push(`${where}: ${found}`);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
function eachMarkup(spec, visit) {
|
|
@@ -575,22 +579,34 @@ const PAGE_GUARANTEES = [
|
|
|
575
579
|
what: 'the exported JSON is the run as answered, and nothing is exported before a run exists',
|
|
576
580
|
markers: ['download(runId + ".json", JSON.stringify(run, null, 2))'],
|
|
577
581
|
},
|
|
582
|
+
{
|
|
583
|
+
id: 'no-third-party-requests',
|
|
584
|
+
what: 'opening and using the page asks nothing of any origin but its own: no remote font, stylesheet, script, image or frame',
|
|
585
|
+
markers: [],
|
|
586
|
+
// An absence has no marker: the template is read as a browser reads it, and every address
|
|
587
|
+
// it would reach on another origin, in markup, style or script, is refused by name.
|
|
588
|
+
refuses: foreignAddresses,
|
|
589
|
+
},
|
|
578
590
|
];
|
|
579
591
|
|
|
580
592
|
/**
|
|
581
593
|
* Which guarantees a template carries: every marker present, in the required order where one
|
|
582
|
-
* is set. Checked on the TEMPLATE, never on the built page: a recipe's
|
|
583
|
-
* able to satisfy or defeat a marker.
|
|
594
|
+
* is set, and nothing it refuses. Checked on the TEMPLATE, never on the built page: a recipe's
|
|
595
|
+
* own text must not be able to satisfy or defeat a marker.
|
|
584
596
|
*/
|
|
585
597
|
function pageGuarantees(template) {
|
|
586
598
|
const missing = [];
|
|
587
599
|
for (const guarantee of PAGE_GUARANTEES) {
|
|
588
|
-
const lost = guarantee.markers
|
|
600
|
+
const lost = guarantee.markers
|
|
601
|
+
.filter((marker) => !template.includes(marker))
|
|
602
|
+
.map((marker) => `missing ${marker}`);
|
|
589
603
|
if (!lost.length && guarantee.order) {
|
|
590
604
|
const positions = guarantee.order.map((marker) => template.indexOf(marker));
|
|
591
605
|
if (positions.some((at) => at < 0) || positions.some((at, i) => i && at < positions[i - 1]))
|
|
592
|
-
lost.push(`order ${guarantee.order.join(' → ')}`);
|
|
606
|
+
lost.push(`missing order ${guarantee.order.join(' → ')}`);
|
|
593
607
|
}
|
|
608
|
+
if (guarantee.refuses)
|
|
609
|
+
for (const found of guarantee.refuses(template)) lost.push(`carries ${found}`);
|
|
594
610
|
if (lost.length) missing.push({ id: guarantee.id, what: guarantee.what, lost });
|
|
595
611
|
}
|
|
596
612
|
const carried = PAGE_GUARANTEES.map((g) => g.id).filter(
|
|
@@ -607,7 +623,7 @@ function buildPage(spec, options = {}) {
|
|
|
607
623
|
if (!guarantees.ok) {
|
|
608
624
|
throw new Error(
|
|
609
625
|
`the page template (${options.template ? 'the template given' : TEMPLATE}) lost a guarantee — not built: ${guarantees.missing
|
|
610
|
-
.map((entry) => `${entry.id}: ${entry.what} —
|
|
626
|
+
.map((entry) => `${entry.id}: ${entry.what} — ${entry.lost.join(', ')}`)
|
|
611
627
|
.join('; ')}`
|
|
612
628
|
);
|
|
613
629
|
}
|
|
@@ -1013,6 +1029,7 @@ module.exports = {
|
|
|
1013
1029
|
TRIAGE_SCHEMA,
|
|
1014
1030
|
DEFAULT_DIR,
|
|
1015
1031
|
DEFAULT_BUDGET,
|
|
1032
|
+
ALLOWED_TAGS,
|
|
1016
1033
|
STATES,
|
|
1017
1034
|
TRIAGE_CLASSES,
|
|
1018
1035
|
TRIAGE_DECISIONS,
|