toolroll 0.9.75 → 0.9.76
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 +49 -0
- package/dist/agent-fence.js +8 -0
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/batch-checks.js +3 -1
- package/dist/builder.js +1 -1
- package/dist/coding-workspace.js +7 -5
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/first-run.js +3 -1
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +3 -1
- package/dist/operate.js +7 -1
- package/dist/planner.js +5 -1
- package/dist/project-knowledge.js +1 -1
- package/dist/project-learning.js +22 -2
- package/dist/pull-request-flow.js +4 -0
- package/dist/release-adapters.d.ts +24 -2
- package/dist/release-adapters.js +136 -10
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +64 -0
- package/dist/release-auto.d.ts +153 -0
- package/dist/release-auto.js +496 -0
- package/dist/release-cli.js +56 -2
- package/dist/release-coverage.js +1 -1
- package/dist/release.d.ts +105 -1
- package/dist/release.js +229 -9
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +44 -3
- package/dist/scout.js +5 -1
- package/dist/server/render-tasks.d.ts +10 -0
- package/dist/server/render-tasks.js +23 -13
- package/dist/server/route-table.js +3 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +8 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.js +19 -4
- package/dist/task-base.js +1 -1
- package/dist/teardown-git.d.ts +38 -10
- package/dist/teardown-git.js +894 -108
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The product's entry points: the CLI binary, the processes it starts by file name, and the browser bundles. */
|
|
2
|
+
export declare const PRODUCT_ENTRIES: readonly string[];
|
|
3
|
+
/** A specifier reached from the tests that names no repository file and no known package: `prefix` is the repository
|
|
4
|
+
* path it might name (a folder or file stem), or null when it might name anything. */
|
|
5
|
+
export type Unresolved = {
|
|
6
|
+
from: string;
|
|
7
|
+
specifier: string | null;
|
|
8
|
+
prefix: string | null;
|
|
9
|
+
};
|
|
10
|
+
export type TestInfrastructure = {
|
|
11
|
+
infra: Set<string>;
|
|
12
|
+
unresolved: Unresolved[];
|
|
13
|
+
/** What the product's entry points reach (trusted product code, by Alex's decision). */
|
|
14
|
+
product: Set<string>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The infrastructure among `files` (path → text: the tree's sources, and its tsconfig.json and package.json): reachable
|
|
18
|
+
* from a test root — every test file, every file under `test/`, every vitest config and its setup files — not itself a
|
|
19
|
+
* test, and not reachable from PRODUCT_ENTRIES; plus every specifier reached from a test root that couldn't be resolved.
|
|
20
|
+
*/
|
|
21
|
+
export declare function testInfrastructure(files: ReadonlyMap<string, string>, allPaths?: ReadonlySet<string>): TestInfrastructure;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a changed path is something an unresolved specifier might name. A computed one (no prefix) might name anything
|
|
24
|
+
* the other rules don't already trust: any source file that is neither a test nor product code.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mightName(open: Unresolved, path: string, found: TestInfrastructure): boolean;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test infrastructure, found structurally (docs/plans/auto-approve-green.md): every non-test source file that the tests
|
|
3
|
+
* or the test runner's own setup import, directly or through other files, and that the product's entry points never
|
|
4
|
+
* reach. Changing one changes how tests check things — an automatic release approval leaves that to a person.
|
|
5
|
+
*
|
|
6
|
+
* Imports are read with the TypeScript compiler (typescript-parser, a runtime dependency): `preProcessFile` for static,
|
|
7
|
+
* dynamic and `require` specifiers (comments never count), and the syntax tree for dynamic imports or requires whose
|
|
8
|
+
* specifier isn't a plain string. Specifiers resolve as the build resolves them: relative paths (`./x.js` → `./x.ts`),
|
|
9
|
+
* the project's tsconfig `paths`, and package.json `imports`. Fail closed: a specifier reached from the tests that
|
|
10
|
+
* resolves to no file and to no known package is "unresolved", with what it might name, and the caller refuses when the
|
|
11
|
+
* change touches anything it might name.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { builtinModules } from "node:module";
|
|
15
|
+
let compiler;
|
|
16
|
+
const typescript = () => compiler ??= createRequire(import.meta.url)("typescript-parser");
|
|
17
|
+
const SOURCE = /\.(?:[cm]?[jt]sx?)$/;
|
|
18
|
+
const TEST = /\.test\.[cm]?[jt]sx?$/;
|
|
19
|
+
const VITEST_CONFIG = /(^|\/)vitest\.config\.[cm]?[jt]s$/;
|
|
20
|
+
const EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "mjs", "cjs", "jsx"];
|
|
21
|
+
/** The product's entry points: the CLI binary, the processes it starts by file name, and the browser bundles. */
|
|
22
|
+
export const PRODUCT_ENTRIES = Object.freeze([
|
|
23
|
+
"src/bin.ts", "src/cli.ts", "src/controller-service.ts", "src/desktop-host.ts", "src/sandbox-launch.ts", "src/tool-launcher.ts",
|
|
24
|
+
"src/browser/app.tsx", "src/browser/terminal-app.tsx",
|
|
25
|
+
]);
|
|
26
|
+
const join = (dir, spec) => {
|
|
27
|
+
const parts = dir === "" ? [] : dir.split("/");
|
|
28
|
+
for (const part of spec.split("/")) {
|
|
29
|
+
if (part === "." || part === "")
|
|
30
|
+
continue;
|
|
31
|
+
if (part === "..") {
|
|
32
|
+
if (parts.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
parts.pop();
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
parts.push(part);
|
|
38
|
+
}
|
|
39
|
+
return parts.join("/");
|
|
40
|
+
};
|
|
41
|
+
const dirOf = (path) => path.split("/").slice(0, -1).join("/");
|
|
42
|
+
/** A repository path, tried as the build tries it (with or without its extension, or as a folder's index). */
|
|
43
|
+
function fileAt(target, files) {
|
|
44
|
+
const stem = target.replace(/\.(?:[cm]?js|jsx)$/, "");
|
|
45
|
+
const tries = [target, ...EXTENSIONS.map(ext => `${stem}.${ext}`), ...EXTENSIONS.map(ext => `${target}/index.${ext}`)];
|
|
46
|
+
return tries.find(one => files.has(one)) ?? null;
|
|
47
|
+
}
|
|
48
|
+
/** The project's resolution rules, read from its own tsconfig.json and package.json (as text in the same tree). */
|
|
49
|
+
function resolverFor(files, names, allPaths, aliases) {
|
|
50
|
+
const tsc = typescript();
|
|
51
|
+
let paths = {}, baseUrl = "";
|
|
52
|
+
const config = files.get("tsconfig.json");
|
|
53
|
+
if (config !== undefined) {
|
|
54
|
+
const parsed = tsc.parseConfigFileTextToJson("tsconfig.json", config).config;
|
|
55
|
+
paths = parsed?.compilerOptions?.paths ?? {};
|
|
56
|
+
baseUrl = join("", parsed?.compilerOptions?.baseUrl ?? ".") ?? "";
|
|
57
|
+
}
|
|
58
|
+
let pkg = {};
|
|
59
|
+
try {
|
|
60
|
+
pkg = JSON.parse(files.get("package.json") ?? "{}");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
pkg = {};
|
|
64
|
+
}
|
|
65
|
+
const packages = new Set([pkg.dependencies, pkg.devDependencies, pkg.peerDependencies, pkg.optionalDependencies].flatMap(one => Object.keys(one ?? {})));
|
|
66
|
+
// The package's own name resolves to itself: never "some installed package".
|
|
67
|
+
const own = typeof pkg.name === "string" ? pkg.name : null;
|
|
68
|
+
const builtins = new Set(builtinModules);
|
|
69
|
+
const pattern = (key, spec) => {
|
|
70
|
+
if (!key.includes("*"))
|
|
71
|
+
return key === spec ? "" : null;
|
|
72
|
+
const [head, tail] = key.split("*");
|
|
73
|
+
return spec.startsWith(head) && spec.endsWith(tail) && spec.length >= head.length + tail.length ? spec.slice(head.length, spec.length - tail.length) : null;
|
|
74
|
+
};
|
|
75
|
+
return (from, spec) => {
|
|
76
|
+
// vitest's own aliases come first, as vitest applies them.
|
|
77
|
+
for (const alias of aliases) {
|
|
78
|
+
if (spec !== alias.find && !spec.startsWith(`${alias.find}/`))
|
|
79
|
+
continue;
|
|
80
|
+
const target = join(alias.replacement, spec.slice(alias.find.length).replace(/^\//, ""));
|
|
81
|
+
const file = target === null ? null : fileAt(target, names);
|
|
82
|
+
return file !== null ? { file } : { unresolved: target };
|
|
83
|
+
}
|
|
84
|
+
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
85
|
+
const target = join(dirOf(from), spec);
|
|
86
|
+
const file = target === null ? null : fileAt(target, names);
|
|
87
|
+
if (file !== null)
|
|
88
|
+
return { file };
|
|
89
|
+
// A file that isn't source (a stylesheet, JSON, an asset) is a leaf: it imports nothing.
|
|
90
|
+
if (target !== null && allPaths.has(target))
|
|
91
|
+
return { external: true };
|
|
92
|
+
return { unresolved: target };
|
|
93
|
+
}
|
|
94
|
+
for (const [key, targets] of Object.entries(paths)) {
|
|
95
|
+
const star = pattern(key, spec);
|
|
96
|
+
if (star === null)
|
|
97
|
+
continue;
|
|
98
|
+
for (const one of targets) {
|
|
99
|
+
const target = join(baseUrl, one.replace("*", star));
|
|
100
|
+
const file = target === null ? null : fileAt(target, names);
|
|
101
|
+
if (file !== null)
|
|
102
|
+
return { file };
|
|
103
|
+
}
|
|
104
|
+
return { unresolved: join(baseUrl, (targets[0] ?? "").replace("*", star)) };
|
|
105
|
+
}
|
|
106
|
+
if (spec.startsWith("#")) {
|
|
107
|
+
for (const [key, value] of Object.entries(pkg.imports ?? {})) {
|
|
108
|
+
const star = pattern(key, spec);
|
|
109
|
+
if (star === null)
|
|
110
|
+
continue;
|
|
111
|
+
const target = typeof value === "string" ? value : typeof value?.default === "string" ? value.default : null;
|
|
112
|
+
if (target === null)
|
|
113
|
+
return { unresolved: null };
|
|
114
|
+
const path = join("", target.replace("*", star));
|
|
115
|
+
const file = path === null ? null : fileAt(path, names) ?? fileAt(path.replace(/^dist\//, "src/"), names);
|
|
116
|
+
return file !== null ? { file } : { unresolved: path };
|
|
117
|
+
}
|
|
118
|
+
return { unresolved: null };
|
|
119
|
+
}
|
|
120
|
+
const bare = spec.startsWith("node:") ? spec.slice(5) : spec;
|
|
121
|
+
const name = bare.startsWith("@") ? bare.split("/").slice(0, 2).join("/") : bare.split("/")[0];
|
|
122
|
+
if (name === own)
|
|
123
|
+
return { unresolved: null };
|
|
124
|
+
if (spec.startsWith("node:") || builtins.has(name) || packages.has(name))
|
|
125
|
+
return { external: true };
|
|
126
|
+
// A package the manifest doesn't declare: what it names can't be known here.
|
|
127
|
+
return { unresolved: null };
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** The module-loading helpers vitest offers: each takes a specifier first. */
|
|
131
|
+
const VI_LOADERS = new Set(["mock", "doMock", "unmock", "doUnmock", "importActual", "importMock"]);
|
|
132
|
+
/**
|
|
133
|
+
* Every specifier a file names: string ones (preProcessFile: static, dynamic, `require`; comments don't count); every
|
|
134
|
+
* call that loads a module from its first argument — `require`, `import()`, `createRequire(…)(…)` and any name bound to
|
|
135
|
+
* `createRequire(…)`, `vi.*`/`vitest.*` mock and import helpers — whose argument isn't a plain string is computed (`null`,
|
|
136
|
+
* or the template's literal head); and `soft`: every other relative string literal, an edge when it names a repo file.
|
|
137
|
+
*/
|
|
138
|
+
function specifiersOf(path, text) {
|
|
139
|
+
const tsc = typescript();
|
|
140
|
+
const literal = tsc.preProcessFile(text, true, true).importedFiles.map(one => one.fileName);
|
|
141
|
+
const computed = [], soft = [];
|
|
142
|
+
const source = tsc.createSourceFile(path, text, tsc.ScriptTarget.Latest, false, /x$/.test(path) ? tsc.ScriptKind.TSX : tsc.ScriptKind.TS);
|
|
143
|
+
// Names bound to createRequire(…) anywhere in the file (a loader by another name).
|
|
144
|
+
const requires = new Set(["require"]);
|
|
145
|
+
const isCreateRequire = (node) => tsc.isCallExpression(node) && ((tsc.isIdentifier(node.expression) && node.expression.text === "createRequire")
|
|
146
|
+
|| (tsc.isPropertyAccessExpression(node.expression) && node.expression.name.text === "createRequire"));
|
|
147
|
+
const bind = (node) => {
|
|
148
|
+
if (tsc.isVariableDeclaration(node) && tsc.isIdentifier(node.name) && node.initializer !== undefined && isCreateRequire(node.initializer))
|
|
149
|
+
requires.add(node.name.text);
|
|
150
|
+
if (tsc.isBinaryExpression(node) && node.operatorToken.kind === tsc.SyntaxKind.EqualsToken && tsc.isIdentifier(node.left) && isCreateRequire(node.right))
|
|
151
|
+
requires.add(node.left.text);
|
|
152
|
+
tsc.forEachChild(node, bind);
|
|
153
|
+
};
|
|
154
|
+
bind(source);
|
|
155
|
+
const loader = (callee) => callee.kind === tsc.SyntaxKind.ImportKeyword
|
|
156
|
+
|| (tsc.isIdentifier(callee) && requires.has(callee.text))
|
|
157
|
+
|| isCreateRequire(callee)
|
|
158
|
+
|| (tsc.isPropertyAccessExpression(callee) && tsc.isIdentifier(callee.expression) && (callee.expression.text === "vi" || callee.expression.text === "vitest") && VI_LOADERS.has(callee.name.text))
|
|
159
|
+
|| (tsc.isPropertyAccessExpression(callee) && callee.name.text === "require");
|
|
160
|
+
const visit = (node) => {
|
|
161
|
+
if (tsc.isCallExpression(node)) {
|
|
162
|
+
const first = node.arguments[0];
|
|
163
|
+
if (loader(node.expression) && first !== undefined) {
|
|
164
|
+
if (tsc.isStringLiteral(first) || tsc.isNoSubstitutionTemplateLiteral(first))
|
|
165
|
+
literal.push(first.text);
|
|
166
|
+
else
|
|
167
|
+
computed.push(tsc.isTemplateExpression(first) && (first.head.text.startsWith("./") || first.head.text.startsWith("../")) ? first.head.text : null);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if ((tsc.isStringLiteral(node) || tsc.isNoSubstitutionTemplateLiteral(node)) && (node.text.startsWith("./") || node.text.startsWith("../")))
|
|
171
|
+
soft.push(node.text);
|
|
172
|
+
tsc.forEachChild(node, visit);
|
|
173
|
+
};
|
|
174
|
+
visit(source);
|
|
175
|
+
return { literal, computed, soft };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The vitest configs' `resolve.alias` and `test.alias` (string keys to string paths, as an object or a list of
|
|
179
|
+
* `{ find, replacement }`), and whether any alias couldn't be read statically.
|
|
180
|
+
*/
|
|
181
|
+
function vitestAliases(files) {
|
|
182
|
+
const tsc = typescript(), aliases = [], unreadable = [];
|
|
183
|
+
for (const [path, text] of files) {
|
|
184
|
+
if (!VITEST_CONFIG.test(path))
|
|
185
|
+
continue;
|
|
186
|
+
const source = tsc.createSourceFile(path, text, tsc.ScriptTarget.Latest, false, tsc.ScriptKind.TS);
|
|
187
|
+
const text_ = (node) => node !== undefined && (tsc.isStringLiteral(node) || tsc.isNoSubstitutionTemplateLiteral(node)) ? node.text : null;
|
|
188
|
+
const visit = (node) => {
|
|
189
|
+
if (tsc.isPropertyAssignment(node) && (tsc.isIdentifier(node.name) || tsc.isStringLiteral(node.name)) && node.name.text === "alias") {
|
|
190
|
+
const value = node.initializer;
|
|
191
|
+
const add = (find, replacement) => {
|
|
192
|
+
if (find === null || replacement === null || !(replacement.startsWith("./") || replacement.startsWith("../"))) {
|
|
193
|
+
unreadable.push(path);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const target = join(dirOf(path), replacement);
|
|
197
|
+
if (target === null)
|
|
198
|
+
unreadable.push(path);
|
|
199
|
+
else
|
|
200
|
+
aliases.push({ find, replacement: target });
|
|
201
|
+
};
|
|
202
|
+
if (tsc.isObjectLiteralExpression(value)) {
|
|
203
|
+
for (const property of value.properties) {
|
|
204
|
+
if (!tsc.isPropertyAssignment(property)) {
|
|
205
|
+
unreadable.push(path);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
add(tsc.isIdentifier(property.name) || tsc.isStringLiteral(property.name) ? property.name.text : null, text_(property.initializer));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (tsc.isArrayLiteralExpression(value)) {
|
|
212
|
+
for (const element of value.elements) {
|
|
213
|
+
if (!tsc.isObjectLiteralExpression(element)) {
|
|
214
|
+
unreadable.push(path);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const field = (name) => element.properties.find((one) => tsc.isPropertyAssignment(one) && (tsc.isIdentifier(one.name) || tsc.isStringLiteral(one.name)) && one.name.text === name)?.initializer;
|
|
218
|
+
add(text_(field("find")), text_(field("replacement")));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
unreadable.push(path);
|
|
223
|
+
}
|
|
224
|
+
tsc.forEachChild(node, visit);
|
|
225
|
+
};
|
|
226
|
+
visit(source);
|
|
227
|
+
}
|
|
228
|
+
return { aliases, unreadable };
|
|
229
|
+
}
|
|
230
|
+
/** The vitest configs' setupFiles and globalSetup entries (string literals), as repository paths. */
|
|
231
|
+
function setupRoots(files, names) {
|
|
232
|
+
const tsc = typescript(), roots = [], unresolved = [];
|
|
233
|
+
for (const [path, text] of files) {
|
|
234
|
+
if (!VITEST_CONFIG.test(path))
|
|
235
|
+
continue;
|
|
236
|
+
const source = tsc.createSourceFile(path, text, tsc.ScriptTarget.Latest, false, tsc.ScriptKind.TS);
|
|
237
|
+
const visit = (node) => {
|
|
238
|
+
if (tsc.isPropertyAssignment(node) && (tsc.isIdentifier(node.name) || tsc.isStringLiteral(node.name)) && (node.name.text === "setupFiles" || node.name.text === "globalSetup")) {
|
|
239
|
+
const values = tsc.isArrayLiteralExpression(node.initializer) ? [...node.initializer.elements] : [node.initializer];
|
|
240
|
+
for (const value of values) {
|
|
241
|
+
if (!tsc.isStringLiteral(value)) {
|
|
242
|
+
unresolved.push({ from: path, specifier: null, prefix: null });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// vitest resolves them from its root: the config's own folder, or the repository root a config names.
|
|
246
|
+
const found = [dirOf(path), ""].map(dir => { const target = join(dir, value.text); return target === null ? null : fileAt(target, names); }).find(one => one !== null) ?? null;
|
|
247
|
+
if (found !== null)
|
|
248
|
+
roots.push(found);
|
|
249
|
+
else
|
|
250
|
+
unresolved.push({ from: path, specifier: value.text, prefix: join("", value.text) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
tsc.forEachChild(node, visit);
|
|
254
|
+
};
|
|
255
|
+
visit(source);
|
|
256
|
+
}
|
|
257
|
+
return { roots, unresolved };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The infrastructure among `files` (path → text: the tree's sources, and its tsconfig.json and package.json): reachable
|
|
261
|
+
* from a test root — every test file, every file under `test/`, every vitest config and its setup files — not itself a
|
|
262
|
+
* test, and not reachable from PRODUCT_ENTRIES; plus every specifier reached from a test root that couldn't be resolved.
|
|
263
|
+
*/
|
|
264
|
+
export function testInfrastructure(files, allPaths = new Set(files.keys())) {
|
|
265
|
+
const names = new Set([...files.keys()].filter(path => SOURCE.test(path)));
|
|
266
|
+
const vitest = vitestAliases(files);
|
|
267
|
+
const resolve = resolverFor(files, names, allPaths, vitest.aliases);
|
|
268
|
+
const edges = new Map(), loose = new Map();
|
|
269
|
+
for (const path of names) {
|
|
270
|
+
const { literal, computed, soft } = specifiersOf(path, files.get(path));
|
|
271
|
+
const targets = new Set(), open = [];
|
|
272
|
+
for (const spec of literal) {
|
|
273
|
+
const found = resolve(path, spec);
|
|
274
|
+
if ("file" in found)
|
|
275
|
+
targets.add(found.file);
|
|
276
|
+
else if ("unresolved" in found)
|
|
277
|
+
open.push({ from: path, specifier: spec, prefix: found.unresolved });
|
|
278
|
+
}
|
|
279
|
+
for (const head of computed)
|
|
280
|
+
open.push({ from: path, specifier: head, prefix: head === null ? null : join(dirOf(path), head) });
|
|
281
|
+
// Any other relative string that names a repository source file is an edge (a path handed to a loader by any name).
|
|
282
|
+
for (const spec of soft) {
|
|
283
|
+
const found = resolve(path, spec);
|
|
284
|
+
if ("file" in found)
|
|
285
|
+
targets.add(found.file);
|
|
286
|
+
}
|
|
287
|
+
edges.set(path, [...targets]);
|
|
288
|
+
loose.set(path, open);
|
|
289
|
+
}
|
|
290
|
+
const setup = setupRoots(files, names);
|
|
291
|
+
const testRoots = [...[...names].filter(path => TEST.test(path) || path.startsWith("test/") || VITEST_CONFIG.test(path)), ...setup.roots];
|
|
292
|
+
const reach = (roots) => {
|
|
293
|
+
const seen = new Set(), todo = [...roots];
|
|
294
|
+
while (todo.length > 0) {
|
|
295
|
+
const at = todo.pop();
|
|
296
|
+
if (seen.has(at))
|
|
297
|
+
continue;
|
|
298
|
+
seen.add(at);
|
|
299
|
+
for (const next of edges.get(at) ?? [])
|
|
300
|
+
if (!seen.has(next))
|
|
301
|
+
todo.push(next);
|
|
302
|
+
}
|
|
303
|
+
return seen;
|
|
304
|
+
};
|
|
305
|
+
const product = reach(PRODUCT_ENTRIES.filter(path => names.has(path)));
|
|
306
|
+
const tested = reach(testRoots);
|
|
307
|
+
const infra = new Set([...tested].filter(path => !TEST.test(path) && !product.has(path)));
|
|
308
|
+
const unresolved = [...setup.unresolved, ...vitest.unreadable.map(from => ({ from, specifier: null, prefix: null })), ...[...tested].flatMap(path => loose.get(path) ?? [])];
|
|
309
|
+
return { infra, unresolved, product };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Whether a changed path is something an unresolved specifier might name. A computed one (no prefix) might name anything
|
|
313
|
+
* the other rules don't already trust: any source file that is neither a test nor product code.
|
|
314
|
+
*/
|
|
315
|
+
export function mightName(open, path, found) {
|
|
316
|
+
if (open.prefix === null)
|
|
317
|
+
return SOURCE.test(path) && !TEST.test(path) && !found.product.has(path);
|
|
318
|
+
const prefix = open.prefix.replace(/\/$/, "");
|
|
319
|
+
const stem = path.replace(/\.[^/.]+$/, "");
|
|
320
|
+
return path === prefix || stem === prefix.replace(/\.(?:[cm]?js|jsx)$/, "") || path.startsWith(`${prefix}/`) || (open.specifier !== null && open.specifier.endsWith("/") === false && path.startsWith(prefix));
|
|
321
|
+
}
|
package/dist/worktree.d.ts
CHANGED
|
@@ -152,6 +152,10 @@ export declare class WorktreePool {
|
|
|
152
152
|
private readonly store;
|
|
153
153
|
private readonly options;
|
|
154
154
|
private readonly runner;
|
|
155
|
+
/** The pool's own git in a project's repository and its checkouts (making, moving and reading them): hardened against
|
|
156
|
+
* a frozen view of the repository's config and info (teardown-git.ts checkoutGit), which agents can write. An injected
|
|
157
|
+
* runner (tests' stubbed git) is used as given. */
|
|
158
|
+
private readonly hardened;
|
|
155
159
|
/** Carried to the independent storage sweep process. */
|
|
156
160
|
get root(): string;
|
|
157
161
|
constructor(store: Store, options: PoolOptions);
|
package/dist/worktree.js
CHANGED
|
@@ -32,7 +32,7 @@ import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } fr
|
|
|
32
32
|
import { parseHandoff } from "./decision.js";
|
|
33
33
|
import { diskBytes } from "./storage.js";
|
|
34
34
|
import { markNeverIndex } from "./never-index.js";
|
|
35
|
-
import { teardownGit } from "./teardown-git.js";
|
|
35
|
+
import { checkoutGit, teardownGit } from "./teardown-git.js";
|
|
36
36
|
/**
|
|
37
37
|
* The note a lease leaves in the checkout, naming the process holding it.
|
|
38
38
|
*
|
|
@@ -268,12 +268,18 @@ export class WorktreePool {
|
|
|
268
268
|
store;
|
|
269
269
|
options;
|
|
270
270
|
runner;
|
|
271
|
+
/** The pool's own git in a project's repository and its checkouts (making, moving and reading them): hardened against
|
|
272
|
+
* a frozen view of the repository's config and info (teardown-git.ts checkoutGit), which agents can write. An injected
|
|
273
|
+
* runner (tests' stubbed git) is used as given. */
|
|
274
|
+
hardened;
|
|
271
275
|
/** Carried to the independent storage sweep process. */
|
|
272
276
|
get root() { return this.options.root; }
|
|
273
277
|
constructor(store, options) {
|
|
274
278
|
this.store = store;
|
|
275
279
|
this.options = options;
|
|
276
280
|
this.runner = options.runner ?? run;
|
|
281
|
+
// Bound to the project's repository from the pool's own request or record (teardown-git.ts GitBinding).
|
|
282
|
+
this.hardened = repo => this.runner === run ? checkoutGit(run, "build", { repo }) : this.runner;
|
|
277
283
|
}
|
|
278
284
|
/**
|
|
279
285
|
* Take a working copy for this branch.
|
|
@@ -475,7 +481,7 @@ export class WorktreePool {
|
|
|
475
481
|
// checkout between the inspection and the move.
|
|
476
482
|
let base;
|
|
477
483
|
if (request.freshBase !== undefined && request.taskRef !== undefined) {
|
|
478
|
-
const fresh = await this.freshen(path, request.branch, request.taskRef, request.freshBase, request.now);
|
|
484
|
+
const fresh = await this.freshen(request.repo, path, request.branch, request.taskRef, request.freshBase, request.now);
|
|
479
485
|
if (!fresh.ok)
|
|
480
486
|
return { ok: false, reason: "git", message: fresh.message };
|
|
481
487
|
base = fresh.standing;
|
|
@@ -524,9 +530,9 @@ export class WorktreePool {
|
|
|
524
530
|
* untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
|
|
525
531
|
* again right before and after it, and any surprise fails the lease with the checkout left for a person.
|
|
526
532
|
*/
|
|
527
|
-
async freshen(path, branch, taskRef, fresh, now) {
|
|
533
|
+
async freshen(repo, path, branch, taskRef, fresh, now) {
|
|
528
534
|
const read = async (args) => {
|
|
529
|
-
const out = await this.
|
|
535
|
+
const out = await this.hardened(repo)(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
530
536
|
return out.code === 0 ? out.stdout.trim() : null;
|
|
531
537
|
};
|
|
532
538
|
const main = await read(["rev-parse", "--verify", "--quiet", `${fresh.main}^{commit}`]);
|
|
@@ -544,14 +550,14 @@ export class WorktreePool {
|
|
|
544
550
|
return stay("not on its branch");
|
|
545
551
|
if (fresh.recordedBase !== null && fresh.recordedBase !== head)
|
|
546
552
|
return stay("has commits");
|
|
547
|
-
const contained = await this.
|
|
553
|
+
const contained = await this.hardened(repo)(GIT, [...READ_ONLY, "merge-base", "--is-ancestor", head, main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
548
554
|
if (contained.code !== 0)
|
|
549
555
|
return stay("has commits");
|
|
550
556
|
if (await this.isDirty(path) !== false)
|
|
551
557
|
return stay("has changes");
|
|
552
558
|
// Recorded before git moves it: a move that then fails leaves the next attempt's own base as the branch's origin.
|
|
553
559
|
this.store.recordBranchBaseMove(taskRef, branch, head, main, now);
|
|
554
|
-
const moved = await this.
|
|
560
|
+
const moved = await this.hardened(repo)(GIT, ["reset", "--keep", main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
555
561
|
const after = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
556
562
|
const branchNow = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
|
|
557
563
|
if (moved.code !== 0 || after !== main || branchNow !== main || await this.isDirty(path) !== false) {
|
|
@@ -1052,7 +1058,7 @@ export class WorktreePool {
|
|
|
1052
1058
|
/** All disposal and inspection paths share the hardened teardown boundary. */
|
|
1053
1059
|
teardown = (cwd, args) => teardownGit(this.runner)(cwd, args);
|
|
1054
1060
|
git(cwd, args, prefix = []) {
|
|
1055
|
-
return this.
|
|
1061
|
+
return this.hardened(cwd)(GIT, [...prefix, ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
1056
1062
|
}
|
|
1057
1063
|
}
|
|
1058
1064
|
function firstLine(text) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.76",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|