@descent-vtt/spec-harness 0.19.0 → 0.21.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +325 -0
  4. package/README.md +39 -18
  5. package/dist/briefs.d.ts +45 -0
  6. package/dist/briefs.js +73 -0
  7. package/dist/briefs.js.map +1 -1
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +308 -142
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands.js +7 -2
  12. package/dist/commands.js.map +1 -1
  13. package/dist/config.d.ts +13 -1
  14. package/dist/config.js +14 -3
  15. package/dist/config.js.map +1 -1
  16. package/dist/configure.d.ts +94 -3
  17. package/dist/configure.js +204 -30
  18. package/dist/configure.js.map +1 -1
  19. package/dist/fs.d.ts +104 -1
  20. package/dist/fs.js +383 -11
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git.d.ts +88 -2
  23. package/dist/git.js +155 -8
  24. package/dist/git.js.map +1 -1
  25. package/dist/guard.d.ts +20 -1
  26. package/dist/guard.js +31 -0
  27. package/dist/guard.js.map +1 -1
  28. package/dist/hooks.d.ts +59 -0
  29. package/dist/hooks.js +76 -3
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/round.d.ts +27 -1
  32. package/dist/round.js +75 -17
  33. package/dist/round.js.map +1 -1
  34. package/dist/sandbox.js +4 -3
  35. package/dist/sandbox.js.map +1 -1
  36. package/dist/server.d.ts +25 -1
  37. package/dist/server.js +230 -28
  38. package/dist/server.js.map +1 -1
  39. package/dist/setup.js +40 -10
  40. package/dist/setup.js.map +1 -1
  41. package/dist/siblings.d.ts +29 -6
  42. package/dist/siblings.js +152 -39
  43. package/dist/siblings.js.map +1 -1
  44. package/dist/usage.d.ts +42 -7
  45. package/dist/usage.js +65 -6
  46. package/dist/usage.js.map +1 -1
  47. package/dist/workspace.d.ts +45 -3
  48. package/dist/workspace.js +143 -13
  49. package/dist/workspace.js.map +1 -1
  50. package/package.json +3 -3
package/dist/fs.d.ts CHANGED
@@ -2,18 +2,41 @@
2
2
  * The edge where the harness meets the disk: the configuration, the state
3
3
  * directory outside the work tree, and paths as the filesystem spells them.
4
4
  */
5
+ import type { BriefInputs } from './briefs.js';
5
6
  import { type HarnessConfig } from './config.js';
7
+ /**
8
+ * Whether a regular file stands at a path, links followed. Only one is read
9
+ * where a file is expected: opened, a named pipe nothing writes to is read
10
+ * without end, and one read as the process waits on it, as the harness reads
11
+ * a few small files, stops every timer with it (ADR-0002).
12
+ */
13
+ export declare function isFile(path: string): boolean;
6
14
  /**
7
15
  * Reads `.spec-harness.json` at the root, or the defaults when there is none.
8
16
  *
9
17
  * Only a file is read. A named pipe under the file's name was read without
10
18
  * end, by every command and by the hook before every write; and Node's words
11
- * for a directory, EISDIR, are not the harness's.
19
+ * for a directory, EISDIR, are not the harness's. A file that cannot be read
20
+ * is said as that, with the reason: one the account may not read, or one
21
+ * another process holds open on Windows, was said to be JSON that is not
22
+ * valid, which sent a person to its text. What does not load is thrown with
23
+ * the path it stands at, which the hook lets a write reach (ADR-0005).
12
24
  */
13
25
  export declare function loadConfig(root: string): Promise<{
14
26
  config: HarnessConfig;
15
27
  file: string | null;
16
28
  }>;
29
+ /**
30
+ * Whether every path of a write names `file` and nothing else: a file that
31
+ * stands under that name alone, which the path names as the hook resolves a
32
+ * path to decide it ({@link realSpelling}), and as the filesystem resolves
33
+ * it to write, which on Linux and macOS follows a link before a `..` where
34
+ * the hook reads the `..` first. A path that only reads like it, a link to
35
+ * it, a second name of it, and a file of another work tree are none of
36
+ * these. `cwd` is what a relative path is read from, its words kept as
37
+ * written.
38
+ */
39
+ export declare function writesOnly(paths: readonly string[], file: string, cwd: string): boolean;
17
40
  /**
18
41
  * A path where the harness keeps its state that holds something else: said
19
42
  * in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking
@@ -31,6 +54,31 @@ export declare class StateError extends Error {
31
54
  * `src/db/schema.ts` on Windows or macOS, where both name one file.
32
55
  */
