@descent-vtt/spec-brief 0.9.0 → 0.11.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,92 @@ 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.11.0
15
+
16
+ A template, a plugin, or what stands where `archive` or `unarchive`
17
+ would write a brief is read only where it is a file, or a link to one:
18
+ a named pipe there had a run wait without end, and a directory or a
19
+ socket ended it on a stack. Each is now refused, exit 2, before it is
20
+ opened.
21
+
22
+ ### Changed
23
+
24
+ - **A template, a plugin, or what stands where a brief would move, is read
25
+ only where it is a file.** A named pipe as the `template`, where
26
+ `archive` or `unarchive` would write the brief, or as a plugin's module or
27
+ the `package.json` its package is found by, had the run wait without end;
28
+ a link to `/dev/null` as the template wrote a brief of no bytes; a
29
+ directory or a socket ended the run on Node's error and a stack. Each is
30
+ now exit 2 with one line before it is opened, nothing written, as a
31
+ configuration has been since 0.10.0. A link is read as what it names
32
+ ([An input that names nothing](README.md#an-input-that-names-nothing);
33
+ ADR-0003, amended 2026-10-24). Upgrading: nothing to change where each is
34
+ a file, or a link to one; a template that is a link to `/dev/null` now
35
+ refuses, so point `template` at a file, or set it to `null`.
36
+
37
+ ## 0.10.0
38
+
39
+ One value written two ways is taken once in three more cases, each of
40
+ which was refused as two values: a drive letter in either case on
41
+ Windows, two revisions git says name one commit, and two lists that name
42
+ the same briefs. Nothing that ran is refused.
43
+
44
+ `list --format json` names what its answer was read from, so that another
45
+ tool can give the answer again without starting spec-brief. A
46
+ configuration that is not a file is refused, exit 2, where a named pipe
47
+ under its name was read without end.
48
+
49
+ ### Fixed
50
+
51
+ - **A drive letter names its volume in either case.** On Windows
52
+ `--root C:\x` beside `--root c:\x`, and two `--config` values the same
53
+ way, were refused as two directories; each is taken once. The rest of a
54
+ path is compared as it resolves, so `C:\x` beside `C:\X` is still two
55
+ ([An option given two values](README.md#an-option-given-two-values);
56
+ [the family contract](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md),
57
+ amended 2026-10-19).
58
+ - **A revision written two ways is asked of git.** `--commit HEAD` beside
59
+ the hash it names, or `--base main` beside the commit `main` is at, was
60
+ refused as two values. `archive` now asks git once what commit each
61
+ names, and takes two that name one as one. Two that name two commits,
62
+ one git cannot name, and any line with `--no-git`, `--help` or
63
+ `--version`, none of which asks git, are refused as they were
64
+ (ADR-0001, amended 2026-10-19).
65
+ - **`--depends-on` is read as the set of ids it names**, each as a brief's
66
+ id is matched: `7,8` beside `8,7`, `7` beside `007` and `7,8` beside
67
+ `7,8,7` are taken once, and the brief is written with the later as
68
+ given. A list that names another brief, or one fewer, is still two
69
+ values.
70
+
71
+ ### Added
72
+
73
+ - **`list --format json` names its inputs**, in a field `inputs`: each
74
+ file the answer was read from, with the SHA-256 of its bytes; each name a
75
+ configuration was looked for under and was not there, with `null`; each
76
+ directory the briefs were listed from, with the files in it, or `null`
77
+ where there was none, as an archive not yet made; and the version that
78
+ read them. `keep` is `false`, and nothing is listed, wherever that
79
+ cannot be named whole: a run that starts below its configuration's
80
+ directory, and one with no configuration file
81
+ ([`spec-brief list`](README.md#spec-brief-list); ADR-0002, amended
82
+ 2026-10-19). `schemaVersion` stays 2, since a field is added and none
83
+ changes. `list` asks git nothing where a configuration file names the
84
+ root, which is a process fewer.
85
+
86
+ ### Changed
87
+
88
+ - **A configuration that is not a file is refused.** A named pipe at
89
+ `.spec-brief.json` or `spec-brief.json`, or named by `--config`, was read
90
+ without end by every command; a socket ended the run on `ENXIO` and a
91
+ stack, and `--config /dev/zero` on `RangeError`. Each is now exit 2 with
92
+ one line, `is neither a file nor a directory, not a configuration file`,
93
+ before it is opened, as a directory was already. A link is read as what
94
+ it names ([An input that names nothing](README.md#an-input-that-names-nothing);
95
+ ADR-0003, amended 2026-10-19). Upgrading: nothing to change for a
96
+ configuration that is a file; `--config <(...)`, which a shell hands as a
97
+ pipe, ran in `/dev/fd` and found no briefs, and now says what it was
98
+ handed.
99
+
14
100
  ## 0.9.0
15
101
 
16
102
  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,8 @@ 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.
309
+ - **A template, a plugin or what stands where a brief would move that is not a file** is refused in a line before it is opened, and nothing is written: the `template` a configuration names, a plugin's module and each `package.json` spec-brief reads to find one, and what stands where `archive` or `unarchive` would write the brief, which a plan made from the files a listing names cannot have seen. A named pipe there had the run wait without end, a link to `/dev/null` as the template wrote a brief of no bytes, and a directory or a socket ended the run on a stack. A link is read as what it names.
284
310
 
285
311
  ### An option a command does not read
286
312
 
@@ -309,8 +335,8 @@ An option that takes one value is given one. Given two that read as two, the run
309
335
  spec-brief: --root is given "nowhere" and ".", and takes one value; give one of the two
310
336
  ```
311
337
 
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.
338
+ - **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.
339
+ - **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
340
  - **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
341
  - 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
342
 
@@ -336,7 +362,7 @@ spec-brief: --help is given beside --version, and one of the two answers would g
336
362
 
337
363
  ## Configuration
338
364
 
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.
365
+ `.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
366
 
341
367
  | Key | Default | What it says |
342
368
  | --- | --- | --- |
package/dist/apply.d.ts CHANGED
@@ -12,10 +12,11 @@
12
12
  * which `lint` reports by name.
13
13
  */
14
14
  import type { FileOp } from './archive.js';
15
- import type { FileSystem } from './fs.js';
15
+ import { type FileSystem, type NotAFile } from './fs.js';
16
16
  export declare class ConflictError extends Error {
17
17
  readonly path: string;
18
- constructor(path: string);
18
+ /** `found` is what stands at the path where it is not a file, which is not read. */
19
+ constructor(path: string, found?: NotAFile);
19
20
  }
20
21
  export declare class TransactionError extends Error {
21
22
  /** Whether every completed operation was undone. */
package/dist/apply.js CHANGED
@@ -11,10 +11,14 @@
11
11
  * operation and the last, and a crash inside it leaves two briefs with one id,
12
12
  * which `lint` reports by name.
13
13
  */
14
+ import { NotAFileError } from './fs.js';
14
15
  export class ConflictError extends Error {
15
16
  path;
16
- constructor(path) {
17
- super(`${path} changed after the plan was made; nothing was written. Run the command again.`);
17
+ /** `found` is what stands at the path where it is not a file, which is not read. */
18
+ constructor(path, found) {
19
+ super(found === undefined
20
+ ? `${path} changed after the plan was made; nothing was written. Run the command again.`
21
+ : `${path} is ${found}; nothing was written. Move it out of the way, then run the command again.`);
18
22
  this.name = 'ConflictError';
19
23
  this.path = path;
20
24
  }
@@ -35,7 +39,12 @@ export class TransactionError extends Error {
35
39
  }
36
40
  export async function applyPlan(fs, ops) {
37
41
  for (const op of ops) {
38
- const current = await fs.read(op.path);
42
+ const current = await fs.read(op.path).catch((error) => {
43
+ // A plan is made from the files a listing names, so what stands where
44
+ // it would write a new one can be a named pipe, read without end, or a
45
+ // directory. Nothing is written yet, and the refusal can say so.
46
+ throw error instanceof NotAFileError ? new ConflictError(op.path, error.what) : error;
47
+ });
39
48
  if (current !== op.before)
40
49
  throw new ConflictError(op.path);
41
50
  }
package/dist/apply.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"apply.js","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,MAAM,OAAO,aAAc,SAAQ,KAAK;IAC7B,IAAI,CAAS;IAEtB,YAAY,IAAY;QACtB,KAAK,CAAC,GAAG,IAAI,+EAA+E,CAAC,CAAC;QAC9F,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,oDAAoD;IAC3C,UAAU,CAAU;IACpB,QAAQ,CAAoB;IAErC,YAAY,KAAc,EAAE,UAAmB,EAAE,QAA2B;QAC1E,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,KAAK,CACH,UAAU;YACR,CAAC,CAAC,GAAG,MAAM,gCAAgC;YAC3C,CAAC,CAAC,GAAG,MAAM,+BAA+B,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,iCAAiC,CACjG,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,EAAc,EAAE,GAAsB;IACpE,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QACvC,IAAI,OAAO,KAAK,EAAE,CAAC,MAAM;YAAE,MAAM,IAAI,aAAa,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;IAC9D,CAAC;IAED,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,CAAC;QACH,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;YACrB,IAAI,EAAE,CAAC,IAAI,KAAK,OAAO;gBAAE,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC;;gBACxD,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;YAC9B,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,QAAQ,GAAa,EAAE,CAAC;QAC9B,KAAK,MAAM,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;YACrC,oEAAoE;YACpE,mEAAmE;YACnE,iBAAiB;YACjB,IAAI,CAAC;gBACH,IAAI,EAAE,CAAC,MAAM,KAAK,IAAI;oBAAE,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;;oBAC5C,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC;YAC1C,CAAC;YAAC,OAAO,IAAI,EAAE,CAAC;gBACd,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,KAAK,IAAI,YAAY,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACtF,CAAC;QACH,CAAC;QACD,MAAM,IAAI,gBAAgB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC;IACrE,CAAC;AACH,CAAC","sourcesContent":["/**\n * Executing a plan as a transaction over files.\n *\n * Three phases. First every file is read back and compared with what the plan\n * saw, so a file somebody edited in the meantime stops the transaction before\n * anything changes. Then the operations run in order - the new file first, the\n * old one removed last - and each one that completes is recorded. If any\n * fails, the recorded ones are undone in reverse from the contents the plan\n * already holds. Each write is itself atomic (a rename over the target), so\n * the only window in which the tree shows both copies is between the first\n * operation and the last, and a crash inside it leaves two briefs with one id,\n * which `lint` reports by name.\n */\n\nimport type { FileOp } from './archive.js';\nimport type { FileSystem } from './fs.js';\n\nexport class ConflictError extends Error {\n readonly path: string;\n\n constructor(path: string) {\n super(`${path} changed after the plan was made; nothing was written. Run the command again.`);\n this.name = 'ConflictError';\n this.path = path;\n }\n}\n\nexport class TransactionError extends Error {\n /** Whether every completed operation was undone. */\n readonly rolledBack: boolean;\n readonly failures: readonly string[];\n\n constructor(cause: unknown, rolledBack: boolean, failures: readonly string[]) {\n const reason = cause instanceof Error ? cause.message : String(cause);\n super(\n rolledBack\n ? `${reason}; every change was rolled back`\n : `${reason}; the rollback also failed (${failures.join('; ')}), so check these files by hand`,\n );\n this.name = 'TransactionError';\n this.rolledBack = rolledBack;\n this.failures = failures;\n }\n}\n\nexport async function applyPlan(fs: FileSystem, ops: readonly FileOp[]): Promise<void> {\n for (const op of ops) {\n const current = await fs.read(op.path);\n if (current !== op.before) throw new ConflictError(op.path);\n }\n\n const done: FileOp[] = [];\n try {\n for (const op of ops) {\n if (op.kind === 'write') await fs.write(op.path, op.content);\n else await fs.remove(op.path);\n done.push(op);\n }\n } catch (error) {\n const failures: string[] = [];\n for (const op of [...done].reverse()) {\n // Undoing is putting back what was there: a file that was absent is\n // removed, and one that was present - written over or removed - is\n // written again.\n try {\n if (op.before === null) await fs.remove(op.path);\n else await fs.write(op.path, op.before);\n } catch (undo) {\n failures.push(`${op.path}: ${undo instanceof Error ? undo.message : String(undo)}`);\n }\n }\n throw new TransactionError(error, failures.length === 0, failures);\n }\n}\n"]}
1
+ {"version":3,"file":"apply.js","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Executing a plan as a transaction over files.\n *\n * Three phases. First every file is read back and compared with what the plan\n * saw, so a file somebody edited in the meantime stops the transaction before\n * anything changes. Then the operations run in order - the new file first, the\n * old one removed last - and each one that completes is recorded. If any\n * fails, the recorded ones are undone in reverse from the contents the plan\n * already holds. Each write is itself atomic (a rename over the target), so\n * the only window in which the tree shows both copies is between the first\n * operation and the last, and a crash inside it leaves two briefs with one id,\n * which `lint` reports by name.\n */\n\nimport type { FileOp } from './archive.js';\nimport { type FileSystem, type NotAFile, NotAFileError } from './fs.js';\n\nexport class ConflictError extends Error {\n readonly path: string;\n\n /** `found` is what stands at the path where it is not a file, which is not read. */\n constructor(path: string, found?: NotAFile) {\n super(\n found === undefined\n ? `${path} changed after the plan was made; nothing was written. Run the command again.`\n : `${path} is ${found}; nothing was written. Move it out of the way, then run the command again.`,\n );\n this.name = 'ConflictError';\n this.path = path;\n }\n}\n\nexport class TransactionError extends Error {\n /** Whether every completed operation was undone. */\n readonly rolledBack: boolean;\n readonly failures: readonly string[];\n\n constructor(cause: unknown, rolledBack: boolean, failures: readonly string[]) {\n const reason = cause instanceof Error ? cause.message : String(cause);\n super(\n rolledBack\n ? `${reason}; every change was rolled back`\n : `${reason}; the rollback also failed (${failures.join('; ')}), so check these files by hand`,\n );\n this.name = 'TransactionError';\n this.rolledBack = rolledBack;\n this.failures = failures;\n }\n}\n\nexport async function applyPlan(fs: FileSystem, ops: readonly FileOp[]): Promise<void> {\n for (const op of ops) {\n const current = await fs.read(op.path).catch((error: unknown) => {\n // A plan is made from the files a listing names, so what stands where\n // it would write a new one can be a named pipe, read without end, or a\n // directory. Nothing is written yet, and the refusal can say so.\n throw error instanceof NotAFileError ? new ConflictError(op.path, error.what) : error;\n });\n if (current !== op.before) throw new ConflictError(op.path);\n }\n\n const done: FileOp[] = [];\n try {\n for (const op of ops) {\n if (op.kind === 'write') await fs.write(op.path, op.content);\n else await fs.remove(op.path);\n done.push(op);\n }\n } catch (error) {\n const failures: string[] = [];\n for (const op of [...done].reverse()) {\n // Undoing is putting back what was there: a file that was absent is\n // removed, and one that was present - written over or removed - is\n // written again.\n try {\n if (op.before === null) await fs.remove(op.path);\n else await fs.write(op.path, op.before);\n } catch (undo) {\n failures.push(`${op.path}: ${undo instanceof Error ? undo.message : String(undo)}`);\n }\n }\n throw new TransactionError(error, failures.length === 0, failures);\n }\n}\n"]}
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) {