@descent-vtt/spec-harness 0.17.0 → 0.19.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/dist/fs.d.ts CHANGED
@@ -3,11 +3,25 @@
3
3
  * directory outside the work tree, and paths as the filesystem spells them.
4
4
  */
5
5
  import { type HarnessConfig } from './config.js';
6
- /** Reads `.spec-harness.json` at the root, or the defaults when there is none. */
6
+ /**
7
+ * Reads `.spec-harness.json` at the root, or the defaults when there is none.
8
+ *
9
+ * Only a file is read. A named pipe under the file's name was read without
10
+ * 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.
12
+ */
7
13
  export declare function loadConfig(root: string): Promise<{
8
14
  config: HarnessConfig;
9
15
  file: string | null;
10
16
  }>;
17
+ /**
18
+ * A path where the harness keeps its state that holds something else: said
19
+ * in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking
20
+ * for a defect (spec-core ADR-0005).
21
+ */
22
+ export declare class StateError extends Error {
23
+ constructor(message: string);
24
+ }
11
25
  /**
12
26
  * The real spelling of a path: links resolved and, on a filesystem that
13
27
  * ignores case, the case the directory entries have. A path that does not
@@ -17,16 +31,82 @@ export declare function loadConfig(root: string): Promise<{
17
31
  * `src/db/schema.ts` on Windows or macOS, where both name one file.
18
32
  */
19
33
  export declare function realSpelling(path: string): string;
34
+ /**
35
+ * Whether `directory` is the one git names as the top of a work tree: the
36
+ * two spelled alike once the filesystem has spelled the directory, links
37
+ * and short names resolved, as git spells a top. A spelling that differs is
38
+ * read as another directory: what git said from the one is then not taken
39
+ * for what it would say from the other.
40
+ */
41
+ export declare function isTop(directory: string, top: string): boolean;
42
+ /**
43
+ * The repository's main work tree, where git keeps the repository as that
44
+ * work tree's `.git`, as `git init` and `git clone` do and every linked
45
+ * worktree's common directory then is: `null` for a common directory of any
46
+ * other name, a bare repository's, a submodule's under its superproject's
47
+ * `modules`, or one kept apart with `--separate-git-dir`. Read from the
48
+ * name, with no process: it says only where an install is looked for.
49
+ */
50
+ export declare function mainWorkTree(commonDir: string): string | null;
51
+ /** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */
52
+ export declare function holdsGit(directory: string): boolean;
20
53
  /**
21
54
  * A path as the repository names it - relative to the root, POSIX-separated,
22
55
  * real spelling - or `null` when it lies outside the root.
23
56
  */
24
57
  export declare function repositoryPath(given: string, root: string, cwd: string): string | null;
25
- /** The harness's own directory under the git common directory, created on demand (ADR-0003). */
58
+ /**
59
+ * The harness's own directory under the git common directory, created on
60
+ * demand (ADR-0003). Where something that is no directory stands at its path,
61
+ * or at one between it and git's, Node refuses to make it, with ENOTDIR or
62
+ * EEXIST, and the harness says which path holds what.
63
+ */
26
64
  export declare function stateDirectory(commonDir: string, ...parts: string[]): Promise<string>;
65
+ /**
66
+ * A work tree and its git directories as the files git keeps name them, read
67
+ * with no process: the nearest directory at or above `root` that holds a
68
+ * `.git`; the git directory, that `.git` or the one a `.git` file names
69
+ * (`gitdir: <path>`, relative to the file's directory); and the common
70
+ * directory, the one that git directory's `commondir` file names, relative
71
+ * to it, or the git directory itself (gitrepository-layout(5)). `exact` is
72
+ * whether git, asked, would name the same work tree: not where
73
+ * `GIT_DIR` or `GIT_WORK_TREE` sends it elsewhere, and not where the
74
+ * repository's configuration names a `worktree`, which `core.worktree`
75
+ * moves it to (git-config(1)), or keeps one of its own for a worktree.
76
+ * `null` where no directory up to the top of the filesystem holds one, or
77
+ * a `.git` file names none.
78
+ */
79
+ export declare function gitDirectories(root: string, env: Readonly<Record<string, string | undefined>>): {
80
+ readonly top: string;
81
+ readonly common: string;
82
+ readonly exact: boolean;
83
+ } | null;
84
+ /**
85
+ * How long a call's decisions are kept for a run after the write that does
86
+ * not come: a call refused by the person, one that failed, one stopped
87
+ * (ADR-0002). A day, since a call waits on a person's prompt for as long as
88
+ * the person is away, and an entry removed too soon costs only that call's
89
+ * answer being decided again.
90
+ */
91
+ export declare const CALL_LIFE_MS: number;
92
+ /**
93
+ * Keeps what a run before a write decided, under the common directory, for
94
+ * the run after it, and removes what no run after a write read in a day.
95
+ * Nothing here may change the answer before the write: whatever fails is
96
+ * passed over, and the run after the write then decides as it did.
97
+ */
98
+ export declare function keepCall(commonDir: string, name: string, text: string, now?: number): Promise<void>;
99
+ /** What was kept for a call, taken away as it is read; `null` where nothing was. */
100
+ export declare function takeCall(commonDir: string, name: string): Promise<string | null>;
27
101
  /** Writes a file whole or not at all: a temporary file renamed over the target. */
28
102
  export declare function writeAtomic(file: string, content: string): Promise<void>;