33
56
  export declare function realSpelling(path: string): string;
57
+ /**
58
+ * The real spelling of the file a write of `given` reaches, read from `cwd`
59
+ * where it is relative; `null` where it reaches none, through more links
60
+ * than the system follows or a loop of them. Linux reads a `..` after the
61
+ * link before it, and macOS is read so, so `deep/../schema.ts`, `deep` a
62
+ * link to `lib/x`, is written as `lib/schema.ts` (measured on Linux), where
63
+ * {@link realSpelling} reads the `..` first and names `schema.ts`; Windows
64
+ * reads the `..` first, as resolve does (measured, through a junction). And
65
+ * a link to nothing is followed, since a write opens the file it names and
66
+ * makes it there (measured on Linux and on GitHub's Windows runner), where
67
+ * `realSpelling` names the link.
68
+ * The names are walked one by one from the root, each link replaced by what
69
+ * it names, so the directory reached holds no link, and `join` reads a `.`,
70
+ * a `..` and an empty name from it as the system does; what is not there
71
+ * yet is made as written.
72
+ */
73
+ export declare function writtenSpelling(given: string, cwd: string): string | null;
74
+ /**
75
+ * Every file a write of `given` may reach, each by its real spelling: the
76
+ * one the path reads as ({@link realSpelling}), and the one the system
77
+ * writes ({@link writtenSpelling}) where that is another. The hook does not
78
+ * know which of the two a tool writes, so it decides both (ADR-0005). A
79
+ * path that reaches no file is read as it reads.
80
+ */
81
+ export declare function reachedBy(given: string, cwd: string): string[];
34
82
  /**
35
83
  * Whether `directory` is the one git names as the top of a work tree: the
36
84
  * two spelled alike once the filesystem has spelled the directory, links
@@ -55,6 +103,26 @@ export declare function holdsGit(directory: string): boolean;
55
103
  * real spelling - or `null` when it lies outside the root.
56
104
  */
57
105
  export declare function repositoryPath(given: string, root: string, cwd: string): string | null;
106
+ /**
107
+ * The top of every work tree of the repository whose common directory is
108
+ * `common`, `top` among them, read with no process: the main work tree,
109
+ * whose `.git` the common directory is, and each linked worktree, by the
110
+ * `gitdir` file git keeps for it under the common directory's `worktrees`,
111
+ * which names the worktree's `.git` (gitrepository-layout(5)), absolute or
112
+ * from its own directory. `null` where they cannot all be named: a common
113
+ * directory of any other name, whose main work tree only its configuration
114
+ * names, and an entry under `worktrees` that cannot be read.
115
+ */
116
+ export declare function workTrees(top: string, common: string): string[] | null;
117
+ /**
118
+ * Whether every path lies outside every work tree of the repository whose
119
+ * top and common directory are given, as it reads and as the system writes
120
+ * it ({@link reachedBy}): such a path is no file of the repository's, so no
121
+ * brief of its decides it, whatever is wrong with the brief or the
122
+ * configuration (ADR-0005). Where the work trees cannot all be named, a
123
+ * path is not known to be outside them, and this is `false`.
124
+ */
125
+ export declare function outsideEvery(paths: readonly string[], top: string, common: string, cwd: string): boolean;
58
126
  /**
59
127
  * The harness's own directory under the git common directory, created on
60
128
  * demand (ADR-0003). Where something that is no directory stands at its path,
@@ -78,6 +146,7 @@ export declare function stateDirectory(commonDir: string, ...parts: string[]): P
78
146
  */
79
147
  export declare function gitDirectories(root: string, env: Readonly<Record<string, string | undefined>>): {
80
148
  readonly top: string;
149
+ readonly git: string;
81
150
  readonly common: string;
82
151
  readonly exact: boolean;
83
152
  } | null;
@@ -98,6 +167,39 @@ export declare const CALL_LIFE_MS: number;
98
167
  export declare function keepCall(commonDir: string, name: string, text: string, now?: number): Promise<void>;
