@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 +86 -0
- package/README.md +31 -5
- package/dist/apply.d.ts +3 -2
- package/dist/apply.js +12 -3
- package/dist/apply.js.map +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +74 -16
- package/dist/cli.js.map +1 -1
- package/dist/engine.d.ts +43 -0
- package/dist/engine.js +82 -13
- package/dist/engine.js.map +1 -1
- package/dist/fs.d.ts +40 -4
- package/dist/fs.js +59 -9
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +11 -0
- package/dist/git.js +32 -2
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins.js +19 -4
- package/dist/plugins.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
|
313
|
-
- **A revision written two ways is
|
|
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
|
|
15
|
+
import { type FileSystem, type NotAFile } from './fs.js';
|
|
16
16
|
export declare class ConflictError extends Error {
|
|
17
17
|
readonly path: string;
|
|
18
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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;
|
|
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,
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|