@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +325 -0
- package/README.md +39 -18
- package/dist/briefs.d.ts +45 -0
- package/dist/briefs.js +73 -0
- package/dist/briefs.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +308 -142
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +7 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -3
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +94 -3
- package/dist/configure.js +204 -30
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +104 -1
- package/dist/fs.js +383 -11
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +88 -2
- package/dist/git.js +155 -8
- package/dist/git.js.map +1 -1
- package/dist/guard.d.ts +20 -1
- package/dist/guard.js +31 -0
- package/dist/guard.js.map +1 -1
- package/dist/hooks.d.ts +59 -0
- package/dist/hooks.js +76 -3
- package/dist/hooks.js.map +1 -1
- package/dist/round.d.ts +27 -1
- package/dist/round.js +75 -17
- package/dist/round.js.map +1 -1
- package/dist/sandbox.js +4 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +230 -28
- package/dist/server.js.map +1 -1
- package/dist/setup.js +40 -10
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +29 -6
- package/dist/siblings.js +152 -39
- package/dist/siblings.js.map +1 -1
- package/dist/usage.d.ts +42 -7
- package/dist/usage.js +65 -6
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +45 -3
- package/dist/workspace.js +143 -13
- package/dist/workspace.js.map +1 -1
- 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
|
|
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
|
-
|
|
48
|
+
text = await readFile(file, 'utf8');
|
|
36
49
|
}
|
|
37
50
|
catch (error) {
|
|
38
|
-
throw new ConfigError(`${CONFIG_FILE}
|
|
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
|
-
|
|
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
|
}
|