99
168
  /** What was kept for a call, taken away as it is read; `null` where nothing was. */
100
169
  export declare function takeCall(commonDir: string, name: string): Promise<string | null>;
170
+ /** SHA-256 of a text or of bytes, in hex. */
171
+ export type Hash = (data: string | Uint8Array) => string;
172
+ /** SHA-256, loaded where something is hashed: a run that keeps no answer loads no hash (ADR-0002). */
173
+ export declare function hasher(): Promise<Hash>;
174
+ /**
175
+ * How long a session's answer of spec-brief's is kept once it was last
176
+ * kept (ADR-0003). A day, as a call's: an answer removed costs one start of
177
+ * spec-brief, and one kept is a few kilobytes.
178
+ */
179
+ export declare const ANSWER_LIFE_MS: number;
180
+ /**
181
+ * Keeps a session's answer of spec-brief's under the common directory, with
182
+ * the key it answers, and removes what was kept over a day ago. Whatever
183
+ * fails is passed over: the answer is the one given whether or not it is
184
+ * kept.
185
+ */
186
+ export declare function keepAnswer(commonDir: string, name: string, key: string, answer: string, now?: number): Promise<void>;
187
+ /**
188
+ * The answer kept under `name` for `key`, or `null` where there is none:
189
+ * nothing there, something that is not a file, a text that is not what
190
+ * `keepAnswer` writes, or another key's.
191
+ */
192
+ export declare function keptAnswer(commonDir: string, name: string, key: string): Promise<string | null>;
193
+ /**
194
+ * Whether every input of an answer reads as listed, with no process
195
+ * started: each file by the digest of its bytes, or as not there; each
196
+ * directory by its entries; and, where git was asked, the index of the work
197
+ * tree's git directory and each ignore file. A path is read from the work
198
+ * tree's top, or as it stands where it is absolute. Anything that cannot be
199
+ * read, and an index that is not the one git reads for the work tree, is a
200
+ * doubt, and answers `false` (ADR-0002).
201
+ */
202
+ export declare function readsAsListed(root: string, inputs: BriefInputs, hash: Hash, env?: Readonly<Record<string, string | undefined>>): Promise<boolean>;
101
203
  /** Writes a file whole or not at all: a temporary file renamed over the target. */
102
204
  export declare function writeAtomic(file: string, content: string): Promise<void>;
103
205
  /**
@@ -110,4 +212,5 @@ export declare function writeAtomic(file: string, content: string): Promise<void
110
212
  export declare function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'>;
111
213
  /** Whether a path names a directory that is there; one that cannot be read names none. */
112
214
  export declare function isDirectory(path: string): boolean;
215
+ /** A file's text, or `null` where it cannot be read or is no file: a brief, a document one cites, a shim. */
113
216
  export declare function readText(file: string): Promise<string | null>;
package/dist/fs.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * The edge where the harness meets the disk: the configuration, the state
3
3
  * directory outside the work tree, and paths as the filesystem spells them.
4
4
  */
