bmad-plus 0.22.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +23 -13
  3. package/SECURITY.md +1 -1
  4. package/package.json +4 -1
  5. package/readme-international/README.de.md +23 -13
  6. package/readme-international/README.es.md +23 -13
  7. package/readme-international/README.fr.md +23 -13
  8. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -1
  9. package/src/bmad-plus/data/role-triggers.yaml +34 -0
  10. package/src/bmad-plus/module-help.csv +2 -0
  11. package/src/bmad-plus/module.yaml +21 -0
  12. package/src/bmad-plus/packs/pack-design/SKILL.md +72 -0
  13. package/src/bmad-plus/packs/pack-design/bmad-skill-manifest.yaml +13 -0
  14. package/src/bmad-plus/packs/pack-design/design-engine.md +126 -0
  15. package/src/bmad-plus/packs/pack-design/intake.yaml +105 -0
  16. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +7 -0
  17. package/src/bmad-plus/skills/bmad-plus-intake/SKILL.md +87 -0
  18. package/src/bmad-plus/skills/bmad-plus-intake/ref/intake-format.md +112 -0
  19. package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +18 -0
  20. package/src/bmad-plus/skills/bmad-plus-swarm/SKILL.md +115 -0
  21. package/src/bmad-plus/skills/bmad-plus-swarm/ref/definition.md +188 -0
  22. package/tools/build/adapters.config.js +2 -0
  23. package/tools/build/generate-adapters.js +25 -1
  24. package/tools/build/generate.js +269 -7
  25. package/tools/build/intake-contract.js +522 -0
  26. package/tools/cli/bmad-plus-cli.js +2 -0
  27. package/tools/cli/commands/install.js +28 -1
  28. package/tools/cli/commands/intake.js +124 -0
  29. package/tools/cli/commands/swarm.js +119 -0
  30. package/tools/cli/commands/uninstall.js +17 -0
  31. package/tools/cli/i18n.js +20 -0
  32. package/tools/cli/lib/intake.js +202 -0
  33. package/tools/cli/lib/pack-copy.js +95 -5
  34. package/tools/cli/lib/packs.js +97 -7
  35. package/tools/cli/lib/swarm-deal.js +116 -0
  36. package/tools/cli/lib/swarm-defaults.js +6 -0
  37. package/tools/cli/lib/swarm-tally.js +195 -0
  38. package/tools/cli/lib/swarm-validate.js +589 -0
  39. package/tools/cli/lib/swarm.js +712 -0
  40. package/tools/cli/lib/update-transaction.js +8 -4
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Swarm contracts: definition, profile, unit list, brief template and answer.
3
+ * Pure functions only: the file is copied verbatim into a run as its Nexus verifier,
4
+ * so it must stay self-contained (no file system, no other module).
5
+ */
6
+ 'use strict';
7
+
8
+ const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
9
+ const UNIT_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
10
+ const NEXUS_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
11
+ const FIELD = /^[a-z][a-z0-9_]{0,31}$/;
12
+ const CONTEXT = /^[a-z][a-z0-9_-]{0,31}$/;
13
+ const PREFIX = /^[A-Za-z]{1,4}$/;
14
+ const PLACEHOLDER = /\{\{([^{}]*)\}\}/g;
15
+ const SOURCES = { panel: 'profile', jury: 'list', fanout: 'input' };
16
+ const FIELD_TYPES = ['boolean', 'integer', 'number', 'string', 'enum', 'list'];
17
+ const MAX_UNITS = 1000;
18
+ const MAX_FIELDS = 50;
19
+ const MAX_TEXT_DEFAULT = 4000;
20
+ const MAX_TEXT_CEILING = 20000;
21
+ const MAX_LIST_ITEMS = 200;
22
+ const MAX_QUOTES = 10;
23
+ const RESERVED = ['id', 'scope', 'lens'];
24
+
25
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ const fail = (message) => {
27
+ throw new Error('Swarm: ' + message);
28
+ };
29
+
30
+ function object(value, label) {
31
+ if (!isObject(value)) fail(label + ' must be a JSON object.');
32
+ return value;
33
+ }
34
+
35
+ function onlyKeys(value, allowed, label) {
36
+ for (const key of Object.keys(value))
37
+ if (!allowed.includes(key)) fail(label + ': unknown field "' + key + '".');
38
+ }
39
+
40
+ function str(value, label, max = 200) {
41
+ if (typeof value !== 'string' || !value.trim() || value.length > max || value.includes('\0'))
42
+ fail(label + ' must be a nonempty string of at most ' + max + ' characters.');
43
+ return value;
44
+ }
45
+
46
+ function int(value, label, min, max) {
47
+ if (!Number.isInteger(value) || value < min || value > max)
48
+ fail(label + ' must be an integer from ' + min + ' to ' + max + '.');
49
+ return value;
50
+ }
51
+
52
+ function num(value, label) {
53
+ if (typeof value !== 'number' || !Number.isFinite(value)) fail(label + ' must be a number.');
54
+ return value;
55
+ }
56
+
57
+ function list(value, label, max, min = 1) {
58
+ if (!Array.isArray(value) || value.length < min || value.length > max)
59
+ fail(label + ' must be a list of ' + min + ' to ' + max + ' entries.');
60
+ return value;
61
+ }
62
+
63
+ function fieldName(value, label) {
64
+ if (typeof value !== 'string' || !FIELD.test(value))
65
+ fail(label + ' must use 1-32 lowercase letters, numbers or _ and start with a letter.');
66
+ return value;
67
+ }
68
+
69
+ function distinct(values, label) {
70
+ if (new Set(values).size !== values.length) fail(label + ' must not repeat.');
71
+ return values;
72
+ }
73
+
74
+ /** The same rules Nexus applies to a plan path, so a fanout scope exports unchanged. */
75
+ function portablePath(value, label = 'Path') {
76
+ if (typeof value !== 'string' || !value || value.length > 500 || value.includes('\0'))
77
+ fail(label + ' must be a nonempty project-relative path.');
78
+ const parts = value.split('/');
79
+ if (
80
+ /[\\:<>"|?*]/.test(value) ||
81
+ [...value].some((char) => char.charCodeAt(0) < 32) ||
82
+ value.startsWith('/') ||
83
+ /^[A-Za-z]:/.test(value) ||
84
+ parts.some(
85
+ (part) =>
86
+ !part ||
87
+ part === '.' ||
88
+ part === '..' ||
89
+ /[. ]$/.test(part) ||
90
+ /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
91
+ )
92
+ )
93
+ fail(label + ' is not a portable project-relative path: ' + value);
94
+ return value;
95
+ }
96
+
97
+ /** A write scope: portable, and never .git or the BMAD+ state tree. */
98
+ function scopePath(value, label) {
99
+ portablePath(value, label);
100
+ const head = value.split('/')[0].toLowerCase();
101
+ if (head === '.git' || head === '.bmad-plus')
102
+ fail(label + ' may not reach .git or the BMAD+ state tree: ' + value);
103
+ return value;
104
+ }
105
+
106
+ const inScope = (file, scope) =>
107
+ file.toLowerCase() === scope.toLowerCase() ||
108
+ file.toLowerCase().startsWith(scope.toLowerCase() + '/');
109
+ const overlap = (a, b) => inScope(a, b) || inScope(b, a);
110
+
111
+ function cardValue(value, label) {
112
+ if (typeof value === 'string') return str(value, label, 2000);
113
+ if (typeof value === 'number') return num(value, label);
114
+ if (typeof value === 'boolean') return value;
115
+ fail(label + ' must be a string, a number or a boolean.');
116
+ return value;
117
+ }
118
+
119
+ function normalizeField(name, raw) {
120
+ const field = object(raw, 'Answer field "' + name + '"');
121
+ onlyKeys(field, ['type', 'min', 'max', 'values', 'maxItems'], 'Answer field "' + name + '"');
122
+ const label = 'Answer field "' + name + '"';
123
+ if (!FIELD_TYPES.includes(field.type))
124
+ fail(label + ' type must be one of ' + FIELD_TYPES.join(', ') + '.');
125
+ const out = { type: field.type };
126
+ const bounds = (key) => {
127
+ if (field[key] === undefined) return undefined;
128
+ return field.type === 'integer'
129
+ ? int(field[key], label + ' ' + key, -1e15, 1e15)
130
+ : num(field[key], label + ' ' + key);
131
+ };
132
+ switch (field.type) {
133
+ case 'boolean':
134
+ onlyKeys(field, ['type'], label);
135
+ break;
136
+ case 'integer':
137
+ case 'number':
138
+ onlyKeys(field, ['type', 'min', 'max'], label);
139
+ out.min = bounds('min');
140
+ out.max = bounds('max');
141
+ if (out.min !== undefined && out.max !== undefined && out.min > out.max)
142
+ fail(label + ' min exceeds max.');
143
+ break;
144
+ case 'string':
145
+ onlyKeys(field, ['type', 'min', 'max'], label);
146
+ out.min = field.min === undefined ? 0 : int(field.min, label + ' min', 0, MAX_TEXT_CEILING);
147
+ out.max =
148
+ field.max === undefined
149
+ ? MAX_TEXT_DEFAULT
150
+ : int(field.max, label + ' max', 1, MAX_TEXT_CEILING);
151
+ if (out.min > out.max) fail(label + ' min exceeds max.');
152
+ break;
153
+ case 'enum':
154
+ onlyKeys(field, ['type', 'values'], label);
155
+ out.values = distinct(
156
+ list(field.values, label + ' values', 50).map((value) => str(value, label + ' value', 100)),
157
+ label + ' values'
158
+ );
159
+ break;
160
+ case 'list':
161
+ onlyKeys(field, ['type', 'maxItems'], label);
162
+ out.maxItems =
163
+ field.maxItems === undefined
164
+ ? 50
165
+ : int(field.maxItems, label + ' maxItems', 1, MAX_LIST_ITEMS);
166
+ break;
167
+ default:
168
+ break;
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function normalizeAnswer(raw) {
174
+ const answer = object(raw, 'Definition answer');
175
+ onlyKeys(answer, ['required', 'fields'], 'Definition answer');
176
+ const source = object(answer.fields, 'Definition answer.fields');
177
+ const names = Object.keys(source);
178
+ if (names.length > MAX_FIELDS) fail('Definition answer.fields allows at most 50 fields.');
179
+ const fields = {};
180
+ for (const name of names) {
181
+ fieldName(name, 'Answer field name');
182
+ if (name === 'id') fail('Answer field "id" is implicit: it always equals the unit id.');
183
+ fields[name] = normalizeField(name, source[name]);
184
+ }
185
+ const required = distinct(
186
+ list(answer.required ?? [], 'Definition answer.required', MAX_FIELDS + 1, 0),
187
+ 'Definition answer.required'
188
+ );
189
+ for (const name of required)
190
+ if (name !== 'id' && !Object.hasOwn(fields, name))
191
+ fail('Definition answer.required names an undeclared field "' + name + '".');
192
+ return { required: ['id', ...required.filter((name) => name !== 'id')], fields };
193
+ }
194
+
195
+ function normalizeTally(raw, fields) {
196
+ const tally = object(raw ?? {}, 'Definition tally');
197
+ onlyKeys(tally, ['rate', 'groupBy', 'counts', 'quotes', 'numbers'], 'Definition tally');
198
+ const typed = (name, types, label) => {
199
+ fieldName(name, label);
200
+ if (!Object.hasOwn(fields, name) || !types.includes(fields[name].type))
201
+ fail(label + ' "' + name + '" must be a declared ' + types.join(' or ') + ' field.');
202
+ return name;
203
+ };
204
+ const out = { rate: null, groupBy: [], counts: [], quotes: {}, numbers: [] };
205
+ if (tally.rate !== undefined && tally.rate !== null)
206
+ out.rate = typed(tally.rate, ['boolean'], 'Tally rate');
207
+ if (tally.groupBy !== undefined)
208
+ out.groupBy = distinct(
209
+ list(tally.groupBy, 'Tally groupBy', 5, 0).map((name) => fieldName(name, 'Tally groupBy')),
210
+ 'Tally groupBy'
211
+ );
212
+ if (tally.counts !== undefined)
213
+ out.counts = distinct(
214
+ list(tally.counts, 'Tally counts', MAX_FIELDS, 0).map((name) =>
215
+ typed(name, ['enum'], 'Tally counts')
216
+ ),
217
+ 'Tally counts'
218
+ );
219
+ if (tally.quotes !== undefined) {
220
+ const quotes = object(tally.quotes, 'Tally quotes');
221
+ for (const [name, count] of Object.entries(quotes)) {
222
+ typed(name, ['string'], 'Tally quotes');
223
+ out.quotes[name] = int(count, 'Tally quotes "' + name + '"', 1, MAX_QUOTES);
224
+ }
225
+ }
226
+ if (tally.numbers !== undefined)
227
+ out.numbers = distinct(
228
+ list(tally.numbers, 'Tally numbers', MAX_FIELDS, 0).map((name) =>
229
+ typed(name, ['integer', 'number'], 'Tally numbers')
230
+ ),
231
+ 'Tally numbers'
232
+ );
233
+ return out;
234
+ }
235
+
236
+ function normalizeJuryList(raw) {
237
+ const entries = list(raw, 'Definition units.list', MAX_UNITS);
238
+ return entries.map((raw, index) => {
239
+ const label = 'Jury unit ' + (index + 1);
240
+ const entry = object(raw, label);
241
+ const unit = {};
242
+ for (const [key, value] of Object.entries(entry)) {
243
+ if (key === 'id') {
244
+ if (typeof value !== 'string' || !UNIT_ID.test(value)) fail(label + ' has an invalid id.');
245
+ unit.id = value;
246
+ } else {
247
+ fieldName(key, label + ' field name');
248
+ unit[key] = cardValue(value, label + ' field "' + key + '"');
249
+ }
250
+ }
251
+ if (typeof unit.lens !== 'string') fail(label + ' needs a "lens" string.');
252
+ return unit;
253
+ });
254
+ }
255
+
256
+ function normalizeDefinition(raw) {
257
+ const definition = object(raw, 'Definition');
258
+ onlyKeys(
259
+ definition,
260
+ [
261
+ 'schemaVersion',
262
+ 'id',
263
+ 'kind',
264
+ 'title',
265
+ 'pack',
266
+ 'units',
267
+ 'brief',
268
+ 'answer',
269
+ 'tally',
270
+ 'disclosure',
271
+ ],
272
+ 'Definition'
273
+ );
274
+ if (definition.schemaVersion !== 1) fail('Definition schemaVersion must be 1.');
275
+ if (typeof definition.id !== 'string' || !ID.test(definition.id))
276
+ fail('Definition id must use 1-64 lowercase letters, numbers or -.');
277
+ if (!Object.hasOwn(SOURCES, definition.kind))
278
+ fail('Definition kind must be panel, jury or fanout.');
279
+ const kind = definition.kind;
280
+ str(definition.title, 'Definition title', 200);
281
+ let pack = null;
282
+ if (definition.pack !== undefined && definition.pack !== null) {
283
+ if (typeof definition.pack !== 'string' || !ID.test(definition.pack))
284
+ fail('Definition pack must be a pack id.');
285
+ pack = definition.pack;
286
+ }
287
+ const units = object(definition.units, 'Definition units');
288
+ onlyKeys(units, ['source', 'idPrefix', 'list'], 'Definition units');
289
+ if (units.source !== SOURCES[kind])
290
+ fail('A ' + kind + ' swarm needs units.source "' + SOURCES[kind] + '".');
291
+ let idPrefix = null;
292
+ if (units.idPrefix !== undefined && units.idPrefix !== null) {
293
+ if (kind === 'fanout') fail('Fanout units carry their own ids: idPrefix does not apply.');
294
+ if (typeof units.idPrefix !== 'string' || !PREFIX.test(units.idPrefix))
295
+ fail('Definition units.idPrefix must be 1-4 letters.');
296
+ idPrefix = units.idPrefix;
297
+ } else if (kind === 'panel') fail('A panel needs units.idPrefix.');
298
+ let juryList = null;
299
+ if (kind === 'jury') {
300
+ juryList = normalizeJuryList(units.list);
301
+ if (!idPrefix && juryList.some((unit) => !unit.id))
302
+ fail('Every jury unit needs an id, or the definition needs units.idPrefix.');
303
+ } else if (units.list !== undefined) fail('Only a jury lists its units in the definition.');
304
+
305
+ const brief = object(definition.brief, 'Definition brief');
306
+ onlyKeys(brief, ['template', 'context'], 'Definition brief');
307
+ const template = str(brief.template, 'Definition brief.template', 500);
308
+ if (
309
+ template.split('/').some((part) => !part || part === '.' || part === '..') ||
310
+ /[\\:]/.test(template) ||
311
+ template.startsWith('/')
312
+ )
313
+ fail('Definition brief.template must be a plain relative path without . or .. parts.');
314
+ const contexts = distinct(
315
+ list(brief.context ?? [], 'Definition brief.context', 20, 0).map((name) => {
316
+ if (typeof name !== 'string' || !CONTEXT.test(name))
317
+ fail('Definition brief.context names must use 1-32 lowercase letters, numbers, _ or -.');
318
+ return name;
319
+ }),
320
+ 'Definition brief.context'
321
+ );
322
+ const answer = normalizeAnswer(definition.answer);
323
+ const tally = normalizeTally(definition.tally, answer.fields);
324
+ return {
325
+ schemaVersion: 1,
326
+ id: definition.id,
327
+ kind,
328
+ title: definition.title,
329
+ pack,
330
+ units: { source: units.source, idPrefix, ...(juryList ? { list: juryList } : {}) },
331
+ brief: { template, context: contexts },
332
+ answer,
333
+ tally,
334
+ disclosure: str(definition.disclosure, 'Definition disclosure', 500),
335
+ };
336
+ }
337
+
338
+ function normalizeRange(raw, label) {
339
+ const range = list(raw, label, 2, 2);
340
+ const [low, high] = range.map((value) => num(value, label));
341
+ if (low > high) fail(label + ' low bound exceeds its high bound.');
342
+ return [low, high];
343
+ }
344
+
345
+ function normalizeRanges(raw, label, max = 20) {
346
+ const ranges = object(raw ?? {}, label);
347
+ const out = {};
348
+ const names = Object.keys(ranges);
349
+ if (names.length > max) fail(label + ' allows at most ' + max + ' entries.');
350
+ for (const name of names) {
351
+ fieldName(name, label + ' name');
352
+ out[name] = normalizeRange(ranges[name], label + ' "' + name + '"');
353
+ }
354
+ return out;
355
+ }
356
+
357
+ function normalizeProfile(raw) {
358
+ const profile = object(raw, 'Profile');
359
+ onlyKeys(
360
+ profile,
361
+ ['schemaVersion', 'subject', 'target', 'dimensions', 'ranges', 'pools'],
362
+ 'Profile'
363
+ );
364
+ if (profile.schemaVersion !== 1) fail('Profile schemaVersion must be 1.');
365
+ str(profile.subject, 'Profile subject', 200);
366
+ str(profile.target, 'Profile target', 500);
367
+ const fields = ['id'];
368
+ const claim = (name, label) => {
369
+ if (fields.includes(name) || RESERVED.includes(name))
370
+ fail(label + ' "' + name + '" collides with another card field.');
371
+ fields.push(name);
372
+ };
373
+ const dimensions = list(profile.dimensions, 'Profile dimensions', 10).map((raw, index) => {
374
+ const label = 'Profile dimension ' + (index + 1);
375
+ const dimension = object(raw, label);
376
+ onlyKeys(dimension, ['name', 'values'], label);
377
+ const name = fieldName(dimension.name, label + ' name');
378
+ claim(name, label + ' name');
379
+ claim(name + '_note', label + ' note');
380
+ let rangeNames = null;
381
+ const values = list(dimension.values, label + ' values', 50).map((raw, position) => {
382
+ const valueLabel = label + ' value ' + (position + 1);
383
+ const entry = object(raw, valueLabel);
384
+ onlyKeys(entry, ['value', 'share', 'ranges', 'note'], valueLabel);
385
+ const share = num(entry.share, valueLabel + ' share');
386
+ if (share <= 0 || share > 1) fail(valueLabel + ' share must be above 0 and at most 1.');
387
+ const ranges = normalizeRanges(entry.ranges, valueLabel + ' ranges');
388
+ const names = Object.keys(ranges).sort();
389
+ if (rangeNames === null) rangeNames = names;
390
+ else if (names.join(',') !== rangeNames.join(','))
391
+ fail(label + ': every value must declare the same range names.');
392
+ return {
393
+ value: str(entry.value, valueLabel + ' value', 100),
394
+ share,
395
+ ranges,
396
+ note: entry.note === undefined ? null : str(entry.note, valueLabel + ' note', 500),
397
+ };
398
+ });
399
+ distinct(
400
+ values.map((entry) => entry.value),
401
+ label + ' values'
402
+ );
403
+ const total = values.reduce((sum, entry) => sum + entry.share, 0);
404
+ if (Math.abs(total - 1) > 1e-6) fail(label + ' shares must add up to 1.');
405
+ for (const rangeName of rangeNames) claim(rangeName, label + ' range');
406
+ return { name, values };
407
+ });
408
+ const ranges = normalizeRanges(profile.ranges, 'Profile ranges');
409
+ for (const name of Object.keys(ranges)) claim(name, 'Profile range');
410
+ const pools = object(profile.pools ?? {}, 'Profile pools');
411
+ const poolNames = Object.keys(pools);
412
+ if (poolNames.length > 20) fail('Profile pools allows at most 20 entries.');
413
+ const normalizedPools = {};
414
+ for (const name of poolNames) {
415
+ fieldName(name, 'Profile pool name');
416
+ claim(name, 'Profile pool');
417
+ normalizedPools[name] = list(pools[name], 'Profile pool "' + name + '"', 200).map((entry) =>
418
+ str(entry, 'Profile pool "' + name + '" entry', 500)
419
+ );
420
+ }
421
+ return {
422
+ schemaVersion: 1,
423
+ subject: profile.subject,
424
+ target: profile.target,
425
+ dimensions,
426
+ ranges,
427
+ pools: normalizedPools,
428
+ fields,
429
+ };
430
+ }
431
+
432
+ function normalizeUnits(raw) {
433
+ const file = object(raw, 'Units file');
434
+ onlyKeys(file, ['schemaVersion', 'units'], 'Units file');
435
+ if (file.schemaVersion !== 1) fail('Units file schemaVersion must be 1.');
436
+ const units = list(file.units, 'Units', MAX_UNITS).map((raw, index) => {
437
+ const label = 'Unit ' + (index + 1);
438
+ const entry = object(raw, label);
439
+ if (typeof entry.id !== 'string' || !NEXUS_ID.test(entry.id))
440
+ fail(label + ' needs an id of 1-64 lowercase letters, numbers, _ or -.');
441
+ const scopes = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
442
+ const scope = distinct(
443
+ list(scopes, label + ' scope', 20).map((item) => scopePath(item, label + ' scope')),
444
+ label + ' scope'
445
+ );
446
+ const unit = { id: entry.id, scope };
447
+ for (const [key, value] of Object.entries(entry)) {
448
+ if (key === 'id' || key === 'scope') continue;
449
+ fieldName(key, label + ' field name');
450
+ if (RESERVED.includes(key)) fail(label + ' field "' + key + '" is reserved.');
451
+ unit[key] = cardValue(value, label + ' field "' + key + '"');
452
+ }
453
+ return unit;
454
+ });
455
+ distinct(
456
+ units.map((unit) => unit.id),
457
+ 'Unit ids'
458
+ );
459
+ for (let a = 0; a < units.length; a += 1)
460
+ for (let b = a + 1; b < units.length; b += 1)
461
+ for (const left of units[a].scope)
462
+ for (const right of units[b].scope)
463
+ if (overlap(left, right))
464
+ fail('Units ' + units[a].id + ' and ' + units[b].id + ' share a write scope.');
465
+ return units;
466
+ }
467
+
468
+ /** Every placeholder of a template, or the first unknown one as an error. */
469
+ function templatePlaceholders(template) {
470
+ const found = [];
471
+ for (const match of template.matchAll(PLACEHOLDER)) {
472
+ const token = match[1].trim();
473
+ const [kind, name, extra] = token.split('.');
474
+ const known =
475
+ extra === undefined &&
476
+ ((kind === 'card' && name !== undefined && FIELD.test(name)) ||
477
+ (kind === 'context' && name !== undefined && CONTEXT.test(name)) ||
478
+ (kind === 'answer' && name === 'path') ||
479
+ (kind === 'run' && name === 'id') ||
480
+ (kind === 'unit' && name === 'id') ||
481
+ (kind === 'disclosure' && name === undefined));
482
+ if (!known) fail('Unknown placeholder {{' + token + '}} in the brief template.');
483
+ found.push({ kind, name: name ?? null });
484
+ }
485
+ return found;
486
+ }
487
+
488
+ function checkTemplate(template, cards, contexts) {
489
+ for (const { kind, name } of templatePlaceholders(template)) {
490
+ if (kind === 'card') {
491
+ const missing = cards.find((card) => !Object.hasOwn(card, name));
492
+ if (missing)
493
+ fail('Brief placeholder {{card.' + name + '}} is absent from unit ' + missing.id + '.');
494
+ }
495
+ if (kind === 'context' && !contexts.includes(name))
496
+ fail(
497
+ 'Brief placeholder {{context.' +
498
+ name +
499
+ '}} names a context the definition does not declare.'
500
+ );
501
+ }
502
+ }
503
+
504
+ const renderValue = (value) => (Array.isArray(value) ? value.join(', ') : String(value));
505
+
506
+ function renderBrief(template, { card, contexts, answerPath, runId, disclosure }) {
507
+ return template.replace(PLACEHOLDER, (_, inner) => {
508
+ const [kind, name] = inner.trim().split('.');
509
+ if (kind === 'card') return renderValue(card[name]);
510
+ if (kind === 'context') return contexts[name];
511
+ if (kind === 'answer') return answerPath;
512
+ if (kind === 'run') return runId;
513
+ if (kind === 'unit') return card.id;
514
+ return disclosure;
515
+ });
516
+ }
517
+
518
+ function fieldProblems(name, field, value) {
519
+ const label = 'field "' + name + '"';
520
+ switch (field.type) {
521
+ case 'boolean':
522
+ return typeof value === 'boolean' ? [] : [label + ' must be true or false'];
523
+ case 'integer':
524
+ case 'number': {
525
+ if (typeof value !== 'number' || !Number.isFinite(value))
526
+ return [label + ' must be a number'];
527
+ if (field.type === 'integer' && !Number.isInteger(value))
528
+ return [label + ' must be an integer'];
529
+ if (field.min !== undefined && value < field.min) return [label + ' is below ' + field.min];
530
+ if (field.max !== undefined && value > field.max) return [label + ' is above ' + field.max];
531
+ return [];
532
+ }
533
+ case 'string':
534
+ if (typeof value !== 'string') return [label + ' must be a string'];
535
+ if (value.includes('\0')) return [label + ' contains a NUL character'];
536
+ if (value.trim().length < field.min) return [label + ' is shorter than ' + field.min];
537
+ if (value.length > field.max) return [label + ' is longer than ' + field.max];
538
+ return [];
539
+ case 'enum':
540
+ return field.values.includes(value)
541
+ ? []
542
+ : [label + ' must be one of ' + field.values.join(', ')];
543
+ case 'list':
544
+ if (!Array.isArray(value)) return [label + ' must be a list of strings'];
545
+ if (value.length > field.maxItems)
546
+ return [label + ' has more than ' + field.maxItems + ' items'];
547
+ if (value.some((item) => typeof item !== 'string' || !item.trim() || item.length > 1000))
548
+ return [label + ' items must be nonempty strings of at most 1000 characters'];
549
+ return [];
550
+ default:
551
+ return [label + ' has an unknown type'];
552
+ }
553
+ }
554
+
555
+ /** Reasons an answer does not count; an empty list means it does. */
556
+ function answerProblems(definition, unitId, answer) {
557
+ if (!isObject(answer)) return ['the answer is not a JSON object'];
558
+ const problems = [];
559
+ if (answer.id !== unitId)
560
+ problems.push('id is ' + JSON.stringify(answer.id) + ', expected "' + unitId + '"');
561
+ for (const name of definition.answer.required)
562
+ if (name !== 'id' && !Object.hasOwn(answer, name))
563
+ problems.push('missing required field "' + name + '"');
564
+ for (const [name, value] of Object.entries(answer)) {
565
+ if (name === 'id') continue;
566
+ // Own keys only: a "__proto__" or "constructor" key in the answer is not a declared field.
567
+ if (!Object.hasOwn(definition.answer.fields, name))
568
+ problems.push('unknown field "' + name + '"');
569
+ else problems.push(...fieldProblems(name, definition.answer.fields[name], value));
570
+ }
571
+ return problems;
572
+ }
573
+
574
+ module.exports = {
575
+ MAX_UNITS,
576
+ ID,
577
+ UNIT_ID,
578
+ NEXUS_ID,
579
+ isObject,
580
+ portablePath,
581
+ scopePath,
582
+ normalizeDefinition,
583
+ normalizeProfile,
584
+ normalizeUnits,
585
+ templatePlaceholders,
586
+ checkTemplate,
587
+ renderBrief,
588
+ answerProblems,
589
+ };