@descent-vtt/spec-brief 0.8.0 → 0.9.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/dist/cli.js CHANGED
@@ -7,11 +7,12 @@
7
7
  * must never exit as though it found nothing.
8
8
  */
9
9
  import { readFile, stat } from 'node:fs/promises';
10
+ import { resolve } from 'node:path';
10
11
  import { parseArgs } from 'node:util';
11
12
  import { TransactionError, ConflictError } from './apply.js';
12
13
  import { reported } from './collisions.js';
13
14
  import { CONFIG_FILES, ConfigError, initialConfig } from './config.js';
14
- import { BriefEngine, EngineError, today } from './engine.js';
15
+ import { BriefEngine, EngineError, isDate, today } from './engine.js';
15
16
  import { NodeFileSystem } from './fs.js';
16
17
  import { GitError } from './git.js';
17
18
  import { failing, sortFindings, summarise } from './lint.js';
@@ -93,11 +94,11 @@ function untrue(command, option) {
93
94
  return command === 'init' || command === 'new' || command === 'list';
94
95
  case 'color':
95
96
  return command === 'init' || command === 'new';
96
- // Each caller asks only whether the answer is true, so the mutant that
97
- // leaves this case out, and answers nothing, is equivalent.
98
- default:
99
- return false;
100
97
  }
98
+ // After the switch and not as its `default`: a `default` with its return
99
+ // taken out answers nothing, which each caller reads as false, and no test
100
+ // can tell that mutant from the code (ADR-0009).
101
+ return false;
101
102
  }
102
103
  /**
103
104
  * Whether the option asks the command to leave out what it never does, and
@@ -115,11 +116,9 @@ function alreadySo(command, option) {
115
116
  return command === 'init';
116
117
  case 'no-color':
117
118
  return command === 'init' || command === 'new';
118
- // Each caller asks only whether the answer is true, so the mutant that
119
- // leaves this case out, and answers nothing, is equivalent.
120
- default:
121
- return false;
122
119
  }
120
+ // After the switch, for the reason `untrue` has.
121
+ return false;
123
122
  }
124
123
  /** Options by their names on the command line, as a sentence lists them. */
125
124
  function listed(names) {
@@ -151,8 +150,117 @@ function unread(command, spec, values) {
151
150
  }
152
151
  if (flag(values, 'color') && flag(values, 'no-color'))
153
152
  return '--color is given beside --no-color, which turns colour off; give one of the two';
153
+ // The engine refuses the pair as well, for a caller of the library; here it
154
+ // is refused with the line, where --help and --version are no way past it.
155
+ if (flag(values, 'no-git') && (text(values, 'commit') !== undefined || text(values, 'base') !== undefined)) {
156
+ return 'a commit or a base needs git; drop --no-git, or drop --commit and --base';
157
+ }
158
+ return null;
159
+ }
160
+ /**
161
+ * An option's value as the command reads it, so that one thing written two
162
+ * ways is asked for once: a directory as the run resolves it, a number by
163
+ * what its digits count, a list by its ids, a summary as it is trimmed. Any
164
+ * other value is read as it is written, in its own case, which is how each
165
+ * of those options reads it. A revision is read by git, which a line is not
166
+ * checked against, so `HEAD` and the commit it names are two values here.
167
+ */
168
+ function asRead(option, value, root) {
169
+ switch (option) {
170
+ case 'root':
171
+ return resolve(value);
172
+ case 'config':
173
+ return resolve(root, value);
174
+ case 'wave':
175
+ case 'pr':
176
+ return String(digits(value) ?? value);
177
+ case 'depends-on':
178
+ return JSON.stringify(idsOf(value));
179
+ case 'summary':
180
+ return value.trim();
181
+ case 'briefs':
182
+ case 'archive':
183
+ // Last: one outside the repository leaves the switch and is read as it
184
+ // was written, for `directories` to refuse by what it was given. A catch
185
+ // that returned it would have a mutant, its return taken out, that fell
186
+ // into the next case and read it the same, which no test can tell apart.
187
+ try {
188
+ return normalisePath(value);
189
+ }
190
+ catch { }
191
+ }
192
+ // After the switch, for the reason `untrue` has.
193
+ return value;
194
+ }
195
+ /**
196
+ * Why a line that gives one option two values is not one to run: the later
197
+ * was read, and the earlier went unread with no word of it, so `--root
198
+ * nowhere --root .` ran in `.` (spec-core ADR-0005, amended 2026-10-18).
199
+ */
200
+ function twice(options, values, cwd) {
201
+ // A configuration is found from the root, as the engine finds it.
202
+ const root = resolve(text(values, 'root') ?? cwd);
203
+ const first = new Map();
204
+ for (const token of options) {
205
+ // A flag has no value to compare, and one given twice is that flag once.
206
+ // Taken in here it would be read as nothing each time, which decides
207
+ // nothing either: the mutant that takes it in is equivalent.
208
+ if (token.value === undefined)
209
+ continue;
210
+ const read = asRead(token.name, token.value, root);
211
+ const earlier = first.get(token.name);
212
+ if (earlier === undefined)
213
+ first.set(token.name, { written: token.value, read });
214
+ else if (earlier.read !== read) {
215
+ // Shown as JSON, as an empty value is, where what cannot be seen can be.
216
+ return `--${token.name} is given ${JSON.stringify(earlier.written)} and ${JSON.stringify(token.value)}, and takes one value; give one of the two`;
217
+ }
218
+ }
154
219
  return null;
155
220
  }