5
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
6
- import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
7
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
6
+ import { lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
7
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
8
8
  import { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig } from './config.js';
9
9
  /** What stands at a path, links followed, or `null` where nothing does: a link to nothing is nothing. */
10
10
  function standing(path) {
@@ -15,12 +15,25 @@ function standing(path) {
15
15
  return null;
16
16
  }
17
17
  }
18
+ /**
19
+ * Whether a regular file stands at a path, links followed. Only one is read
20
+ * where a file is expected: opened, a named pipe nothing writes to is read
21
+ * without end, and one read as the process waits on it, as the harness reads
22
+ * a few small files, stops every timer with it (ADR-0002).
23
+ */
24
+ export function isFile(path) {
25
+ return standing(path)?.isFile() === true;
26
+ }
18
27
  /**
19
28
  * Reads `.spec-harness.json` at the root, or the defaults when there is none.
20
29
  *
21
30
  * Only a file is read. A named pipe under the file's name was read without
22
31
  * end, by every command and by the hook before every write; and Node's words
23
- * for a directory, EISDIR, are not the harness's.
32
+ * for a directory, EISDIR, are not the harness's. A file that cannot be read
33
+ * is said as that, with the reason: one the account may not read, or one
34
+ * another process holds open on Windows, was said to be JSON that is not
35
+ * valid, which sent a person to its text. What does not load is thrown with
36
+ * the path it stands at, which the hook lets a write reach (ADR-0005).
24
37
  */
25
38
  export async function loadConfig(root) {
26
39
  const file = join(root, CONFIG_FILE);
@@ -28,16 +41,65 @@ export async function loadConfig(root) {
28
41
  if (there === null)
29
42
  return { config: DEFAULT_CONFIG, file: null };
30
43
  if (!there.isFile())
31
- throw new ConfigError(`${CONFIG_FILE} is ${there.isDirectory() ? 'a directory' : 'neither a file nor a directory'}, not a configuration file`);
32
- let raw;
44
+ throw new ConfigError(`${CONFIG_FILE} is ${there.isDirectory() ? 'a directory' : 'neither a file nor a directory'}, not a configuration file`, file);
45
+ let text;
33
46
  try {
34
47
  // A Buffer parses as its text, so the encoding is equivalent to its mutant.
35
- raw = JSON.parse(await readFile(file, 'utf8'));
48
+ text = await readFile(file, 'utf8');
36
49
  }
37
50
  catch (error) {
38
- throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${error.message}`);
51
+ throw new ConfigError(`${CONFIG_FILE} cannot be read: ${error.message}`, file);
52
+ }
53
+ let raw;
54
+ try {
55
+ raw = JSON.parse(text);
56
+ }
57
+ catch (error) {
58
+ throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${error.message}`, file);
59
+ }
60
+ try {
61
+ return { config: parseConfig(raw), file };
62
+ }
63
+ catch (error) {
64
+ // parseConfig throws nothing but its own refusal, whose words are kept.
65
+ throw new ConfigError(error.message, file);
39
66
  }
40
- return { config: parseConfig(raw), file };
67
+ }
68
+ /**
69
+ * The real path of a file that stands at `path` under that name alone: a
70
+ * file, not a link to one, and with no other name, since a write to a file
71
+ * with two names writes both. `null` for anything else, and for nothing.
72
+ */
73
+ function soleFile(path) {
74
+ try {
75
+ const entry = lstatSync(path);
76
+ return entry.isFile() && entry.nlink === 1 ? realpathSync.native(path) : null;
77
+ }
78
+ catch {
79
+ // Nothing at the path, or nothing the account may look at. Its callers
80
+ // compare the answer with a real path, which neither `null` nor what an
81
+ // empty block answers is, so that mutant is equivalent.
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * Whether every path of a write names `file` and nothing else: a file that
87
+ * stands under that name alone, which the path names as the hook resolves a
88
+ * path to decide it ({@link realSpelling}), and as the filesystem resolves
89
+ * it to write, which on Linux and macOS follows a link before a `..` where
90
+ * the hook reads the `..` first. A path that only reads like it, a link to
91
+ * it, a second name of it, and a file of another work tree are none of
92
+ * these. `cwd` is what a relative path is read from, its words kept as
93
+ * written.
94
+ */
95
+ export function writesOnly(paths, file, cwd) {
96
+ // Where the file is none, no path's real spelling is `null`, so no path
97
+ // names it.
98
+ const own = soleFile(file);
99
+ return paths.every((given) => {
100
+ const path = isAbsolute(given) ? given : `${cwd}${sep}${given}`;
101
+ return soleFile(path) === own && realSpelling(path) === own;
102
+ });
41
103
  }
42
104
  /**
43
105
  * A path where the harness keeps its state that holds something else: said
@@ -78,6 +140,94 @@ export function realSpelling(path) {
78
140
  }
79
141
  }
80
142
  }
143
+ /**
144
+ * The most links the written reading of a path follows. Linux follows at
145
+ * most 40 in resolving a pathname, and fails the call with ELOOP past them
146
+ * (path_resolution(7)), so no write reaches a file through more.
147
+ */
148
+ const MOST_LINKS = 40;
149
+ /**
150
+ * A path's names, separated as the system separates them: Windows takes `/`
151
+ * as well as `\`, where Linux and macOS take a `\` for a character of a name.
152
+ * The sweep runs on Linux, where the mutants that read the pattern there, or
153
+ * change it, are equivalent; the suite's Windows leg holds them.
154
+ */
155
+ function namesOf(path) {
156
+ return path.split(sep === '/' ? '/' : /[\\/]/);
157
+ }
158
+ /**
159
+ * The real spelling of the file a write of `given` reaches, read from `cwd`
160
+ * where it is relative; `null` where it reaches none, through more links
161
+ * than the system follows or a loop of them. Linux reads a `..` after the
162
+ * link before it, and macOS is read so, so `deep/../schema.ts`, `deep` a
163
+ * link to `lib/x`, is written as `lib/schema.ts` (measured on Linux), where
164
+ * {@link realSpelling} reads the `..` first and names `schema.ts`; Windows
165
+ * reads the `..` first, as resolve does (measured, through a junction). And
166
+ * a link to nothing is followed, since a write opens the file it names and
167
+ * makes it there (measured on Linux and on GitHub's Windows runner), where
168
+ * `realSpelling` names the link.
169
+ * The names are walked one by one from the root, each link replaced by what
170
+ * it names, so the directory reached holds no link, and `join` reads a `.`,
171
+ * a `..` and an empty name from it as the system does; what is not there
172
+ * yet is made as written.
173
+ */
174
+ export function writtenSpelling(given, cwd) {
175
+ // On Linux, where the sweep runs, the mutant that reads every path as
176
+ // Windows does is caught, and the one that reads every path as Linux does
177
+ // is equivalent; the suite's Windows leg holds it. The root is taken off
178
+ // the names as it is off a link's, below.
179
+ const absolute = sep === '/' ? (isAbsolute(given) ? given : `${cwd}/${given}`) : resolve(cwd, given);
180
+ const root = parse(absolute).root;
181
+ const names = namesOf(absolute.slice(root.length));
182
+ let at = root;
183
+ let links = 0;
184
+ while (names.length > 0) {
185
+ const next = join(at, names.shift());
186
+ let target;
187
+ try {
188
+ if (!lstatSync(next).isSymbolicLink()) {
189
+ at = next;
190
+ continue;
191
+ }
192
+ target = readlinkSync(next);
193
+ }
194
+ catch {
195
+ // Nothing there, or nothing the account may look into: a write makes
196
+ // what is not there as written, and reaches nothing past a directory
197
+ // it may not look into.
198
+ at = join(next, ...names);
199
+ break;
200
+ }
201
+ links += 1;
202
+ if (links > MOST_LINKS)
203
+ return null;
204
+ // A link names a path from the directory it stands in, or from a root,
205
+ // which is taken off its names: on Linux and macOS the root is `/`,
206
+ // whose name is empty and read as none, so keeping it there is
207
+ // equivalent; on Windows it is the drive (a test holds it there).
208
+ const top = parse(target).root;
209
+ if (top !== '')
210
+ at = top;
211
+ names.unshift(...namesOf(target.slice(top.length)));
212
+ }
213
+ // The walk spells what exists as given, links followed; where case is
214
+ // ignored, the entries' case is the real one. On Linux, where the sweep
215
+ // runs, the two are one, so the mutant that leaves this out is equivalent
216
+ // there (a test holds it on Windows and macOS).
217
+ return realSpelling(at);
218
+ }
219
+ /**
220
+ * Every file a write of `given` may reach, each by its real spelling: the
221
+ * one the path reads as ({@link realSpelling}), and the one the system
222
+ * writes ({@link writtenSpelling}) where that is another. The hook does not
223
+ * know which of the two a tool writes, so it decides both (ADR-0005). A
224
+ * path that reaches no file is read as it reads.
225
+ */
226
+ export function reachedBy(given, cwd) {
227
+ const read = realSpelling(resolve(cwd, given));
228
+ const written = writtenSpelling(given, cwd);
229
+ return written === null || written === read ? [read] : [read, written];
230
+ }
81
231
  /**
82
232
  * Whether `directory` is the one git names as the top of a work tree: the
83
233
  * two spelled alike once the filesystem has spelled the directory, links
@@ -117,6 +267,69 @@ export function repositoryPath(given, root, cwd) {
117
267
  return null;
118
268
  return rel.split(sep).join('/');
119
269
  }
270
+ /**
271
+ * The top of every work tree of the repository whose common directory is
272
+ * `common`, `top` among them, read with no process: the main work tree,
273
+ * whose `.git` the common directory is, and each linked worktree, by the
274
+ * `gitdir` file git keeps for it under the common directory's `worktrees`,
275
+ * which names the worktree's `.git` (gitrepository-layout(5)), absolute or
276
+ * from its own directory. `null` where they cannot all be named: a common
277
+ * directory of any other name, whose main work tree only its configuration
278
+ * names, and an entry under `worktrees` that cannot be read.
279
+ */
280
+ export function workTrees(top, common) {
281
+ const main = mainWorkTree(common);
282
+ if (main === null)
283
+ return null;
284
+ const kept = join(common, 'worktrees');
285
+ let names = [];
286
+ try {
287
+ names = readdirSync(kept);
288
+ }
289
+ catch (error) {
290
+ // No such directory is no linked worktree, and anything else there is
291
+ // one that cannot be read.
292
+ if (error.code !== 'ENOENT')
293
+ return null;
294
+ }
295
+ const linked = names.map((name) => textOf(join(kept, name, 'gitdir')));
296
+ if (linked.includes(null))
297
+ return null;
298
+ // The line's end stands in the last name, the worktree's `.git`, which
299
+ // dirname takes away with it.
300
+ return [top, main, ...names.map((name, at) => dirname(resolve(kept, name, linked[at])))];
301
+ }
302
+ /**
303
+ * Whether every path lies outside every work tree of the repository whose
304
+ * top and common directory are given, as it reads and as the system writes
305
+ * it ({@link reachedBy}): such a path is no file of the repository's, so no
306
+ * brief of its decides it, whatever is wrong with the brief or the
307
+ * configuration (ADR-0005). Where the work trees cannot all be named, a
308
+ * path is not known to be outside them, and this is `false`.
309
+ */
310
+ export function outsideEvery(paths, top, common, cwd) {
311
+ // A write that names no path is no write outside: no caller asks of
312
+ // one, and a test holds the answer.
313
+ if (paths.length === 0)
314
+ return false;
315
+ // A path in the work tree that was opened, as nearly every write's is,
316
+ // is told with nothing of git's directory read; read anyway, `top` is
317
+ // among the work trees, so the mutant without this test is equivalent.
318
+ if (paths.some((path) => repositoryPath(path, top, cwd) !== null))
319
+ return false;
320
+ // A path with a `..` in it was held here when the hook read a path one
321
+ // way, its `..` before its links: `/elsewhere/link/../a.ts`, the link to
322
+ // a directory of the work tree, read as outside and was written inside.
323
+ // Both readings are read below, and such a path is held as it was all
324
+ // the same (ADR-0005).
325
+ if (paths.some((path) => path.split(/[\\/]/).includes('..')))
326
+ return false;
327
+ // As a path reads and as the system writes it, a link to nothing among
328
+ // its links: the file it reaches is outside too, or the path is not.
329
+ const reached = paths.flatMap((path) => reachedBy(path, cwd));
330
+ const trees = workTrees(top, common);
331
+ return trees !== null && reached.every((path) => trees.every((tree) => repositoryPath(path, tree, cwd) === null));
332
+ }
120
333
  /**
121
334
  * The harness's own directory under the git common directory, created on
122
335
  * demand (ADR-0003). Where something that is no directory stands at its path,
@@ -183,10 +396,12 @@ export function gitDirectories(root, env) {
183
396
  // No configuration holds no key, as a text without one does: the mutant
184
397
  // of the empty text is equivalent.
185
398
  const elsewhere = /^\s*worktree\s*=/im.test(textOf(join(common, 'config')) ?? '') || existsSync(join(gitDir, 'config.worktree'));
186
- return { top, common, exact: !moved && !elsewhere };
399
+ return { top, git: gitDir, common, exact: !moved && !elsewhere };
187
400
  }
188
- /** A file's text, or `null` where it cannot be read. */
401
+ /** A file's text, or `null` where it cannot be read or is no file. */
189
402
  function textOf(file) {
403
+ if (!isFile(file))
404
+ return null;
190
405
  try {
191
406
  return readFileSync(file, 'utf8');
192
407
  }
@@ -229,6 +444,9 @@ export async function keepCall(commonDir, name, text, now = Date.now()) {
229
444
  /** What was kept for a call, taken away as it is read; `null` where nothing was. */
230
445
  export async function takeCall(commonDir, name) {
231
446
  const file = join(commonDir, 'spec-harness', 'calls', name);
447
+ // What is no file holds nothing kept, and is written over at the next write.
448
+ if (!isFile(file))
449
+ return null;
232
450
  try {
233
451
  const text = await readFile(file, 'utf8');
234
452
  // Taken away by another run since it was read, the mutants of `force`
@@ -241,6 +459,157 @@ export async function takeCall(commonDir, name) {
241
459
  return null;
242
460
  }
243
461
  }
462
+ /** SHA-256, loaded where something is hashed: a run that keeps no answer loads no hash (ADR-0002). */
463
+ export async function hasher() {
464
+ const { createHash } = await import('node:crypto');
465
+ return (data) => createHash('sha256').update(data).digest('hex');
466
+ }
467
+ /**
468
+ * How long a session's answer of spec-brief's is kept once it was last
469
+ * kept (ADR-0003). A day, as a call's: an answer removed costs one start of
470
+ * spec-brief, and one kept is a few kilobytes.
471
+ */
472
+ export const ANSWER_LIFE_MS = 24 * 60 * 60 * 1000;
473
+ /**
474
+ * Keeps a session's answer of spec-brief's under the common directory, with
475
+ * the key it answers, and removes what was kept over a day ago. Whatever
476
+ * fails is passed over: the answer is the one given whether or not it is
477
+ * kept.
478
+ */
479
+ export async function keepAnswer(commonDir, name, key, answer, now = Date.now()) {
480
+ try {
481
+ const directory = await stateDirectory(commonDir, 'answers');
482
+ await writeAtomic(join(directory, name), `${JSON.stringify({ key, answer })}\n`);
483
+ // A file another run takes away before it is removed here ends the
484
+ // pruning in the catch below, where `force` would pass over it, and what
485
+ // is left is pruned by a later run: the mutants of `force` are
486
+ // equivalent, and so is the one that also removes a file kept a day ago
487
+ // to the millisecond.
488
+ for (const entry of await readdir(directory)) {
489
+ const file = join(directory, entry);
490
+ if (now - (await stat(file)).mtimeMs > ANSWER_LIFE_MS)
491
+ await rm(file, { force: true });
492
+ }
493
+ }
494
+ catch {
495
+ // A full disk, a directory another run removed: the next run starts spec-brief.
496
+ }
497
+ }
498
+ /**
499
+ * The answer kept under `name` for `key`, or `null` where there is none:
500
+ * nothing there, something that is not a file, a text that is not what
501
+ * `keepAnswer` writes, or another key's.
502
+ */
503
+ export async function keptAnswer(commonDir, name, key) {
504
+ const file = join(commonDir, 'spec-harness', 'answers', name);
505
+ try {
506
+ // Only a file is read: a named pipe would be read without end.
507
+ if (!(await stat(file)).isFile())
508
+ return null;
509
+ // A text that holds null throws on the read, and any other that is no
510
+ // object has no key: the catch answers for both. A Buffer parses as its
511
+ // text, so the encoding is equivalent to its mutant.
512
+ const kept = JSON.parse(await readFile(file, 'utf8'));
513
+ return kept.key === key && typeof kept.answer === 'string' ? kept.answer : null;
514
+ }
515
+ catch {
516
+ // Falling out of the catch answers undefined, which no answer is read
517
+ // from either: the mutant without the return is equivalent.
518
+ return null;
519
+ }
520
+ }
521
+ /** Whether a path that could not be read names nothing: it, or a directory on the way to it, is not there. */
522
+ function nothingThere(error) {
523
+ const { code } = error;
524
+ return code === 'ENOENT' || code === 'ENOTDIR';
525
+ }
526
+ /**
527
+ * Whether a file reads as listed: a file, never a pipe or a directory, whose
528
+ * bytes have the digest; or, listed as not there, nothing at its path.
529
+ */
530
+ async function fileReads(file, sha256, hash) {
531
+ let there;
532
+ try {
533
+ there = await stat(file);
534
+ }
535
+ catch (error) {
536
+ return sha256 === null && nothingThere(error);
537
+ }
538
+ // Only a file is read: a named pipe would be read without end. A file
539
+ // listed as not there has no digest a file's bytes have.
540
+ if (!there.isFile())
541
+ return false;
542
+ return hash(await readFile(file)) === sha256;
543
+ }
544
+ /**
545
+ * Whether a directory is there and holds the files listed, and no other: a
546
+ * file as spec-brief lists one, an entry that is a file itself, where a
547
+ * directory or a link is not one. What is no directory, a named pipe among
548
+ * it, is refused by the listing, which throws. Listed as not there, nothing
549
+ * may stand at its path in any form, a link that leads nowhere among them.
550
+ */
551
+ async function entriesRead(directory, entries) {
552
+ if (entries === null) {
553
+ try {
554
+ await lstat(directory);
555
+ return false;
556
+ }
557
+ catch (error) {
558
+ return nothingThere(error);
559
+ }
560
+ }
561
+ // The entries are names no two of which are one (inputsOf), and so are a
562
+ // directory's: as many of each, every one of the one among the other, is
563
+ // the same names, in whatever order the disk lists them.
564
+ const names = (await readdir(directory, { withFileTypes: true })).flatMap((entry) => (entry.isFile() ? [entry.name] : []));
565
+ const listed = new Set(entries);
566
+ return names.length === listed.size && names.every((name) => listed.has(name));
567
+ }
568
+ /**
569
+ * Whether every input of an answer reads as listed, with no process
570
+ * started: each file by the digest of its bytes, or as not there; each
571
+ * directory by its entries; and, where git was asked, the index of the work
572
+ * tree's git directory and each ignore file. A path is read from the work
573
+ * tree's top, or as it stands where it is absolute. Anything that cannot be
574
+ * read, and an index that is not the one git reads for the work tree, is a
575
+ * doubt, and answers `false` (ADR-0002).
576
+ */
577
+ export async function readsAsListed(root, inputs, hash, env = process.env) {
578
+ const at = (path) => (isAbsolute(path) ? path : join(root, ...path.split('/')));
579
+ const git = inputs.git;
580
+ let index = null;
581
+ if (git !== null) {
582
+ // The index of the work tree's own git directory, as the files git keeps
583
+ // name it: not where git is sent elsewhere, GIT_DIR, GIT_WORK_TREE or a
584
+ // worktree key (`exact`), GIT_INDEX_FILE or GIT_COMMON_DIR. A top git
585
+ // named always holds a `.git`, so the test of none is the compiler's, and
586
+ // a null read past it throws, which the caller reads as a doubt: that
587
+ // mutant is equivalent. The top found is the work tree's own but where
588
+ // git names a top that holds no `.git`, a work tree `core.worktree`
589
+ // names away from its repository and inside another, whose index the
590
+ // one found would be.
591
+ const found = gitDirectories(root, env);
592
+ if (found === null || !found.exact || env['GIT_INDEX_FILE'] !== undefined || env['GIT_COMMON_DIR'] !== undefined || found.top !== realSpelling(root))
593
+ return false;
594
+ index = join(found.git, 'index');
595
+ }
596
+ // Made once every check is known to be made, so that none is left to fail
597
+ // with no one reading it. A text put among them where git was asked
598
+ // nothing is one `every` reads as true, so that mutant is equivalent.
599
+ const checks = [
600
+ ...inputs.files.map((file) => fileReads(at(file.path), file.sha256, hash)),
601
+ ...inputs.directories.map((directory) => entriesRead(at(directory.path), directory.entries)),
602
+ ...(git === null ? [] : [fileReads(index, git.index, hash), ...git.ignores.map((file) => fileReads(at(file.path), file.sha256, hash))]),
603
+ ];
604
+ try {
605
+ return (await Promise.all(checks)).every((reads) => reads);
606
+ }
607
+ catch {
608
+ // Falling out of the catch answers undefined, which the caller reads as
609
+ // false: the mutant without the return is equivalent.
610
+ return false;
611
+ }
612
+ }
244
613
  /** Writes a file whole or not at all: a temporary file renamed over the target. */
245
614
  export async function writeAtomic(file, content) {
246
615
  await mkdir(dirname(file), { recursive: true });
@@ -282,7 +651,10 @@ export function isDirectory(path) {
282
651
  return false;
283
652
  }
284
653
  }
654
+ /** A file's text, or `null` where it cannot be read or is no file: a brief, a document one cites, a shim. */
285
655
  export async function readText(file) {
656
+ if (!isFile(file))
657
+ return null;
286
658
  try {
287
659
  return await readFile(file, 'utf8');
288
660
  }