@descent-vtt/spec-brief 0.9.0 → 0.10.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 CHANGED
@@ -11,6 +11,69 @@ release that can turn a run red. This is the family's policy, recorded in
11
11
  releases up to 0.2.7 came before it, and several of their patches changed
12
12
  what a run reports.
13
13
 
14
+ ## 0.10.0
15
+
16
+ One value written two ways is taken once in three more cases, each of
17
+ which was refused as two values: a drive letter in either case on
18
+ Windows, two revisions git says name one commit, and two lists that name
19
+ the same briefs. Nothing that ran is refused.
20
+
21
+ `list --format json` names what its answer was read from, so that another
22
+ tool can give the answer again without starting spec-brief. A
23
+ configuration that is not a file is refused, exit 2, where a named pipe
24
+ under its name was read without end.
25
+
26
+ ### Fixed
27
+
28
+ - **A drive letter names its volume in either case.** On Windows
29
+ `--root C:\x` beside `--root c:\x`, and two `--config` values the same
30
+ way, were refused as two directories; each is taken once. The rest of a
31
+ path is compared as it resolves, so `C:\x` beside `C:\X` is still two
32
+ ([An option given two values](README.md#an-option-given-two-values);
33
+ [the family contract](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md),
34
+ amended 2026-10-19).
35
+ - **A revision written two ways is asked of git.** `--commit HEAD` beside
36
+ the hash it names, or `--base main` beside the commit `main` is at, was
37
+ refused as two values. `archive` now asks git once what commit each
38
+ names, and takes two that name one as one. Two that name two commits,
39
+ one git cannot name, and any line with `--no-git`, `--help` or
40
+ `--version`, none of which asks git, are refused as they were
41
+ (ADR-0001, amended 2026-10-19).
42
+ - **`--depends-on` is read as the set of ids it names**, each as a brief's
43
+ id is matched: `7,8` beside `8,7`, `7` beside `007` and `7,8` beside
44
+ `7,8,7` are taken once, and the brief is written with the later as
45
+ given. A list that names another brief, or one fewer, is still two
46
+ values.
47
+
48
+ ### Added
49
+
50
+ - **`list --format json` names its inputs**, in a field `inputs`: each
51
+ file the answer was read from, with the SHA-256 of its bytes; each name a
52
+ configuration was looked for under and was not there, with `null`; each
53
+ directory the briefs were listed from, with the files in it, or `null`
54
+ where there was none, as an archive not yet made; and the version that
55
+ read them. `keep` is `false`, and nothing is listed, wherever that
56
+ cannot be named whole: a run that starts below its configuration's
57
+ directory, and one with no configuration file
58
+ ([`spec-brief list`](README.md#spec-brief-list); ADR-0002, amended
59
+ 2026-10-19). `schemaVersion` stays 2, since a field is added and none
60
+ changes. `list` asks git nothing where a configuration file names the
61
+ root, which is a process fewer.
62
+
63
+ ### Changed
64
+
65
+ - **A configuration that is not a file is refused.** A named pipe at
66
+ `.spec-brief.json` or `spec-brief.json`, or named by `--config`, was read
67
+ without end by every command; a socket ended the run on `ENXIO` and a
68
+ stack, and `--config /dev/zero` on `RangeError`. Each is now exit 2 with
69
+ one line, `is neither a file nor a directory, not a configuration file`,
70
+ before it is opened, as a directory was already. A link is read as what
71
+ it names ([An input that names nothing](README.md#an-input-that-names-nothing);
72
+ ADR-0003, amended 2026-10-19). Upgrading: nothing to change for a
73
+ configuration that is a file; `--config <(...)`, which a shell hands as a
74
+ pipe, ran in `/dev/fd` and found no briefs, and now says what it was
75
+ handed.
76
+
14
77
  ## 0.9.0
15
78
 
16
79
  An option that takes one value and is given two is refused, exit 2, where
package/README.md CHANGED
@@ -126,7 +126,32 @@ Live briefs with their status, in the word the brief writes (`封存` where the
126
126
  | --- | --- |
127
127
  | `--ready` | Only the ready briefs, which is the question an orchestrator asks. |
128
128
  | `--archived` | The archive as well. |
129
- | `--format json` | The whole table, for an orchestrator, and the sections the configuration asks for, each with its `hint`. |
129
+ | `--format json` | The whole table, for an orchestrator, the sections the configuration asks for, each with its `hint`, and what the answer was read from. |
130
+
131
+ **What the answer was read from.** `--format json` names, under `inputs`, every input of its answer, so that a tool that asks for the same answer again, as spec-harness does before each write it checks, can give the one it kept while every input reads as listed, without starting spec-brief ([the family contract](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md), amended 2026-10-19):
132
+
133
+ ```json
134
+ "inputs": {
135
+ "keep": true,
136
+ "tool": "0.10.0",
137
+ "files": [
138
+ { "path": ".spec-brief.json", "sha256": "ca3d163b..." },
139
+ { "path": "briefs/001_a.md", "sha256": "5ee9361a..." },
140
+ { "path": "spec-brief.json", "sha256": null }
141
+ ],
142
+ "directories": [
143
+ { "path": "briefs", "entries": ["001_a.md", "README.md"] },
144
+ { "path": "briefs/archive", "entries": null }
145
+ ],
146
+ "git": null
147
+ }
148
+ ```
149
+
150
+ - **`files`**: each file read, with the SHA-256 of its bytes in hex, and each name a configuration was looked for under and was not there, with `null`: it must still not be there.
151
+ - **`directories`**: each directory the briefs were listed from, with the names of the regular files directly in it, sorted as JavaScript sorts strings. A link and a directory are no file here, and a file that is no brief is named though it is not read, since one renamed would be. A directory looked for and not found, as an archive not yet made, has `null`: there must still be no directory there.
152
+ - **`tool`**: the version that read them. **`git`**: `null`, since `list` reads nothing of git, and asks it nothing where a configuration file names the root.
153
+ - **Paths** are POSIX, from the root, which wherever `keep` is `true` is the directory the run started in.
154
+ - **`keep` is `false`**, and the lists are empty, wherever spec-brief cannot name every input: a run that starts below its configuration's directory, or names a configuration in another, and one with no configuration file, where git or the search above the root finds the root.
130
155
 
131
156
  ### `spec-brief matrix`
132
157
 
@@ -280,7 +305,7 @@ An option, an argument or a path that is given and names nothing is refused: exi
280
305
  - **A list that names nothing.** `--depends-on ,` names no brief, and a brief that depends on nothing is one scaffolded without the option. A place in a list that names no brief, the comma too many in `7,8,` or the gap in `7,,8`, is refused as well: it is a brief left out, as `"$A,$B"` is with one of the two unset.
281
306
  - **An argument a command does not take.** `init`, `list`, `matrix` and `schedule` take none and refuse one by name, with their usage. `init mydir` set up the current directory; `init --root mydir` is what sets up another.
282
307
  - **A `--root` that is no directory.** A file, or a path that does not exist, is refused: the search for a configuration would go on from its parent, and the run would report on, or `new` write into, the tree above. A directory below the configuration's is a place to start from, as the current directory is.
283
- - **A configuration that is a directory**, named by `--config` or found under the configuration's name, does not load, and is said so in a line, as one that is not JSON is.
308
+ - **A configuration that is not a file**, named by `--config` or found under the configuration's name, does not load, and is said so in a line before it is opened, as one that is not JSON is: a directory, a named pipe, which was read without end, a socket or a device. A link is read as what it names.
284
309
 
285
310
  ### An option a command does not read
286
311
 
@@ -309,8 +334,8 @@ An option that takes one value is given one. Given two that read as two, the run
309
334
  spec-brief: --root is given "nowhere" and ".", and takes one value; give one of the two
310
335
  ```
311
336
 
312
- - **One value given twice is taken once, however it is written.** Two values are compared as each is read: `--root` and `--config` as the paths they resolve to, so `--root .` beside `--root ./`; `--briefs` and `--archive` as directories of the repository, so `specs` beside `./specs/`; `--wave` and `--pr` by the number their digits write, so `1` beside `01`; `--depends-on` by its ids in their order, so `7,8` beside `7, 8`; `--summary` with the spaces around it taken off. Any other value is read as it is written, in its own case.
313
- - **A revision written two ways is two values**: `--commit HEAD` beside the hash it names, since only git can say they are one, and the line is checked before git is asked anything.
337
+ - **One value given twice is taken once, however it is written.** Two values are compared as each is read: `--root` and `--config` as the paths they resolve to, so `--root .` beside `--root ./`, and on Windows with the drive letter in either case, so `C:\x` beside `c:\x`, where `C:\x` beside `C:\X` is two; `--briefs` and `--archive` as directories of the repository, so `specs` beside `./specs/`; `--wave` and `--pr` by the number their digits write, so `1` beside `01`; `--depends-on` by the set of ids it names, each as an id is matched to a brief, so `7,8` beside `8,7` or `7, 8`, and `7` beside `007`, and the brief is written with the later; `--summary` with the spaces around it taken off. Any other value is read as it is written, in its own case.
338
+ - **A revision written two ways is asked of git**: `archive` asks git once what commit each value of `--commit`, or of `--base`, names, and takes `HEAD` beside the hash it names as one value. Two that name two commits, or one git cannot name, are two values; so are any two beside `--no-git`, `--help` or `--version`, none of which asks git.
314
339
  - **Every option that takes a value takes one.** `--depends-on` is a list written in one value, and no option adds to a list each time it is given. A flag given twice is that flag once.
315
340
  - A shell alias or a script that gives an option, and a line that gives it again to override it, is refused: give the option once.
316
341
 
@@ -336,7 +361,7 @@ spec-brief: --help is given beside --version, and one of the two answers would g
336
361
 
337
362
  ## Configuration
338
363
 
339
- `.spec-brief.json` (or `spec-brief.json`) at the root; the nearest one above the working directory is used, and its directory is the root. It is JSON, validated against [`schema.json`](schema.json): an unknown key, a misspelt rule or a value of the wrong type stops the run with exit 2 rather than falling back to defaults and reporting on the wrong rules.
364
+ `.spec-brief.json` (or `spec-brief.json`) at the root; the nearest one above the working directory is used, and its directory is the root. It is a file, and JSON, validated against [`schema.json`](schema.json): an unknown key, a misspelt rule or a value of the wrong type stops the run with exit 2 rather than falling back to defaults and reporting on the wrong rules.
340
365
 
341
366
  | Key | Default | What it says |
342
367
  | --- | --- | --- |
package/dist/cli.d.ts CHANGED
@@ -24,11 +24,21 @@ export interface CliIO {
24
24
  readonly env?: Readonly<Record<string, string | undefined>>;
25
25
  }
26
26
  type Values = Record<string, string | boolean | undefined>;
27
+ /**
28
+ * A resolved path as two values of one option compare. On Windows a drive
29
+ * letter names its volume in either case, and a path resolved there starts
30
+ * with its drive letter or a backslash, so its first character is folded;
31
+ * the rest is compared as resolved (spec-core ADR-0005, amended 2026-10-19).
32
+ * A path written `\\?\c:\x` keeps its letter as written.
33
+ */
34
+ export declare function onVolume(path: string, platform?: string): string;
27
35
  export declare const HELP: string;
28
36
  interface Parsed {
29
37
  readonly command: string | undefined;
30
38
  readonly values: Values;
31
39
  readonly positionals: readonly string[];
40
+ /** Each option's revisions as they were written, where two are written two ways, for git to read. */
41
+ readonly revisions: ReadonlyMap<string, readonly string[]>;
32
42
  }
33
43
  /**
34
44
  * The line, read and checked. Everything the line alone decides is refused
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { parseArgs } from 'node:util';
12
12
  import { TransactionError, ConflictError } from './apply.js';
13
13
  import { reported } from './collisions.js';
14
14
  import { CONFIG_FILES, ConfigError, initialConfig } from './config.js';
15
+ import { idKey } from './corpus.js';
15
16
  import { BriefEngine, EngineError, isDate, today } from './engine.js';
16
17
  import { NodeFileSystem } from './fs.js';
17
18
  import { GitError } from './git.js';
@@ -157,25 +158,38 @@ function unread(command, spec, values) {
157
158
  }
158
159
  return null;
159
160
  }
161
+ /**
162
+ * A resolved path as two values of one option compare. On Windows a drive
163
+ * letter names its volume in either case, and a path resolved there starts
164
+ * with its drive letter or a backslash, so its first character is folded;
165
+ * the rest is compared as resolved (spec-core ADR-0005, amended 2026-10-19).
166
+ * A path written `\\?\c:\x` keeps its letter as written.
167
+ */
168
+ export function onVolume(path, platform = process.platform) {
169
+ // Folded to either case, two letters compare the same, and the folded path is never shown.
170
+ return platform === 'win32' ? `${path.charAt(0).toUpperCase()}${path.slice(1)}` : path;
171
+ }
160
172
  /**
161
173
  * 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.
174
+ * ways is asked for once: a directory as the run resolves it, its drive
175
+ * letter in either case, a number by what its digits count, a list by the
176
+ * set of ids it names, each as a brief's id is matched, a summary as it is
177
+ * trimmed. Any other value is read as it is written, in its own case, which
178
+ * is how each of those options reads it. A revision is read by git, which
179
+ * the run asks once there are two (`sameCommits`).
167
180
  */
168
181
  function asRead(option, value, root) {
169
182
  switch (option) {
170
183
  case 'root':
171
- return resolve(value);
184
+ return onVolume(resolve(value));
172
185
  case 'config':
173
- return resolve(root, value);
186
+ return onVolume(resolve(root, value));
174
187
  case 'wave':
175
188
  case 'pr':
176
189
  return String(digits(value) ?? value);
177
190
  case 'depends-on':
178
- return JSON.stringify(idsOf(value));
191
+ // `7` and `007` name one brief, and `8,7` the briefs `7,8` does.
192
+ return JSON.stringify([...new Set(idsOf(value).map(idKey))].sort());
179
193
  case 'summary':
180
194
  return value.trim();
181
195
  case 'briefs':
@@ -192,12 +206,18 @@ function asRead(option, value, root) {
192
206
  // After the switch, for the reason `untrue` has.
193
207
  return value;
194
208
  }
209
+ /** The refusal of an option given two values, each shown as JSON, as an empty value is, where what cannot be seen can be. */
210
+ function twoValues(option, first, second) {
211
+ return `--${option} is given ${JSON.stringify(first)} and ${JSON.stringify(second)}, and takes one value; give one of the two`;
212
+ }
195
213
  /**
196
214
  * Why a line that gives one option two values is not one to run: the later
197
215
  * was read, and the earlier went unread with no word of it, so `--root
198
216
  * nowhere --root .` ran in `.` (spec-core ADR-0005, amended 2026-10-18).
217
+ * Two revisions written two ways are kept in `revisions`, where it is
218
+ * given, for git to say whether they name one commit.
199
219
  */
200
- function twice(options, values, cwd) {
220
+ function twice(options, values, cwd, revisions) {
201
221
  // A configuration is found from the root, as the engine finds it.
202
222
  const root = resolve(text(values, 'root') ?? cwd);
203
223
  const first = new Map();
@@ -212,8 +232,9 @@ function twice(options, values, cwd) {
212
232
  if (earlier === undefined)
213
233
  first.set(token.name, { written: token.value, read });
214
234
  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`;
235
+ if (revisions === null || (token.name !== 'commit' && token.name !== 'base'))
236
+ return twoValues(token.name, earlier.written, token.value);
237
+ revisions.set(token.name, [...(revisions.get(token.name) ?? [earlier.written]), token.value]);
217
238
  }
218
239
  }
219
240
  return null;
@@ -421,13 +442,40 @@ export function parse(argv, cwd = process.cwd()) {
421
442
  throw new UsageError(`${command} takes no argument, not ${extra}: spec-brief ${command}${spec.noArgument}`);
422
443
  }
423
444
  const options = parsed.tokens.filter((token) => token.kind === 'option');
424
- const refusal = twice(options, parsed.values, cwd) ??
445
+ // Two revisions written two ways are git's to read, by the run: `--help`
446
+ // and `--version` start none, and `--no-git` asks git nothing, so with
447
+ // any of the three they are two values, as the line reads them.
448
+ const revisions = flag(parsed.values, 'help') || flag(parsed.values, 'version') || flag(parsed.values, 'no-git') ? null : new Map();
449
+ const refusal = twice(options, parsed.values, cwd, revisions) ??
425
450
  answers(options) ??
426
451
  (spec === undefined ? null : (unread(command, spec, parsed.values) ?? unwritten(command, spec, parsed.values)));
427
452
  if (refusal !== null)
428
453
  throw new UsageError(refusal);
429
454
  checkValues(command, parsed.values, positionals);
430
- return { command, values: parsed.values, positionals };
455
+ return { command, values: parsed.values, positionals, revisions: revisions ?? new Map() };
456
+ }
457
+ /**
458
+ * Two revisions of one option are one value where the run's git says both
459
+ * name one commit, and two where it names two, or cannot say of one: with
460
+ * no repository, outside one, or for a revision that names none. It is asked
461
+ * once, for every revision of both options.
462
+ */
463
+ async function sameCommits(engine, revisions) {
464
+ // Asked nothing, a git process answers nothing: the mutant that starts one is equivalent.
465
+ if (revisions.size === 0)
466
+ return;
467
+ const asked = [...revisions.values()].flat();
468
+ // A git a caller of the library hands the engine may have no `commits`;
469
+ // the command line's is always NodeGit's, which has, so the mutant that
470
+ // calls it without asking is equivalent here.
471
+ const named = await engine.git?.commits?.(asked);
472
+ const commitOf = new Map(asked.map((revision, index) => [revision, named?.[index] ?? null]));
473
+ for (const [option, [first, ...later]] of revisions) {
474
+ const commit = commitOf.get(first) ?? null;
475
+ const other = later.find((revision) => commit === null || commitOf.get(revision) !== commit);
476
+ if (other !== undefined)
477
+ throw new UsageError(twoValues(option, first, other));
478
+ }
431
479
  }
432
480
  /** A `--format` the command does not write, which the help lists by command. */
433
481
  function unwritten(command, spec, values) {
@@ -524,12 +572,14 @@ function useColor(values, io) {
524
572
  return false;
525
573
  return (io.stdout ?? process.stdout).isTTY === true;
526
574
  }
527
- async function openEngine(run) {
575
+ /** The engine for a run, which asks git nothing where `needsGit` is false and a configuration file names the root. */
576
+ async function openEngine(run, needsGit = true) {
528
577
  return BriefEngine.open({
529
578
  cwd: text(run.values, 'root') ?? run.cwd,
530
579
  config: text(run.values, 'config'),
531
580
  ...(flag(run.values, 'no-git') ? { git: null } : {}),
532
581
  noConfig: flag(run.values, 'no-config'),
582
+ needsGit,
533
583
  });
534
584
  }
535
585
  function emitFindings(run, command, findings, checked, extra = {}) {
@@ -607,7 +657,9 @@ async function runLint(run) {
607
657
  return emitFindings(run, 'lint', findings, checked, { sections: sectionsJson(engine.config) });
608
658
  }
609
659
  async function runList(run) {
610
- const engine = await openEngine(run);
660
+ // Nothing `list` answers is read from git, so git is asked nothing where
661
+ // the root is a configuration file's, and the answer can say so.
662
+ const engine = await openEngine(run, false);
611
663
  engine.requireBriefs();
612
664
  const briefs = flag(run.values, 'archived') ? engine.corpus.briefs : engine.corpus.live;
613
665
  const ready = new Set(engine.ready());
@@ -615,10 +667,14 @@ async function runList(run) {
615
667
  .map((brief) => ({ brief, ready: ready.has(brief), waitingOn: engine.waitingOn(brief) }))
616
668
  .filter((row) => !flag(run.values, 'ready') || row.ready);
617
669
  if (run.format === 'json') {
670
+ const { keep, ...read } = engine.inputs();
618
671
  run.out(jsonDocument('list', run.version, {
619
672
  ok: true,
620
673
  briefs: rows.map((r) => briefJson(r.brief, { ready: r.ready, waitingOn: r.waitingOn })),
621
674
  sections: sectionsJson(engine.config),
675
+ // What the answer was read from, for a tool that would give it again
676
+ // without starting this one, and the version that read it.
677
+ inputs: { keep, tool: run.version, ...read },
622
678
  }));
623
679
  }
624
680
  else {
@@ -671,6 +727,7 @@ async function runTransition(run, action) {
671
727
  if (reference === undefined)
672
728
  throw new UsageError(`${action} takes one brief: spec-brief ${action} <brief>`);
673
729
  const engine = await openEngine(run);
730
+ await sameCommits(engine, run.revisions);
674
731
  const dryRun = flag(run.values, 'dry-run');
675
732
  const plan = action === 'archive'
676
733
  ? await engine.planArchive(reference, {
@@ -708,7 +765,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
708
765
  err(error.message);
709
766
  return EXIT_ERROR;
710
767
  }
711
- const { command, values, positionals } = parsed;
768
+ const { command, values, positionals, revisions } = parsed;
712
769
  // From here on, so that what comes before a command is covered as the
713
770
  // command is: --version and --help, the package's own manifest, the colour.
714
771
  // An error there left main as a rejection, which the launcher ended as
@@ -751,6 +808,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
751
808
  version: await version(),
752
809
  values,
753
810
  positionals,
811
+ revisions,
754
812
  cwd: io.cwd ?? process.cwd(),
755
813
  };
756
814
  switch (command) {