@jitsusama/agentic-harness.core 0.6.3 → 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.
- package/dist/command/tokenize.js +51 -5
- package/dist/internal/quest/bash-effects.d.ts +26 -0
- package/dist/internal/quest/bash-effects.js +477 -0
- package/dist/internal/quest/bash-write.d.ts +37 -0
- package/dist/internal/quest/bash-write.js +311 -37
- package/dist/internal/quest/record-audit.d.ts +43 -0
- package/dist/internal/quest/record-audit.js +126 -0
- package/dist/internal/quest/record-gate.d.ts +36 -0
- package/dist/internal/quest/record-gate.js +101 -0
- package/dist/internal/quest/record.d.ts +112 -0
- package/dist/internal/quest/record.js +284 -0
- package/dist/internal/quest/workspace.d.ts +32 -0
- package/dist/internal/quest/workspace.js +51 -0
- package/package.json +7 -1
|
@@ -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
|
-
|
|
100
|
-
add(target);
|
|
101
|
-
return [...new Set(targets)];
|
|
119
|
+
return found;
|
|
102
120
|
}
|
|
103
121
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
122
|
+
* Resolve where a bash command writes, in the directory each writing
|
|
123
|
+
* command actually runs in.
|
|
106
124
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
for (const
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record's rules for a single write, applied before it lands.
|
|
3
|
+
*
|
|
4
|
+
* A write is refused only when the rule it breaks is certain from the
|
|
5
|
+
* path alone, and every refusal says where the write belongs instead, so
|
|
6
|
+
* the refusal is a redirection rather than a dead end. What a path cannot
|
|
7
|
+
* decide (an attachment's size, whether it is text, whether a document
|
|
8
|
+
* cites it) is left to the check on disk after the write.
|
|
9
|
+
*
|
|
10
|
+
* The rules hold in every quest's folder, not only the loaded quest's,
|
|
11
|
+
* since a write into another quest's record breaks that record just the
|
|
12
|
+
* same.
|
|
13
|
+
*/
|
|
14
|
+
/** One write or removal, already resolved to an absolute path. */
|
|
15
|
+
export interface RecordWrite {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly effect: "write" | "remove";
|
|
18
|
+
/** A bash command, or the write and edit tools. */
|
|
19
|
+
readonly via: "bash" | "tool";
|
|
20
|
+
/** Whether something is already at the path. */
|
|
21
|
+
readonly exists: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Where the record and the workspaces live. */
|
|
24
|
+
export interface RecordRoots {
|
|
25
|
+
readonly questsRoot: string;
|
|
26
|
+
readonly workspaceRoot: string;
|
|
27
|
+
}
|
|
28
|
+
/** A write the record refuses, why, and where it belongs instead. */
|
|
29
|
+
export interface RecordRefusal {
|
|
30
|
+
readonly rule: "document-by-hand" | "document-by-bash" | "document-removed" | "stray" | "attachment";
|
|
31
|
+
readonly reason: string;
|
|
32
|
+
/** The path the write belongs at, when it belongs somewhere else. */
|
|
33
|
+
readonly instead?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Judge one write or removal against the record's rules. */
|
|
36
|
+
export declare function judgeRecordWrite(write: RecordWrite, roots: RecordRoots): RecordRefusal | undefined;
|