@jitsusama/agentic-harness.core 0.6.2 → 0.6.4

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.
@@ -6,8 +6,10 @@
6
6
  * does not trip the gate. This is a nudge toward the right stage,
7
7
  * not a security boundary.
8
8
  */
9
+ import { isAbsolute, join, resolve } from "node:path";
9
10
  import { tokenize } from "../../command/index.js";
10
11
  import { stripHeredocBodies, stripShellData, unquote, } from "../../shell/index.js";
12
+ import { createdBy, removedBy, unwrap } from "./bash-effects.js";
11
13
  /** Git subcommands that change repository or working-tree state. */
12
14
  const GIT_MUTATING = /\bgit(?:\s+(?:-c\s+\S+|-C\s+\S+|--git-dir=\S+|--work-tree=\S+|--no-pager))*\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|switch|restore|am|format-patch)\b/i;
13
15
  /** Shell patterns that write to the filesystem via redirection or in-place edit. */
@@ -69,6 +71,24 @@ export function bashWriteTargets(command) {
69
71
  return;
70
72
  targets.push(value);
71
73
  };
74
+ for (const target of patternTargets(skeleton))
75
+ add(target);
76
+ for (const simple of tokenize(command).commands) {
77
+ for (const target of commandTargets(simple))
78
+ add(target);
79
+ }
80
+ return [...new Set(targets)];
81
+ }
82
+ /**
83
+ * Write destinations found by pattern in a data-stripped skeleton, for
84
+ * the grammar the command model declines, such as a loop or a subshell.
85
+ */
86
+ function patternTargets(skeleton) {
87
+ const found = [];
88
+ const add = (token) => {
89
+ if (token)
90
+ found.push(token);
91
+ };
72
92
  // Redirect destinations: the token following > or >>. A leading
73
93
  // digit or & marks an fd redirect (2>, &>), which routes a stream
74
94
  // rather than naming a content target, so it is skipped.
@@ -96,55 +116,309 @@ export function bashWriteTargets(command) {
96
116
  add(token);
97
117
  }
98
118
  }
99
- for (const target of modelledTargets(command))
100
- add(target);
101
- return [...new Set(targets)];
119
+ return found;
102
120
  }
103
121
  /**
104
- * Write destinations read from the command model, where quoting is
105
- * already understood.
122
+ * Resolve where a bash command writes, in the directory each writing
123
+ * command actually runs in.
106
124
  *
107
- * Three shapes, the same three the patterns above look for: a redirect's
108
- * target, `tee`'s file arguments, and the file arguments of an in-place
109
- * editor. A file-descriptor redirect (`2>`, `&>`) is skipped, matching
110
- * what the pattern pass does, since it routes a stream rather than naming
111
- * a content target.
125
+ * The session directory is only where the command starts: `cd <tree> &&
126
+ * cat >> x_test.go` writes inside the tree, and reading it against the
127
+ * session directory is how a write in a tracked tree came to be judged as
128
+ * one in the repository the session happened to open. So the command
129
+ * model is walked in order, each `cd` moving the directory for what
130
+ * follows, with the command's own variables filled in.
112
131
  *
113
- * An editor's script is reported alongside its file, because telling one
114
- * from the other means knowing whether this `sed` is the BSD or the GNU
115
- * one. A script cannot name tracked code, so including it costs nothing,
116
- * whereas missing the file was the whole defect.
132
+ * A target whose location the command does not say is reported as
133
+ * unresolved rather than guessed: a `cd` into a command substitution, a
134
+ * variable from outside the command, or a relative target in a subshell
135
+ * or loop that changes directory. A guess judges a file nobody wrote,
136
+ * while an unresolved target can still be checked on disk afterwards.
137
+ *
138
+ * What a command removes is placed the same way and kept apart, since
139
+ * removing a document is a different question from writing one. It is
140
+ * only read where the command model applies; a removal inside a loop or
141
+ * a subshell is left to the check on disk.
117
142
  */
