@descent-vtt/spec-brief 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +60 -11
- package/dist/archive.js.map +1 -1
- package/dist/brief.js.map +1 -1
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +298 -46
- package/dist/cli.js.map +1 -1
- package/dist/config.js +3 -2
- package/dist/config.js.map +1 -1
- package/dist/corpus.js +1 -0
- package/dist/corpus.js.map +1 -1
- package/dist/git.js +6 -2
- package/dist/git.js.map +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/literal.d.ts +11 -0
- package/dist/literal.js +14 -0
- package/dist/literal.js.map +1 -0
- package/dist/markdown.js.map +1 -1
- package/dist/plugins.js +2 -0
- package/dist/plugins.js.map +1 -1
- package/dist/report.js +3 -2
- package/dist/report.js.map +1 -1
- package/dist/rules.js +2 -1
- package/dist/rules.js.map +1 -1
- package/dist/schedule.js.map +1 -1
- package/dist/schema.js.map +1 -1
- package/dist/scope.js.map +1 -1
- package/dist/text.js.map +1 -1
- package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
- package/dist/vendor/spec-core/markdown/headings.js.map +1 -1
- package/dist/vendor/spec-core/markdown/links.js.map +1 -1
- package/dist/vendor/spec-core/markdown/scan.js.map +1 -1
- package/dist/vendor/spec-core/pattern/automaton.js.map +1 -1
- package/dist/vendor/spec-core/pattern/charset.js.map +1 -1
- package/dist/vendor/spec-core/pattern/regex.js.map +1 -1
- package/dist/vendor/spec-core/text/lines.js.map +1 -1
- package/dist/vendor/spec-core/text/width.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* must never exit as though it found nothing.
|
|
8
8
|
*/
|
|
9
9
|
import { readFile, stat } from 'node:fs/promises';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
10
11
|
import { parseArgs } from 'node:util';
|
|
11
12
|
import { TransactionError, ConflictError } from './apply.js';
|
|
12
13
|
import { reported } from './collisions.js';
|
|
13
14
|
import { CONFIG_FILES, ConfigError, initialConfig } from './config.js';
|
|
14
|
-
import { BriefEngine, EngineError, today } from './engine.js';
|
|
15
|
+
import { BriefEngine, EngineError, isDate, today } from './engine.js';
|
|
15
16
|
import { NodeFileSystem } from './fs.js';
|
|
16
17
|
import { GitError } from './git.js';
|
|
17
18
|
import { failing, sortFindings, summarise } from './lint.js';
|
|
@@ -72,6 +73,194 @@ const TABLE = {
|
|
|
72
73
|
},
|
|
73
74
|
unarchive: { options: { 'dry-run': { type: 'boolean' } }, formats: ['pretty', 'json'] },
|
|
74
75
|
};
|
|
76
|
+
/**
|
|
77
|
+
* Whether taking the option would tell someone what is not so. `init
|
|
78
|
+
* --strict` says the briefs were set up strictly, where nothing was strict;
|
|
79
|
+
* `init --config team.json` that the configuration was written there, where
|
|
80
|
+
* `.spec-brief.json` was; `init --color` and `new --color` that colour was
|
|
81
|
+
* printed, where neither prints any; `new --strict` and `list --strict` that
|
|
82
|
+
* a warning would have failed the run, where neither has one. Each ran as
|
|
83
|
+
* the line without the option does, exit 0.
|
|
84
|
+
*
|
|
85
|
+
* A function, not a table beside the commands': a table built when the
|
|
86
|
+
* module loads is static to Stryker, and each of its mutants then runs the
|
|
87
|
+
* whole suite (ADR-0009).
|
|
88
|
+
*/
|
|
89
|
+
function untrue(command, option) {
|
|
90
|
+
switch (option) {
|
|
91
|
+
case 'config':
|
|
92
|
+
return command === 'init';
|
|
93
|
+
case 'strict':
|
|
94
|
+
return command === 'init' || command === 'new' || command === 'list';
|
|
95
|
+
case 'color':
|
|
96
|
+
return command === 'init' || command === 'new';
|
|
97
|
+
}
|
|
98
|
+
// After the switch and not as its `default`: a `default` with its return
|
|
99
|
+
// taken out answers nothing, which each caller reads as false, and no test
|
|
100
|
+
// can tell that mutant from the code (ADR-0009).
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Whether the option asks the command to leave out what it never does, and
|
|
105
|
+
* so asks for what is already so: `init` opens no configuration file and
|
|
106
|
+
* asks git nothing, and neither it nor `new` prints colour. Such an option
|
|
107
|
+
* is taken. A script that runs every command in a tree without git, or into
|
|
108
|
+
* a log, passes it to each of them, as spec-harness passes `--no-color`.
|
|
109
|
+
* The command reads nothing of it, so a refusal's line lists it apart from
|
|
110
|
+
* what the command reads, as taken.
|
|
111
|
+
*/
|
|
112
|
+
function alreadySo(command, option) {
|
|
113
|
+
switch (option) {
|
|
114
|
+
case 'no-config':
|
|
115
|
+
case 'no-git':
|
|
116
|
+
return command === 'init';
|
|
117
|
+
case 'no-color':
|
|
118
|
+
return command === 'init' || command === 'new';
|
|
119
|
+
}
|
|
120
|
+
// After the switch, for the reason `untrue` has.
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
/** Options by their names on the command line, as a sentence lists them. */
|
|
124
|
+
function listed(names) {
|
|
125
|
+
const options = names.map((name) => `--${name}`);
|
|
126
|
+
return options.length === 1 ? options[0] : `${options.slice(0, -1).join(', ')} and ${options.at(-1)}`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Why a command line that parsed is still not one to run.
|
|
130
|
+
*
|
|
131
|
+
* An option is refused where taking it would tell someone what is not so,
|
|
132
|
+
* and so are two options of which one would go unread: `--config team.json
|
|
133
|
+
* --no-config` says the file was read, where the defaults ran, and `--color
|
|
134
|
+
* --no-color` that colour was forced on, where it was off. An option that
|
|
135
|
+
* asks a command to leave out what it never does asks for what is already
|
|
136
|
+
* so, and is taken.
|
|
137
|
+
*/
|
|
138
|
+
function unread(command, spec, values) {
|
|
139
|
+
// The first such option as the line was typed, which is the order the
|
|
140
|
+
// parser keeps.
|
|
141
|
+
const stray = Object.keys(values).find((option) => untrue(command, option));
|
|
142
|
+
if (stray !== undefined) {
|
|
143
|
+
const shared = Object.keys(GLOBAL).filter((option) => option !== 'help' && option !== 'version' && !untrue(command, option));
|
|
144
|
+
const reads = [...shared.filter((option) => !alreadySo(command, option)), ...Object.keys(spec.options)];
|
|
145
|
+
const takes = shared.filter((option) => alreadySo(command, option));
|
|
146
|
+
return `${command} does not read --${stray}; it reads ${listed(reads)}${takes.length > 0 ? `, and takes ${listed(takes)}` : ''}`;
|
|
147
|
+
}
|
|
148
|
+
if (text(values, 'config') !== undefined && flag(values, 'no-config')) {
|
|
149
|
+
return '--config is given beside --no-config, which has the run read no configuration file; give one of the two';
|
|
150
|
+
}
|
|
151
|
+
if (flag(values, 'color') && flag(values, 'no-color'))
|
|
152
|
+
return '--color is given beside --no-color, which turns colour off; give one of the two';
|
|
153
|
+
// The engine refuses the pair as well, for a caller of the library; here it
|
|
154
|
+
// is refused with the line, where --help and --version are no way past it.
|
|
155
|
+
if (flag(values, 'no-git') && (text(values, 'commit') !== undefined || text(values, 'base') !== undefined)) {
|
|
156
|
+
return 'a commit or a base needs git; drop --no-git, or drop --commit and --base';
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* An option's value as the command reads it, so that one thing written two
|
|
162
|
+
* ways is asked for once: a directory as the run resolves it, a number by
|
|
163
|
+
* what its digits count, a list by its ids, a summary as it is trimmed. Any
|
|
164
|
+
* other value is read as it is written, in its own case, which is how each
|
|
165
|
+
* of those options reads it. A revision is read by git, which a line is not
|
|
166
|
+
* checked against, so `HEAD` and the commit it names are two values here.
|
|
167
|
+
*/
|
|
168
|
+
function asRead(option, value, root) {
|
|
169
|
+
switch (option) {
|
|
170
|
+
case 'root':
|
|
171
|
+
return resolve(value);
|
|
172
|
+
case 'config':
|
|
173
|
+
return resolve(root, value);
|
|
174
|
+
case 'wave':
|
|
175
|
+
case 'pr':
|
|
176
|
+
return String(digits(value) ?? value);
|
|
177
|
+
case 'depends-on':
|
|
178
|
+
return JSON.stringify(idsOf(value));
|
|
179
|
+
case 'summary':
|
|
180
|
+
return value.trim();
|
|
181
|
+
case 'briefs':
|
|
182
|
+
case 'archive':
|
|
183
|
+
// Last: one outside the repository leaves the switch and is read as it
|
|
184
|
+
// was written, for `directories` to refuse by what it was given. A catch
|
|
185
|
+
// that returned it would have a mutant, its return taken out, that fell
|
|
186
|
+
// into the next case and read it the same, which no test can tell apart.
|
|
187
|
+
try {
|
|
188
|
+
return normalisePath(value);
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
}
|
|
192
|
+
// After the switch, for the reason `untrue` has.
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Why a line that gives one option two values is not one to run: the later
|
|
197
|
+
* was read, and the earlier went unread with no word of it, so `--root
|
|
198
|
+
* nowhere --root .` ran in `.` (spec-core ADR-0005, amended 2026-10-18).
|
|
199
|
+
*/
|
|
200
|
+
function twice(options, values, cwd) {
|
|
201
|
+
// A configuration is found from the root, as the engine finds it.
|
|
202
|
+
const root = resolve(text(values, 'root') ?? cwd);
|
|
203
|
+
const first = new Map();
|
|
204
|
+
for (const token of options) {
|
|
205
|
+
// A flag has no value to compare, and one given twice is that flag once.
|
|
206
|
+
// Taken in here it would be read as nothing each time, which decides
|
|
207
|
+
// nothing either: the mutant that takes it in is equivalent.
|
|
208
|
+
if (token.value === undefined)
|
|
209
|
+
continue;
|
|
210
|
+
const read = asRead(token.name, token.value, root);
|
|
211
|
+
const earlier = first.get(token.name);
|
|
212
|
+
if (earlier === undefined)
|
|
213
|
+
first.set(token.name, { written: token.value, read });
|
|
214
|
+
else if (earlier.read !== read) {
|
|
215
|
+
// Shown as JSON, as an empty value is, where what cannot be seen can be.
|
|
216
|
+
return `--${token.name} is given ${JSON.stringify(earlier.written)} and ${JSON.stringify(token.value)}, and takes one value; give one of the two`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* `--help` beside `--version` asks for two answers, and printed one of
|
|
223
|
+
* them; the other went unread. Named as each was written, `-h` or `--help`.
|
|
224
|
+
*/
|
|
225
|
+
function answers(options) {
|
|
226
|
+
let asked;
|
|
227
|
+
for (const token of options) {
|
|
228
|
+
if (token.name !== 'help' && token.name !== 'version')
|
|
229
|
+
continue;
|
|
230
|
+
if (asked === undefined)
|
|
231
|
+
asked = token;
|
|
232
|
+
else if (asked.name !== token.name)
|
|
233
|
+
return `${asked.rawName} is given beside ${token.rawName}, and one of the two answers would go unread; give one of the two`;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* What a command's own options hold, refused where the line is read, by what
|
|
239
|
+
* each holds: a number, a list of ids, a date, a revision, a directory, and a
|
|
240
|
+
* second brief where a command takes one. What only the tree can answer - a
|
|
241
|
+
* root that is not there, an id that is taken, a type the configuration does
|
|
242
|
+
* not have - is asked by the run, which `--help` and `--version` do not start.
|
|
243
|
+
*/
|
|
244
|
+
function checkValues(command, values, positionals) {
|
|
245
|
+
integer(values, 'wave', 0);
|
|
246
|
+
integer(values, 'pr', 1);
|
|
247
|
+
dependencies(values);
|
|
248
|
+
const date = text(values, 'date');
|
|
249
|
+
if (date !== undefined && !isDate(date))
|
|
250
|
+
throw new UsageError(`"${date}" is not a date written YYYY-MM-DD`);
|
|
251
|
+
for (const option of ['commit', 'base']) {
|
|
252
|
+
// Handed to git, it would be read as one of git's options.
|
|
253
|
+
const revision = text(values, option);
|
|
254
|
+
if (revision?.startsWith('-') === true)
|
|
255
|
+
throw new UsageError(`"${revision}" is not a revision`);
|
|
256
|
+
}
|
|
257
|
+
// Only init takes --briefs and --archive; for any other command they are
|
|
258
|
+
// the defaults, which are two directories inside the root.
|
|
259
|
+
directories(values);
|
|
260
|
+
if ((command === 'archive' || command === 'unarchive') && positionals.length > 1) {
|
|
261
|
+
throw new UsageError(`${command} takes one brief: spec-brief ${command} <brief>`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
75
264
|
/**
|
|
76
265
|
* The commands by name, and nothing for a command line that names none. A map,
|
|
77
266
|
* because an object answers to more names than it was given: `constructor` and
|
|
@@ -108,14 +297,14 @@ Commands:
|
|
|
108
297
|
archive <brief> close a round: check it, stamp it, freeze it, move it
|
|
109
298
|
unarchive <brief> reopen an archived brief
|
|
110
299
|
|
|
111
|
-
Options for every command:
|
|
300
|
+
Options for every command, except where a line says otherwise:
|
|
112
301
|
--root <dir> run from this directory instead of the current one; it must exist, unless init makes it
|
|
113
|
-
--config <file> use this configuration file
|
|
302
|
+
--config <file> use this configuration file; not for init, which writes .spec-brief.json
|
|
114
303
|
--no-config use the defaults, ignoring any configuration file
|
|
115
304
|
--format <format> the output format, pretty by default; each command's are below
|
|
116
|
-
--strict treat warnings as errors
|
|
305
|
+
--strict treat warnings as errors; not for init, new or list, which have none
|
|
117
306
|
--no-git leave git out; read the files on disk instead
|
|
118
|
-
--color, --no-color force colour on or off
|
|
307
|
+
--color, --no-color force colour on or off; --color not for init or new, which print none
|
|
119
308
|
-h, --help show this help
|
|
120
309
|
-v, --version show the version
|
|
121
310
|
|
|
@@ -156,7 +345,17 @@ unarchive:
|
|
|
156
345
|
--dry-run print the plan and change nothing
|
|
157
346
|
|
|
158
347
|
An option given an empty value, a list that names nothing and an argument a
|
|
159
|
-
command does not take are refused, not read as if they were not there.
|
|
348
|
+
command does not take are refused, not read as if they were not there. So is
|
|
349
|
+
an option where taking it would say what is not so, as init --strict, and so
|
|
350
|
+
are --config beside --no-config and --color beside --no-color, of which one
|
|
351
|
+
would go unread. An option that asks a command to leave out what it never
|
|
352
|
+
does is taken, as init --no-git: it asks for what is already so.
|
|
353
|
+
|
|
354
|
+
An option that takes one value and is given two is refused, as --root a
|
|
355
|
+
--root b, since the earlier would go unread; one value given twice, however
|
|
356
|
+
it is written, is taken once, as --root . --root ./. A line refused for any
|
|
357
|
+
of these is refused with --help or --version on it too, and so is --help
|
|
358
|
+
beside --version.
|
|
160
359
|
|
|
161
360
|
Exit codes: 0 clean, 1 findings or refused, 2 the run could not be trusted.
|
|
162
361
|
`;
|
|
@@ -176,7 +375,14 @@ function commandOf(argv) {
|
|
|
176
375
|
}
|
|
177
376
|
return undefined;
|
|
178
377
|
}
|
|
179
|
-
|
|
378
|
+
/**
|
|
379
|
+
* The line, read and checked. Everything the line alone decides is refused
|
|
380
|
+
* here, before `--help` and `--version` are answered, as an option the
|
|
381
|
+
* parser does not know always was: a wrong line is wrong with either of them
|
|
382
|
+
* on it (spec-core ADR-0005, amended 2026-10-18). `cwd` is the directory a
|
|
383
|
+
* run without `--root` starts in, from which a `--config` is found.
|
|
384
|
+
*/
|
|
385
|
+
export function parse(argv, cwd = process.cwd()) {
|
|
180
386
|
const command = commandOf(argv);
|
|
181
387
|
const spec = COMMANDS.get(command);
|
|
182
388
|
if (command !== undefined && spec === undefined) {
|
|
@@ -184,12 +390,17 @@ export function parse(argv) {
|
|
|
184
390
|
}
|
|
185
391
|
let parsed;
|
|
186
392
|
try {
|
|
187
|
-
|
|
393
|
+
const read = parseArgs({
|
|
188
394
|
args: [...argv],
|
|
189
395
|
options: { ...GLOBAL, ...(spec?.options ?? {}) },
|
|
190
396
|
allowPositionals: true,
|
|
191
397
|
strict: true,
|
|
398
|
+
tokens: true,
|
|
192
399
|
});
|
|
400
|
+
// The cast stands on the name, with nothing under it: Stryker makes no
|
|
401
|
+
// mutant of what an `as` holds, and the call above is what the parser
|
|
402
|
+
// is asked (ADR-0009).
|
|
403
|
+
parsed = read;
|
|
193
404
|
}
|
|
194
405
|
catch (error) {
|
|
195
406
|
throw new UsageError(error.message);
|
|
@@ -209,8 +420,22 @@ export function parse(argv) {
|
|
|
209
420
|
const extra = positionals.map((argument) => `"${argument}"`).join(', ');
|
|
210
421
|
throw new UsageError(`${command} takes no argument, not ${extra}: spec-brief ${command}${spec.noArgument}`);
|
|
211
422
|
}
|
|
423
|
+
const options = parsed.tokens.filter((token) => token.kind === 'option');
|
|
424
|
+
const refusal = twice(options, parsed.values, cwd) ??
|
|
425
|
+
answers(options) ??
|
|
426
|
+
(spec === undefined ? null : (unread(command, spec, parsed.values) ?? unwritten(command, spec, parsed.values)));
|
|
427
|
+
if (refusal !== null)
|
|
428
|
+
throw new UsageError(refusal);
|
|
429
|
+
checkValues(command, parsed.values, positionals);
|
|
212
430
|
return { command, values: parsed.values, positionals };
|
|
213
431
|
}
|
|
432
|
+
/** A `--format` the command does not write, which the help lists by command. */
|
|
433
|
+
function unwritten(command, spec, values) {
|
|
434
|
+
const asked = text(values, 'format') ?? 'pretty';
|
|
435
|
+
// Cast on the name, as above. The check below is what makes it true.
|
|
436
|
+
const format = asked;
|
|
437
|
+
return spec.formats.includes(format) ? null : `--format for ${command} is one of ${spec.formats.join(', ')}, not "${format}"`;
|
|
438
|
+
}
|
|
214
439
|
/** The value of an option declared a string, which is all `parseArgs` gives one. */
|
|
215
440
|
function text(values, key) {
|
|
216
441
|
return values[key];
|
|
@@ -218,15 +443,63 @@ function text(values, key) {
|
|
|
218
443
|
function flag(values, key) {
|
|
219
444
|
return values[key] === true;
|
|
220
445
|
}
|
|
446
|
+
/** The number a value's digits count, or nothing for a value that is not digits alone. */
|
|
447
|
+
function digits(raw) {
|
|
448
|
+
return /^\d+$/.test(raw) ? Number(raw) : undefined;
|
|
449
|
+
}
|
|
221
450
|
function integer(values, key, minimum) {
|
|
222
451
|
const raw = text(values, key);
|
|
223
452
|
if (raw === undefined)
|
|
224
453
|
return undefined;
|
|
225
|
-
|
|
454
|
+
const number = digits(raw);
|
|
455
|
+
if (number === undefined || number < minimum)
|
|
226
456
|
throw new UsageError(`--${key} must be a whole number of at least ${minimum}, not "${raw}"`);
|
|
227
|
-
return
|
|
457
|
+
return number;
|
|
458
|
+
}
|
|
459
|
+
/** The ids of a list written with commas between them, spaces beside each taken off. */
|
|
460
|
+
function idsOf(listed) {
|
|
461
|
+
return listed.split(',').map((id) => id.trim());
|
|
462
|
+
}
|
|
463
|
+
/** `--depends-on` as the ids it names, or nothing where it is not given. */
|
|
464
|
+
function dependencies(values) {
|
|
465
|
+
const listed = text(values, 'depends-on');
|
|
466
|
+
const dependsOn = listed === undefined ? undefined : idsOf(listed);
|
|
467
|
+
// A list that names no brief is not a brief that depends on none: written
|
|
468
|
+
// as given, "," scaffolded a brief with no dependency and said nothing.
|
|
469
|
+
if (dependsOn?.every((d) => d === '')) {
|
|
470
|
+
throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief; give it ids with commas between them, as in 7,8, or leave the option out`);
|
|
471
|
+
}
|
|
472
|
+
// And a place in it that names none is a brief left out: "$A,$B" with one
|
|
473
|
+
// of the two unset scaffolded a brief that depends on the other alone.
|
|
474
|
+
if (dependsOn?.includes('')) {
|
|
475
|
+
throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief in one of its places; give it ids with one comma between each two, as in 7,8, and none before or after`);
|
|
476
|
+
}
|
|
477
|
+
return dependsOn;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The directories `init` sets up. One above the root is refused as the
|
|
481
|
+
* configuration refuses one (config.ts), in the same words, before anything
|
|
482
|
+
* is written.
|
|
483
|
+
*/
|
|
484
|
+
function directories(values) {
|
|
485
|
+
const inside = (option, given) => {
|
|
486
|
+
try {
|
|
487
|
+
return normalisePath(given);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
throw new UsageError(`--${option} must be a directory inside the repository, not "${given}"`);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const briefs = inside('briefs', text(values, 'briefs') ?? 'briefs');
|
|
494
|
+
const archive = inside('archive', text(values, 'archive') ?? `${briefs}/archive`);
|
|
495
|
+
if (briefs === '' || archive === '' || briefs === archive)
|
|
496
|
+
throw new UsageError('--briefs and --archive must be two directories');
|
|
497
|
+
return { briefs, archive };
|
|
228
498
|
}
|
|
229
499
|
async function version() {
|
|
500
|
+
// `JSON.parse` reads a file's bytes as it reads their text in UTF-8, so the
|
|
501
|
+
// encoding says what the manifest is and decides nothing: with none named,
|
|
502
|
+
// the version read is the same, and no test can tell the two apart.
|
|
230
503
|
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
231
504
|
return pkg.version;
|
|
232
505
|
}
|
|
@@ -238,9 +511,13 @@ function useColor(values, io) {
|
|
|
238
511
|
const env = io.env ?? process.env;
|
|
239
512
|
if ((env['NO_COLOR'] ?? '') !== '')
|
|
240
513
|
return false;
|
|
514
|
+
// Off for exactly "0" and exactly "false": Node itself and supports-color
|
|
515
|
+
// read "false" as off, and here it forced colour on. Any other value that
|
|
516
|
+
// is not empty is on, "FALSE" too, which is the one reading the family's
|
|
517
|
+
// tools share (spec-core ADR-0005).
|
|
241
518
|
const force = env['FORCE_COLOR'];
|
|
242
519
|
if (force !== undefined && force !== '')
|
|
243
|
-
return force !== '0';
|
|
520
|
+
return force !== '0' && force !== 'false';
|
|
244
521
|
// A terminal that says it is dumb has asked for no escape sequence, and
|
|
245
522
|
// Node itself counts one colour for it: the family's tools read it alike.
|
|
246
523
|
if (env['TERM'] === 'dumb')
|
|
@@ -285,20 +562,7 @@ async function runInit(run) {
|
|
|
285
562
|
if (await fs.exists(name))
|
|
286
563
|
throw new UsageError(`${name} already exists here; edit it instead`);
|
|
287
564
|
}
|
|
288
|
-
|
|
289
|
-
// (config.ts), in the same words, before anything is written.
|
|
290
|
-
const inside = (option, given) => {
|
|
291
|
-
try {
|
|
292
|
-
return normalisePath(given);
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
throw new UsageError(`--${option} must be a directory inside the repository, not "${given}"`);
|
|
296
|
-
}
|
|
297
|
-
};
|
|
298
|
-
const briefs = inside('briefs', text(run.values, 'briefs') ?? 'briefs');
|
|
299
|
-
const archive = inside('archive', text(run.values, 'archive') ?? `${briefs}/archive`);
|
|
300
|
-
if (briefs === '' || archive === '' || briefs === archive)
|
|
301
|
-
throw new UsageError('--briefs and --archive must be two directories');
|
|
565
|
+
const { briefs, archive } = directories(run.values);
|
|
302
566
|
const config = `${JSON.stringify(initialConfig(briefs, archive), null, 2)}\n`;
|
|
303
567
|
await fs.write('.spec-brief.json', config);
|
|
304
568
|
const written = ['.spec-brief.json'];
|
|
@@ -319,18 +583,7 @@ async function runNew(run) {
|
|
|
319
583
|
const title = run.positionals.join(' ').trim();
|
|
320
584
|
if (title === '')
|
|
321
585
|
throw new UsageError('new needs a title: spec-brief new "<title>"');
|
|
322
|
-
const
|
|
323
|
-
const dependsOn = listed?.split(',').map((d) => d.trim());
|
|
324
|
-
// A list that names no brief is not a brief that depends on none: written
|
|
325
|
-
// as given, "," scaffolded a brief with no dependency and said nothing.
|
|
326
|
-
if (dependsOn?.every((d) => d === '')) {
|
|
327
|
-
throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief; give it ids with commas between them, as in 7,8, or leave the option out`);
|
|
328
|
-
}
|
|
329
|
-
// And a place in it that names none is a brief left out: "$A,$B" with one
|
|
330
|
-
// of the two unset scaffolded a brief that depends on the other alone.
|
|
331
|
-
if (dependsOn?.includes('')) {
|
|
332
|
-
throw new UsageError(`--depends-on is ${JSON.stringify(listed)}, which names no brief in one of its places; give it ids with one comma between each two, as in 7,8, and none before or after`);
|
|
333
|
-
}
|
|
586
|
+
const dependsOn = dependencies(run.values);
|
|
334
587
|
const engine = await openEngine(run);
|
|
335
588
|
const created = await engine.create({
|
|
336
589
|
title,
|
|
@@ -414,7 +667,8 @@ async function runSchedule(run) {
|
|
|
414
667
|
}
|
|
415
668
|
async function runTransition(run, action) {
|
|
416
669
|
const reference = run.positionals[0];
|
|
417
|
-
|
|
670
|
+
// A second brief is refused with the line; none at all is what --help is asked for.
|
|
671
|
+
if (reference === undefined)
|
|
418
672
|
throw new UsageError(`${action} takes one brief: spec-brief ${action} <brief>`);
|
|
419
673
|
const engine = await openEngine(run);
|
|
420
674
|
const dryRun = flag(run.values, 'dry-run');
|
|
@@ -427,7 +681,8 @@ async function runTransition(run, action) {
|
|
|
427
681
|
date: text(run.values, 'date') ?? run.today(),
|
|
428
682
|
allowDirty: flag(run.values, 'allow-dirty'),
|
|
429
683
|
strict: run.strict,
|
|
430
|
-
noGit:
|
|
684
|
+
// No noGit: under --no-git the engine is opened without git, and a
|
|
685
|
+
// commit or a base beside it is refused with the line.
|
|
431
686
|
})
|
|
432
687
|
: engine.planUnarchive(reference, { strict: run.strict });
|
|
433
688
|
const refused = plan.blocking.length > 0;
|
|
@@ -447,7 +702,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
447
702
|
};
|
|
448
703
|
let parsed;
|
|
449
704
|
try {
|
|
450
|
-
parsed = parse(argv);
|
|
705
|
+
parsed = parse(argv, io.cwd ?? process.cwd());
|
|
451
706
|
}
|
|
452
707
|
catch (error) {
|
|
453
708
|
err(error.message);
|
|
@@ -468,12 +723,9 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
468
723
|
// Asked for, the help is the answer; printed because no command was named, it is the error.
|
|
469
724
|
return flag(values, 'help') ? EXIT_OK : EXIT_ERROR;
|
|
470
725
|
}
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
err(`--format for ${command} is one of ${spec.formats.join(', ')}, not "${format}"`);
|
|
475
|
-
return EXIT_ERROR;
|
|
476
|
-
}
|
|
726
|
+
const asked = text(values, 'format') ?? 'pretty';
|
|
727
|
+
// Cast on the name, as in `parse`, which refused a format the command does not write.
|
|
728
|
+
const format = asked;
|
|
477
729
|
// A file, or a name with nothing behind it, had the search for a
|
|
478
730
|
// configuration go on from its parent: the run reported on a tree nobody
|
|
479
731
|
// had named, and `new` wrote into it. A path that cannot be reached is
|