bmad-plus 0.21.0 → 0.23.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 +39 -0
- package/README.md +24 -14
- package/SECURITY.md +63 -1
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +4 -1
- package/readme-international/README.de.md +24 -14
- package/readme-international/README.es.md +24 -14
- package/readme-international/README.fr.md +24 -14
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
- package/src/bmad-plus/data/role-triggers.yaml +34 -0
- package/src/bmad-plus/module-help.csv +2 -0
- package/src/bmad-plus/module.yaml +21 -0
- package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
- package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
- package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
- package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
- 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-autopilot/SKILL.md +7 -0
- package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
- package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
- package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
- package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
- 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/adapters.config.js +2 -0
- package/tools/build/generate-adapters.js +32 -1
- package/tools/build/generate.js +283 -7
- package/tools/build/intake-contract.js +522 -0
- package/tools/cli/bmad-plus-cli.js +4 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/install.js +28 -1
- package/tools/cli/commands/intake.js +124 -0
- package/tools/cli/commands/review.js +10 -3
- package/tools/cli/commands/swarm.js +119 -0
- package/tools/cli/commands/uninstall.js +17 -0
- package/tools/cli/i18n.js +20 -0
- 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/intake.js +202 -0
- package/tools/cli/lib/pack-copy.js +95 -5
- package/tools/cli/lib/packs.js +156 -8
- 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/swarm-deal.js +116 -0
- package/tools/cli/lib/swarm-defaults.js +6 -0
- package/tools/cli/lib/swarm-tally.js +195 -0
- package/tools/cli/lib/swarm-validate.js +589 -0
- package/tools/cli/lib/swarm.js +712 -0
- package/tools/cli/lib/uat.js +22 -5
- package/tools/cli/lib/update-transaction.js +8 -4
|
@@ -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}`)) }
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Deterministic dealing: the same profile, n and seed give the same cards, on any platform. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
const WORD = 2 ** 32;
|
|
7
|
+
|
|
8
|
+
/** A counter-mode generator over SHA-256: the stream depends on the seed alone. */
|
|
9
|
+
class Dealer {
|
|
10
|
+
constructor(seed) {
|
|
11
|
+
this.seed = String(seed);
|
|
12
|
+
this.block = 0;
|
|
13
|
+
this.words = [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
next() {
|
|
17
|
+
if (!this.words.length) {
|
|
18
|
+
const digest = crypto
|
|
19
|
+
.createHash('sha256')
|
|
20
|
+
.update(this.seed + ':' + this.block)
|
|
21
|
+
.digest();
|
|
22
|
+
this.block += 1;
|
|
23
|
+
for (let offset = 0; offset < digest.length; offset += 4)
|
|
24
|
+
this.words.push(digest.readUInt32BE(offset));
|
|
25
|
+
}
|
|
26
|
+
return this.words.shift();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Uniform integer in [0, n): values above the last full cycle are rejected, never folded. */
|
|
30
|
+
below(n) {
|
|
31
|
+
const limit = WORD - (WORD % n);
|
|
32
|
+
let value = this.next();
|
|
33
|
+
while (value >= limit) value = this.next();
|
|
34
|
+
return value % n;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
between(low, high) {
|
|
38
|
+
if (Number.isInteger(low) && Number.isInteger(high)) return low + this.below(high - low + 1);
|
|
39
|
+
return Math.round((low + (this.next() / WORD) * (high - low)) * 100) / 100;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
shuffle(items) {
|
|
43
|
+
for (let index = items.length - 1; index > 0; index -= 1) {
|
|
44
|
+
const other = this.below(index + 1);
|
|
45
|
+
[items[index], items[other]] = [items[other], items[index]];
|
|
46
|
+
}
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Largest-remainder quotas: the counts add up to n exactly and follow the shares. */
|
|
52
|
+
function quotas(shares, n) {
|
|
53
|
+
const exact = shares.map((share) => share * n);
|
|
54
|
+
const counts = exact.map((value) => Math.floor(value));
|
|
55
|
+
let left = n - counts.reduce((sum, count) => sum + count, 0);
|
|
56
|
+
const order = exact
|
|
57
|
+
.map((value, index) => ({ index, remainder: value - Math.floor(value) }))
|
|
58
|
+
.sort((a, b) => b.remainder - a.remainder || a.index - b.index);
|
|
59
|
+
for (const { index } of order) {
|
|
60
|
+
if (left === 0) break;
|
|
61
|
+
counts[index] += 1;
|
|
62
|
+
left -= 1;
|
|
63
|
+
}
|
|
64
|
+
return counts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unitId(prefix, position, n) {
|
|
68
|
+
return prefix + String(position).padStart(Math.max(3, String(n).length), '0');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dealCards(profile, n, seed, idPrefix) {
|
|
72
|
+
const dealer = new Dealer(seed);
|
|
73
|
+
const slots = profile.dimensions.map((dimension) => {
|
|
74
|
+
const counts = quotas(
|
|
75
|
+
dimension.values.map((entry) => entry.share),
|
|
76
|
+
n
|
|
77
|
+
);
|
|
78
|
+
const column = [];
|
|
79
|
+
counts.forEach((count, index) => {
|
|
80
|
+
for (let k = 0; k < count; k += 1) column.push(index);
|
|
81
|
+
});
|
|
82
|
+
return dealer.shuffle(column);
|
|
83
|
+
});
|
|
84
|
+
const cards = [];
|
|
85
|
+
for (let position = 0; position < n; position += 1) {
|
|
86
|
+
const card = { id: unitId(idPrefix, position + 1, n) };
|
|
87
|
+
profile.dimensions.forEach((dimension, index) => {
|
|
88
|
+
const entry = dimension.values[slots[index][position]];
|
|
89
|
+
card[dimension.name] = entry.value;
|
|
90
|
+
// A note is a field of the whole dimension, so every card carries it, empty or not.
|
|
91
|
+
if (dimension.values.some((value) => value.note !== null))
|
|
92
|
+
card[dimension.name + '_note'] = entry.note ?? '';
|
|
93
|
+
for (const [name, [low, high]] of Object.entries(entry.ranges))
|
|
94
|
+
card[name] = dealer.between(low, high);
|
|
95
|
+
});
|
|
96
|
+
for (const [name, [low, high]] of Object.entries(profile.ranges))
|
|
97
|
+
card[name] = dealer.between(low, high);
|
|
98
|
+
for (const [name, pool] of Object.entries(profile.pools))
|
|
99
|
+
card[name] = pool[dealer.below(pool.length)];
|
|
100
|
+
cards.push(card);
|
|
101
|
+
}
|
|
102
|
+
return cards;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function listCards(units, idPrefix) {
|
|
106
|
+
const cards = units.map((unit, index) => ({
|
|
107
|
+
...unit,
|
|
108
|
+
id: unit.id ?? unitId(idPrefix, index + 1, units.length),
|
|
109
|
+
}));
|
|
110
|
+
// Answer files are named by id, and some file systems fold case.
|
|
111
|
+
if (new Set(cards.map((card) => card.id.toLowerCase())).size !== cards.length)
|
|
112
|
+
throw new Error('Swarm: jury unit ids must be distinct once dealt, whatever their case.');
|
|
113
|
+
return cards;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { Dealer, quotas, unitId, dealCards, listCards };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** Counts valid answers by group; quotes stay verbatim and the disclosure comes first. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const round = (value) => Math.round(value * 10000) / 10000;
|
|
5
|
+
|
|
6
|
+
function stats(values) {
|
|
7
|
+
if (!values.length) return { count: 0, median: null, mean: null, min: null, max: null };
|
|
8
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
9
|
+
const middle = sorted.length >> 1;
|
|
10
|
+
const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
11
|
+
return {
|
|
12
|
+
count: sorted.length,
|
|
13
|
+
median: round(median),
|
|
14
|
+
mean: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
|
|
15
|
+
min: sorted[0],
|
|
16
|
+
max: sorted[sorted.length - 1],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rateOf(rows, field) {
|
|
21
|
+
const of = rows.length;
|
|
22
|
+
const yes = rows.filter(({ answer }) => answer[field] === true).length;
|
|
23
|
+
return { yes, of, rate: of ? round(yes / of) : null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function countsOf(rows, field, values) {
|
|
27
|
+
const counts = Object.fromEntries(values.map((value) => [value, 0]));
|
|
28
|
+
for (const { answer } of rows) if (Object.hasOwn(answer, field)) counts[answer[field]] += 1;
|
|
29
|
+
return counts;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function quotesOf(rows, field, keep) {
|
|
33
|
+
return rows
|
|
34
|
+
.filter(({ answer }) => typeof answer[field] === 'string' && answer[field].trim())
|
|
35
|
+
.slice(0, keep)
|
|
36
|
+
.map(({ card, answer }) => ({ unit: card.id, text: answer[field] }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function numbersOf(rows, field) {
|
|
40
|
+
return stats(
|
|
41
|
+
rows.map(({ answer }) => answer[field]).filter((value) => typeof value === 'number')
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param rows valid answers in unit order: [{ card, answer }]
|
|
47
|
+
* @param skipped units without a valid answer: [{ unit, reason }]
|
|
48
|
+
*/
|
|
49
|
+
function tally({ run, definition, cards, rows, skipped }) {
|
|
50
|
+
const { groupBy } = definition.tally;
|
|
51
|
+
const groupValues = Object.fromEntries(
|
|
52
|
+
groupBy.map((field) => [field, [...new Set(cards.map((card) => String(card[field])))]])
|
|
53
|
+
);
|
|
54
|
+
const partition = (compute) => ({
|
|
55
|
+
all: compute(rows),
|
|
56
|
+
groups: Object.fromEntries(
|
|
57
|
+
groupBy.map((field) => [
|
|
58
|
+
field,
|
|
59
|
+
Object.fromEntries(
|
|
60
|
+
groupValues[field].map((value) => [
|
|
61
|
+
value,
|
|
62
|
+
compute(rows.filter(({ card }) => String(card[field]) === value)),
|
|
63
|
+
])
|
|
64
|
+
),
|
|
65
|
+
])
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
const { rate, counts, quotes, numbers } = definition.tally;
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
run: run.id,
|
|
72
|
+
definition: definition.id,
|
|
73
|
+
kind: definition.kind,
|
|
74
|
+
title: definition.title,
|
|
75
|
+
disclosure: definition.disclosure,
|
|
76
|
+
units: { total: cards.length, valid: rows.length, skipped },
|
|
77
|
+
groupBy,
|
|
78
|
+
rate: rate ? { field: rate, ...partition((set) => rateOf(set, rate)) } : null,
|
|
79
|
+
counts: Object.fromEntries(
|
|
80
|
+
counts.map((field) => [
|
|
81
|
+
field,
|
|
82
|
+
partition((set) => countsOf(set, field, definition.answer.fields[field].values)),
|
|
83
|
+
])
|
|
84
|
+
),
|
|
85
|
+
quotes: Object.fromEntries(
|
|
86
|
+
Object.entries(quotes).map(([field, keep]) => [
|
|
87
|
+
field,
|
|
88
|
+
{ keep, ...partition((set) => quotesOf(set, field, keep)) },
|
|
89
|
+
])
|
|
90
|
+
),
|
|
91
|
+
numbers: Object.fromEntries(
|
|
92
|
+
numbers.map((field) => [field, partition((set) => numbersOf(set, field))])
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const percent = (rate) => (rate === null ? 'n/a' : (rate * 100).toFixed(1) + '%');
|
|
98
|
+
const cell = (value) => (value === null ? 'n/a' : String(value));
|
|
99
|
+
|
|
100
|
+
function sections(section, render) {
|
|
101
|
+
const lines = render('all', section.all);
|
|
102
|
+
for (const [field, values] of Object.entries(section.groups))
|
|
103
|
+
for (const [value, subset] of Object.entries(values))
|
|
104
|
+
lines.push(...render(field + ' = ' + value, subset));
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderTally(result) {
|
|
109
|
+
const lines = [
|
|
110
|
+
'> ' + result.disclosure,
|
|
111
|
+
'',
|
|
112
|
+
'# ' + result.title + ' (run ' + result.run + ')',
|
|
113
|
+
'',
|
|
114
|
+
];
|
|
115
|
+
const skipped = result.units.skipped;
|
|
116
|
+
lines.push(
|
|
117
|
+
result.units.valid +
|
|
118
|
+
' of ' +
|
|
119
|
+
result.units.total +
|
|
120
|
+
' units gave a valid answer; ' +
|
|
121
|
+
skipped.length +
|
|
122
|
+
' skipped' +
|
|
123
|
+
(skipped.length
|
|
124
|
+
? ': ' + skipped.map((entry) => entry.unit + ' (' + entry.reason + ')').join(', ')
|
|
125
|
+
: '') +
|
|
126
|
+
'.'
|
|
127
|
+
);
|
|
128
|
+
if (result.rate) {
|
|
129
|
+
lines.push(
|
|
130
|
+
'',
|
|
131
|
+
'## Rate: ' + result.rate.field,
|
|
132
|
+
'',
|
|
133
|
+
'| group | yes | of | rate |',
|
|
134
|
+
'|---|---|---|---|'
|
|
135
|
+
);
|
|
136
|
+
lines.push(
|
|
137
|
+
...sections(result.rate, (name, value) => [
|
|
138
|
+
'| ' + name + ' | ' + value.yes + ' | ' + value.of + ' | ' + percent(value.rate) + ' |',
|
|
139
|
+
])
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
for (const [field, section] of Object.entries(result.counts)) {
|
|
143
|
+
const values = Object.keys(section.all);
|
|
144
|
+
lines.push(
|
|
145
|
+
'',
|
|
146
|
+
'## Counts: ' + field,
|
|
147
|
+
'',
|
|
148
|
+
'| group | ' + values.join(' | ') + ' |',
|
|
149
|
+
'|---|' + values.map(() => '---|').join('')
|
|
150
|
+
);
|
|
151
|
+
lines.push(
|
|
152
|
+
...sections(section, (name, value) => [
|
|
153
|
+
'| ' + name + ' | ' + values.map((key) => value[key]).join(' | ') + ' |',
|
|
154
|
+
])
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
for (const [field, section] of Object.entries(result.quotes)) {
|
|
158
|
+
lines.push('', '## Quotes: ' + field + ' (up to ' + section.keep + ' per group, verbatim)');
|
|
159
|
+
lines.push(
|
|
160
|
+
...sections(section, (name, value) => [
|
|
161
|
+
'',
|
|
162
|
+
'**' + name + '**',
|
|
163
|
+
...(value.length
|
|
164
|
+
? value.map((quote) => '- ' + quote.unit + ': ' + JSON.stringify(quote.text))
|
|
165
|
+
: ['- (no quote)']),
|
|
166
|
+
])
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
for (const [field, section] of Object.entries(result.numbers)) {
|
|
170
|
+
lines.push(
|
|
171
|
+
'',
|
|
172
|
+
'## Numbers: ' + field,
|
|
173
|
+
'',
|
|
174
|
+
'| group | count | median | mean | min | max |',
|
|
175
|
+
'|---|---|---|---|---|---|'
|
|
176
|
+
);
|
|
177
|
+
lines.push(
|
|
178
|
+
...sections(section, (name, value) => [
|
|
179
|
+
'| ' +
|
|
180
|
+
[
|
|
181
|
+
name,
|
|
182
|
+
value.count,
|
|
183
|
+
cell(value.median),
|
|
184
|
+
cell(value.mean),
|
|
185
|
+
cell(value.min),
|
|
186
|
+
cell(value.max),
|
|
187
|
+
].join(' | ') +
|
|
188
|
+
' |',
|
|
189
|
+
])
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return lines.join('\n') + '\n';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { tally, renderTally, stats };
|