118
- function modelledTargets(command) {
143
+ export function resolveBashWrites(command, place) {
144
+ const skeleton = stripShellData(stripHeredocBodies(command));
145
+ const known = assignmentsIn(skeleton, command);
146
+ const paths = [];
147
+ const removed = [];
148
+ const unresolved = [];
149
+ const locate = (raw, dir, into = paths) => {
150
+ const bare = raw.replace(/^['"]/, "").replace(/['"]$/, "");
151
+ if (!bare)
152
+ return;
153
+ const value = expand(bare, known);
154
+ const absolute = value === undefined ? undefined : absoluteIn(value, dir, place.home);
155
+ if (absolute === undefined)
156
+ unresolved.push(value ?? bare);
157
+ else
158
+ into.push(absolute);
159
+ };
119
160
  const line = tokenize(command);
120
- const found = [];
121
- for (const simple of line.commands) {
122
- for (const redirect of simple.redirects) {
123
- if (!redirect.target)
161
+ if (line.supported) {
162
+ let dir = place.cwd;
163
+ for (const simple of line.commands) {
164
+ const argv = argvOf(simple);
165
+ if (argv[0] === "cd") {
166
+ dir = changeDirectory(dir, argv[1], known, place.home);
124
167
  continue;
125
- if (/^[0-9&]/.test(redirect.operator))
126
- continue;
127
- found.push(unquote(redirect.target.text));
168
+ }
169
+ for (const target of commandTargets(simple))
170
+ locate(target, dir);
171
+ for (const target of removedBy(unwrap(argv))) {
172
+ locate(target, dir, removed);
173
+ }
128
174
  }
129
- const argv = simple.argv.map((word) => unquote(word.text));
130
- const name = argv[0];
131
- if (!name)
132
- continue;
133
- const rest = argv.slice(1).filter((token) => !token.startsWith("-"));
134
- if (name === "tee")
135
- found.push(...rest);
136
- if (/^(g?sed|perl)$/.test(name) && editsInPlace(simple.argv)) {
137
- found.push(...rest);
175
+ }
176
+ else {
177
+ // Outside the grammar there is no order to follow, so a relative
178
+ // target is only placed when nothing in the command changes directory.
179
+ const moves = /(?:^|[\s;&|(])cd(?:\s|$)/.test(skeleton);
180
+ for (const target of patternTargets(skeleton)) {
181
+ locate(target, moves ? undefined : place.cwd);
138
182
  }
139
183
  }
184
+ return {
185
+ paths: [...new Set(paths)],
186
+ removed: [...new Set(removed)],
187
+ unresolved: [...new Set(unresolved)],
188
+ };
189
+ }
190
+ /**
191
+ * The directory after `cd <target>`, or undefined once it is no longer
192
+ * knowable: `cd -`, or a target the command's own variables cannot fill.
193
+ */
194
+ function changeDirectory(dir, target, known, home) {
195
+ if (target === undefined)
196
+ return home;
197
+ if (target === "-")
198
+ return undefined;
199
+ const value = expand(target, known);
200
+ return value === undefined ? undefined : absoluteIn(value, dir, home);
201
+ }
202
+ /** A path made absolute, or undefined when it is relative to nothing known. */
203
+ function absoluteIn(path, dir, home) {
204
+ if (path === "~")
205
+ return home;
206
+ if (path.startsWith("~/"))
207
+ return join(home, path.slice(2));
208
+ if (isAbsolute(path))
209
+ return resolve(path);
210
+ return dir === undefined ? undefined : resolve(dir, path);
211
+ }
212
+ /**
213
+ * One command's write destinations, read from the command model, where
214
+ * quoting is already understood.
215
+ *
216
+ * The three shapes `patternTargets` also looks for, a redirect's target,
217
+ * `tee`'s file arguments and the file arguments of an in-place editor,
218
+ * plus what a command creates without a redirect (see `createdBy`), read
219
+ * through wrappers such as `nohup` and `env`. A file-descriptor redirect
220
+ * (`2>`, `&>`) is skipped, matching what the pattern pass does, since it
221
+ * routes a stream rather than naming a content target.
222
+ *
223
+ * An editor's script is told apart from its files by reading its options
224
+ * the way the editor does, since a script reported as a file is a path
225
+ * nobody wrote, and one that happens to look like a quest document gets
226
+ * a write refused that never touched it.
227
+ */
228
+ function commandTargets(simple) {
229
+ const found = [];
230
+ for (const redirect of simple.redirects) {
231
+ if (!redirect.target)
232
+ continue;
233
+ if (/^[0-9&]/.test(redirect.operator))
234
+ continue;
235
+ found.push(unquote(redirect.target.text));
236
+ }
237
+ const argv = unwrap(argvOf(simple));
238
+ const name = argv[0];
239
+ if (!name)
240
+ return found;
241
+ found.push(...createdBy(argv));
242
+ if (name === "tee") {
243
+ found.push(...argv.slice(1).filter((token) => !token.startsWith("-")));
244
+ }
245
+ if (/^g?sed$/.test(name))
246
+ found.push(...sedFiles(argv.slice(1)));
247
+ if (name === "perl")
248
+ found.push(...perlFiles(argv.slice(1)));
140
249
  return found;
141
250
  }
142
- /** Whether an editor invocation carries the in-place flag. */
143
- function editsInPlace(argv) {
144
- return argv.some((word) => {
145
- const text = unquote(word.text);
146
- return text === "-i" || text.startsWith("-i");
147
- });
251
+ /** Short sed options that take the next word as their value. */
252
+ const SED_VALUED = new Set(["e", "f", "l"]);
253
+ /**
254
+ * The files a sed invocation edits in place, or none when it does not.
255
+ *
256
+ * Both spellings of the in-place flag are read. GNU attaches its optional
257
+ * suffix (`-i.bak`); BSD always takes one as the next word, and that word
258
+ * is only read as a suffix when it looks like one (empty, or starting with
259
+ * a dot), since `sed -i 's/a/b/' f` is the GNU spelling written on a Mac.
260
+ * Without `-e` or `-f`, the first operand is the script.
261
+ */
262
+ function sedFiles(args) {
263
+ const operands = [];
264
+ let inPlace = false;
265
+ let scripted = false;
266
+ for (let at = 0; at < args.length; at++) {
267
+ const arg = args[at] ?? "";
268
+ if (arg === "--") {
269
+ operands.push(...args.slice(at + 1));
270
+ break;
271
+ }
272
+ if (arg.startsWith("--")) {
273
+ const [option] = arg.split("=", 1);
274
+ if (option === "--in-place")
275
+ inPlace = true;
276
+ if (option === "--expression" || option === "--file") {
277
+ scripted = true;
278
+ if (!arg.includes("="))
279
+ at++;
280
+ }
281
+ continue;
282
+ }
283
+ if (!arg.startsWith("-") || arg === "-") {
284
+ operands.push(arg);
285
+ continue;
286
+ }
287
+ for (let char = 1; char < arg.length; char++) {
288
+ const flag = arg[char] ?? "";
289
+ if (flag === "i") {
290
+ inPlace = true;
291
+ const next = args[at + 1];
292
+ const bare = char === arg.length - 1;
293
+ if (bare && next !== undefined && /^(\.|$)/.test(next))
294
+ at++;
295
+ break;
296
+ }
297
+ if (SED_VALUED.has(flag)) {
298
+ if (flag !== "l")
299
+ scripted = true;
300
+ if (char === arg.length - 1)
301
+ at++;
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ if (!inPlace)
307
+ return [];
308
+ return scripted ? operands : operands.slice(1);
309
+ }
310
+ /** Perl switches whose value is the rest of the word, or the next word. */
311
+ const PERL_VALUED = new Set(["e", "E", "I", "M", "m", "x"]);
312
+ /** Perl switches whose optional value can only be attached. */
313
+ const PERL_ATTACHED = new Set(["i", "l", "0", "C", "d", "D", "F"]);
314
+ /**
315
+ * The files a perl invocation edits in place, or none when it does not.
316
+ *
317
+ * Switches cluster (`-pi`, `-ne`), so each word is read one switch at a
318
+ * time. Without `-e` or `-E`, the first operand is the program file.
319
+ */
320
+ function perlFiles(args) {
321
+ const operands = [];
322
+ let inPlace = false;
323
+ let inline = false;
324
+ for (let at = 0; at < args.length; at++) {
325
+ const arg = args[at] ?? "";
326
+ if (arg === "--") {
327
+ operands.push(...args.slice(at + 1));
328
+ break;
329
+ }
330
+ if (!arg.startsWith("-") || arg === "-") {
331
+ operands.push(...args.slice(at));
332
+ break;
333
+ }
334
+ for (let char = 1; char < arg.length; char++) {
335
+ const flag = arg[char] ?? "";
336
+ if (flag === "i")
337
+ inPlace = true;
338
+ if (PERL_VALUED.has(flag)) {
339
+ if (flag === "e" || flag === "E")
340
+ inline = true;
341
+ if (char === arg.length - 1)
342
+ at++;
343
+ break;
344
+ }
345
+ if (PERL_ATTACHED.has(flag))
346
+ break;
347
+ }
348
+ }
349
+ if (!inPlace)
350
+ return [];
351
+ return inline ? operands : operands.slice(1);
352
+ }
353
+ /** A command's arguments as the command receives them: braces expanded, quotes removed. */
354
+ function argvOf(simple) {
355
+ return simple.argv.flatMap((word) => expandBraces(word.text).map(unquote));
356
+ }
357
+ /**
358
+ * A word with its brace lists expanded, the way bash does before a
359
+ * command sees its arguments: `lab/{a,b}` is `lab/a lab/b`.
360
+ *
361
+ * Only an unquoted list with a comma at its top level expands, so
362
+ * `'{a,b}'`, `{solo}` and a variable's `${NAME}` stay as written. A
363
+ * sequence such as `{1..3}` is left alone too; nothing that writes into a
364
+ * quest folder has been seen to use one.
365
+ */
366
+ function expandBraces(word) {
367
+ for (let open = 0; open < word.length; open = skipQuoted(word, open) + 1) {
368
+ if (word[open] !== "{" || word[open - 1] === "$")
369
+ continue;
370
+ const list = braceList(word, open);
371
+ if (!list)
372
+ continue;
373
+ const prefix = word.slice(0, open);
374
+ const suffix = word.slice(list.close + 1);
375
+ return list.parts.flatMap((part) => expandBraces(prefix + part + suffix));
376
+ }
377
+ return [word];
378
+ }
379
+ /**
380
+ * The alternatives of the brace list opening at `open`, and where it
381
+ * closes, or undefined when the braces hold no top-level comma or never
382
+ * close.
383
+ */
384
+ function braceList(word, open) {
385
+ const parts = [];
386
+ let depth = 0;
387
+ let start = open + 1;
388
+ for (let at = open; at < word.length; at = skipQuoted(word, at) + 1) {
389
+ const char = word[at];
390
+ if (char === "{")
391
+ depth++;
392
+ else if (char === "}" && --depth === 0) {
393
+ if (parts.length === 0)
394
+ return undefined;
395
+ parts.push(word.slice(start, at));
396
+ return { parts, close: at };
397
+ }
398
+ else if (char === "," && depth === 1) {
399
+ parts.push(word.slice(start, at));
400
+ start = at + 1;
401
+ }
402
+ }
403
+ return undefined;
404
+ }
405
+ /**
406
+ * The last index of the quoted span or escape starting at `at`, or `at`
407
+ * itself when nothing starts there, so a scan can step past quoted text.
408
+ */
409
+ function skipQuoted(word, at) {
410
+ const char = word[at];
411
+ if (char === "\\")
412
+ return at + 1;
413
+ if (char !== "'" && char !== '"')
414
+ return at;
415
+ let end = at + 1;
416
+ while (end < word.length && word[end] !== char) {
417
+ if (char === '"' && word[end] === "\\")
418
+ end++;
419
+ end++;
420
+ }
421
+ return end;
148
422
  }
149
423
  /**
150
424
  * The variables a command assigns to itself, last assignment winning.
@@ -26,7 +26,7 @@
26
26
  * that bypasses it can still write through. We rely on
27
27
  * every README write going through `withQuestLock`.
28
28
  */
29
- import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
29
+ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  /** Lock-acquire retry budget per call. */
32
32
  const LOCK_TIMEOUT_MS = 5000;
@@ -102,30 +102,52 @@ function isProcessAlive(pid) {
102
102
  return err.code === "EPERM";
103
103
  }
104
104
  }
105
+ /**
106
+ * Take the lock over if its holder has gone or it is too old to be a
107
+ * write in progress.
108
+ *
109
+ * A lock with no readable record is judged by its file's age alone: a
110
+ * holder creates the lock and then writes its record, so for a moment
111
+ * every live lock is empty, and treating that as abandoned took locks
112
+ * from their holders. Taking over moves the lock aside and checks the
113
+ * move took the one judged stale; deleting by name could delete a lock
114
+ * another writer made after taking over first.
115
+ */
105
116
  function tryStealStaleLock(lockPath, now) {
106
- const record = readLockRecord(lockPath);
107
- if (!record) {
108
- // Malformed lock file; remove and retry.
109
- try {
110
- unlinkSync(lockPath);
111
- }
112
- catch {
113
- // Another process beat us to the cleanup; that's fine.
114
- }
117
+ let judged;
118
+ try {
119
+ judged = statSync(lockPath);
120
+ }
121
+ catch {
122
+ // Released since the failed create; the next attempt takes it.
115
123
  return true;
116
124
  }
117
- const age = now - record.startedAt;
118
- if (age < STALE_LOCK_MS && isProcessAlive(record.pid))
125
+ const record = readLockRecord(lockPath);
126
+ const stale = record
127
+ ? now - record.startedAt >= STALE_LOCK_MS || !isProcessAlive(record.pid)
128
+ : now - judged.mtimeMs >= STALE_LOCK_MS;
129
+ if (!stale)
119
130
  return false;
131
+ const aside = `${lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
120
132
  try {
121
- unlinkSync(lockPath);
122
- return true;
133
+ renameSync(lockPath, aside);
123
134
  }
124
135
  catch {
125
- // Another process beat us to the cleanup; let the
126
- // next loop iteration retry the acquire.
136
+ // Somebody else took it over first; the next attempt sees theirs.
127
137
  return false;
128
138
  }
139
+ if (statSync(aside).ino !== judged.ino) {
140
+ // Moved a fresh lock another writer made after taking over first.
141
+ // Put it back, unless a third already made one, which then stands.
142
+ try {
143
+ linkSync(aside, lockPath);
144
+ }
145
+ catch {
146
+ // EEXIST: the newer lock stands, as above.
147
+ }
148
+ }
149
+ unlinkSync(aside);
150
+ return true;
129
151
  }
130
152
  function acquireLock(lockPath) {
131
153
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
@@ -139,7 +161,7 @@ function acquireLock(lockPath) {
139
161
  };
140
162
  writeSync(fd, JSON.stringify(payload));
141
163
  fsyncSync(fd);
142
- return fd;
164
+ return { fd, ino: fstatSync(fd).ino };
143
165
  }
144
166
  catch (err) {
145
167
  const code = err.code;
@@ -165,15 +187,18 @@ function acquireLock(lockPath) {
165
187
  }
166
188
  }
167
189
  }
168
- function releaseLock(lockPath, fd) {
190
+ function releaseLock(lockPath, held) {
169
191
  try {
170
- closeSync(fd);
192
+ closeSync(held.fd);
171
193
  }
172
194
  catch {
173
195
  // Already closed; cleanup of the path below still runs.
174
196
  }
175
197
  try {
176
- unlinkSync(lockPath);
198
+ // Only this holder's own lock: one taken over from it belongs to
199
+ // whoever made the new one.
200
+ if (statSync(lockPath).ino === held.ino)
201
+ unlinkSync(lockPath);
177
202
  }
178
203
  catch {
179
204
  // Lock file was stolen out from under us or never
@@ -203,12 +228,12 @@ export function withQuestLock(questDir, fn) {
203
228
  // Stat failed; the acquire loop handles it.
204
229
  }
205
230
  }
206
- const fd = acquireLock(lockPath);
231
+ const held = acquireLock(lockPath);
207
232
  try {
208
233
  return fn();
209
234
  }
210
235
  finally {
211
- releaseLock(lockPath, fd);
236
+ releaseLock(lockPath, held);
212
237
  }
213
238
  }
214
239
  /**
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The record's rules, read from disk rather than from a single write.
3
+ *
4
+ * The gate judges a write by its path before it lands, which cannot see
5
+ * what a command made without saying so, how big a file turned out, or
6
+ * whether a document cites it. The audit reads the whole quest folder
7
+ * afterwards and reports what the record holds that it should not: the
8
+ * check after each write, conclude's refusal and the migration all ask
9
+ * it the same question.
10
+ */
11
+ import { type Stats } from "node:fs";
12
+ import { type AttachmentFile, type AttachmentProblem } from "./record.js";
13
+ /** What a quest folder holds that its record should not. */
14
+ export interface RecordAudit {
15
+ /** Paths outside the record, each by its outermost folder or file. */
16
+ readonly strays: string[];
17
+ /** Attachments that break the limits, and how. */
18
+ readonly attachments: {
19
+ rel: string;
20
+ problem: AttachmentProblem;
21
+ }[];
22
+ /** Links into `attachments/` that name no attachment. */
23
+ readonly broken: {
24
+ document: string;
25
+ target: string;
26
+ }[];
27
+ /** Attachments no document cites, which the backup leaves out. */
28
+ readonly uncited: string[];
29
+ }
30
+ /** Read a quest folder and report what its record should not hold. */
31
+ export declare function auditQuestRecord(questDir: string): RecordAudit;
32
+ /**
33
+ * Whether a record may conclude as it is. Uncited attachments do not
34
+ * stop it: the backup leaves them out, which is their only cost.
35
+ */
36
+ export declare function isRecordSound(audit: RecordAudit): boolean;
37
+ /**
38
+ * What the attachment rules need to know about one path on disk, named
39
+ * by `rel` as it would sit in the record. The migration asks this of a
40
+ * file before it moves, so it is judged the same way the audit would
41
+ * judge it once it lands.
42
+ */
43
+ export declare function attachmentFileAt(path: string, rel: string, stats?: Stats): AttachmentFile;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The record's rules, read from disk rather than from a single write.
3
+ *
4
+ * The gate judges a write by its path before it lands, which cannot see
5
+ * what a command made without saying so, how big a file turned out, or
6
+ * whether a document cites it. The audit reads the whole quest folder
7
+ * afterwards and reports what the record holds that it should not: the
8
+ * check after each write, conclude's refusal and the migration all ask
9
+ * it the same question.
10
+ */
11
+ import { closeSync, lstatSync, openSync, readdirSync, readFileSync, readSync, } from "node:fs";
12
+ import { basename, join } from "node:path";
13
+ import { ATTACHMENT_LIMITS, attachmentCitations, attachmentProblem, questRecordPath, } from "./record.js";
14
+ /**
15
+ * Files the quest machinery and the system keep in a quest folder: the
16
+ * quest lock, the atomic writer's half-written files and Finder's notes.
17
+ */
18
+ function isMachinery(name) {
19
+ return (name === ".quest.lock" ||
20
+ name === ".DS_Store" ||
21
+ /^\..+\.tmp-\d+-/.test(name));
22
+ }
23
+ /** Read a quest folder and report what its record should not hold. */
24
+ export function auditQuestRecord(questDir) {
25
+ const questsRoot = join(questDir, "..");
26
+ const quest = basename(questDir);
27
+ const strays = [];
28
+ const problems = [];
29
+ const documents = [];
30
+ const attachments = [];
31
+ const walk = (dir) => {
32
+ for (const name of readdirSync(dir).sort()) {
33
+ if (isMachinery(name))
34
+ continue;
35
+ const path = join(dir, name);
36
+ const place = questRecordPath(questsRoot, path);
37
+ if (!place)
38
+ continue;
39
+ const stats = lstatSync(path);
40
+ switch (place.place) {
41
+ case "stray":
42
+ strays.push(place.rel);
43
+ break;
44
+ case "readme":
45
+ case "document":
46
+ if (stats.isFile()) {
47
+ documents.push({
48
+ rel: place.rel,
49
+ text: readFileSync(path, "utf8"),
50
+ });
51
+ }
52
+ else
53
+ strays.push(place.rel);
54
+ break;
55
+ case "folder":
56
+ if (stats.isDirectory())
57
+ walk(path);
58
+ else
59
+ strays.push(place.rel);
60
+ break;
61
+ case "attachment": {
62
+ const problem = attachmentProblem(attachmentFileAt(path, place.rel, stats));
63
+ if (problem)
64
+ problems.push({ rel: place.rel, problem });
65
+ else if (stats.isDirectory())
66
+ walk(path);
67
+ else
68
+ attachments.push(place.rel);
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ };
74
+ walk(questDir);
75
+ const { cited, broken } = attachmentCitations(quest, documents, attachments);
76
+ return {
77
+ strays: strays.sort(),
78
+ attachments: problems.sort((a, b) => compare(a.rel, b.rel)),
79
+ broken,
80
+ uncited: attachments.filter((rel) => !cited.has(rel)).sort(),
81
+ };
82
+ }
83
+ /**
84
+ * Whether a record may conclude as it is. Uncited attachments do not
85
+ * stop it: the backup leaves them out, which is their only cost.
86
+ */
87
+ export function isRecordSound(audit) {
88
+ return (audit.strays.length === 0 &&
89
+ audit.attachments.length === 0 &&
90
+ audit.broken.length === 0);
91
+ }
92
+ /**
93
+ * What the attachment rules need to know about one path on disk, named
94
+ * by `rel` as it would sit in the record. The migration asks this of a
95
+ * file before it moves, so it is judged the same way the audit would
96
+ * judge it once it lands.
97
+ */
98
+ export function attachmentFileAt(path, rel, stats = lstatSync(path)) {
99
+ const kind = stats.isSymbolicLink()
100
+ ? "symlink"
101
+ : stats.isDirectory()
102
+ ? "directory"
103
+ : stats.isFile()
104
+ ? "file"
105
+ : "other";
106
+ return {
107
+ rel,
108
+ kind,
109
+ size: stats.size,
110
+ head: kind === "file" ? headOf(path) : new Uint8Array(),
111
+ };
112
+ }
113
+ /** A file's first bytes, as many as the text sniff reads. */
114
+ function headOf(path) {
115
+ const buffer = new Uint8Array(ATTACHMENT_LIMITS.sniffBytes);
116
+ const fd = openSync(path, "r");
117
+ try {
118
+ return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
119
+ }
120
+ finally {
121
+ closeSync(fd);
122
+ }
123
+ }
124
+ function compare(a, b) {
125
+ return a < b ? -1 : a > b ? 1 : 0;
126
+ }