@turri-dev/cli 0.1.28-next.d5f2680 → 0.2.1-next.47b578c
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/README.md +3 -2
- package/dist/chats.d.ts +31 -4
- package/dist/chats.js +146 -52
- package/dist/cli.js +4 -2
- package/dist/config.d.ts +4 -1
- package/dist/config.js +39 -4
- package/dist/environment.d.ts +0 -2
- package/dist/environment.js +0 -6
- package/dist/extension.d.ts +4 -3
- package/dist/extension.js +110 -23
- package/dist/host-session.d.ts +14 -0
- package/dist/host-session.js +2 -0
- package/dist/host.d.ts +6 -1
- package/dist/host.js +5 -1
- package/dist/init-config.d.ts +1 -1
- package/dist/init-config.js +33 -3
- package/dist/installed-providers.d.ts +40 -0
- package/dist/installed-providers.js +98 -0
- package/dist/model-check.d.ts +4 -2
- package/dist/model-check.js +33 -17
- package/dist/notice.d.ts +30 -6
- package/dist/pi.js +8 -24
- package/dist/plain/files.d.ts +16 -0
- package/dist/plain/files.js +68 -5
- package/dist/plain/graph.js +2 -2
- package/dist/plain/module-concepts.js +3 -2
- package/dist/plain/parser.d.ts +8 -3
- package/dist/plain/parser.js +47 -6
- package/dist/plain/stdlib.js +3 -3
- package/dist/regenerate/base-folder.d.ts +2 -0
- package/dist/regenerate/base-folder.js +24 -6
- package/dist/regenerate/command-check.d.ts +12 -1
- package/dist/regenerate/command-check.js +74 -0
- package/dist/regenerate/confine.d.ts +5 -0
- package/dist/regenerate/confine.js +9 -9
- package/dist/regenerate/ledger.d.ts +7 -0
- package/dist/regenerate/lineage.js +43 -2
- package/dist/regenerate/loop.d.ts +8 -0
- package/dist/regenerate/loop.js +11 -4
- package/dist/regenerate/os-sandbox.d.ts +2 -0
- package/dist/regenerate/os-sandbox.js +55 -20
- package/dist/regenerate/release.d.ts +2 -0
- package/dist/regenerate/requires.d.ts +12 -0
- package/dist/regenerate/requires.js +70 -1
- package/dist/regenerate/spec-steps.js +18 -1
- package/dist/regenerate/standing.d.ts +28 -0
- package/dist/regenerate/standing.js +30 -0
- package/dist/regenerate/testing.d.ts +2 -0
- package/dist/regenerate/testing.js +14 -0
- package/dist/release-agent.d.ts +17 -0
- package/dist/release-agent.js +19 -7
- package/dist/release-tools.d.ts +6 -0
- package/dist/release-tools.js +24 -5
- package/dist/sentinel-chat.d.ts +1 -1
- package/dist/sentinel-chat.js +21 -8
- package/dist/sentinel-check.d.ts +36 -0
- package/dist/sentinel-check.js +78 -0
- package/dist/sentinel.d.ts +40 -9
- package/dist/sentinel.js +240 -37
- package/dist/setup.d.ts +24 -3
- package/dist/setup.js +117 -31
- package/dist/spawn-async.d.ts +18 -0
- package/dist/spawn-async.js +35 -0
- package/dist/stack.d.ts +14 -0
- package/dist/stack.js +44 -2
- package/dist/turri-versions.d.ts +71 -0
- package/dist/turri-versions.js +275 -0
- package/docs/README.md +2 -0
- package/docs/claude-subscription.md +131 -0
- package/docs/config.md +5 -0
- package/docs/deploy.md +2 -2
- package/docs/hosting.md +42 -4
- package/docs/installation.md +39 -2
- package/docs/parser.md +1 -1
- package/docs/regenerating.md +1 -1
- package/docs/sandbox.md +2 -1
- package/docs/sentinel.md +11 -3
- package/docs/using.md +4 -2
- package/genome/rules/turri-regeneration.md +1 -1
- package/package.json +1 -1
|
@@ -128,6 +128,77 @@ function installIn(segment) {
|
|
|
128
128
|
return "it runs an installer";
|
|
129
129
|
return undefined;
|
|
130
130
|
}
|
|
131
|
+
/** The files a shell reads as it starts, and the system's own configuration: what changes the person's environment. */
|
|
132
|
+
const PROFILE_FILE = /(^|[\/])\.(bashrc|bash_profile|bash_login|profile|zshrc|zprofile|zshenv|zlogin|kshrc|cshrc|tcshrc)$|(^|[\/])\.config[\/]fish[\/]|^\/etc\//;
|
|
133
|
+
/** Why one simple command changes a setting of the machine's, or nothing when it does not. */
|
|
134
|
+
function systemSettingIn(segment) {
|
|
135
|
+
const found = programOf(segment);
|
|
136
|
+
if (found === undefined)
|
|
137
|
+
return undefined;
|
|
138
|
+
const { program, args } = found;
|
|
139
|
+
const has = (...wanted) => wanted.some((word) => args.includes(word));
|
|
140
|
+
if (program === "defaults" && has("write", "delete", "import", "rename"))
|
|
141
|
+
return "defaults writes the machine's settings";
|
|
142
|
+
if (program === "launchctl" && args.some((arg) => !["list", "print", "print-disabled", "blame", "help", "version"].includes(arg) && !arg.startsWith("-")))
|
|
143
|
+
return "launchctl changes the machine's services";
|
|
144
|
+
if (program === "systemctl" && args.some((arg) => ["enable", "disable", "start", "stop", "restart", "reload", "mask", "unmask", "set-default", "set-property", "edit", "daemon-reload"].includes(arg)))
|
|
145
|
+
return "systemctl changes the machine's services";
|
|
146
|
+
if (program === "sysctl" && (has("-w") || args.some((arg) => /^[\w.]+=/.test(arg))))
|
|
147
|
+
return "sysctl changes the kernel's settings";
|
|
148
|
+
if (["scutil", "networksetup", "pmset", "nvram", "csrutil", "spctl", "systemsetup", "update-alternatives", "chsh", "dscl", "visudo", "usermod", "useradd", "passwd", "timedatectl", "hostnamectl"].includes(program) && args.length > 0) {
|
|
149
|
+
const reads = ["--get", "-get", "-getcomputername", "status", "-g", "--list", "--display", "--query", "show", "-read", "-list"];
|
|
150
|
+
if (!args.some((arg) => reads.includes(arg) || arg.startsWith("-get")))
|
|
151
|
+
return `${program} changes the machine's settings`;
|
|
152
|
+
}
|
|
153
|
+
if (program === "xcode-select" && has("--switch", "-s", "--reset", "-r", "--install"))
|
|
154
|
+
return "xcode-select changes the machine's developer tools";
|
|
155
|
+
if (program === "git" && has("config") && has("--global", "--system"))
|
|
156
|
+
return "git config --global changes the person's git settings";
|
|
157
|
+
if ((program === "npm" || program === "pnpm" || program === "yarn") && has("config") && has("set", "delete", "edit"))
|
|
158
|
+
return `${program} config changes the person's ${program} settings`;
|
|
159
|
+
if ((program === "pip" || program === "pip3") && has("config") && has("set", "unset", "edit"))
|
|
160
|
+
return "pip config changes the person's pip settings";
|
|
161
|
+
if (program === "mise" && has("settings", "trust"))
|
|
162
|
+
return "mise changes the person's mise settings";
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Why a command line changes the machine - installs software outside the project, or changes a setting of the
|
|
167
|
+
* machine's or the person's environment - or nothing when it does not. The project's own dependencies, installed into
|
|
168
|
+
* the project (npm install, a venv, mvn, cargo), change nothing of the machine's. turri changes the machine nowhere: not
|
|
169
|
+
* an agent, not the chat, not a release - what the machine lacks is the person's to install, in their own words.
|
|
170
|
+
*/
|
|
171
|
+
export function machineChange(command) {
|
|
172
|
+
if (PIPED_DOWNLOAD.test(command) || RUN_DOWNLOAD.test(command))
|
|
173
|
+
return "it runs a script downloaded from the web";
|
|
174
|
+
for (const segment of segments(command)) {
|
|
175
|
+
const why = installIn(segment) ?? systemSettingIn(segment);
|
|
176
|
+
if (why !== undefined)
|
|
177
|
+
return why;
|
|
178
|
+
}
|
|
179
|
+
for (const part of command.split(/&&|\|\||[;\n]/)) {
|
|
180
|
+
const profile = writeTargets(part).find((target) => PROFILE_FILE.test(target.replace(/^~|^\$HOME|^\$\{HOME\}/, "")));
|
|
181
|
+
if (profile !== undefined)
|
|
182
|
+
return `it writes ${profile}, which sets the person's environment`;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
/** The .plain file a command line writes - a ***plain module or a test plain file - or nothing when it writes none. */
|
|
187
|
+
export function writesPlain(command) {
|
|
188
|
+
for (const part of command.split(/&&|\|\||[;\n]/)) {
|
|
189
|
+
const found = writeTargets(part).find((target) => target.replace(/["']/g, "").endsWith(".plain"));
|
|
190
|
+
if (found !== undefined)
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
/** What turri says when a command would change the machine: what it does instead. */
|
|
196
|
+
export function machineChangeReason(why) {
|
|
197
|
+
return (`turri changes nothing of this machine's - ${why}. Nothing is installed by turri, and no setting of the machine's or the ` +
|
|
198
|
+
"person's environment is changed: that is theirs to do. Find out what is there instead - off the PATH, another name for " +
|
|
199
|
+
"the same tool (python for python3), a version manager's folder - and use it, or give the person the exact command to " +
|
|
200
|
+
"run themselves, and go on once they say it is done.");
|
|
201
|
+
}
|
|
131
202
|
/** Downloads run as programs: a script piped to a shell, or run from a download. */
|
|
132
203
|
const PIPED_DOWNLOAD = /\b(curl|wget)\b[^|;&\n]*\|\s*(sudo\s+(-\S+\s+)*)?((ba|z|da|k)?sh|python\d*|perl|ruby|node)\b/;
|
|
133
204
|
const RUN_DOWNLOAD = /\b(ba|z|da|k)?sh\s+(-\w+\s+)*["']?\$\(\s*(curl|wget)\b|<\(\s*(curl|wget)\b/;
|
|
@@ -267,6 +338,9 @@ export function checkCommand(command, fence) {
|
|
|
267
338
|
const why = installIn(segment);
|
|
268
339
|
if (why !== undefined)
|
|
269
340
|
return refusal("install", installReason(why));
|
|
341
|
+
const setting = systemSettingIn(segment);
|
|
342
|
+
if (setting !== undefined)
|
|
343
|
+
return refusal("system", machineChangeReason(setting));
|
|
270
344
|
const found = programOf(segment);
|
|
271
345
|
if (found !== undefined && (["pkill", "killall", "taskkill"].includes(found.program) || (found.program === "fuser" && found.args.some((arg) => /^-\w*k/.test(arg))))) {
|
|
272
346
|
return refusal("kill", killReason());
|
|
@@ -31,6 +31,11 @@ export interface Confinement {
|
|
|
31
31
|
reportsEnvironment?: boolean;
|
|
32
32
|
/** Whose home and temporary folder its shell has, by the module folder they are kept for - its workdir's module when not given. */
|
|
33
33
|
shellHome?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The project's testing scripts are its to fix, where `write` reaches them - the sentinel's. Every other agent's file
|
|
36
|
+
* tools refuse them: the harness runs them, and an agent's copy of one is its own version of what the harness runs.
|
|
37
|
+
*/
|
|
38
|
+
fixesScripts?: boolean;
|
|
34
39
|
}
|
|
35
40
|
export declare class ConfinementError extends Error {
|
|
36
41
|
constructor(message: string);
|
|
@@ -63,11 +63,11 @@ function refuseNotes(path) {
|
|
|
63
63
|
* the codebase is an agent running its own version of what the harness runs, or building from a
|
|
64
64
|
* spec it was never meant to hold.
|
|
65
65
|
*/
|
|
66
|
-
function refuseHarnessFiles(path) {
|
|
66
|
+
function refuseHarnessFiles(path, fixesScripts = false) {
|
|
67
67
|
const name = basename(path);
|
|
68
68
|
if (isCredentialFile(path))
|
|
69
69
|
throw new ConfinementError(credentialRefusal(path));
|
|
70
|
-
if (path.split(sep).includes("test_scripts") || /^(run_unittests|run_unit_tests|run_conformance_tests|prepare_environment)[^/]*\.sh$/.test(name)) {
|
|
70
|
+
if (!fixesScripts && (path.split(sep).includes("test_scripts") || /^(run_unittests|run_unit_tests|run_conformance_tests|prepare_environment)[^/]*\.sh$/.test(name))) {
|
|
71
71
|
throw new ConfinementError(`${name} is a copy of the project's testing scripts, and those are not written here. They are not in your ` +
|
|
72
72
|
"workspace, and are never copied, recreated or run by you: call run_unit_tests and run_conformance_tests, " +
|
|
73
73
|
"which run them exactly as the harness does.");
|
|
@@ -90,7 +90,7 @@ function refuseKeptOutput(raw, path, cwd) {
|
|
|
90
90
|
return;
|
|
91
91
|
throw new ConfinementError(`${id} is an output turri kept, not a file in your folder: call read_output with that id.`);
|
|
92
92
|
}
|
|
93
|
-
function check(raw, cwd, roots, verb) {
|
|
93
|
+
function check(raw, cwd, roots, verb, fixesScripts = false) {
|
|
94
94
|
if (raw === undefined)
|
|
95
95
|
return;
|
|
96
96
|
const path = absolutize(raw, cwd);
|
|
@@ -101,7 +101,7 @@ function check(raw, cwd, roots, verb) {
|
|
|
101
101
|
refuseRecord(path);
|
|
102
102
|
if (verb === "write") {
|
|
103
103
|
refuseNotes(path);
|
|
104
|
-
refuseHarnessFiles(path);
|
|
104
|
+
refuseHarnessFiles(path, fixesScripts);
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
/** A glob is a path too: `../../*` escapes just as well as `..`. */
|
|
@@ -141,14 +141,14 @@ function guard(def, inspect) {
|
|
|
141
141
|
* They agree in every ordinary case, and when they do not - a `~` spelt oddly, a filename
|
|
142
142
|
* normalised to another form on disk - this is the one that is true.
|
|
143
143
|
*/
|
|
144
|
-
function guardedOps(roots, verb) {
|
|
144
|
+
function guardedOps(roots, verb, fixesScripts = false) {
|
|
145
145
|
return (path) => {
|
|
146
146
|
if (!insideAny(path, roots))
|
|
147
147
|
refuse(path, roots, verb);
|
|
148
148
|
refuseRecord(path);
|
|
149
149
|
if (verb === "write") {
|
|
150
150
|
refuseNotes(path);
|
|
151
|
-
refuseHarnessFiles(path);
|
|
151
|
+
refuseHarnessFiles(path, fixesScripts);
|
|
152
152
|
}
|
|
153
153
|
};
|
|
154
154
|
}
|
|
@@ -219,7 +219,7 @@ export function confinedTools(c) {
|
|
|
219
219
|
const read = (c.read ?? [workdir]).map((dir) => resolve(dir));
|
|
220
220
|
const write = (c.write ?? [workdir]).map((dir) => resolve(dir));
|
|
221
221
|
const readOps = guardedOps(read, "read");
|
|
222
|
-
const writeOps = guardedOps(write, "write");
|
|
222
|
+
const writeOps = guardedOps(write, "write", c.fixesScripts === true);
|
|
223
223
|
const tools = [
|
|
224
224
|
guard(createReadToolDefinition(workdir, {
|
|
225
225
|
operations: {
|
|
@@ -236,14 +236,14 @@ export function confinedTools(c) {
|
|
|
236
236
|
mkdirSync(dir, { recursive: true });
|
|
237
237
|
},
|
|
238
238
|
},
|
|
239
|
-
}), (p) => check(p.path, workdir, write, "write")),
|
|
239
|
+
}), (p) => check(p.path, workdir, write, "write", c.fixesScripts === true)),
|
|
240
240
|
guard(createEditToolDefinition(workdir, {
|
|
241
241
|
operations: {
|
|
242
242
|
readFile: async (path) => (writeOps(path), readFileSync(path)),
|
|
243
243
|
writeFile: async (path, content) => (writeOps(path), writeFileSync(path, content)),
|
|
244
244
|
access: async (path) => writeOps(path),
|
|
245
245
|
},
|
|
246
|
-
}), (p) => check(p.path, workdir, write, "write")),
|
|
246
|
+
}), (p) => check(p.path, workdir, write, "write", c.fixesScripts === true)),
|
|
247
247
|
withoutRecord(guard(createLsToolDefinition(workdir), (p) => check(p.path, workdir, read, "read"))),
|
|
248
248
|
withoutRecord(guard(createGrepToolDefinition(workdir), (p) => {
|
|
249
249
|
check(p.path, workdir, read, "read");
|
|
@@ -61,14 +61,21 @@ export type LedgerEvent = {
|
|
|
61
61
|
kind: "notice.over";
|
|
62
62
|
notice: string;
|
|
63
63
|
state: string;
|
|
64
|
+
} | {
|
|
65
|
+
kind: "sentinel.asked";
|
|
66
|
+
check: string;
|
|
67
|
+
module: string;
|
|
68
|
+
funcIds: string[];
|
|
64
69
|
} | {
|
|
65
70
|
kind: "sentinel.checking";
|
|
66
71
|
module?: string;
|
|
67
72
|
replaced?: string;
|
|
73
|
+
check?: string;
|
|
68
74
|
} | {
|
|
69
75
|
kind: "sentinel.concluded";
|
|
70
76
|
verdict: string;
|
|
71
77
|
module?: string;
|
|
78
|
+
check?: string;
|
|
72
79
|
} | {
|
|
73
80
|
kind: "chat.closed";
|
|
74
81
|
} | {
|
|
@@ -301,16 +301,57 @@ export function abandonWork(start, moduleName, why) {
|
|
|
301
301
|
export function committedText(dir, file) {
|
|
302
302
|
try {
|
|
303
303
|
const moduleDir = moduleDirOf(dir);
|
|
304
|
-
|
|
304
|
+
const gitDir = join(moduleDir, HISTORY_DIR);
|
|
305
|
+
if (!existsSync(gitDir))
|
|
305
306
|
return undefined;
|
|
306
307
|
const path = [basename(dir), file].join("/");
|
|
308
|
+
const head = headCommit(gitDir);
|
|
309
|
+
if (head === null)
|
|
310
|
+
return undefined;
|
|
311
|
+
// What a commit holds never changes: read once a commit. The panel asks for the records on every frame, and a git
|
|
312
|
+
// run each time - synchronous, on the thread that draws the screen - held the chat up for most of every second.
|
|
313
|
+
const key = head !== undefined ? `${gitDir}\0${head}\0${path}` : undefined;
|
|
314
|
+
if (key !== undefined && committedCache.has(key))
|
|
315
|
+
return committedCache.get(key) ?? undefined;
|
|
307
316
|
const shown = git(moduleDir, ["show", `HEAD:${path}`]);
|
|
308
|
-
|
|
317
|
+
const text = shown.status === 0 ? shown.stdout : undefined;
|
|
318
|
+
if (key !== undefined) {
|
|
319
|
+
committedCache.set(key, text ?? null);
|
|
320
|
+
if (committedCache.size > COMMITTED_CACHE_SIZE)
|
|
321
|
+
committedCache.delete(committedCache.keys().next().value);
|
|
322
|
+
}
|
|
323
|
+
return text;
|
|
309
324
|
}
|
|
310
325
|
catch {
|
|
311
326
|
return undefined;
|
|
312
327
|
}
|
|
313
328
|
}
|
|
329
|
+
/** `committedText`'s reads, by git dir, commit and path - null for a file the commit does not have. */
|
|
330
|
+
const committedCache = new Map();
|
|
331
|
+
const COMMITTED_CACHE_SIZE = 256;
|
|
332
|
+
/**
|
|
333
|
+
* The commit a history's HEAD is at, read from its files without running git: null when HEAD names a branch with no
|
|
334
|
+
* commit yet, undefined when the files say nothing this reads - git is asked then.
|
|
335
|
+
*/
|
|
336
|
+
function headCommit(gitDir) {
|
|
337
|
+
const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim();
|
|
338
|
+
if (/^[0-9a-f]{40,64}$/.test(head))
|
|
339
|
+
return head;
|
|
340
|
+
const ref = /^ref: (refs\/\S+)$/.exec(head)?.[1];
|
|
341
|
+
// A history on reftable keeps its refs in tables this does not read.
|
|
342
|
+
if (ref === undefined || existsSync(join(gitDir, "reftable")))
|
|
343
|
+
return undefined;
|
|
344
|
+
const loose = join(gitDir, ...ref.split("/"));
|
|
345
|
+
if (existsSync(loose)) {
|
|
346
|
+
const commit = readFileSync(loose, "utf8").trim();
|
|
347
|
+
return /^[0-9a-f]{40,64}$/.test(commit) ? commit : undefined;
|
|
348
|
+
}
|
|
349
|
+
const packed = join(gitDir, "packed-refs");
|
|
350
|
+
if (!existsSync(packed))
|
|
351
|
+
return null;
|
|
352
|
+
const line = readFileSync(packed, "utf8").split("\n").find((entry) => entry.endsWith(` ${ref}`));
|
|
353
|
+
return line !== undefined ? line.slice(0, line.indexOf(" ")) : null;
|
|
354
|
+
}
|
|
314
355
|
/**
|
|
315
356
|
* Commit these paths of a module, and nothing else of it - removed, added or changed. Whatever else is in the folder,
|
|
316
357
|
* uncommitted - what an interrupted job left - stays as it is, for the next unit of work to put back. Returns the commit.
|
|
@@ -323,6 +323,14 @@ export declare function functionalityCompletion(plainFile: string): Array<{
|
|
|
323
323
|
codebase: string;
|
|
324
324
|
modules: ModuleCompletion[];
|
|
325
325
|
}>;
|
|
326
|
+
/**
|
|
327
|
+
* The functional specs of a chain, completed or not, in the codebase it delivers: its furthest-built module's - see
|
|
328
|
+
* `functionalityCompletion`. A module's own chain (`resolveRequiresChain`) gives what its own codebase holds.
|
|
329
|
+
*/
|
|
330
|
+
export declare function codebaseCompletion(chain: ChainEntry[]): {
|
|
331
|
+
codebase: string;
|
|
332
|
+
modules: ModuleCompletion[];
|
|
333
|
+
};
|
|
326
334
|
export declare function createWorkLoop(options: {
|
|
327
335
|
registry: JobRegistry;
|
|
328
336
|
cwd: string;
|
package/dist/regenerate/loop.js
CHANGED
|
@@ -25,7 +25,7 @@ import { agentChanges, changeDiffInFiles } from "./change-view.js";
|
|
|
25
25
|
import { createSteering, MAX_STEERS, markTold, steer, waitingSteer } from "./steering.js";
|
|
26
26
|
import { projectDirOf } from "./paths.js";
|
|
27
27
|
import { changesBetween, parseTree, serializeTree } from "./spec-steps.js";
|
|
28
|
-
import {
|
|
28
|
+
import { hasFiles } from "./testing.js";
|
|
29
29
|
import { makeRenames, pureRenames, renameText } from "./renames.js";
|
|
30
30
|
import { casesOf, codeWork, stillWanted, whyNotWanted } from "./drift.js";
|
|
31
31
|
import { testingGap } from "./testing-steps.js";
|
|
@@ -90,7 +90,7 @@ const modifiedAt = (path) => {
|
|
|
90
90
|
}
|
|
91
91
|
};
|
|
92
92
|
/** Whether a module's codebase has been started: code in it, or a record. */
|
|
93
|
-
const started = (entry) =>
|
|
93
|
+
const started = (entry) => readRecord(entry.buildDir) !== null || hasFiles(entry.buildDir);
|
|
94
94
|
/** The spec a codebase is built from, as the .plain files have it now: its whole requires chain's. */
|
|
95
95
|
export function chainSpec(chain) {
|
|
96
96
|
// Each module's linked resources hashed where its links point: what a file holds is part of the spec.
|
|
@@ -141,7 +141,14 @@ function ownBuilt(entry) {
|
|
|
141
141
|
* The modules above the delivered codebase have nothing completed. One entry per chain; planning nothing, calling no model.
|
|
142
142
|
*/
|
|
143
143
|
export function functionalityCompletion(plainFile) {
|
|
144
|
-
return withParseCache(() => chainsFor(resolve(plainFile)).map((chain) =>
|
|
144
|
+
return withParseCache(() => chainsFor(resolve(plainFile)).map((chain) => codebaseCompletion(chain)));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The functional specs of a chain, completed or not, in the codebase it delivers: its furthest-built module's - see
|
|
148
|
+
* `functionalityCompletion`. A module's own chain (`resolveRequiresChain`) gives what its own codebase holds.
|
|
149
|
+
*/
|
|
150
|
+
export function codebaseCompletion(chain) {
|
|
151
|
+
return withParseCache(() => {
|
|
145
152
|
let delivered = -1;
|
|
146
153
|
chain.forEach((entry, index) => {
|
|
147
154
|
if (readRecord(entry.buildDir) !== null)
|
|
@@ -209,7 +216,7 @@ export function functionalityCompletion(plainFile) {
|
|
|
209
216
|
count.incomplete = entry.funcIds.filter((funcId) => !completed.includes(funcId));
|
|
210
217
|
});
|
|
211
218
|
return { codebase: tip.buildDir, modules: counts };
|
|
212
|
-
})
|
|
219
|
+
});
|
|
213
220
|
}
|
|
214
221
|
/** The modules of a chain to build: its furthest-built one, and those above it - or, none built, all of it. */
|
|
215
222
|
function toBuild(chain) {
|
|
@@ -82,6 +82,8 @@ export declare function setSandboxForTests(wall: Wall | undefined): void;
|
|
|
82
82
|
export declare function sandboxAvailable(): Wall;
|
|
83
83
|
/** Which wall would be up with `env`'s PATH - doctor's for a host whose engine runs with another PATH; tried each time. */
|
|
84
84
|
export declare function sandboxWith(env: NodeJS.ProcessEnv): Wall;
|
|
85
|
+
/** Which wall would be up with `env`'s PATH, as `sandboxWith` finds - its probes run without holding the thread up. */
|
|
86
|
+
export declare function sandboxWithAsync(env?: NodeJS.ProcessEnv): Promise<Wall>;
|
|
85
87
|
/** Whether `path` is one of the fence's roots or under one - for the tests and the audit. */
|
|
86
88
|
export declare function underAny(path: string, roots: string[]): boolean;
|
|
87
89
|
/** Where the agents' commands run, in one line for the person: which walls are up, or that only the shared ones are. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { spawnAsync } from "../spawn-async.js";
|
|
4
5
|
/** The caps set inside the sandbox before the command runs: processes, and file size in 1024-byte blocks (8 GiB). */
|
|
5
6
|
export const PROCESS_CAP = 4096;
|
|
6
7
|
export const FILE_SIZE_CAP_BLOCKS = 8 * 1024 * 1024;
|
|
@@ -207,31 +208,65 @@ export function sandboxAvailable() {
|
|
|
207
208
|
export function sandboxWith(env) {
|
|
208
209
|
return env === process.env ? sandboxAvailable() : probe(env);
|
|
209
210
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
});
|
|
223
|
-
const mounted = tryProc("mount");
|
|
224
|
-
if (mounted.status === 0)
|
|
225
|
-
return { kind: "bubblewrap", proc: "mount" };
|
|
226
|
-
const output = `${mounted.stderr ?? ""}${mounted.error !== undefined ? String(mounted.error) : ""}`;
|
|
227
|
-
if (/Can't mount proc on (\/newroot)?\/proc/.test(output) && tryProc("bind").status === 0)
|
|
228
|
-
return { kind: "bubblewrap", proc: "bind" };
|
|
229
|
-
return { kind: "none", ...whyBubblewrapFailed(output) };
|
|
211
|
+
/** Which wall would be up with `env`'s PATH, as `sandboxWith` finds - its probes run without holding the thread up. */
|
|
212
|
+
export async function sandboxWithAsync(env = process.env) {
|
|
213
|
+
if (env === process.env && probed !== undefined)
|
|
214
|
+
return probed;
|
|
215
|
+
const settled = noProbe();
|
|
216
|
+
let wall;
|
|
217
|
+
if (settled !== undefined)
|
|
218
|
+
wall = settled;
|
|
219
|
+
else if (process.platform === "darwin")
|
|
220
|
+
wall = seatbeltWall(await spawnAsync(SEATBELT_PROBE[0], SEATBELT_PROBE.slice(1), { timeout: 10_000 }));
|
|
221
|
+
else {
|
|
222
|
+
const mounted = await spawnAsync("bwrap", bubblewrapProbe("mount"), { timeout: 10_000, env });
|
|
223
|
+
wall = bubblewrapWall(mounted, needsBind(mounted) ? await spawnAsync("bwrap", bubblewrapProbe("bind"), { timeout: 10_000, env }) : undefined);
|
|
230
224
|
}
|
|
225
|
+
if (env === process.env)
|
|
226
|
+
probed = wall;
|
|
227
|
+
return wall;
|
|
228
|
+
}
|
|
229
|
+
function probe(env = process.env) {
|
|
230
|
+
const settled = noProbe();
|
|
231
|
+
if (settled !== undefined)
|
|
232
|
+
return settled;
|
|
233
|
+
if (process.platform === "darwin")
|
|
234
|
+
return seatbeltWall(spawnSync(SEATBELT_PROBE[0], SEATBELT_PROBE.slice(1), { encoding: "utf8", timeout: 10_000 }));
|
|
235
|
+
const tryProc = (proc) => spawnSync("bwrap", bubblewrapProbe(proc), { encoding: "utf8", timeout: 10_000, env });
|
|
236
|
+
const mounted = tryProc("mount");
|
|
237
|
+
return bubblewrapWall(mounted, needsBind(mounted) ? tryProc("bind") : undefined);
|
|
238
|
+
}
|
|
239
|
+
/** The wall where nothing need be run to tell: no Seatbelt to try, or a platform turri drives none on. */
|
|
240
|
+
function noProbe() {
|
|
241
|
+
if (process.platform === "darwin")
|
|
242
|
+
return existsSync("/usr/bin/sandbox-exec") ? undefined : { kind: "none", why: "this macOS has no sandbox-exec (Seatbelt)" };
|
|
243
|
+
if (process.platform === "linux")
|
|
244
|
+
return undefined;
|
|
231
245
|
if (process.platform === "win32")
|
|
232
246
|
return { kind: "none", why: "Windows has no OS sandbox turri drives: the command check and the audit hold alone" };
|
|
233
247
|
return { kind: "none", why: `${process.platform} has no OS sandbox turri drives` };
|
|
234
248
|
}
|
|
249
|
+
/** A sandbox that runs `true`, on macOS. */
|
|
250
|
+
const SEATBELT_PROBE = ["/usr/bin/sandbox-exec", "-p", "(version 1)(allow default)", "/usr/bin/true"];
|
|
251
|
+
function seatbeltWall(run) {
|
|
252
|
+
return run.status === 0 ? { kind: "seatbelt" } : { kind: "none", why: `Seatbelt did not start: ${(run.stderr || String(run.error ?? "")).trim()}` };
|
|
253
|
+
}
|
|
254
|
+
/** bubblewrap's arguments for a sandbox that runs `true`, its /proc mounted or bound. */
|
|
255
|
+
function bubblewrapProbe(proc) {
|
|
256
|
+
return ["--ro-bind", "/", "/", ...(proc === "mount" ? ["--proc", "/proc"] : ["--ro-bind", "/proc", "/proc"]), "--dev", "/dev", "--unshare-pid", "--", "true"];
|
|
257
|
+
}
|
|
258
|
+
const probeOutput = (run) => `${run.stderr ?? ""}${run.error !== undefined ? String(run.error) : ""}`;
|
|
259
|
+
/** Whether bubblewrap failed only to mount a /proc - tried again binding the host's. */
|
|
260
|
+
function needsBind(mounted) {
|
|
261
|
+
return mounted.status !== 0 && /Can't mount proc on (\/newroot)?\/proc/.test(probeOutput(mounted));
|
|
262
|
+
}
|
|
263
|
+
function bubblewrapWall(mounted, bound) {
|
|
264
|
+
if (mounted.status === 0)
|
|
265
|
+
return { kind: "bubblewrap", proc: "mount" };
|
|
266
|
+
if (bound?.status === 0)
|
|
267
|
+
return { kind: "bubblewrap", proc: "bind" };
|
|
268
|
+
return { kind: "none", ...whyBubblewrapFailed(probeOutput(mounted)) };
|
|
269
|
+
}
|
|
235
270
|
/** Whether `path` is one of the fence's roots or under one - for the tests and the audit. */
|
|
236
271
|
export function underAny(path, roots) {
|
|
237
272
|
const target = real(path);
|
|
@@ -39,6 +39,8 @@ export interface Deployment {
|
|
|
39
39
|
unfinished: boolean;
|
|
40
40
|
/** The module the spec's code lives in had nothing committed yet: the nearest one below it that had, instead. */
|
|
41
41
|
instead?: string;
|
|
42
|
+
/** When the release tool shipped it: what it started since is this release's. */
|
|
43
|
+
at?: number;
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
46
|
* Which module to deploy for a spec: the tip of its codebase - code accumulates up a requires chain, so
|
|
@@ -30,6 +30,18 @@ export declare function locateModule(moduleName: string, templateDirs: string[])
|
|
|
30
30
|
export declare function resolveRequiresChain(plainFile: string, templateDirs?: string[], buildRoot?: string,
|
|
31
31
|
/** Accept the named module without functional specs yet. Those it requires must have them. */
|
|
32
32
|
options?: ParseOptions): ChainEntry[];
|
|
33
|
+
/** Every .plain file in a project's folder, sorted - the modules a host lists. */
|
|
34
|
+
export declare function projectPlainFiles(projectDir: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* A project's .plain files in the order they are built: each after every module it requires, as its requires chain has
|
|
37
|
+
* them (`resolveRequiresChain`) - taken in the order given, by name without `files`, each brought in after what it
|
|
38
|
+
* requires. A module whose chain does not resolve - it does not parse, a module it requires is not there, a cycle - goes
|
|
39
|
+
* after what its front matter says it requires, as far as those are found. `files`, absolute or from the project: the
|
|
40
|
+
* ones to order, every other module of a chain left out; without it, every .plain file in the project's folder.
|
|
41
|
+
*/
|
|
42
|
+
export declare function requiresOrder(projectDir: string, options?: {
|
|
43
|
+
files?: string[];
|
|
44
|
+
}): string[];
|
|
33
45
|
/**
|
|
34
46
|
* Whether a .plain file is an import module - one a .plain file imports, or one kept in a `template/` folder - rather than
|
|
35
47
|
* a module of its own. It is no codebase: its reqs and definitions are built in the modules that import it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
-
import { IMPORT_DIRECTIVE, PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, readPlainSourceMetadata, withParseCache } from "../plain/parser.js";
|
|
3
|
+
import { IMPORT_DIRECTIVE, PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, readPlainSourceMetadata, REQUIRES_DIRECTIVE, withParseCache } from "../plain/parser.js";
|
|
4
4
|
import { getFrids, getInheritedFunctionalSpecs } from "../plain/spec.js";
|
|
5
5
|
import { buildDirUnder, modulesRoot, projectDirOf } from "./paths.js";
|
|
6
6
|
import { notesPath } from "./notes.js";
|
|
@@ -56,6 +56,75 @@ options = {}) {
|
|
|
56
56
|
visit(resolve(plainFile), []);
|
|
57
57
|
return chain;
|
|
58
58
|
}
|
|
59
|
+
/** Every .plain file in a project's folder, sorted - the modules a host lists. */
|
|
60
|
+
export function projectPlainFiles(projectDir) {
|
|
61
|
+
const root = resolve(projectDir);
|
|
62
|
+
try {
|
|
63
|
+
return readdirSync(root, { withFileTypes: true })
|
|
64
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(PLAIN_SOURCE_FILE_EXTENSION))
|
|
65
|
+
.map((entry) => join(root, entry.name))
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A project's .plain files in the order they are built: each after every module it requires, as its requires chain has
|
|
74
|
+
* them (`resolveRequiresChain`) - taken in the order given, by name without `files`, each brought in after what it
|
|
75
|
+
* requires. A module whose chain does not resolve - it does not parse, a module it requires is not there, a cycle - goes
|
|
76
|
+
* after what its front matter says it requires, as far as those are found. `files`, absolute or from the project: the
|
|
77
|
+
* ones to order, every other module of a chain left out; without it, every .plain file in the project's folder.
|
|
78
|
+
*/
|
|
79
|
+
export function requiresOrder(projectDir, options = {}) {
|
|
80
|
+
const root = resolve(projectDir);
|
|
81
|
+
const files = (options.files ?? projectPlainFiles(root)).map((file) => resolve(root, file));
|
|
82
|
+
const wanted = new Set(files);
|
|
83
|
+
const order = [];
|
|
84
|
+
const placed = new Set();
|
|
85
|
+
const add = (file) => {
|
|
86
|
+
if (placed.has(file))
|
|
87
|
+
return;
|
|
88
|
+
placed.add(file);
|
|
89
|
+
if (wanted.has(file))
|
|
90
|
+
order.push(file);
|
|
91
|
+
};
|
|
92
|
+
const visiting = new Set();
|
|
93
|
+
const place = (file) => {
|
|
94
|
+
if (placed.has(file) || visiting.has(file))
|
|
95
|
+
return;
|
|
96
|
+
visiting.add(file);
|
|
97
|
+
try {
|
|
98
|
+
for (const entry of resolveRequiresChain(file, [], undefined, { allowNoFunctionalities: true }))
|
|
99
|
+
add(entry.plainFile);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
let requires = [];
|
|
103
|
+
try {
|
|
104
|
+
requires = (readPlainSourceMetadata(readFileSync(file, "utf8")).metadata[REQUIRES_DIRECTIVE] ?? []).filter((name) => typeof name === "string");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Not read: nothing to put it after.
|
|
108
|
+
}
|
|
109
|
+
const dirs = resolveTemplateDirs(file);
|
|
110
|
+
for (const name of requires) {
|
|
111
|
+
try {
|
|
112
|
+
place(resolve(locateModule(name, dirs)));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Not there: nothing to put it after.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
add(file);
|
|
119
|
+
}
|
|
120
|
+
visiting.delete(file);
|
|
121
|
+
};
|
|
122
|
+
withParseCache(() => {
|
|
123
|
+
for (const file of files)
|
|
124
|
+
place(file);
|
|
125
|
+
});
|
|
126
|
+
return order;
|
|
127
|
+
}
|
|
59
128
|
/** The .plain files that import `plainFile` - read from beside it, and, kept in a `template/` folder, from the folder it is in. */
|
|
60
129
|
function importersOf(plainFile) {
|
|
61
130
|
const file = resolve(plainFile);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
@@ -130,7 +131,23 @@ export function diffTrees(before, after, context = 3) {
|
|
|
130
131
|
return gitDiff(before, after, context, ["spec.before", "spec.after"]);
|
|
131
132
|
}
|
|
132
133
|
/** git's diff of two texts, written to files of the names given. */
|
|
133
|
-
function gitDiff(before, after, context,
|
|
134
|
+
function gitDiff(before, after, context, names) {
|
|
135
|
+
if (before === after)
|
|
136
|
+
return "";
|
|
137
|
+
// The same texts, the same diff: a project looked at again - every codebase of every chain - runs git only for what changed.
|
|
138
|
+
const key = createHash("sha1").update(`${context}\0${names.join("\0")}\0${before.length}\0${before}\0${after}`).digest("hex");
|
|
139
|
+
const kept = keptDiffs.get(key);
|
|
140
|
+
if (kept !== undefined)
|
|
141
|
+
return kept;
|
|
142
|
+
const diff = gitDiffNow(before, after, context, names);
|
|
143
|
+
keptDiffs.set(key, diff);
|
|
144
|
+
while (keptDiffs.size > KEPT_DIFFS)
|
|
145
|
+
keptDiffs.delete(keptDiffs.keys().next().value);
|
|
146
|
+
return diff;
|
|
147
|
+
}
|
|
148
|
+
const keptDiffs = new Map();
|
|
149
|
+
const KEPT_DIFFS = 200;
|
|
150
|
+
function gitDiffNow(before, after, context, [from, to]) {
|
|
134
151
|
const dir = mkdtempSync(join(tmpdir(), "turri-spec-steps-"));
|
|
135
152
|
try {
|
|
136
153
|
// The names are paths - "plain/hubspot.plain" for a spec in a folder of the project: their folders first.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { functionalityCompletion, type DriftReport, type WorkLoop } from "./loop.ts";
|
|
2
|
+
/**
|
|
3
|
+
* How each module of a project stands, in one call: what `WorkLoop.drift` and `functionalityCompletion` say of it, each
|
|
4
|
+
* as they say it alone. One look at the whole project: each file parsed once, each record read once, the modules below
|
|
5
|
+
* shared by every chain they are in - where asking module by module parses and reads the chain below each again.
|
|
6
|
+
*/
|
|
7
|
+
export interface ModuleStanding {
|
|
8
|
+
/** The module's .plain file. */
|
|
9
|
+
plainFile: string;
|
|
10
|
+
module: string;
|
|
11
|
+
/** `drift(plainFile)`: what is left to regenerate in every codebase of its chain - undefined when drift could not say (`driftError`). */
|
|
12
|
+
drift?: DriftReport[];
|
|
13
|
+
driftError?: string;
|
|
14
|
+
/** `functionalityCompletion(plainFile)`: its chain's functional specs completed, one entry per chain - undefined with `completionError`. */
|
|
15
|
+
completion?: ReturnType<typeof functionalityCompletion>;
|
|
16
|
+
completionError?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface StandingOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The work loop whose drift is read - the session's, which knows its running pulses and halts. Without one, a loop of
|
|
21
|
+
* its own that starts nothing, as the codebases' records have them.
|
|
22
|
+
*/
|
|
23
|
+
loop?: WorkLoop;
|
|
24
|
+
/** The .plain files to look at. Without them, every .plain file in the project's folder. */
|
|
25
|
+
files?: string[];
|
|
26
|
+
}
|
|
27
|
+
/** Every module of a project, how it stands - see `ModuleStanding`. */
|
|
28
|
+
export declare function projectStanding(projectDir: string, options?: StandingOptions): ModuleStanding[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { PLAIN_SOURCE_FILE_EXTENSION, withParseCache } from "../plain/parser.js";
|
|
3
|
+
import { createJobRegistry } from "./jobs.js";
|
|
4
|
+
import { createWorkLoop, functionalityCompletion } from "./loop.js";
|
|
5
|
+
import { projectPlainFiles } from "./requires.js";
|
|
6
|
+
/** Every module of a project, how it stands - see `ModuleStanding`. */
|
|
7
|
+
export function projectStanding(projectDir, options = {}) {
|
|
8
|
+
const root = resolve(projectDir);
|
|
9
|
+
const files = (options.files ?? projectPlainFiles(root)).map((file) => resolve(root, file));
|
|
10
|
+
const loop = options.loop ?? createWorkLoop({ registry: createJobRegistry(), cwd: root, dryRun: true });
|
|
11
|
+
return withParseCache(() => files.map((plainFile) => {
|
|
12
|
+
const standing = { plainFile, module: basename(plainFile, PLAIN_SOURCE_FILE_EXTENSION) };
|
|
13
|
+
try {
|
|
14
|
+
standing.drift = loop.drift(plainFile);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
standing.driftError = messageOf(error);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
standing.completion = functionalityCompletion(plainFile);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
standing.completionError = messageOf(error);
|
|
24
|
+
}
|
|
25
|
+
return standing;
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function messageOf(error) {
|
|
29
|
+
return error instanceof Error ? error.message : String(error);
|
|
30
|
+
}
|
|
@@ -56,6 +56,8 @@ export declare function shownOutput(dir: string, label: string, output: string):
|
|
|
56
56
|
* harness's own bookkeeping. Installed dependencies alone can be tens of thousands of files.
|
|
57
57
|
*/
|
|
58
58
|
export declare const NOT_CODE: Set<string>;
|
|
59
|
+
/** Whether a codebase holds a file of its own - as `listFiles` counts them - stopping at the first. */
|
|
60
|
+
export declare function hasFiles(root: string): boolean;
|
|
59
61
|
export declare function listFiles(root: string): string[];
|
|
60
62
|
/** A tool that runs the tests and lets the harness see the result, rather than taking the agent's word. */
|
|
61
63
|
export declare const TEST_FILE_PATTERN: RegExp;
|