toolroll 0.9.75 → 0.9.77

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -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/up-role-child.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
+ }
@@ -0,0 +1,137 @@
1
+ export type UpRoleName = "console" | "worker";
2
+ export type UpConsoleConfig = {
3
+ host: string;
4
+ port: number;
5
+ allowedHosts: string[] | null;
6
+ publicUrl: string | null;
7
+ localRunner: string;
8
+ poolRoot: string;
9
+ repos: string[];
10
+ projectRoots: string[];
11
+ registryPath: string;
12
+ editor: boolean;
13
+ };
14
+ export type UpWorkerConfig = {
15
+ runner: string;
16
+ token: string;
17
+ repos: string[];
18
+ pinnedRepos: string[];
19
+ projectRoots: string[];
20
+ registryPath: string;
21
+ pool: string;
22
+ forMs: number | null;
23
+ publicUrl: string | null;
24
+ stopGraceMs: number;
25
+ verbose: boolean;
26
+ terminal: boolean;
27
+ /** How long a start waits for a predecessor's watch lease to expire (a crashed worker's, after a restart). */
28
+ leaseWaitMs: number;
29
+ };
30
+ export type UpRoleStart = {
31
+ type: "start";
32
+ role: "console";
33
+ databaseFile: string;
34
+ containment: string | null;
35
+ build: string;
36
+ config: UpConsoleConfig;
37
+ } | {
38
+ type: "start";
39
+ role: "worker";
40
+ databaseFile: string;
41
+ containment: string | null;
42
+ build: string;
43
+ config: UpWorkerConfig;
44
+ };
45
+ export type ParentToRole = {
46
+ type: "repos";
47
+ repos: string[];
48
+ } | {
49
+ type: "stopping";
50
+ } | {
51
+ type: "hard-stop";
52
+ } | {
53
+ type: "sign-in-link";
54
+ id: number;
55
+ account: string;
56
+ };
57
+ export type RoleToParent = {
58
+ type: "ready";
59
+ role: UpRoleName;
60
+ build: string;
61
+ pid: number;
62
+ url: string | null;
63
+ port: number | null;
64
+ } | {
65
+ type: "log";
66
+ line: string;
67
+ } | {
68
+ type: "repos";
69
+ repos: string[];
70
+ } | {
71
+ type: "failed";
72
+ reason: "port-busy" | "start";
73
+ detail: string;
74
+ } | {
75
+ type: "fatal";
76
+ detail: string;
77
+ } | {
78
+ type: "sign-in-link";
79
+ id: number;
80
+ link: string | null;
81
+ } | {
82
+ type: "closed";
83
+ problem: string | null;
84
+ };
85
+ /** One role's side of the channel: the same shape over IPC (a child process) and in memory (one process). */
86
+ export type RolePort = {
87
+ send: (message: RoleToParent) => void;
88
+ onMessage: (handler: (message: ParentToRole) => void) => void;
89
+ /** First call: drain. Every later call: stop now (the second signal's hard stop). */
90
+ onStop: (handler: (hard: boolean) => void) => void;
91
+ };
92
+ /** The parent's word to a role, checked before anything acts on it. */
93
+ export declare function parentMessage(value: unknown): ParentToRole | null;
94
+ /** A role's word to the parent, checked before the parent relays or acts on it. */
95
+ export declare function roleMessage(value: unknown, role: UpRoleName): RoleToParent | null;
96
+ /** The start message a role child waits for. The console's config carries no runner token; anything else refuses. */
97
+ export declare function startMessage(value: unknown): UpRoleStart | null;
98
+ /** Two ends of an in-memory channel: one process composing both roles (tests and the desktop host), same messages. */
99
+ export declare function memoryChannel(role: UpRoleName): {
100
+ port: RolePort;
101
+ parent: {
102
+ send: (message: ParentToRole) => void;
103
+ onMessage: (handler: (message: RoleToParent) => void) => void;
104
+ stop: (hard: boolean) => void;
105
+ };
106
+ };
107
+ /** A build identity: the version and the exact code of the module that composes `up`. Both roles must report the
108
+ * parent's own, or the parent stops rather than run a console and a worker from different builds. */
109
+ export declare function buildIdentity(version: string, moduleFile: string): string;
110
+ /** How many replacements in a row may end before becoming ready, with no refusal said, before `up` stops. */
111
+ export declare const ROLE_NOT_READY_REPLACEMENTS = 3;
112
+ /**
113
+ * One supervised role's generations (Codex review round 1, finding 5): readiness is per generation, not for the role's
114
+ * whole life. When a generation ends, the verdict is:
115
+ * - `fail-start`: the role never became ready at all; the start fails as one, as before the split;
116
+ * - `restart`: this generation was ready and crashed afterwards (the supervisor starts it again);
117
+ * - `end`: a replacement refused to start (another build, another database, a start refused) — permanent, so the whole
118
+ * `up` stops rather than loop — or ROLE_NOT_READY_REPLACEMENTS replacements in a row ended before readiness.
119
+ */
120
+ export declare class RoleGenerations {
121
+ private everReady;
122
+ private generationReady;
123
+ private refusal;
124
+ private notReadyStreak;
125
+ /** A new generation was spawned: nothing about it is known yet. */
126
+ spawned(): void;
127
+ /** This generation became ready; true when it is the role's first readiness ever. */
128
+ ready(): boolean;
129
+ /** This generation refused its start for good (it said why, or its build is another). */
130
+ refused(detail: string): void;
131
+ /** What this generation said when it refused, if it did. */
132
+ refusedWith(): string | null;
133
+ /** Whether any generation ever became ready. */
134
+ wasReady(): boolean;
135
+ /** This generation ended (not stopped by `up`): what happens next. */
136
+ ended(): "fail-start" | "restart" | "end";
137
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `toolroll up` as two processes (docs/plans/isolated-checks.md, stage 1): the console (HTTP: serving, reading and the
3
+ * person's ceremonies) and the worker (the watch loops: builds, checks, batch checks, follow-ups, reconcilers, the
4
+ * storage sweep and checkout sizing). `up` itself keeps the identities it minted (the approver's login, the runner and
5
+ * its token), supervises both with controller-supervisor, and relays between them. Nothing here grants authority:
6
+ * each role is handed only what it needs, by message, never on a command line or in the environment.
7
+ *
8
+ * What each holds:
9
+ * console the database, the console's own session and sign-in secrets, the attach (instance) secret, push keys, the
10
+ * port. Never the runner token: it can't claim, lease or build.
11
+ * worker the database and the runner name and token, for the watch leases and claims. It opens no port and mints
12
+ * no console session or attach secret.
13
+ * Both are the same user on the same database folder, so files beside the database (the saved login, tool secrets)
14
+ * are reachable by either: the split keeps secrets out of each other's memory and messages, not out of the folder.
15
+ */
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import { readFileSync } from "node:fs";
18
+ const MAX_TEXT = 16_384;
19
+ const MAX_LIST = 4_096;
20
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
21
+ const text = (value, max = MAX_TEXT) => typeof value === "string" && value.length <= max;
22
+ const nonEmpty = (value, max = MAX_TEXT) => text(value, max) && value !== "" && !value.includes("\0");
23
+ const texts = (value) => Array.isArray(value) && value.length <= MAX_LIST && value.every(one => nonEmpty(one));
24
+ const whole = (value, min = 0, max = 2_147_483_647) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
25
+ const orNull = (value, check) => value === null || check(value);
26
+ /** The parent's word to a role, checked before anything acts on it. */
27
+ export function parentMessage(value) {
28
+ if (!isObject(value))
29
+ return null;
30
+ switch (value["type"]) {
31
+ case "repos": return texts(value["repos"]) ? { type: "repos", repos: [...value["repos"]] } : null;
32
+ case "stopping": return { type: "stopping" };
33
+ case "hard-stop": return { type: "hard-stop" };
34
+ case "sign-in-link": return whole(value["id"]) && nonEmpty(value["account"], 256) ? { type: "sign-in-link", id: value["id"], account: value["account"] } : null;
35
+ default: return null;
36
+ }
37
+ }
38
+ /** A role's word to the parent, checked before the parent relays or acts on it. */
39
+ export function roleMessage(value, role) {
40
+ if (!isObject(value))
41
+ return null;
42
+ switch (value["type"]) {
43
+ case "ready":
44
+ if (value["role"] !== role || !nonEmpty(value["build"], 512) || !whole(value["pid"], 1))
45
+ return null;
46
+ if (!orNull(value["url"], (one) => nonEmpty(one, 2_048)) || !orNull(value["port"], (one) => whole(one, 1, 65_535)))
47
+ return null;
48
+ // Only the console serves: a worker that names an address is not a worker.
49
+ if (role === "worker" && (value["url"] !== null || value["port"] !== null))
50
+ return null;
51
+ if (role === "console" && (value["url"] === null || value["port"] === null))
52
+ return null;
53
+ return { type: "ready", role, build: value["build"], pid: value["pid"], url: value["url"], port: value["port"] };
54
+ case "log": return text(value["line"]) ? { type: "log", line: value["line"] } : null;
55
+ case "repos": return role === "worker" && texts(value["repos"]) ? { type: "repos", repos: [...value["repos"]] } : null;
56
+ case "failed": return (value["reason"] === "port-busy" || value["reason"] === "start") && text(value["detail"]) ? { type: "failed", reason: value["reason"], detail: value["detail"] } : null;
57
+ case "fatal": return text(value["detail"]) ? { type: "fatal", detail: value["detail"] } : null;
58
+ case "sign-in-link": return role === "console" && whole(value["id"]) && orNull(value["link"], (one) => nonEmpty(one, 2_048)) ? { type: "sign-in-link", id: value["id"], link: value["link"] } : null;
59
+ case "closed": return orNull(value["problem"], (one) => text(one)) ? { type: "closed", problem: value["problem"] } : null;
60
+ default: return null;
61
+ }
62
+ }
63
+ /** The start message a role child waits for. The console's config carries no runner token; anything else refuses. */
64
+ export function startMessage(value) {
65
+ if (!isObject(value) || value["type"] !== "start" || !nonEmpty(value["databaseFile"], 4_096) || !orNull(value["containment"], (one) => nonEmpty(one, 64)) || !nonEmpty(value["build"], 512))
66
+ return null;
67
+ const config = value["config"];
68
+ if (!isObject(config))
69
+ return null;
70
+ const base = { type: "start", databaseFile: value["databaseFile"], containment: value["containment"], build: value["build"] };
71
+ if (value["role"] === "console") {
72
+ const keys = ["host", "port", "allowedHosts", "publicUrl", "localRunner", "poolRoot", "repos", "projectRoots", "registryPath", "editor"];
73
+ if (Object.keys(config).some(key => !keys.includes(key)))
74
+ return null;
75
+ if (!nonEmpty(config["host"], 253) || !whole(config["port"], 0, 65_535) || !orNull(config["allowedHosts"], texts) || !orNull(config["publicUrl"], (one) => nonEmpty(one, 2_048)) ||
76
+ !nonEmpty(config["localRunner"], 256) || !nonEmpty(config["poolRoot"], 4_096) || !texts(config["repos"]) || !texts(config["projectRoots"]) || !nonEmpty(config["registryPath"], 4_096) || typeof config["editor"] !== "boolean")
77
+ return null;
78
+ return { ...base, role: "console", config: config };
79
+ }
80
+ if (value["role"] === "worker") {
81
+ const keys = ["runner", "token", "repos", "pinnedRepos", "projectRoots", "registryPath", "pool", "forMs", "publicUrl", "stopGraceMs", "verbose", "terminal", "leaseWaitMs"];
82
+ if (Object.keys(config).some(key => !keys.includes(key)))
83
+ return null;
84
+ if (!nonEmpty(config["runner"], 256) || !nonEmpty(config["token"], 1_024) || !texts(config["repos"]) || !texts(config["pinnedRepos"]) || !texts(config["projectRoots"]) ||
85
+ !nonEmpty(config["registryPath"], 4_096) || !nonEmpty(config["pool"], 4_096) || !orNull(config["forMs"], (one) => whole(one, 1)) ||
86
+ !orNull(config["publicUrl"], (one) => nonEmpty(one, 2_048)) || !whole(config["stopGraceMs"], 0) || typeof config["verbose"] !== "boolean" || typeof config["terminal"] !== "boolean" || !whole(config["leaseWaitMs"], 0))
87
+ return null;
88
+ return { ...base, role: "worker", config: config };
89
+ }
90
+ return null;
91
+ }
92
+ /** Two ends of an in-memory channel: one process composing both roles (tests and the desktop host), same messages. */
93
+ export function memoryChannel(role) {
94
+ const toParent = [];
95
+ const toRole = [];
96
+ const stops = [];
97
+ // Delivered on a later turn, as IPC would be, and through the same checks: a role can't lean on synchronous replies.
98
+ const later = (fn) => { queueMicrotask(fn); };
99
+ return {
100
+ port: {
101
+ send: message => { const checked = roleMessage(JSON.parse(JSON.stringify(message)), role); if (checked !== null)
102
+ later(() => { for (const one of toParent)
103
+ one(checked); }); },
104
+ onMessage: handler => { toRole.push(handler); },
105
+ onStop: handler => { stops.push(handler); },
106
+ },
107
+ parent: {
108
+ send: message => { const checked = parentMessage(JSON.parse(JSON.stringify(message))); if (checked !== null)
109
+ later(() => { for (const one of toRole)
110
+ one(checked); }); },
111
+ onMessage: handler => { toParent.push(handler); },
112
+ stop: hard => { for (const one of stops)
113
+ one(hard); },
114
+ },
115
+ };
116
+ }
117
+ /** A build identity: the version and the exact code of the module that composes `up`. Both roles must report the
118
+ * parent's own, or the parent stops rather than run a console and a worker from different builds. */
119
+ export function buildIdentity(version, moduleFile) {
120
+ let digest = `unreadable-${randomUUID()}`;
121
+ try {
122
+ digest = createHash("sha256").update(readFileSync(moduleFile)).digest("hex").slice(0, 16);
123
+ }
124
+ catch { /* unique: never equal to any other identity */ }
125
+ return `${version}+${digest}`;
126
+ }
127
+ /** How many replacements in a row may end before becoming ready, with no refusal said, before `up` stops. */
128
+ export const ROLE_NOT_READY_REPLACEMENTS = 3;
129
+ /**
130
+ * One supervised role's generations (Codex review round 1, finding 5): readiness is per generation, not for the role's
131
+ * whole life. When a generation ends, the verdict is:
132
+ * - `fail-start`: the role never became ready at all; the start fails as one, as before the split;
133
+ * - `restart`: this generation was ready and crashed afterwards (the supervisor starts it again);
134
+ * - `end`: a replacement refused to start (another build, another database, a start refused) — permanent, so the whole
135
+ * `up` stops rather than loop — or ROLE_NOT_READY_REPLACEMENTS replacements in a row ended before readiness.
136
+ */
137
+ export class RoleGenerations {
138
+ everReady = false;
139
+ generationReady = false;
140
+ refusal = null;
141
+ notReadyStreak = 0;
142
+ /** A new generation was spawned: nothing about it is known yet. */
143
+ spawned() { this.generationReady = false; this.refusal = null; }
144
+ /** This generation became ready; true when it is the role's first readiness ever. */
145
+ ready() { const first = !this.everReady; this.everReady = true; this.generationReady = true; return first; }
146
+ /** This generation refused its start for good (it said why, or its build is another). */
147
+ refused(detail) { this.refusal = detail; }
148
+ /** What this generation said when it refused, if it did. */
149
+ refusedWith() { return this.refusal; }
150
+ /** Whether any generation ever became ready. */
151
+ wasReady() { return this.everReady; }
152
+ /** This generation ended (not stopped by `up`): what happens next. */
153
+ ended() {
154
+ if (!this.everReady)
155
+ return "fail-start";
156
+ if (this.refusal !== null)
157
+ return "end";
158
+ if (this.generationReady) {
159
+ this.notReadyStreak = 0;
160
+ return "restart";
161
+ }
162
+ this.notReadyStreak += 1;
163
+ return this.notReadyStreak >= ROLE_NOT_READY_REPLACEMENTS ? "end" : "restart";
164
+ }
165
+ }
@@ -0,0 +1 @@
1
+ export {};