221
+ /**
222
+ * `--help` beside `--version` asks for two answers, and printed one of
223
+ * them; the other went unread. Named as each was written, `-h` or `--help`.
224
+ */
225
+ function answers(options) {
226
+ let asked;
227
+ for (const token of options) {
228
+ if (token.name !== 'help' && token.name !== 'version')
229
+ continue;
230
+ if (asked === undefined)
231
+ asked = token;
232
+ else if (asked.name !== token.name)
233
+ return `${asked.rawName} is given beside ${token.rawName}, and one of the two answers would go unread; give one of the two`;
234
+ }
235
+ return null;
236
+ }
237
+ /**
238
+ * What a command's own options hold, refused where the line is read, by what
239
+ * each holds: a number, a list of ids, a date, a revision, a directory, and a
240
+ * second brief where a command takes one. What only the tree can answer - a
241
+ * root that is not there, an id that is taken, a type the configuration does
242
+ * not have - is asked by the run, which `--help` and `--version` do not start.
243
+ */
244
+ function checkValues(command, values, positionals) {
245
+ integer(values, 'wave', 0);
246
+ integer(values, 'pr', 1);
247
+ dependencies(values);
248
+ const date = text(values, 'date');
249
+ if (date !== undefined && !isDate(date))
250
+ throw new UsageError(`"${date}" is not a date written YYYY-MM-DD`);
251
+ for (const option of ['commit', 'base']) {
252
+ // Handed to git, it would be read as one of git's options.
253
+ const revision = text(values, option);
254
+ if (revision?.startsWith('-') === true)
255
+ throw new UsageError(`"${revision}" is not a revision`);
256
+ }
257
+ // Only init takes --briefs and --archive; for any other command they are
258
+ // the defaults, which are two directories inside the root.
259
+ directories(values);
260
+ if ((command === 'archive' || command === 'unarchive') && positionals.length > 1) {
261
+ throw new UsageError(`${command} takes one brief: spec-brief ${command} <brief>`);
262
+ }
263
+ }
156
264
  /**
157
265
  * The commands by name, and nothing for a command line that names none. A map,
158
266
  * because an object answers to more names than it was given: `constructor` and
@@ -243,6 +351,12 @@ are --config beside --no-config and --color beside --no-color, of which one
243
351
  would go unread. An option that asks a command to leave out what it never
244
352
  does is taken, as init --no-git: it asks for what is already so.
245
353
 
354
+ An option that takes one value and is given two is refused, as --root a
355
+ --root b, since the earlier would go unread; one value given twice, however
356
+ it is written, is taken once, as --root . --root ./. A line refused for any
357
+ of these is refused with --help or --version on it too, and so is --help
358
+ beside --version.
359
+
246
360
  Exit codes: 0 clean, 1 findings or refused, 2 the run could not be trusted.
247
361
  `;
248
362
  /** Global options that take a value, so the value is not mistaken for the command. */
@@ -261,7 +375,14 @@ function commandOf(argv) {
261
375
  }
262
376
  return undefined;
263
377
  }