29
- /** A JSON object from a file: `null` when there is no file, `'unreadable'` when it holds anything else. */
103
+ /**
104
+ * A JSON object from a file: `null` when there is no file, `'unreadable'`
105
+ * when it holds anything else. Only a file is read: a named pipe where a
106
+ * settings file is expected was read without end, and neither doctor nor
107
+ * init came back. What is neither a file nor a directory holds no JSON
108
+ * object, as a directory holds none.
109
+ */
30
110
  export declare function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'>;
31
111
  /** Whether a path names a directory that is there; one that cannot be read names none. */
32
112
  export declare function isDirectory(path: string): boolean;
package/dist/fs.js CHANGED
@@ -2,29 +2,54 @@
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, realpathSync, statSync } from 'node:fs';
6
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
7
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
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';
8
8
  import { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig } from './config.js';
9
- /** Reads `.spec-harness.json` at the root, or the defaults when there is none. */
9
+ /** What stands at a path, links followed, or `null` where nothing does: a link to nothing is nothing. */
10
+ function standing(path) {
11
+ try {
12
+ return statSync(path);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /**
19
+ * Reads `.spec-harness.json` at the root, or the defaults when there is none.
20
+ *
21
+ * Only a file is read. A named pipe under the file's name was read without
22
+ * 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.
24
+ */
10
25
  export async function loadConfig(root) {
11
26
  const file = join(root, CONFIG_FILE);
12
- if (!existsSync(file))
27
+ const there = standing(file);
28
+ if (there === null)
13
29
  return { config: DEFAULT_CONFIG, file: null };
30
+ if (!there.isFile())
31
+ throw new ConfigError(`${CONFIG_FILE} is ${there.isDirectory() ? 'a directory' : 'neither a file nor a directory'}, not a configuration file`);
14
32
  let raw;
15
33
  try {
16
34
  // A Buffer parses as its text, so the encoding is equivalent to its mutant.
17
35
  raw = JSON.parse(await readFile(file, 'utf8'));
18
36
  }
19
37
  catch (error) {
20
- // A directory under the file's name is not JSON that went wrong, and
21
- // Node's words for it, EISDIR, are not the harness's.
22
- if (error.code === 'EISDIR')
23
- throw new ConfigError(`${CONFIG_FILE} is a directory, not a configuration file`);
24
38
  throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${error.message}`);
25
39
  }
26
40
  return { config: parseConfig(raw), file };
27
41
  }
42
+ /**
43
+ * A path where the harness keeps its state that holds something else: said
44
+ * in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking
45
+ * for a defect (spec-core ADR-0005).
46
+ */
47
+ export class StateError extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'StateError';
51
+ }
52
+ }
28
53
  /**
29
54
  * The real spelling of a path: links resolved and, on a filesystem that
30
55
  * ignores case, the case the directory entries have. A path that does not
@@ -53,6 +78,31 @@ export function realSpelling(path) {
53
78
  }
54
79
  }
55
80
  }
81
+ /**
82
+ * Whether `directory` is the one git names as the top of a work tree: the
83
+ * two spelled alike once the filesystem has spelled the directory, links
84
+ * and short names resolved, as git spells a top. A spelling that differs is
85
+ * read as another directory: what git said from the one is then not taken
86
+ * for what it would say from the other.
87
+ */
88
+ export function isTop(directory, top) {
89
+ return realSpelling(directory).split(sep).join('/') === top;
90
+ }
91
+ /**
92
+ * The repository's main work tree, where git keeps the repository as that
93
+ * work tree's `.git`, as `git init` and `git clone` do and every linked
94
+ * worktree's common directory then is: `null` for a common directory of any
95
+ * other name, a bare repository's, a submodule's under its superproject's
96
+ * `modules`, or one kept apart with `--separate-git-dir`. Read from the
97
+ * name, with no process: it says only where an install is looked for.
98
+ */
99
+ export function mainWorkTree(commonDir) {
100
+ return basename(commonDir) === '.git' ? dirname(commonDir) : null;
101
+ }
102
+ /** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */
103
+ export function holdsGit(directory) {
104
+ return existsSync(join(directory, '.git'));
105
+ }
56
106
  /**
57
107
  * A path as the repository names it - relative to the root, POSIX-separated,
58
108
  * real spelling - or `null` when it lies outside the root.
@@ -67,12 +117,130 @@ export function repositoryPath(given, root, cwd) {
67
117
  return null;
68
118
  return rel.split(sep).join('/');
69
119
  }
70
- /** The harness's own directory under the git common directory, created on demand (ADR-0003). */
120
+ /**
121
+ * The harness's own directory under the git common directory, created on
122
+ * demand (ADR-0003). Where something that is no directory stands at its path,
123
+ * or at one between it and git's, Node refuses to make it, with ENOTDIR or
124
+ * EEXIST, and the harness says which path holds what.
125
+ */
71
126
  export async function stateDirectory(commonDir, ...parts) {
72
127
  const directory = join(commonDir, 'spec-harness', ...parts);
73
- await mkdir(directory, { recursive: true });
128
+ try {
129
+ await mkdir(directory, { recursive: true });
130
+ }
131
+ catch (error) {
132
+ const { code } = error;
133
+ if (code !== 'ENOTDIR' && code !== 'EEXIST')
134
+ throw error;
135
+ // mkdir refuses for this alone, so one of the steps is there and is no
136
+ // directory, and every step before it is one.
137
+ const steps = ['spec-harness', ...parts].map((_, at, all) => join(commonDir, ...all.slice(0, at + 1)));
138
+ const there = steps.find((step) => !standing(step).isDirectory());
139
+ throw new StateError(`${there} is not a directory, where spec-harness keeps its state in one: move it away, and run the command again`);
140
+ }
74
141
  return directory;
75
142
  }
143
+ /**
144
+ * A work tree and its git directories as the files git keeps name them, read
145
+ * with no process: the nearest directory at or above `root` that holds a
146
+ * `.git`; the git directory, that `.git` or the one a `.git` file names
147
+ * (`gitdir: <path>`, relative to the file's directory); and the common
148
+ * directory, the one that git directory's `commondir` file names, relative
149
+ * to it, or the git directory itself (gitrepository-layout(5)). `exact` is
150
+ * whether git, asked, would name the same work tree: not where
151
+ * `GIT_DIR` or `GIT_WORK_TREE` sends it elsewhere, and not where the
152
+ * repository's configuration names a `worktree`, which `core.worktree`
153
+ * moves it to (git-config(1)), or keeps one of its own for a worktree.
154
+ * `null` where no directory up to the top of the filesystem holds one, or
155
+ * a `.git` file names none.
156
+ */
157
+ export function gitDirectories(root, env) {
158
+ let top = realSpelling(root);
159
+ let found = null;
160
+ for (;;) {
161
+ found = standing(join(top, '.git'));
162
+ if (found !== null)
163
+ break;
164
+ const parent = dirname(top);
165
+ if (parent === top)
166
+ return null;
167
+ top = parent;
168
+ }
169
+ const dotGit = join(top, '.git');
170
+ let gitDir = dotGit;
171
+ if (found.isFile()) {
172
+ // The path is the rest of the line: `.` stops at a line's end, a
173
+ // carriage return among them, so the mutant that drops `$` is
174
+ // equivalent; and a Buffer is read as its text, so is the encoding's.
175
+ const named = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1];
176
+ if (named === undefined)
177
+ return null;
178
+ gitDir = resolve(top, named);
179
+ }
180
+ const commondir = textOf(join(gitDir, 'commondir'));
181
+ const common = commondir === null ? gitDir : resolve(gitDir, commondir.trim());
182
+ const moved = env['GIT_DIR'] !== undefined || env['GIT_WORK_TREE'] !== undefined;
183
+ // No configuration holds no key, as a text without one does: the mutant
184
+ // of the empty text is equivalent.
185
+ const elsewhere = /^\s*worktree\s*=/im.test(textOf(join(common, 'config')) ?? '') || existsSync(join(gitDir, 'config.worktree'));
186
+ return { top, common, exact: !moved && !elsewhere };
187
+ }
188
+ /** A file's text, or `null` where it cannot be read. */
189
+ function textOf(file) {
190
+ try {
191
+ return readFileSync(file, 'utf8');
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ /**
198
+ * How long a call's decisions are kept for a run after the write that does
199
+ * not come: a call refused by the person, one that failed, one stopped
200
+ * (ADR-0002). A day, since a call waits on a person's prompt for as long as
201
+ * the person is away, and an entry removed too soon costs only that call's
202
+ * answer being decided again.
203
+ */
204
+ export const CALL_LIFE_MS = 24 * 60 * 60 * 1000;
205
+ /**
206
+ * Keeps what a run before a write decided, under the common directory, for
207
+ * the run after it, and removes what no run after a write read in a day.
208
+ * Nothing here may change the answer before the write: whatever fails is
209
+ * passed over, and the run after the write then decides as it did.
210
+ */
211
+ export async function keepCall(commonDir, name, text, now = Date.now()) {
212
+ try {
213
+ const directory = await stateDirectory(commonDir, 'calls');
214
+ await writeAtomic(join(directory, name), text);
215
+ // A file another run takes away before it is removed here ends the
216
+ // pruning in the catch below, where `force` would pass over it, and what
217
+ // is left is pruned by a later run: the mutants of `force` are
218
+ // equivalent.
219
+ for (const entry of await readdir(directory)) {
220
+ const file = join(directory, entry);
221
+ if (now - (await stat(file)).mtimeMs > CALL_LIFE_MS)
222
+ await rm(file, { force: true });
223
+ }
224
+ }
225
+ catch {
226
+ // A full disk, a directory another run removed: the call is decided again after the write.
227
+ }
228
+ }
229
+ /** What was kept for a call, taken away as it is read; `null` where nothing was. */
230
+ export async function takeCall(commonDir, name) {
231
+ const file = join(commonDir, 'spec-harness', 'calls', name);
232
+ try {
233
+ const text = await readFile(file, 'utf8');
234
+ // Taken away by another run since it was read, the mutants of `force`
235
+ // decide the call again, which answers as the text read does: they are
236
+ // equivalent.
237
+ await rm(file, { force: true });
238
+ return text;
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
76
244
  /** Writes a file whole or not at all: a temporary file renamed over the target. */
77
245
  export async function writeAtomic(file, content) {
78
246
  await mkdir(dirname(file), { recursive: true });
@@ -81,10 +249,19 @@ export async function writeAtomic(file, content) {
81
249
  await writeFile(temporary, content, 'utf8');
82
250
  await rename(temporary, file);
83
251
  }
84
- /** A JSON object from a file: `null` when there is no file, `'unreadable'` when it holds anything else. */
252
+ /**
253
+ * A JSON object from a file: `null` when there is no file, `'unreadable'`
254
+ * when it holds anything else. Only a file is read: a named pipe where a
255
+ * settings file is expected was read without end, and neither doctor nor
256
+ * init came back. What is neither a file nor a directory holds no JSON
257
+ * object, as a directory holds none.
258
+ */
85
259
  export async function readJsonObject(file) {
86
- if (!existsSync(file))
260
+ const there = standing(file);
261
+ if (there === null)
87
262
  return null;
263
+ if (!there.isFile())
264
+ return 'unreadable';
88
265
  try {
89
266
  // A Buffer parses as its text, so the encoding is equivalent to its mutant.
90
267
  const value = JSON.parse(await readFile(file, 'utf8'));
package/dist/fs.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets the disk: the configuration, the state\n * directory outside the work tree, and paths as the filesystem spells them.\n */\n\nimport { existsSync, realpathSync, statSync } from 'node:fs';\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';\n\nimport { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig, type HarnessConfig } from './config.js';\n\n/** Reads `.spec-harness.json` at the root, or the defaults when there is none. */\nexport async function loadConfig(root: string): Promise<{ config: HarnessConfig; file: string | null }> {\n const file = join(root, CONFIG_FILE);\n if (!existsSync(file)) return { config: DEFAULT_CONFIG, file: null };\n let raw: unknown;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n raw = JSON.parse(await readFile(file, 'utf8'));\n } catch (error) {\n // A directory under the file's name is not JSON that went wrong, and\n // Node's words for it, EISDIR, are not the harness's.\n if ((error as NodeJS.ErrnoException).code === 'EISDIR') throw new ConfigError(`${CONFIG_FILE} is a directory, not a configuration file`);\n throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${(error as Error).message}`);\n }\n return { config: parseConfig(raw), file };\n}\n\n/**\n * The real spelling of a path: links resolved and, on a filesystem that\n * ignores case, the case the directory entries have. A path that does not\n * exist yet is resolved through its nearest existing ancestor.\n *\n * This is what stops `SRC/db/schema.ts` from walking past a protection on\n * `src/db/schema.ts` on Windows or macOS, where both name one file.\n */\nexport function realSpelling(path: string): string {\n const missing: string[] = [];\n let current = resolve(path);\n for (;;) {\n try {\n // join leaves a real path as it is, and reads a segment's leading\n // separator as the one between it and the last.\n return join(realpathSync.native(current), ...missing.reverse());\n } catch {\n const parent = dirname(current);\n // Only a root that does not exist fails at the top, a drive Windows\n // does not have; everywhere else the loop ends in the try.\n if (parent === current) return resolve(path);\n missing.push(current.slice(parent.length));\n current = parent;\n }\n }\n}\n\n/**\n * A path as the repository names it - relative to the root, POSIX-separated,\n * real spelling - or `null` when it lies outside the root.\n */\nexport function repositoryPath(given: string, root: string, cwd: string): string | null {\n const absolute = isAbsolute(given) ? given : resolve(cwd, given);\n const real = realSpelling(absolute);\n const realRoot = realSpelling(root);\n const rel = relative(realRoot, real);\n // `..env` at the root is inside it; only a `..` segment climbs out.\n if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n return rel.split(sep).join('/');\n}\n\n/** The harness's own directory under the git common directory, created on demand (ADR-0003). */\nexport async function stateDirectory(commonDir: string, ...parts: string[]): Promise<string> {\n const directory = join(commonDir, 'spec-harness', ...parts);\n await mkdir(directory, { recursive: true });\n return directory;\n}\n\n/** Writes a file whole or not at all: a temporary file renamed over the target. */\nexport async function writeAtomic(file: string, content: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true });\n const temporary = `${file}.${process.pid}.tmp`;\n // utf8 is writeFile's own default, so naming it is equivalent to its mutant.\n await writeFile(temporary, content, 'utf8');\n await rename(temporary, file);\n}\n\n/** A JSON object from a file: `null` when there is no file, `'unreadable'` when it holds anything else. */\nexport async function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'> {\n if (!existsSync(file)) return null;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n const value: unknown = JSON.parse(await readFile(file, 'utf8'));\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 'unreadable';\n } catch {\n return 'unreadable';\n }\n}\n\n/** Whether a path names a directory that is there; one that cannot be read names none. */\nexport function isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n // Each caller reads the answer with `!`, so the mutant that answers\n // nothing here is equivalent.\n return false;\n }\n}\n\nexport async function readText(file: string): Promise<string | null> {\n try {\n return await readFile(file, 'utf8');\n } catch {\n return null;\n }\n}\n"]}
1
+ {"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets the disk: the configuration, the state\n * directory outside the work tree, and paths as the filesystem spells them.\n */\n\nimport { existsSync, readFileSync, realpathSync, statSync, type Stats } from 'node:fs';\nimport { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';\nimport { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';\n\nimport { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig, type HarnessConfig } from './config.js';\n\n/** What stands at a path, links followed, or `null` where nothing does: a link to nothing is nothing. */\nfunction standing(path: string): Stats | null {\n try {\n return statSync(path);\n } catch {\n return null;\n }\n}\n\n/**\n * Reads `.spec-harness.json` at the root, or the defaults when there is none.\n *\n * Only a file is read. A named pipe under the file's name was read without\n * end, by every command and by the hook before every write; and Node's words\n * for a directory, EISDIR, are not the harness's.\n */\nexport async function loadConfig(root: string): Promise<{ config: HarnessConfig; file: string | null }> {\n const file = join(root, CONFIG_FILE);\n const there = standing(file);\n if (there === null) return { config: DEFAULT_CONFIG, file: null };\n if (!there.isFile()) throw new ConfigError(`${CONFIG_FILE} is ${there.isDirectory() ? 'a directory' : 'neither a file nor a directory'}, not a configuration file`);\n let raw: unknown;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n raw = JSON.parse(await readFile(file, 'utf8'));\n } catch (error) {\n throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${(error as Error).message}`);\n }\n return { config: parseConfig(raw), file };\n}\n\n/**\n * A path where the harness keeps its state that holds something else: said\n * in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking\n * for a defect (spec-core ADR-0005).\n */\nexport class StateError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'StateError';\n }\n}\n\n/**\n * The real spelling of a path: links resolved and, on a filesystem that\n * ignores case, the case the directory entries have. A path that does not\n * exist yet is resolved through its nearest existing ancestor.\n *\n * This is what stops `SRC/db/schema.ts` from walking past a protection on\n * `src/db/schema.ts` on Windows or macOS, where both name one file.\n */\nexport function realSpelling(path: string): string {\n const missing: string[] = [];\n let current = resolve(path);\n for (;;) {\n try {\n // join leaves a real path as it is, and reads a segment's leading\n // separator as the one between it and the last.\n return join(realpathSync.native(current), ...missing.reverse());\n } catch {\n const parent = dirname(current);\n // Only a root that does not exist fails at the top, a drive Windows\n // does not have; everywhere else the loop ends in the try.\n if (parent === current) return resolve(path);\n missing.push(current.slice(parent.length));\n current = parent;\n }\n }\n}\n\n/**\n * Whether `directory` is the one git names as the top of a work tree: the\n * two spelled alike once the filesystem has spelled the directory, links\n * and short names resolved, as git spells a top. A spelling that differs is\n * read as another directory: what git said from the one is then not taken\n * for what it would say from the other.\n */\nexport function isTop(directory: string, top: string): boolean {\n return realSpelling(directory).split(sep).join('/') === top;\n}\n\n/**\n * The repository's main work tree, where git keeps the repository as that\n * work tree's `.git`, as `git init` and `git clone` do and every linked\n * worktree's common directory then is: `null` for a common directory of any\n * other name, a bare repository's, a submodule's under its superproject's\n * `modules`, or one kept apart with `--separate-git-dir`. Read from the\n * name, with no process: it says only where an install is looked for.\n */\nexport function mainWorkTree(commonDir: string): string | null {\n return basename(commonDir) === '.git' ? dirname(commonDir) : null;\n}\n\n/** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */\nexport function holdsGit(directory: string): boolean {\n return existsSync(join(directory, '.git'));\n}\n\n/**\n * A path as the repository names it - relative to the root, POSIX-separated,\n * real spelling - or `null` when it lies outside the root.\n */\nexport function repositoryPath(given: string, root: string, cwd: string): string | null {\n const absolute = isAbsolute(given) ? given : resolve(cwd, given);\n const real = realSpelling(absolute);\n const realRoot = realSpelling(root);\n const rel = relative(realRoot, real);\n // `..env` at the root is inside it; only a `..` segment climbs out.\n if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n return rel.split(sep).join('/');\n}\n\n/**\n * The harness's own directory under the git common directory, created on\n * demand (ADR-0003). Where something that is no directory stands at its path,\n * or at one between it and git's, Node refuses to make it, with ENOTDIR or\n * EEXIST, and the harness says which path holds what.\n */\nexport async function stateDirectory(commonDir: string, ...parts: string[]): Promise<string> {\n const directory = join(commonDir, 'spec-harness', ...parts);\n try {\n await mkdir(directory, { recursive: true });\n } catch (error) {\n const { code } = error as NodeJS.ErrnoException;\n if (code !== 'ENOTDIR' && code !== 'EEXIST') throw error;\n // mkdir refuses for this alone, so one of the steps is there and is no\n // directory, and every step before it is one.\n const steps = ['spec-harness', ...parts].map((_, at, all) => join(commonDir, ...all.slice(0, at + 1)));\n const there = steps.find((step) => !standing(step)!.isDirectory())!;\n throw new StateError(`${there} is not a directory, where spec-harness keeps its state in one: move it away, and run the command again`);\n }\n return directory;\n}\n\n/**\n * A work tree and its git directories as the files git keeps name them, read\n * with no process: the nearest directory at or above `root` that holds a\n * `.git`; the git directory, that `.git` or the one a `.git` file names\n * (`gitdir: <path>`, relative to the file's directory); and the common\n * directory, the one that git directory's `commondir` file names, relative\n * to it, or the git directory itself (gitrepository-layout(5)). `exact` is\n * whether git, asked, would name the same work tree: not where\n * `GIT_DIR` or `GIT_WORK_TREE` sends it elsewhere, and not where the\n * repository's configuration names a `worktree`, which `core.worktree`\n * moves it to (git-config(1)), or keeps one of its own for a worktree.\n * `null` where no directory up to the top of the filesystem holds one, or\n * a `.git` file names none.\n */\nexport function gitDirectories(root: string, env: Readonly<Record<string, string | undefined>>): { readonly top: string; readonly common: string; readonly exact: boolean } | null {\n let top = realSpelling(root);\n let found: Stats | null = null;\n for (;;) {\n found = standing(join(top, '.git'));\n if (found !== null) break;\n const parent = dirname(top);\n if (parent === top) return null;\n top = parent;\n }\n const dotGit = join(top, '.git');\n let gitDir = dotGit;\n if (found.isFile()) {\n // The path is the rest of the line: `.` stops at a line's end, a\n // carriage return among them, so the mutant that drops `$` is\n // equivalent; and a Buffer is read as its text, so is the encoding's.\n const named = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1];\n if (named === undefined) return null;\n gitDir = resolve(top, named);\n }\n const commondir = textOf(join(gitDir, 'commondir'));\n const common = commondir === null ? gitDir : resolve(gitDir, commondir.trim());\n const moved = env['GIT_DIR'] !== undefined || env['GIT_WORK_TREE'] !== undefined;\n // No configuration holds no key, as a text without one does: the mutant\n // of the empty text is equivalent.\n const elsewhere = /^\\s*worktree\\s*=/im.test(textOf(join(common, 'config')) ?? '') || existsSync(join(gitDir, 'config.worktree'));\n return { top, common, exact: !moved && !elsewhere };\n}\n\n/** A file's text, or `null` where it cannot be read. */\nfunction textOf(file: string): string | null {\n try {\n return readFileSync(file, 'utf8');\n } catch {\n return null;\n }\n}\n\n/**\n * How long a call's decisions are kept for a run after the write that does\n * not come: a call refused by the person, one that failed, one stopped\n * (ADR-0002). A day, since a call waits on a person's prompt for as long as\n * the person is away, and an entry removed too soon costs only that call's\n * answer being decided again.\n */\nexport const CALL_LIFE_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Keeps what a run before a write decided, under the common directory, for\n * the run after it, and removes what no run after a write read in a day.\n * Nothing here may change the answer before the write: whatever fails is\n * passed over, and the run after the write then decides as it did.\n */\nexport async function keepCall(commonDir: string, name: string, text: string, now: number = Date.now()): Promise<void> {\n try {\n const directory = await stateDirectory(commonDir, 'calls');\n await writeAtomic(join(directory, name), text);\n // A file another run takes away before it is removed here ends the\n // pruning in the catch below, where `force` would pass over it, and what\n // is left is pruned by a later run: the mutants of `force` are\n // equivalent.\n for (const entry of await readdir(directory)) {\n const file = join(directory, entry);\n if (now - (await stat(file)).mtimeMs > CALL_LIFE_MS) await rm(file, { force: true });\n }\n } catch {\n // A full disk, a directory another run removed: the call is decided again after the write.\n }\n}\n\n/** What was kept for a call, taken away as it is read; `null` where nothing was. */\nexport async function takeCall(commonDir: string, name: string): Promise<string | null> {\n const file = join(commonDir, 'spec-harness', 'calls', name);\n try {\n const text = await readFile(file, 'utf8');\n // Taken away by another run since it was read, the mutants of `force`\n // decide the call again, which answers as the text read does: they are\n // equivalent.\n await rm(file, { force: true });\n return text;\n } catch {\n return null;\n }\n}\n\n/** Writes a file whole or not at all: a temporary file renamed over the target. */\nexport async function writeAtomic(file: string, content: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true });\n const temporary = `${file}.${process.pid}.tmp`;\n // utf8 is writeFile's own default, so naming it is equivalent to its mutant.\n await writeFile(temporary, content, 'utf8');\n await rename(temporary, file);\n}\n\n/**\n * A JSON object from a file: `null` when there is no file, `'unreadable'`\n * when it holds anything else. Only a file is read: a named pipe where a\n * settings file is expected was read without end, and neither doctor nor\n * init came back. What is neither a file nor a directory holds no JSON\n * object, as a directory holds none.\n */\nexport async function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'> {\n const there = standing(file);\n if (there === null) return null;\n if (!there.isFile()) return 'unreadable';\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n const value: unknown = JSON.parse(await readFile(file, 'utf8'));\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 'unreadable';\n } catch {\n return 'unreadable';\n }\n}\n\n/** Whether a path names a directory that is there; one that cannot be read names none. */\nexport function isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n // Each caller reads the answer with `!`, so the mutant that answers\n // nothing here is equivalent.\n return false;\n }\n}\n\nexport async function readText(file: string): Promise<string | null> {\n try {\n return await readFile(file, 'utf8');\n } catch {\n return null;\n }\n}\n"]}
package/dist/git.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * call is an argument vector, never a shell string, so a path or a ref cannot
7
7
  * become a command.
8
8
  */
9
- import type { FileChange } from './types.js';
9
+ import type { FileChange, LocalBranch } from './types.js';
10
10
  export interface Run {
11
11
  readonly code: number;
12
12
  readonly stdout: string;
@@ -21,18 +21,77 @@ export declare class GitError extends Error {
21
21
  export declare function workTreeRoot(cwd: string): Promise<string | null>;
22
22
  /** The git directory every worktree of this repository shares. */
23
23
  export declare function commonDirectory(cwd: string): Promise<string | null>;
24
+ /** A work tree as one start of git names it: its top, and the git directory its worktrees share. */
25
+ export interface WorkTree {
26
+ readonly root: string;
27
+ readonly commonDir: string;
28
+ }
29
+ /**
30
+ * The top of the work tree holding `cwd` and the common directory, asked of
31
+ * git in one start where {@link workTreeRoot} and {@link commonDirectory}
32
+ * are two: `null` outside a work tree, and `'unread'` where the one answer
33
+ * cannot be taken for the two, which are then to be asked for as they were.
34
+ *
35
+ * Measured with git 2.43 and 2.55, rev-parse answers the two in the order
36
+ * they stand on its line, a line each, and prints nothing where the first
37
+ * fails. Its manual says of that only that `--show-toplevel` reports an
38
+ * error where there is no working tree (git-rev-parse(1), Options for
39
+ * Files, as git 2.55.0 has it, read 2026-10-14), so nothing is read on
40
+ * trust. Two lines that are each what `trim` would leave, from a git that
41
+ * did not fail, are the two answers. Nothing printed, by a git that
42
+ * failed, is the first question failing, the one {@link workTreeRoot}
43
+ * asks: `cwd` is in no work tree. Anything else is not read: a path that
44
+ * holds a line break is more lines than two, and so is what a git before
45
+ * 2.31 would print, to which `--path-format=absolute` is a flag to hand
46
+ * back (the same manual, Options for Filtering; the option is new in the
47
+ * release notes of 2.31.0). No such git was measured.
48
+ *
49
+ * `--path-format` reaches only what follows it on the line (Options for
50
+ * Files), so the top is printed as it is where it is asked for alone. The
51
+ * common directory is asked for from `cwd` here, and from the top by
52
+ * {@link commonDirectory}: the same directory only where `cwd` is the top.
53
+ */
54
+ export declare function workTree(cwd: string): Promise<WorkTree | 'unread' | null>;
24
55
  /**
25
56
  * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git
26
57
  * reads it - relative to the work tree, absolute, or under `~` - and a linked
27
- * worktree's shared hooks. `null` when git cannot say, before 2.31.
58
+ * worktree's shared hooks. Where git cannot say, the first line of what it
59
+ * said instead: under a `core.hooksPath` that names a file it refuses the
60
+ * path as one that is not a directory (measured with git 2.47 and 2.55).
61
+ */
62
+ export declare function hookPath(name: string, cwd: string): Promise<{
63
+ readonly path: string;
64
+ } | {
65
+ readonly refused: string;
66
+ }>;
67
+ /**
68
+ * The checked-out branch's name, or `null` on a detached head: what HEAD
69
+ * points at, with `refs/heads/` taken off and nothing else. `--short` gives
70
+ * the shortest name git reads as that ref and no other, which is another
71
+ * name where a tag, or a ref under `refs/`, has the branch's: measured with
72
+ * git 2.55, on `claude/brief-042-x` beside the tag `claude/brief-042-x` it
73
+ * answers `heads/claude/brief-042-x`, which no template reads, and the
74
+ * brief went unnamed (ADR-0005). A HEAD that points outside `refs/heads/`,
75
+ * as `git symbolic-ref HEAD refs/tags/x` leaves it, is named as git status
76
+ * names it, the ref whole.
28
77
  */
29
- export declare function hookPath(name: string, cwd: string): Promise<string | null>;
30
- /** The checked-out branch's short name, or `null` on a detached head. */
31
78
  export declare function currentBranch(cwd: string): Promise<string | null>;
32
- /** The remote's default branch as a local ref, such as `origin/main`, or `null`. */
79
+ /**
80
+ * The remote's default branch as a local ref, such as `origin/main`, or
81
+ * `null`. This is a ref the harness hands git and spec-brief as the base, so
82
+ * it is the short name git reads as that ref and no other: beside a local
83
+ * branch or a tag named `origin/main`, measured with git 2.55, it is
84
+ * `remotes/origin/main`, where `origin/main` would be read as the branch or
85
+ * the tag (gitrevisions(7), the order a ref name is read in).
86
+ */
33
87
  export declare function remoteDefault(cwd: string): Promise<string | null>;
34
- /** Every local branch, by its short name. */
35
- export declare function localBranches(cwd: string): Promise<string[]>;
88
+ /**
89
+ * Every local branch. The two are one name but where a tag, or a ref under
90
+ * `refs/`, has the branch's: `main` beside the tag `main` is the ref
91
+ * `heads/main`. A ref name holds no control character
92
+ * (git-check-ref-format(1)), so a tab parts the two.
93
+ */
94
+ export declare function localBranches(cwd: string): Promise<LocalBranch[]>;
36
95
  export declare function revision(ref: string, cwd: string): Promise<string | null>;
37
96
  export declare function mergeBase(a: string, b: string, cwd: string): Promise<string | null>;
38
97
  /** Parses `git diff --name-status -z` output. */
package/dist/git.js CHANGED
@@ -38,28 +38,100 @@ export async function workTreeRoot(cwd) {
38
38
  export async function commonDirectory(cwd) {
39
39
  return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
40
40
  }
41
+ /**
42
+ * The top of the work tree holding `cwd` and the common directory, asked of
43
+ * git in one start where {@link workTreeRoot} and {@link commonDirectory}
44
+ * are two: `null` outside a work tree, and `'unread'` where the one answer
45
+ * cannot be taken for the two, which are then to be asked for as they were.
46
+ *
47
+ * Measured with git 2.43 and 2.55, rev-parse answers the two in the order
48
+ * they stand on its line, a line each, and prints nothing where the first
49
+ * fails. Its manual says of that only that `--show-toplevel` reports an
50
+ * error where there is no working tree (git-rev-parse(1), Options for
51
+ * Files, as git 2.55.0 has it, read 2026-10-14), so nothing is read on
52
+ * trust. Two lines that are each what `trim` would leave, from a git that
53
+ * did not fail, are the two answers. Nothing printed, by a git that
54
+ * failed, is the first question failing, the one {@link workTreeRoot}
55
+ * asks: `cwd` is in no work tree. Anything else is not read: a path that
56
+ * holds a line break is more lines than two, and so is what a git before
57
+ * 2.31 would print, to which `--path-format=absolute` is a flag to hand
58
+ * back (the same manual, Options for Filtering; the option is new in the
59
+ * release notes of 2.31.0). No such git was measured.
60
+ *
61
+ * `--path-format` reaches only what follows it on the line (Options for
62
+ * Files), so the top is printed as it is where it is asked for alone. The
63
+ * common directory is asked for from `cwd` here, and from the top by
64
+ * {@link commonDirectory}: the same directory only where `cwd` is the top.
65
+ */
66
+ export async function workTree(cwd) {
67
+ const run = await git(['rev-parse', '--show-toplevel', '--path-format=absolute', '--git-common-dir'], cwd);
68
+ if (run.stdout === '')
69
+ return run.code === 0 ? 'unread' : null;
70
+ const lines = run.stdout.split('\n');
71
+ if (run.code !== 0 || lines.length !== 3 || lines[2] !== '')
72
+ return 'unread';
73
+ const root = lines[0];
74
+ const commonDir = lines[1];
75
+ if (root === '' || commonDir === '' || root.trim() !== root || commonDir.trim() !== commonDir)
76
+ return 'unread';
77
+ return { root, commonDir };
78
+ }
41
79
  /**
42
80
  * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git
43
81
  * reads it - relative to the work tree, absolute, or under `~` - and a linked
44
- * worktree's shared hooks. `null` when git cannot say, before 2.31.
82
+ * worktree's shared hooks. Where git cannot say, the first line of what it
83
+ * said instead: under a `core.hooksPath` that names a file it refuses the
84
+ * path as one that is not a directory (measured with git 2.47 and 2.55).
45
85
  */
46
86
  export async function hookPath(name, cwd) {
47
- return value(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);
87
+ const run = await git(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);
88
+ if (run.code === 0)
89
+ return { path: run.stdout.trim() };
90
+ return { refused: run.stderr.split(/\r?\n/)[0] };
48
91
  }
49
- /** The checked-out branch's short name, or `null` on a detached head. */
92
+ const HEADS = 'refs/heads/';
93
+ /**
94
+ * The checked-out branch's name, or `null` on a detached head: what HEAD
95
+ * points at, with `refs/heads/` taken off and nothing else. `--short` gives
96
+ * the shortest name git reads as that ref and no other, which is another
97
+ * name where a tag, or a ref under `refs/`, has the branch's: measured with
98
+ * git 2.55, on `claude/brief-042-x` beside the tag `claude/brief-042-x` it
99
+ * answers `heads/claude/brief-042-x`, which no template reads, and the
100
+ * brief went unnamed (ADR-0005). A HEAD that points outside `refs/heads/`,
101
+ * as `git symbolic-ref HEAD refs/tags/x` leaves it, is named as git status
102
+ * names it, the ref whole.
103
+ */
50
104
  export async function currentBranch(cwd) {
51
- return value(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);
105
+ const ref = await value(['symbolic-ref', '--quiet', 'HEAD'], cwd);
106
+ return ref !== null && ref.startsWith(HEADS) ? ref.slice(HEADS.length) : ref;
52
107
  }
53
- /** The remote's default branch as a local ref, such as `origin/main`, or `null`. */
108
+ /**
109
+ * The remote's default branch as a local ref, such as `origin/main`, or
110
+ * `null`. This is a ref the harness hands git and spec-brief as the base, so
111
+ * it is the short name git reads as that ref and no other: beside a local
112
+ * branch or a tag named `origin/main`, measured with git 2.55, it is
113
+ * `remotes/origin/main`, where `origin/main` would be read as the branch or
114
+ * the tag (gitrevisions(7), the order a ref name is read in).
115
+ */
54
116
  export async function remoteDefault(cwd) {
55
117
  return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);
56
118
  }
57
- /** Every local branch, by its short name. */
119
+ /**
120
+ * Every local branch. The two are one name but where a tag, or a ref under
121
+ * `refs/`, has the branch's: `main` beside the tag `main` is the ref
122
+ * `heads/main`. A ref name holds no control character
123
+ * (git-check-ref-format(1)), so a tab parts the two.
124
+ */
58
125
  export async function localBranches(cwd) {
59
- const listed = await value(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd);
126
+ const listed = await value(['for-each-ref', '--format=%(refname:lstrip=2)%09%(refname:short)', HEADS], cwd);
60
127
  // for-each-ref fails only where git itself does, outside a repository,
61
128
  // where init never runs: the null check is there for the type.
62
- return listed === null || listed === '' ? [] : listed.split('\n');
129
+ if (listed === null || listed === '')
130
+ return [];
131
+ return listed.split('\n').map((line) => {
132
+ const parts = line.split('\t');
133
+ return { name: parts[0], ref: parts[1] };
134
+ });
63
135
  }
64
136
  export async function revision(ref, cwd) {
65
137
  return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);