start-command 0.40.0 → 0.42.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/CHANGELOG.md +22 -0
- package/eslint.config.mjs +31 -0
- package/package.json +3 -3
- package/src/bin/cli.js +11 -1
- package/src/lib/args-parser.js +33 -0
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +390 -0
- package/src/lib/formal-ai-files.js +384 -0
- package/src/lib/formal-ai-policy.js +508 -0
- package/src/lib/formal-ai-runtime.js +304 -0
- package/src/lib/formal-ai-tool.js +182 -0
- package/src/lib/formal-ai.js +482 -0
- package/src/lib/usage.js +5 -1
- package/test/fixtures/formal-ai/fake-server.cjs +17 -0
- package/test/fixtures/formal-ai/helpers.cjs +189 -0
- package/test/fixtures/formal-ai/receipts-258.json +431 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +194 -0
- package/test/regression-255-cli.js +296 -0
- package/test/regression-255-e2e.js +167 -0
- package/test/regression-255-loop.js +480 -0
- package/test/regression-255-policy.js +436 -0
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +430 -0
- package/test/regression-258.js +443 -0
- package/test/shared-golden.js +3 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds and effect policy of the experimental `--use-formal-ai` mode
|
|
3
|
+
* (issue #255).
|
|
4
|
+
*
|
|
5
|
+
* Formal AI owns the interpretation of a natural-language request and picks
|
|
6
|
+
* the tool calls; start owns the permission to run them. Every tool call is a
|
|
7
|
+
* POSIX command line that start tokenizes itself and runs as an argv vector,
|
|
8
|
+
* never through a shell, so a call that needs shell syntax (pipes, redirects,
|
|
9
|
+
* substitutions, globs, variables) is refused instead of being interpreted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
/** Exit statuses of the mode; a finished task keeps its last tool's status. */
|
|
16
|
+
const EXIT = Object.freeze({
|
|
17
|
+
USAGE: 64, // request text or option combination rejected
|
|
18
|
+
UNSUPPORTED: 65, // Formal AI did not understand the task or reported a capability gap
|
|
19
|
+
UNAVAILABLE: 69, // no qualified Formal AI runtime could be resolved
|
|
20
|
+
PROTOCOL: 70, // malformed or 5xx response, unknown tool or unverified effect
|
|
21
|
+
REFUSED: 77, // a tool call was refused by the effect policy
|
|
22
|
+
CONFIG: 78, // invalid START_FORMAL_AI_* bound
|
|
23
|
+
BUDGET: 124, // step, tool, time or retry budget exhausted
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const EFFECT_MODES = ['read-only', 'workspace-write'];
|
|
27
|
+
|
|
28
|
+
const BOUND_DEFAULTS = Object.freeze({
|
|
29
|
+
maxSteps: { env: 'START_FORMAL_AI_MAX_STEPS', value: 8, max: 64 },
|
|
30
|
+
maxRetries: { env: 'START_FORMAL_AI_MAX_RETRIES', value: 1, max: 5, min: 0 },
|
|
31
|
+
timeoutMs: { env: 'START_FORMAL_AI_TIMEOUT_MS', value: 120000 },
|
|
32
|
+
toolTimeoutMs: { env: 'START_FORMAL_AI_TOOL_TIMEOUT_MS', value: 30000 },
|
|
33
|
+
startupTimeoutMs: {
|
|
34
|
+
env: 'START_FORMAL_AI_STARTUP_TIMEOUT_MS',
|
|
35
|
+
value: 30000,
|
|
36
|
+
},
|
|
37
|
+
maxOutputBytes: {
|
|
38
|
+
env: 'START_FORMAL_AI_MAX_OUTPUT_BYTES',
|
|
39
|
+
value: 64 * 1024,
|
|
40
|
+
max: 16 * 1024 * 1024,
|
|
41
|
+
},
|
|
42
|
+
maxFileBytes: {
|
|
43
|
+
env: 'START_FORMAL_AI_MAX_FILE_BYTES',
|
|
44
|
+
value: 1024 * 1024,
|
|
45
|
+
max: 16 * 1024 * 1024,
|
|
46
|
+
},
|
|
47
|
+
maxRequestChars: { env: 'START_FORMAL_AI_MAX_REQUEST_CHARS', value: 4096 },
|
|
48
|
+
maxMemoryMb: { env: 'START_FORMAL_AI_MAX_MEMORY_MB', value: 1024 },
|
|
49
|
+
maxFingerprintEntries: {
|
|
50
|
+
env: 'START_FORMAL_AI_MAX_FINGERPRINT_ENTRIES',
|
|
51
|
+
value: 5000,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the bounds of one run from START_FORMAL_AI_* variables.
|
|
57
|
+
* @param {object} [env] - Environment
|
|
58
|
+
* @returns {object} Bounds plus `effects`
|
|
59
|
+
* @throws {Error} With `exitCode` EXIT.CONFIG on an invalid value
|
|
60
|
+
*/
|
|
61
|
+
function resolveBounds(env = process.env) {
|
|
62
|
+
const bounds = {};
|
|
63
|
+
for (const [key, spec] of Object.entries(BOUND_DEFAULTS)) {
|
|
64
|
+
const raw = env[spec.env];
|
|
65
|
+
if (raw === undefined || raw === '') {
|
|
66
|
+
bounds[key] = spec.value;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const min = spec.min === undefined ? 1 : spec.min;
|
|
70
|
+
const value = Number(raw);
|
|
71
|
+
if (
|
|
72
|
+
!/^\d+$/.test(raw.trim()) ||
|
|
73
|
+
value < min ||
|
|
74
|
+
(spec.max && value > spec.max)
|
|
75
|
+
) {
|
|
76
|
+
throw configError(
|
|
77
|
+
`${spec.env} must be an integer from ${min} to ${spec.max || 'any'}, got "${raw}"`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
bounds[key] = value;
|
|
81
|
+
}
|
|
82
|
+
const effects = (env.START_FORMAL_AI_EFFECTS || 'read-only').trim();
|
|
83
|
+
if (!EFFECT_MODES.includes(effects)) {
|
|
84
|
+
throw configError(
|
|
85
|
+
`START_FORMAL_AI_EFFECTS must be one of ${EFFECT_MODES.join(', ')}, got "${effects}"`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
bounds.effects = effects;
|
|
89
|
+
return bounds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function configError(message) {
|
|
93
|
+
return Object.assign(new Error(message), { exitCode: EXIT.CONFIG });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Split a command line with POSIX quoting rules. Characters that would make a
|
|
98
|
+
* shell do more than split words are reported instead of honoured.
|
|
99
|
+
* @param {string} line - Command line from a tool call
|
|
100
|
+
* @returns {{tokens: string[], problems: string[]}} Words and refused syntax
|
|
101
|
+
*/
|
|
102
|
+
function tokenizeCommand(line) {
|
|
103
|
+
const tokens = [];
|
|
104
|
+
const problems = new Set();
|
|
105
|
+
let current = null;
|
|
106
|
+
let quote = null;
|
|
107
|
+
for (let i = 0; i < line.length; i++) {
|
|
108
|
+
const ch = line[i];
|
|
109
|
+
if (quote === "'") {
|
|
110
|
+
if (ch === "'") {
|
|
111
|
+
quote = null;
|
|
112
|
+
} else {
|
|
113
|
+
current += ch;
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (quote === '"') {
|
|
118
|
+
if (ch === '"') {
|
|
119
|
+
quote = null;
|
|
120
|
+
} else if (
|
|
121
|
+
ch === '\\' &&
|
|
122
|
+
i + 1 < line.length &&
|
|
123
|
+
'"\\$`'.includes(line[i + 1])
|
|
124
|
+
) {
|
|
125
|
+
current += line[++i];
|
|
126
|
+
} else if (ch === '$' || ch === '`') {
|
|
127
|
+
problems.add(`expansion "${ch}"`);
|
|
128
|
+
current += ch;
|
|
129
|
+
} else {
|
|
130
|
+
current += ch;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (ch === ' ' || ch === '\t') {
|
|
135
|
+
if (current !== null) {
|
|
136
|
+
tokens.push(current);
|
|
137
|
+
current = null;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (ch === "'" || ch === '"') {
|
|
142
|
+
quote = ch;
|
|
143
|
+
current = current === null ? '' : current;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (ch === '\\') {
|
|
147
|
+
if (i + 1 < line.length && line[i + 1] !== '\n') {
|
|
148
|
+
current = (current === null ? '' : current) + line[++i];
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
problems.add('line continuation');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if ('\n\r;&|<>()$`'.includes(ch)) {
|
|
155
|
+
problems.add(`shell operator "${ch === '\n' ? '\\n' : ch}"`);
|
|
156
|
+
} else if ('*?[]{}'.includes(ch)) {
|
|
157
|
+
problems.add(`unquoted pattern "${ch}"`);
|
|
158
|
+
} else if (ch === '~' && current === null) {
|
|
159
|
+
problems.add('home directory expansion "~"');
|
|
160
|
+
} else if (ch === '#' && current === null) {
|
|
161
|
+
problems.add('comment "#"');
|
|
162
|
+
}
|
|
163
|
+
current = (current === null ? '' : current) + ch;
|
|
164
|
+
}
|
|
165
|
+
if (quote) {
|
|
166
|
+
problems.add('unterminated quote');
|
|
167
|
+
}
|
|
168
|
+
if (current !== null) {
|
|
169
|
+
tokens.push(current);
|
|
170
|
+
}
|
|
171
|
+
return { tokens, problems: [...problems] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const READ_PROGRAMS = new Set([
|
|
175
|
+
'basename',
|
|
176
|
+
'cat',
|
|
177
|
+
'cmp',
|
|
178
|
+
'diff',
|
|
179
|
+
'dirname',
|
|
180
|
+
'du',
|
|
181
|
+
'file',
|
|
182
|
+
'find',
|
|
183
|
+
'git',
|
|
184
|
+
'grep',
|
|
185
|
+
'head',
|
|
186
|
+
'ls',
|
|
187
|
+
'md5sum',
|
|
188
|
+
'pwd',
|
|
189
|
+
'readlink',
|
|
190
|
+
'realpath',
|
|
191
|
+
'sha256sum',
|
|
192
|
+
'sort',
|
|
193
|
+
'stat',
|
|
194
|
+
'tail',
|
|
195
|
+
'test',
|
|
196
|
+
'tree',
|
|
197
|
+
'uniq',
|
|
198
|
+
'wc',
|
|
199
|
+
]);
|
|
200
|
+
const WRITE_PROGRAMS = new Set(['cp', 'mkdir', 'mv', 'rm', 'rmdir', 'touch']);
|
|
201
|
+
const LISTING_PROGRAMS = new Set(['ls', 'find', 'tree', 'du']);
|
|
202
|
+
|
|
203
|
+
/** Flags that turn an otherwise read-only program into a writer or waiter. */
|
|
204
|
+
const FORBIDDEN_FLAGS = {
|
|
205
|
+
find: /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/,
|
|
206
|
+
sort: /^(-o.*|--output.*)$/,
|
|
207
|
+
tree: /^(-o.*|--output.*)$/,
|
|
208
|
+
file: /^(-C|--compile)$/,
|
|
209
|
+
tail: /^(-f|-F|--follow.*|--retry)$/,
|
|
210
|
+
git: /^(--output.*|--ext-diff|-c|--exec-path.*|--git-dir.*|--work-tree.*)$/,
|
|
211
|
+
rm: /^(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--no-preserve-root)$/,
|
|
212
|
+
cp: /^(-[a-zA-Z]*[l][a-zA-Z]*|--link|-s|--symbolic-link)$/,
|
|
213
|
+
};
|
|
214
|
+
const GIT_SUBCOMMANDS = new Set([
|
|
215
|
+
'branch',
|
|
216
|
+
'diff',
|
|
217
|
+
'log',
|
|
218
|
+
'ls-files',
|
|
219
|
+
'rev-parse',
|
|
220
|
+
'show',
|
|
221
|
+
'status',
|
|
222
|
+
]);
|
|
223
|
+
const GIT_BRANCH_FLAGS = new Set([
|
|
224
|
+
'-a',
|
|
225
|
+
'-r',
|
|
226
|
+
'-v',
|
|
227
|
+
'-vv',
|
|
228
|
+
'--list',
|
|
229
|
+
'--show-current',
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
function isInside(root, target) {
|
|
233
|
+
const relative = path.relative(root, target);
|
|
234
|
+
return (
|
|
235
|
+
relative === '' ||
|
|
236
|
+
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Real path of `target`, resolving its nearest existing ancestor. */
|
|
241
|
+
function realTarget(target) {
|
|
242
|
+
let existing = target;
|
|
243
|
+
const rest = [];
|
|
244
|
+
while (!fs.existsSync(existing)) {
|
|
245
|
+
const parent = path.dirname(existing);
|
|
246
|
+
if (parent === existing) {
|
|
247
|
+
return target;
|
|
248
|
+
}
|
|
249
|
+
rest.unshift(path.basename(existing));
|
|
250
|
+
existing = parent;
|
|
251
|
+
}
|
|
252
|
+
return path.join(fs.realpathSync(existing), ...rest);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Path-like operands of a call (values of `--opt=value` included). */
|
|
256
|
+
function operands(tokens) {
|
|
257
|
+
const values = [];
|
|
258
|
+
for (const token of tokens.slice(1)) {
|
|
259
|
+
if (token.startsWith('-')) {
|
|
260
|
+
const eq = token.indexOf('=');
|
|
261
|
+
if (eq > 0 && token.startsWith('--')) {
|
|
262
|
+
values.push(token.slice(eq + 1));
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
values.push(token);
|
|
267
|
+
}
|
|
268
|
+
return values;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Normalized workspace-relative path of an operand, or null outside it.
|
|
273
|
+
* It uses `/` on every platform, like the fingerprint, so observations,
|
|
274
|
+
* messages and evidence are the same on Windows.
|
|
275
|
+
* @param {string} cwd - Working directory
|
|
276
|
+
* @param {string} operand - Operand text
|
|
277
|
+
* @returns {string|null}
|
|
278
|
+
*/
|
|
279
|
+
function workspacePath(cwd, operand) {
|
|
280
|
+
const root = fs.realpathSync(cwd);
|
|
281
|
+
const resolved = realTarget(path.resolve(cwd, operand));
|
|
282
|
+
if (!isInside(root, resolved)) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return path.relative(root, resolved).split(path.sep).join('/') || '.';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Decide whether a tool call may run.
|
|
290
|
+
* @param {string} line - Command line of the call
|
|
291
|
+
* @param {object} context - {cwd, effects, observed: Set<string>, observedDirs: Set<string>}
|
|
292
|
+
* @returns {{allowed: boolean, reason?: string, tokens: string[], kind?: string, targets?: string[]}}
|
|
293
|
+
*/
|
|
294
|
+
function evaluateCommand(line, context) {
|
|
295
|
+
const { tokens, problems } = tokenizeCommand(line);
|
|
296
|
+
const refuse = (reason) => ({ allowed: false, reason, tokens });
|
|
297
|
+
if (problems.length) {
|
|
298
|
+
return refuse(
|
|
299
|
+
`needs shell syntax start does not run: ${problems.join(', ')}`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (!tokens.length) {
|
|
303
|
+
return refuse('empty command');
|
|
304
|
+
}
|
|
305
|
+
const program = tokens[0];
|
|
306
|
+
const isWrite = WRITE_PROGRAMS.has(program);
|
|
307
|
+
if (!READ_PROGRAMS.has(program) && !isWrite) {
|
|
308
|
+
return refuse(`program "${program}" is not in the supported tool set`);
|
|
309
|
+
}
|
|
310
|
+
if (isWrite && context.effects !== 'workspace-write') {
|
|
311
|
+
return refuse(
|
|
312
|
+
`"${program}" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const forbidden = FORBIDDEN_FLAGS[program];
|
|
316
|
+
const flag = forbidden && tokens.slice(1).find((t) => forbidden.test(t));
|
|
317
|
+
if (flag) {
|
|
318
|
+
return refuse(`option "${flag}" of ${program} is not allowed`);
|
|
319
|
+
}
|
|
320
|
+
if (program === 'git') {
|
|
321
|
+
const sub = tokens[1];
|
|
322
|
+
if (!GIT_SUBCOMMANDS.has(sub)) {
|
|
323
|
+
return refuse(`git subcommand "${sub || ''}" is not read-only`);
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
sub === 'branch' &&
|
|
327
|
+
tokens.slice(2).some((t) => !GIT_BRANCH_FLAGS.has(t))
|
|
328
|
+
) {
|
|
329
|
+
return refuse('git branch may only list branches');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const targets = [];
|
|
333
|
+
for (const operand of operands(tokens)) {
|
|
334
|
+
if (operand.startsWith('~')) {
|
|
335
|
+
return refuse(`"${operand}" is outside the working directory`);
|
|
336
|
+
}
|
|
337
|
+
// Absolute, escaping and symlinked-out operands are refused: reads and
|
|
338
|
+
// changes stay inside the working directory.
|
|
339
|
+
const relative = workspacePath(context.cwd, operand);
|
|
340
|
+
if (relative === null) {
|
|
341
|
+
return refuse(`"${operand}" is outside the working directory`);
|
|
342
|
+
}
|
|
343
|
+
targets.push(relative);
|
|
344
|
+
}
|
|
345
|
+
if (isWrite) {
|
|
346
|
+
if (!targets.length) {
|
|
347
|
+
return refuse(`${program} needs an explicit target`);
|
|
348
|
+
}
|
|
349
|
+
const unobserved = targets.filter(
|
|
350
|
+
(t) =>
|
|
351
|
+
t === '.' ||
|
|
352
|
+
!(context.observed.has(t) || context.observedDirs.has(path.dirname(t)))
|
|
353
|
+
);
|
|
354
|
+
if (unobserved.length) {
|
|
355
|
+
return refuse(
|
|
356
|
+
`target ${unobserved.map((t) => `"${t}"`).join(', ')} was not observed before the change`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { allowed: true, tokens, kind: isWrite ? 'write' : 'read', targets };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Remember what a read call observed, so a later change may name it.
|
|
365
|
+
* @param {object} decision - evaluateCommand() result of the read call
|
|
366
|
+
* @param {object} context - Same context object
|
|
367
|
+
*/
|
|
368
|
+
function recordObservation(decision, context) {
|
|
369
|
+
const listed = decision.targets.length ? decision.targets : ['.'];
|
|
370
|
+
for (const target of decision.targets) {
|
|
371
|
+
context.observed.add(target);
|
|
372
|
+
}
|
|
373
|
+
if (LISTING_PROGRAMS.has(decision.tokens[0])) {
|
|
374
|
+
for (const target of listed) {
|
|
375
|
+
context.observedDirs.add(target);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Bounded fingerprint of the working directory (names, types, sizes, mtimes).
|
|
382
|
+
* @param {string} cwd - Directory
|
|
383
|
+
* @param {number} limit - Maximum entries
|
|
384
|
+
* @returns {{entries: Map<string,string>, complete: boolean}}
|
|
385
|
+
*/
|
|
386
|
+
function fingerprint(cwd, limit) {
|
|
387
|
+
const entries = new Map();
|
|
388
|
+
const queue = [''];
|
|
389
|
+
while (queue.length) {
|
|
390
|
+
const relative = queue.shift();
|
|
391
|
+
let names;
|
|
392
|
+
try {
|
|
393
|
+
names = fs.readdirSync(path.join(cwd, relative)).sort();
|
|
394
|
+
} catch {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
for (const name of names) {
|
|
398
|
+
if (entries.size >= limit) {
|
|
399
|
+
return { entries, complete: false };
|
|
400
|
+
}
|
|
401
|
+
const child = relative ? `${relative}/${name}` : name;
|
|
402
|
+
let stat;
|
|
403
|
+
try {
|
|
404
|
+
stat = fs.lstatSync(path.join(cwd, child));
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const type = stat.isDirectory() ? 'd' : stat.isSymbolicLink() ? 'l' : 'f';
|
|
409
|
+
entries.set(child, `${type}:${stat.size}:${stat.mtimeMs}`);
|
|
410
|
+
if (type === 'd' && name !== '.git') {
|
|
411
|
+
queue.push(child);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { entries, complete: true };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Differences between two fingerprints.
|
|
420
|
+
* @returns {{added: string[], removed: string[], changed: string[], complete: boolean}}
|
|
421
|
+
*/
|
|
422
|
+
function diffFingerprints(before, after) {
|
|
423
|
+
const added = [];
|
|
424
|
+
const removed = [];
|
|
425
|
+
const changed = [];
|
|
426
|
+
for (const [name, value] of after.entries) {
|
|
427
|
+
if (!before.entries.has(name)) {
|
|
428
|
+
added.push(name);
|
|
429
|
+
} else if (before.entries.get(name) !== value) {
|
|
430
|
+
changed.push(name);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
for (const name of before.entries.keys()) {
|
|
434
|
+
if (!after.entries.has(name)) {
|
|
435
|
+
removed.push(name);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
added,
|
|
440
|
+
removed,
|
|
441
|
+
changed,
|
|
442
|
+
complete: before.complete && after.complete,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function hasEffects(diff) {
|
|
447
|
+
return diff.added.length + diff.removed.length + diff.changed.length > 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Read the result of a successful change back from the filesystem.
|
|
452
|
+
* @param {string[]} tokens - Argv of the change
|
|
453
|
+
* @param {string} cwd - Working directory
|
|
454
|
+
* @returns {{verified: boolean, detail: string}}
|
|
455
|
+
*/
|
|
456
|
+
function verifyChange(tokens, cwd) {
|
|
457
|
+
const program = tokens[0];
|
|
458
|
+
const args = operands(tokens);
|
|
459
|
+
const exists = (p) => fs.existsSync(path.resolve(cwd, p));
|
|
460
|
+
const isDir = (p) =>
|
|
461
|
+
exists(p) && fs.statSync(path.resolve(cwd, p)).isDirectory();
|
|
462
|
+
let missing = [];
|
|
463
|
+
if (program === 'mkdir') {
|
|
464
|
+
missing = args.filter((p) => !isDir(p));
|
|
465
|
+
} else if (program === 'touch') {
|
|
466
|
+
missing = args.filter((p) => !exists(p));
|
|
467
|
+
} else if (program === 'rm' || program === 'rmdir') {
|
|
468
|
+
missing = args.filter((p) => exists(p)).map((p) => `${p} (still present)`);
|
|
469
|
+
} else if (program === 'cp' || program === 'mv') {
|
|
470
|
+
const dest = args[args.length - 1];
|
|
471
|
+
const sources = args.slice(0, -1);
|
|
472
|
+
const expected =
|
|
473
|
+
isDir(dest) && sources.length
|
|
474
|
+
? sources.map((s) => path.join(dest, path.basename(s)))
|
|
475
|
+
: [dest];
|
|
476
|
+
missing = expected.filter((p) => !exists(p));
|
|
477
|
+
if (program === 'mv') {
|
|
478
|
+
missing.push(
|
|
479
|
+
...sources
|
|
480
|
+
.filter((s) => exists(s) && !expected.includes(s))
|
|
481
|
+
.map((s) => `${s} (still present)`)
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return missing.length
|
|
486
|
+
? { verified: false, detail: `readback failed: ${missing.join(', ')}` }
|
|
487
|
+
: {
|
|
488
|
+
verified: true,
|
|
489
|
+
detail: `readback confirmed ${program} ${args.join(' ')}`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
module.exports = {
|
|
494
|
+
BOUND_DEFAULTS,
|
|
495
|
+
EFFECT_MODES,
|
|
496
|
+
EXIT,
|
|
497
|
+
READ_PROGRAMS,
|
|
498
|
+
WRITE_PROGRAMS,
|
|
499
|
+
diffFingerprints,
|
|
500
|
+
evaluateCommand,
|
|
501
|
+
fingerprint,
|
|
502
|
+
hasEffects,
|
|
503
|
+
recordObservation,
|
|
504
|
+
resolveBounds,
|
|
505
|
+
tokenizeCommand,
|
|
506
|
+
verifyChange,
|
|
507
|
+
workspacePath,
|
|
508
|
+
};
|