264
- export function parse(argv) {
378
+ /**
379
+ * The line, read and checked. Everything the line alone decides is refused
380
+ * here, before `--help` and `--version` are answered, as an option the
381
+ * parser does not know always was: a wrong line is wrong with either of them
382
+ * on it (spec-core ADR-0005, amended 2026-10-18). `cwd` is the directory a
383
+ * run without `--root` starts in, from which a `--config` is found.
384
+ */
385
+ export function parse(argv, cwd = process.cwd()) {
265
386
  const command = commandOf(argv);
266
387
  const spec = COMMANDS.get(command);
267
388
  if (command !== undefined && spec === undefined) {
@@ -269,12 +390,17 @@ export function parse(argv) {
269
390
  }
270
391
  let parsed;
271
392
  try {
272
- parsed = parseArgs({
393
+ const read = parseArgs({
273
394
  args: [...argv],
274
395
  options: { ...GLOBAL, ...(spec?.options ?? {}) },
275
396
  allowPositionals: true,
276
397
  strict: true,
398
+ tokens: true,
277
399
  });
400
+ // The cast stands on the name, with nothing under it: Stryker makes no
401
+ // mutant of what an `as` holds, and the call above is what the parser
402
+ // is asked (ADR-0009).
403
+ parsed = read;
278
404
  }
279
405
  catch (error) {
280
406
  throw new UsageError(error.message);
@@ -294,8 +420,22 @@ export function parse(argv) {
294
420
  const extra = positionals.map((argument) => `"${argument}"`).join(', ');
295
421
  throw new UsageError(`${command} takes no argument, not ${extra}: spec-brief ${command}${spec.noArgument}`);
296
422
  }
423
+ const options = parsed.tokens.filter((token) => token.kind === 'option');
424
+ const refusal = twice(options, parsed.values, cwd) ??
425
+ answers(options) ??
426
+ (spec === undefined ? null : (unread(command, spec, parsed.values) ?? unwritten(command, spec, parsed.values)));
427
+ if (refusal !== null)
428
+ throw new UsageError(refusal);
429
+ checkValues(command, parsed.values, positionals);
297
430
  return { command, values: parsed.values, positionals };
298
431
  }
432
+ /** A `--format` the command does not write, which the help lists by command. */
433
+ function unwritten(command, spec, values) {
434
+ const asked = text(values, 'format') ?? 'pretty';
435
+ // Cast on the name, as above. The check below is what makes it true.
436
+ const format = asked;
437
+ return spec.formats.includes(format) ? null : `--format for ${command} is one of ${spec.formats.join(', ')}, not "${format}"`;
438
+ }
299
439
  /** The value of an option declared a string, which is all `parseArgs` gives one. */
300
440
  function text(values, key) {
301
441
  return values[key];
@@ -303,15 +443,63 @@ function text(values, key) {
303
443
  function flag(values, key) {
304
444
  return values[key] === true;
305
445
  }
446
+ /** The number a value's digits count, or nothing for a value that is not digits alone. */
447
+ function digits(raw) {
448
+ return /^\d+$/.test(raw) ? Number(raw) : undefined;
449
+ }
306
450
  function integer(values, key, minimum) {
307
451
  const raw = text(values, key);
308
452
  if (raw === undefined)
309
453
  return undefined;
310
- if (!/^\d+$/.test(raw) || Number(raw) < minimum)
454
+ const number = digits(raw);
455
+ if (number === undefined || number < minimum)
311
456
  throw new UsageError(`--${key} must be a whole number of at least ${minimum}, not "${raw}"`);
312
- return Number(raw);
457
+ return number;
458
+ }
459
+ /** The ids of a list written with commas between them, spaces beside each taken off. */
460
+ function idsOf(listed) {
461
+ return listed.split(',').map((id) => id.trim());
462
+ }
463
+ /** `--depends-on` as the ids it names, or nothing where it is not given. */
464
+ function dependencies(values) {
465
+ const listed = text(values, 'depends-on');
466
+ const dependsOn = listed === undefined ? undefined : idsOf(listed);
467
+ // A list that names no brief is not a brief that depends on none: written
468
+ // as given, "," scaffolded a brief with no dependency and said nothing.
469
+ if (dependsOn?.every((d) => d === '')) {
470
+ throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief; give it ids with commas between them, as in 7,8, or leave the option out`);
471
+ }
472
+ // And a place in it that names none is a brief left out: "$A,$B" with one
473
+ // of the two unset scaffolded a brief that depends on the other alone.
474
+ if (dependsOn?.includes('')) {
475
+ throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief in one of its places; give it ids with one comma between each two, as in 7,8, and none before or after`);
476
+ }
477
+ return dependsOn;
478
+ }
479
+ /**
480
+ * The directories `init` sets up. One above the root is refused as the
481
+ * configuration refuses one (config.ts), in the same words, before anything
482
+ * is written.
483
+ */
484
+ function directories(values) {
485
+ const inside = (option, given) => {
486
+ try {
487
+ return normalisePath(given);
488
+ }
489
+ catch {
490
+ throw new UsageError(`--${option} must be a directory inside the repository, not "${given}"`);
491
+ }
492
+ };
493
+ const briefs = inside('briefs', text(values, 'briefs') ?? 'briefs');
494
+ const archive = inside('archive', text(values, 'archive') ?? `${briefs}/archive`);
495
+ if (briefs === '' || archive === '' || briefs === archive)
496
+ throw new UsageError('--briefs and --archive must be two directories');
497
+ return { briefs, archive };
313
498
  }
314
499
  async function version() {
500
+ // `JSON.parse` reads a file's bytes as it reads their text in UTF-8, so the
501
+ // encoding says what the manifest is and decides nothing: with none named,
502
+ // the version read is the same, and no test can tell the two apart.
315
503
  const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
316
504
  return pkg.version;
317
505
  }
@@ -374,20 +562,7 @@ async function runInit(run) {
374
562
  if (await fs.exists(name))
375
563
  throw new UsageError(`${name} already exists here; edit it instead`);
376
564
  }
377
- // A directory above the root is refused as the configuration refuses one
378
- // (config.ts), in the same words, before anything is written.
379
- const inside = (option, given) => {
380
- try {
381
- return normalisePath(given);
382
- }
383
- catch {
384
- throw new UsageError(`--${option} must be a directory inside the repository, not "${given}"`);
385
- }
386
- };
387
- const briefs = inside('briefs', text(run.values, 'briefs') ?? 'briefs');
388
- const archive = inside('archive', text(run.values, 'archive') ?? `${briefs}/archive`);
389
- if (briefs === '' || archive === '' || briefs === archive)
390
- throw new UsageError('--briefs and --archive must be two directories');
565
+ const { briefs, archive } = directories(run.values);
391
566
  const config = `${JSON.stringify(initialConfig(briefs, archive), null, 2)}\n`;
392
567
  await fs.write('.spec-brief.json', config);
393
568
  const written = ['.spec-brief.json'];
@@ -408,18 +583,7 @@ async function runNew(run) {
408
583
  const title = run.positionals.join(' ').trim();
409
584
  if (title === '')
410
585
  throw new UsageError('new needs a title: spec-brief new "<title>"');
411
- const listed = text(run.values, 'depends-on');
412
- const dependsOn = listed?.split(',').map((d) => d.trim());
413
- // A list that names no brief is not a brief that depends on none: written
414
- // as given, "," scaffolded a brief with no dependency and said nothing.
415
- if (dependsOn?.every((d) => d === '')) {
416
- throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief; give it ids with commas between them, as in 7,8, or leave the option out`);
417
- }
418
- // And a place in it that names none is a brief left out: "$A,$B" with one
419
- // of the two unset scaffolded a brief that depends on the other alone.
420
- if (dependsOn?.includes('')) {
421
- throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief in one of its places; give it ids with one comma between each two, as in 7,8, and none before or after`);
422
- }
586
+ const dependsOn = dependencies(run.values);
423
587
  const engine = await openEngine(run);
424
588
  const created = await engine.create({
425
589
  title,
@@ -503,7 +667,8 @@ async function runSchedule(run) {
503
667
  }
504
668
  async function runTransition(run, action) {
505
669
  const reference = run.positionals[0];
506
- if (reference === undefined || run.positionals.length > 1)
670
+ // A second brief is refused with the line; none at all is what --help is asked for.
671
+ if (reference === undefined)
507
672
  throw new UsageError(`${action} takes one brief: spec-brief ${action} <brief>`);
508
673
  const engine = await openEngine(run);
509
674
  const dryRun = flag(run.values, 'dry-run');
@@ -516,7 +681,8 @@ async function runTransition(run, action) {
516
681
  date: text(run.values, 'date') ?? run.today(),
517
682
  allowDirty: flag(run.values, 'allow-dirty'),
518
683
  strict: run.strict,
519
- noGit: flag(run.values, 'no-git'),
684
+ // No noGit: under --no-git the engine is opened without git, and a
685
+ // commit or a base beside it is refused with the line.
520
686
  })
521
687
  : engine.planUnarchive(reference, { strict: run.strict });
522
688
  const refused = plan.blocking.length > 0;
@@ -536,7 +702,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
536
702
  };
537
703
  let parsed;
538
704
  try {
539
- parsed = parse(argv);
705
+ parsed = parse(argv, io.cwd ?? process.cwd());
540
706
  }
541
707
  catch (error) {
542
708
  err(error.message);
@@ -557,15 +723,9 @@ export async function main(argv = process.argv.slice(2), io = {}) {
557
723
  // Asked for, the help is the answer; printed because no command was named, it is the error.
558
724
  return flag(values, 'help') ? EXIT_OK : EXIT_ERROR;
559
725
  }
560
- const spec = COMMANDS.get(command);
561
- const refusal = unread(command, spec, values);
562
- if (refusal !== null)
563
- throw new UsageError(refusal);
564
- const format = (text(values, 'format') ?? 'pretty');
565
- if (!spec.formats.includes(format)) {
566
- err(`--format for ${command} is one of ${spec.formats.join(', ')}, not "${format}"`);
567
- return EXIT_ERROR;
568
- }
726
+ const asked = text(values, 'format') ?? 'pretty';
727
+ // Cast on the name, as in `parse`, which refused a format the command does not write.
728
+ const format = asked;
569
729
  // A file, or a name with nothing behind it, had the search for a
570
730
  // configuration go on from its parent: the run reported on a tree nobody
571
731
  // had named, and `new` wrote into it. A path that cannot be reached is