@gate-forge/pack-playwright 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +31 -0
  2. package/dist/constants.d.ts +13 -140
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +13 -148
  5. package/dist/constants.js.map +1 -1
  6. package/dist/cypress/plugin.d.ts +53 -0
  7. package/dist/cypress/plugin.d.ts.map +1 -0
  8. package/dist/cypress/plugin.js +369 -0
  9. package/dist/cypress/plugin.js.map +1 -0
  10. package/dist/cypress/spec-scan.d.ts +39 -0
  11. package/dist/cypress/spec-scan.d.ts.map +1 -0
  12. package/dist/cypress/spec-scan.js +301 -0
  13. package/dist/cypress/spec-scan.js.map +1 -0
  14. package/dist/diagnosis.d.ts +39 -0
  15. package/dist/diagnosis.d.ts.map +1 -0
  16. package/dist/diagnosis.js +63 -0
  17. package/dist/diagnosis.js.map +1 -0
  18. package/dist/discovery/adapter-projection.d.ts +13 -0
  19. package/dist/discovery/adapter-projection.d.ts.map +1 -0
  20. package/dist/discovery/adapter-projection.js +84 -0
  21. package/dist/discovery/adapter-projection.js.map +1 -0
  22. package/dist/discovery/config-locations.d.ts +5 -0
  23. package/dist/discovery/config-locations.d.ts.map +1 -0
  24. package/dist/discovery/config-locations.js +19 -0
  25. package/dist/discovery/config-locations.js.map +1 -0
  26. package/dist/discovery/cypress-runner-adapter.d.ts +112 -0
  27. package/dist/discovery/cypress-runner-adapter.d.ts.map +1 -0
  28. package/dist/discovery/cypress-runner-adapter.js +501 -0
  29. package/dist/discovery/cypress-runner-adapter.js.map +1 -0
  30. package/dist/discovery/discover.d.ts +53 -1
  31. package/dist/discovery/discover.d.ts.map +1 -1
  32. package/dist/discovery/discover.js +290 -30
  33. package/dist/discovery/discover.js.map +1 -1
  34. package/dist/discovery/git-ignore.d.ts +13 -0
  35. package/dist/discovery/git-ignore.d.ts.map +1 -0
  36. package/dist/discovery/git-ignore.js +62 -0
  37. package/dist/discovery/git-ignore.js.map +1 -0
  38. package/dist/discovery/index.d.ts +10 -0
  39. package/dist/discovery/index.d.ts.map +1 -1
  40. package/dist/discovery/index.js +10 -0
  41. package/dist/discovery/index.js.map +1 -1
  42. package/dist/discovery/playwright-runner-adapter.d.ts +108 -0
  43. package/dist/discovery/playwright-runner-adapter.d.ts.map +1 -0
  44. package/dist/discovery/playwright-runner-adapter.js +185 -0
  45. package/dist/discovery/playwright-runner-adapter.js.map +1 -0
  46. package/dist/discovery/prepare-barrier.d.ts +522 -0
  47. package/dist/discovery/prepare-barrier.d.ts.map +1 -0
  48. package/dist/discovery/prepare-barrier.js +739 -0
  49. package/dist/discovery/prepare-barrier.js.map +1 -0
  50. package/dist/discovery/pytest-adapter.d.ts.map +1 -1
  51. package/dist/discovery/pytest-adapter.js +6 -2
  52. package/dist/discovery/pytest-adapter.js.map +1 -1
  53. package/dist/discovery/pytest-runner-adapter.d.ts +124 -0
  54. package/dist/discovery/pytest-runner-adapter.d.ts.map +1 -0
  55. package/dist/discovery/pytest-runner-adapter.js +454 -0
  56. package/dist/discovery/pytest-runner-adapter.js.map +1 -0
  57. package/dist/discovery/reconcile.d.ts +89 -11
  58. package/dist/discovery/reconcile.d.ts.map +1 -1
  59. package/dist/discovery/reconcile.js +440 -78
  60. package/dist/discovery/reconcile.js.map +1 -1
  61. package/dist/discovery/runner-env.d.ts +88 -2
  62. package/dist/discovery/runner-env.d.ts.map +1 -1
  63. package/dist/discovery/runner-env.js +142 -4
  64. package/dist/discovery/runner-env.js.map +1 -1
  65. package/dist/discovery/runner-file-scope.d.ts +114 -0
  66. package/dist/discovery/runner-file-scope.d.ts.map +1 -0
  67. package/dist/discovery/runner-file-scope.js +638 -0
  68. package/dist/discovery/runner-file-scope.js.map +1 -0
  69. package/dist/discovery/static-discovery.d.ts +49 -3
  70. package/dist/discovery/static-discovery.d.ts.map +1 -1
  71. package/dist/discovery/static-discovery.js +477 -18
  72. package/dist/discovery/static-discovery.js.map +1 -1
  73. package/dist/discovery/supervised-run.d.ts +199 -10
  74. package/dist/discovery/supervised-run.d.ts.map +1 -1
  75. package/dist/discovery/supervised-run.js +350 -39
  76. package/dist/discovery/supervised-run.js.map +1 -1
  77. package/dist/discovery/trusted-config.d.ts +180 -6
  78. package/dist/discovery/trusted-config.d.ts.map +1 -1
  79. package/dist/discovery/trusted-config.js +279 -10
  80. package/dist/discovery/trusted-config.js.map +1 -1
  81. package/dist/discovery/vitest-runner-adapter.d.ts +172 -0
  82. package/dist/discovery/vitest-runner-adapter.d.ts.map +1 -0
  83. package/dist/discovery/vitest-runner-adapter.js +607 -0
  84. package/dist/discovery/vitest-runner-adapter.js.map +1 -0
  85. package/dist/fixture/consumer-runner.d.ts +4 -0
  86. package/dist/fixture/consumer-runner.d.ts.map +1 -0
  87. package/dist/fixture/consumer-runner.js +38 -0
  88. package/dist/fixture/consumer-runner.js.map +1 -0
  89. package/dist/fixture/evidence.d.ts +27 -0
  90. package/dist/fixture/evidence.d.ts.map +1 -1
  91. package/dist/fixture/evidence.js +12 -0
  92. package/dist/fixture/evidence.js.map +1 -1
  93. package/dist/fixture/fixture.d.ts +2 -1
  94. package/dist/fixture/fixture.d.ts.map +1 -1
  95. package/dist/fixture/fixture.js +1 -17
  96. package/dist/fixture/fixture.js.map +1 -1
  97. package/dist/fixture/witness-client.d.ts +6 -174
  98. package/dist/fixture/witness-client.d.ts.map +1 -1
  99. package/dist/fixture/witness-client.js +6 -267
  100. package/dist/fixture/witness-client.js.map +1 -1
  101. package/dist/index.d.ts +9 -6
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +5 -4
  104. package/dist/index.js.map +1 -1
  105. package/dist/json.d.ts +6 -12
  106. package/dist/json.d.ts.map +1 -1
  107. package/dist/json.js +6 -17
  108. package/dist/json.js.map +1 -1
  109. package/dist/reporter/project-graph-reporter.d.ts +121 -0
  110. package/dist/reporter/project-graph-reporter.d.ts.map +1 -0
  111. package/dist/reporter/project-graph-reporter.js +132 -0
  112. package/dist/reporter/project-graph-reporter.js.map +1 -0
  113. package/dist/reporter/reporter.cjs.map +1 -1
  114. package/dist/reporter/reporter.d.ts +93 -4
  115. package/dist/reporter/reporter.d.ts.map +1 -1
  116. package/dist/reporter/reporter.js +174 -18
  117. package/dist/reporter/reporter.js.map +1 -1
  118. package/dist/runner-claims.d.ts +13 -0
  119. package/dist/runner-claims.d.ts.map +1 -0
  120. package/dist/runner-claims.js +46 -0
  121. package/dist/runner-claims.js.map +1 -0
  122. package/dist/runner-resolution.d.ts +3 -0
  123. package/dist/runner-resolution.d.ts.map +1 -0
  124. package/dist/runner-resolution.js +14 -0
  125. package/dist/runner-resolution.js.map +1 -0
  126. package/dist/supervisor/client.d.ts +23 -1
  127. package/dist/supervisor/client.d.ts.map +1 -1
  128. package/dist/supervisor/client.js +49 -0
  129. package/dist/supervisor/client.js.map +1 -1
  130. package/dist/supervisor/drain.d.ts +65 -0
  131. package/dist/supervisor/drain.d.ts.map +1 -1
  132. package/dist/supervisor/drain.js +353 -9
  133. package/dist/supervisor/drain.js.map +1 -1
  134. package/dist/supervisor/index.d.ts +7 -3
  135. package/dist/supervisor/index.d.ts.map +1 -1
  136. package/dist/supervisor/index.js +7 -3
  137. package/dist/supervisor/index.js.map +1 -1
  138. package/dist/supervisor/spool.d.ts +65 -2
  139. package/dist/supervisor/spool.d.ts.map +1 -1
  140. package/dist/supervisor/spool.js +47 -0
  141. package/dist/supervisor/spool.js.map +1 -1
  142. package/dist/surface.d.ts +6 -207
  143. package/dist/surface.d.ts.map +1 -1
  144. package/dist/surface.js +6 -264
  145. package/dist/surface.js.map +1 -1
  146. package/dist/vitest/index.d.ts +23 -0
  147. package/dist/vitest/index.d.ts.map +1 -0
  148. package/dist/vitest/index.js +356 -0
  149. package/dist/vitest/index.js.map +1 -0
  150. package/dist/vitest/reporter.d.ts +83 -0
  151. package/dist/vitest/reporter.d.ts.map +1 -0
  152. package/dist/vitest/reporter.js +211 -0
  153. package/dist/vitest/reporter.js.map +1 -0
  154. package/dist/vitest/worker-slot.d.ts +36 -0
  155. package/dist/vitest/worker-slot.d.ts.map +1 -0
  156. package/dist/vitest/worker-slot.js +51 -0
  157. package/dist/vitest/worker-slot.js.map +1 -0
  158. package/dist/witness/adapter-registry.d.ts +6 -50
  159. package/dist/witness/adapter-registry.d.ts.map +1 -1
  160. package/dist/witness/adapter-registry.js +6 -257
  161. package/dist/witness/adapter-registry.js.map +1 -1
  162. package/dist/witness/behavior-request.d.ts +6 -76
  163. package/dist/witness/behavior-request.d.ts.map +1 -1
  164. package/dist/witness/behavior-request.js +6 -273
  165. package/dist/witness/behavior-request.js.map +1 -1
  166. package/dist/witness/behavior.d.ts +6 -48
  167. package/dist/witness/behavior.d.ts.map +1 -1
  168. package/dist/witness/behavior.js +6 -114
  169. package/dist/witness/behavior.js.map +1 -1
  170. package/dist/witness/bin.d.ts +6 -17
  171. package/dist/witness/bin.d.ts.map +1 -1
  172. package/dist/witness/bin.js +6 -168
  173. package/dist/witness/bin.js.map +1 -1
  174. package/dist/witness/browser.d.ts +14 -206
  175. package/dist/witness/browser.d.ts.map +1 -1
  176. package/dist/witness/browser.js +15 -654
  177. package/dist/witness/browser.js.map +1 -1
  178. package/dist/witness/classifications.d.ts +6 -38
  179. package/dist/witness/classifications.d.ts.map +1 -1
  180. package/dist/witness/classifications.js +6 -83
  181. package/dist/witness/classifications.js.map +1 -1
  182. package/dist/witness/env-attestation.d.ts +6 -94
  183. package/dist/witness/env-attestation.d.ts.map +1 -1
  184. package/dist/witness/env-attestation.js +6 -198
  185. package/dist/witness/env-attestation.js.map +1 -1
  186. package/dist/witness/fixture-provider.d.ts +6 -82
  187. package/dist/witness/fixture-provider.d.ts.map +1 -1
  188. package/dist/witness/fixture-provider.js +6 -105
  189. package/dist/witness/fixture-provider.js.map +1 -1
  190. package/dist/witness/loopback-pins.d.ts +6 -75
  191. package/dist/witness/loopback-pins.d.ts.map +1 -1
  192. package/dist/witness/loopback-pins.js +6 -240
  193. package/dist/witness/loopback-pins.js.map +1 -1
  194. package/dist/witness/server.d.ts +8 -26
  195. package/dist/witness/server.d.ts.map +1 -1
  196. package/dist/witness/server.js +24 -3761
  197. package/dist/witness/server.js.map +1 -1
  198. package/dist/witness/types.d.ts +6 -882
  199. package/dist/witness/types.d.ts.map +1 -1
  200. package/dist/witness/types.js +9 -1
  201. package/dist/witness/types.js.map +1 -1
  202. package/examples/overlay-proof.spec.js +75 -0
  203. package/package.json +12 -3
  204. package/python/gateforge_pytest_plugin.py +462 -0
  205. package/python/__pycache__/gateforge_persistence_intents.cpython-314.pyc +0 -0
@@ -0,0 +1,739 @@
1
+ /**
2
+ * The GLOBAL native preparation freeze barrier (contract: "ONE native
3
+ * process, ONE GLOBAL prepared snapshot").
4
+ *
5
+ * Why this exists. A Playwright config's prerequisite (`setup`) projects
6
+ * produce their artifacts — the saved `playwright/.auth/user.json` of the
7
+ * standard auth pattern above all — WHILE the behavioral projects are
8
+ * already scheduled to run. Gateforge's candidate freeze, on the other
9
+ * hand, is taken BEFORE the runner starts, so a run whose setup writes
10
+ * generated bytes into the workspace always ends with a candidate tree
11
+ * that no longer equals the frozen one, and no receipt is ever sealed.
12
+ * That is the honest failure this module removes — not by excluding the
13
+ * generated bytes (that would weaken every binding), but by taking ONE
14
+ * prepared snapshot AFTER the whole prerequisite stage and BEFORE any
15
+ * body project, and then demanding the workspace stay exactly that.
16
+ *
17
+ * The shape of the barrier, and why each part exists:
18
+ *
19
+ * - ONE controller project. The engine adds a project of its own that
20
+ * depends on the FULL upstream prerequisite closure of the planned
21
+ * graph, and gives every non-prerequisite planned project that
22
+ * controller as its FIRST dependency (its own captured edges follow,
23
+ * unchanged, in their emitted order). Prerequisites keep their edges
24
+ * exactly as captured. The runner's own phase scheduler then orders
25
+ * prerequisite → controller → body inside ONE process, with the
26
+ * runner's real dependency semantics — not a second process, not a flag.
27
+ * - The controller is a real Playwright test, because the environment
28
+ * leak it has to undo happens in WORKERS: the native scheduler unions
29
+ * every dependency worker's produced environment into its dependents
30
+ * (`runner/tasks.js`), so merely emitting an empty controller project
31
+ * would hand each body the whole preparation environment.
32
+ * - A REQUEST/RELEASE handshake. The controller writes a request naming
33
+ * the exact run, invocation and per-invocation nonce; the trusted CLI
34
+ * — the only process holding supervisor credentials — validates every
35
+ * prerequisite identity against the authenticated witness execution
36
+ * trace, freezes, and answers with a SIGNED release. A request that
37
+ * never comes, an unsigned release, a release for another
38
+ * run/invocation/nonce, and a release whose bound controller spec
39
+ * bytes no longer hash to what the CLI pinned are all refusals, and a
40
+ * controller that never receives a valid release fails its own bounded
41
+ * wait instead of letting the bodies run unfrozen.
42
+ * - An ephemeral Ed25519 signature. A release is a file, and a file at
43
+ * this boundary is forgeable by any code running as the same user. The
44
+ * private key is minted per invocation and never leaves the CLI
45
+ * process — never a child environment, never a file; the controller
46
+ * receives only the public key. Same-UID modify/restore or in-process
47
+ * tampering stays OUTSIDE this boundary (this is not a filesystem
48
+ * sandbox); what the signature closes is an ordinary control-file
49
+ * forgery and any replay of an older or different release.
50
+ *
51
+ * Honesty boundary (repeated on purpose): this is a CONTROL barrier, not
52
+ * a physical one. It orders one native process, proves the prerequisites
53
+ * really passed under supervision, and proves the workspace did not move
54
+ * between the freeze and the seal. It does not make the source read-only.
55
+ */
56
+ import { createHash, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
57
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
58
+ import { tmpdir } from 'node:os';
59
+ import { join } from 'node:path';
60
+ import { fileURLToPath, pathToFileURL } from 'node:url';
61
+ /** The engine-owned controller project name (never a candidate project). */
62
+ export const FREEZE_CONTROLLER_PROJECT = 'gateforge-freeze';
63
+ /**
64
+ * Directory (inside the excluded run-state dir) holding the barrier's
65
+ * handshake DOCUMENTS — the controller's request, the CLI's signed
66
+ * release and its refusal document. They are evidence about the run,
67
+ * not test-shaped files, so they stay where the CLI pins and seals
68
+ * them.
69
+ *
70
+ * The generated controller SPEC is deliberately NOT here: a `.mjs` spec
71
+ * file inside the repository is a file a consumer's own Playwright
72
+ * configuration can collect (see {@link FREEZE_CONTROL_SPEC_FILE}).
73
+ */
74
+ export const FREEZE_CONTROL_DIR = 'native-freeze';
75
+ /**
76
+ * The generated controller spec: the ONE worker that performs the handshake.
77
+ *
78
+ * Plain ESM JavaScript under an `.mjs` name, deliberately. The runner
79
+ * decides a file's module kind from its extension first and only then from
80
+ * the nearest `package.json` `type`, and a consumer repository may sit
81
+ * under EITHER: a CommonJS/default root would have this spec `require`d,
82
+ * which turns its ESM imports into `require()` of a `file:` URL and fails
83
+ * to load at all. `.mjs` fixes the module kind on the file itself, so the
84
+ * controller's imports resolve as real ESM imports whatever the
85
+ * consumer's root package says — no manifest inspection, no install
86
+ * restriction, and no candidate module ever loaded by the CLI. The
87
+ * consequence is that the generated bytes must stay plain JavaScript:
88
+ * nothing TypeScript-only may reappear in the template below.
89
+ *
90
+ * WHERE the file is written follows from what it IS: a parseable test
91
+ * file. Inside the repository it is a candidate the consumer's own
92
+ * runner collects — a config at the repo root with no restricting
93
+ * `testDir` enumerates every parseable file below it, so an engine spec
94
+ * parked in the run-state subtree was still enumerated as a consumer
95
+ * case, by the engine's own `--list` and by the owner's `npx playwright
96
+ * test` alike. It is therefore written into a private per-run directory
97
+ * OUTSIDE the repository ({@link FreezeControl.specDir}) and removed
98
+ * once the run no longer reads it.
99
+ */
100
+ export const FREEZE_CONTROL_SPEC_FILE = 'freeze-controller.spec.mjs';
101
+ /** The controller's request document (written by the controller worker). */
102
+ export const FREEZE_REQUEST_FILE = 'freeze-request.json';
103
+ /** The CLI's signed release document (read by the controller worker). */
104
+ export const FREEZE_RELEASE_FILE = 'freeze-release.json';
105
+ /**
106
+ * The trusted CLI's REFUSAL document (read by the controller worker).
107
+ *
108
+ * The handshake needs a failure channel as well as a success one, or a
109
+ * permanent refusal turns into a bounded-but-slow timeout: the trusted
110
+ * side answers "your prerequisite sealed as failed", and the controller
111
+ * should stop waiting the moment it hears that, not poll to its own long
112
+ * bound. This file is FAILURE ONLY. Nothing here can ever unblock a body
113
+ * project — only a correctly signed `FREEZE_RELEASE_FILE` does — so a
114
+ * controller that trusts this document can only ever fail earlier, never
115
+ * proceed. A forged refusal therefore costs a run its barrier, which the
116
+ * signing already guarantees; a missing one costs nothing.
117
+ */
118
+ export const FREEZE_REFUSAL_FILE = 'freeze-refusal.json';
119
+ /**
120
+ * Environment names the runner itself owns in every worker process, and
121
+ * which the projection must therefore never touch.
122
+ *
123
+ * The reason is structural, read from the runner itself:
124
+ * `worker/workerMain.js` assigns `TEST_WORKER_INDEX` and
125
+ * `TEST_PARALLEL_INDEX` AFTER the process booted, and
126
+ * `runner/workerHost.js` spreads `FORCE_COLOR` and `DEBUG_COLORS` into
127
+ * every worker's environment at spawn. `lib/common/process.js` computes
128
+ * the produced-environment diff against the worker's OWN boot
129
+ * environment, so all four names appear in a produced diff without any
130
+ * prerequisite having written them — a subtraction reset that touched
131
+ * them would report a deletion the bodies then inherit.
132
+ *
133
+ * The reset set is otherwise derived BY SUBTRACTION — every key in the
134
+ * controller's environment or in the trusted baseline, minus these four.
135
+ * That is equivalent to a positively-owned namespace ONLY under the
136
+ * invariant below, and the invariant is the real risk: nothing in the
137
+ * run pins it, so a future change that starts injecting environment into
138
+ * workers beyond these names would be silently projected away rather
139
+ * than reported.
140
+ *
141
+ * Gateforge injects nothing into worker environment beyond the runner
142
+ * child's own baseline: `buildRunnerChildEnv` refuses run-state paths
143
+ * and signing material, and the freeze control reaches the controller
144
+ * inlined in its generated spec rather than through the environment.
145
+ * Under that invariant the worker's environment is the baseline, plus
146
+ * preparation-written keys, plus these four names — so the reset set is
147
+ * exactly the baseline keys a prerequisite overrode (restored) and the
148
+ * keys a prerequisite introduced (deleted), whatever they are named.
149
+ */
150
+ export const FREEZE_ENV_EXEMPT = [
151
+ 'TEST_WORKER_INDEX',
152
+ 'TEST_PARALLEL_INDEX',
153
+ 'FORCE_COLOR',
154
+ 'DEBUG_COLORS',
155
+ ];
156
+ /**
157
+ * Deterministic canonical JSON: object keys sorted, no insignificant
158
+ * whitespace. The CLI signs exactly these bytes and the controller
159
+ * verifies exactly these bytes, so neither side may depend on a property
160
+ * order or a serializer default.
161
+ *
162
+ * @param value: any JSON-compatible value.
163
+ *
164
+ * @returns
165
+ * string: the canonical form.
166
+ */
167
+ export function canonicalFreezeJson(value) {
168
+ if (value === null || typeof value !== 'object')
169
+ return JSON.stringify(value) ?? 'null';
170
+ if (Array.isArray(value))
171
+ return `[${value.map((entry) => canonicalFreezeJson(entry)).join(',')}]`;
172
+ const record = value;
173
+ return `{${Object.keys(record)
174
+ .sort()
175
+ .map((key) => `${JSON.stringify(key)}:${canonicalFreezeJson(record[key])}`)
176
+ .join(',')}}`;
177
+ }
178
+ /**
179
+ * Mints one ephemeral Ed25519 key pair for the release signature. The
180
+ * private key is returned to the CLI and NEVER written anywhere: not the
181
+ * trusted config, not the run state, not a child environment.
182
+ *
183
+ * @returns
184
+ * FreezeSigningKeyPair: the pair, with the public key already
185
+ * base64-encoded as raw SPKI.
186
+ */
187
+ export function mintFreezeSigningKeyPair() {
188
+ const pair = generateKeyPairSync('ed25519');
189
+ const spki = pair.publicKey.export({ format: 'der', type: 'spki' });
190
+ return {
191
+ publicKey: pair.publicKey,
192
+ publicKeyBase64: spki.toString('base64'),
193
+ privateKey: pair.privateKey,
194
+ };
195
+ }
196
+ /**
197
+ * Signs one release payload with the invocation's ephemeral private key.
198
+ *
199
+ * @param privateKey: the ephemeral key (CLI memory only).
200
+ * @param payload: the payload to sign.
201
+ *
202
+ * @returns
203
+ * FreezeReleaseDocument: the document the controller verifies.
204
+ */
205
+ export function signFreezeRelease(privateKey, payload) {
206
+ const signature = sign(null, Buffer.from(canonicalFreezeJson(payload), 'utf8'), privateKey);
207
+ return { schemaVersion: 1, payload, signature: signature.toString('base64') };
208
+ }
209
+ /**
210
+ * Verifies one release document against the pinned public key and the
211
+ * exact identities the controller itself was armed with.
212
+ *
213
+ * A release passes only when ALL of these hold: the document shape, the
214
+ * Ed25519 signature under the pinned key, the controller project name, the
215
+ * run, the invocation, the per-invocation nonce, and the sha256 of the
216
+ * controller's OWN current spec bytes. The last check is what makes a
217
+ * replaced control file detectable rather than trusted.
218
+ *
219
+ * @param document: the parsed release document.
220
+ * @param publicKeyBase64: the pinned base64 raw SPKI public key.
221
+ * @param expected: the identities and spec digest the controller was armed with.
222
+ *
223
+ * @returns
224
+ * {ok: true, payload} | {ok: false, reason}: the verified payload, or the
225
+ * single plain reason the release was refused.
226
+ */
227
+ export function verifyFreezeRelease(document, publicKeyBase64, expected) {
228
+ if (typeof document !== 'object' || document === null) {
229
+ return { ok: false, reason: 'the freeze release is not an object' };
230
+ }
231
+ const row = document;
232
+ if (row['schemaVersion'] !== 1 || typeof row['signature'] !== 'string') {
233
+ return { ok: false, reason: 'the freeze release is missing its signature' };
234
+ }
235
+ const payload = row['payload'];
236
+ if (typeof payload !== 'object' || payload === null) {
237
+ return { ok: false, reason: 'the freeze release carries no payload' };
238
+ }
239
+ const fields = payload;
240
+ if (fields['schemaVersion'] !== 1) {
241
+ return { ok: false, reason: 'the freeze release payload has an unknown schema version' };
242
+ }
243
+ for (const key of ['project', 'runId', 'invocationId', 'nonce', 'preparedTreeId', 'specDigest', 'sealedAt']) {
244
+ if (typeof fields[key] !== 'string' || fields[key].length === 0) {
245
+ return { ok: false, reason: `the freeze release payload has no ${key}` };
246
+ }
247
+ }
248
+ let signatureOk = false;
249
+ try {
250
+ signatureOk = verify(null, Buffer.from(canonicalFreezeJson(fields), 'utf8'), createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), format: 'der', type: 'spki' }), Buffer.from(row['signature'], 'base64'));
251
+ }
252
+ catch {
253
+ signatureOk = false;
254
+ }
255
+ if (!signatureOk) {
256
+ return { ok: false, reason: 'the freeze release is unsigned or signed by another key' };
257
+ }
258
+ if (fields['project'] !== expected.project) {
259
+ return { ok: false, reason: 'the freeze release names another controller project' };
260
+ }
261
+ if (fields['runId'] !== expected.runId || fields['invocationId'] !== expected.invocationId) {
262
+ return { ok: false, reason: 'the freeze release belongs to another run or invocation' };
263
+ }
264
+ if (fields['nonce'] !== expected.nonce) {
265
+ return { ok: false, reason: 'the freeze release was minted for another nonce (a replayed release)' };
266
+ }
267
+ if (fields['specDigest'] !== expected.specDigest) {
268
+ return { ok: false, reason: 'the controller control spec no longer matches the bytes the run pinned' };
269
+ }
270
+ return { ok: true, payload: fields };
271
+ }
272
+ /**
273
+ * Projects one worker environment back to the trusted baseline the runner
274
+ * child started with.
275
+ *
276
+ * The native scheduler unions every dependency worker's produced
277
+ * environment into its dependents, so a body worker inherits whatever a
278
+ * prerequisite worker assigned — including a deleted key, which the
279
+ * scheduler carries as `undefined` and Node then simply omits from the
280
+ * child. This restores exactly that difference: every name the trusted
281
+ * baseline OWNS goes back to its own value, and every name the baseline
282
+ * never owned is removed. The keys Playwright's own worker bootstrap
283
+ * writes ({@link FREEZE_ENV_EXEMPT}) are left alone — they are the
284
+ * runner's, not a prerequisite's.
285
+ *
286
+ * Both sides are read by OWN membership, never through an inherited
287
+ * lookup, because the names at stake are ordinary environment names the
288
+ * runner itself spawns workers with: `constructor`, `toString` and
289
+ * `__proto__` are each a property every plain object inherits, so a
290
+ * baseline read as `base[key]` reports those as owned values of `Object`,
291
+ * `Object.prototype.toString` and `Object.prototype` itself and hands
292
+ * the body worker that instead of deleting what a prerequisite
293
+ * introduced. No prefix restriction avoids that, and none is acceptable.
294
+ *
295
+ * The restore side is therefore a `defineProperty`, not an assignment:
296
+ * on an ordinary object an assignment to `__proto__` does not create an
297
+ * own key at all — the inherited accessor swallows it — so a baseline
298
+ * name a prerequisite deleted could never be put back.
299
+ *
300
+ * @param current: the controller worker's own environment (mutated in place).
301
+ * @param base: the trusted runner child's baseline environment.
302
+ * @param exempt: names the projection must not touch.
303
+ *
304
+ * @returns
305
+ * BaselineEnvProjection: what the projection changed, sorted, for the
306
+ * controller's own diagnostic.
307
+ */
308
+ export function projectBaselineEnv(current, base, exempt = FREEZE_ENV_EXEMPT) {
309
+ const skip = new Set(exempt);
310
+ const restored = [];
311
+ const deleted = [];
312
+ // One value descriptor for the whole projection, allocated on the first
313
+ // restoration and reused: every name needs the same configurable,
314
+ // writable, enumerable shape, and one descriptor avoids a per-key
315
+ // allocation in the one worker that runs on this path.
316
+ let descriptor;
317
+ for (const key of Object.keys(base)) {
318
+ if (skip.has(key))
319
+ continue;
320
+ const want = base[key];
321
+ if (Object.hasOwn(current, key) && current[key] === want)
322
+ continue;
323
+ if (descriptor === undefined) {
324
+ descriptor = { value: want, writable: true, enumerable: true, configurable: true };
325
+ }
326
+ else {
327
+ descriptor.value = want;
328
+ }
329
+ Object.defineProperty(current, key, descriptor);
330
+ restored.push(key);
331
+ }
332
+ // `Object.keys` is own-only, so a name the baseline never owned is an
333
+ // OWN entry a prerequisite introduced, whatever its value: an own entry
334
+ // the scheduler carries as `undefined` is still an entry no baseline
335
+ // ever had, and the worker must not keep it. A name that is only
336
+ // inherited is not an entry and is never reached here.
337
+ for (const key of Object.keys(current)) {
338
+ if (skip.has(key) || Object.hasOwn(base, key))
339
+ continue;
340
+ delete current[key];
341
+ deleted.push(key);
342
+ }
343
+ restored.sort();
344
+ deleted.sort();
345
+ return { restored, deleted };
346
+ }
347
+ /**
348
+ * Resolves the engine barrier module the generated controller imports.
349
+ * Absolute and pack-relative for the same reason the trusted reporter
350
+ * entry is: a generated worker spec must load an ENGINE file by absolute
351
+ * path, never a candidate-relative one. Resolves from both the `src/` and
352
+ * the `dist/` layout.
353
+ *
354
+ * @param fromModule: module URL to resolve the pack from (default: this file).
355
+ *
356
+ * @returns
357
+ * string: absolute `<pack>/dist|src/discovery/prepare-barrier.js` path.
358
+ */
359
+ export function barrierModulePath(fromModule = import.meta.url) {
360
+ return fileURLToPath(new URL('./prepare-barrier.js', fromModule));
361
+ }
362
+ /**
363
+ * The controller worker's own bytes.
364
+ *
365
+ * The generated spec imports the ENGINE barrier module by absolute path so
366
+ * the canonical payload form, the signature verification and the
367
+ * environment projection are ONE implementation on both sides — never a
368
+ * second copy that can drift from the one the CLI signed.
369
+ *
370
+ * The armed control reaches the worker as a JSON document bound through
371
+ * `JSON.parse`, never as an object literal inlined in these bytes: the
372
+ * baseline is a map of ordinary environment NAMES, so a name like
373
+ * `__proto__` has to arrive as an own key, and a value has to arrive
374
+ * escaped. `JSON.parse` is the one binding that gives both — it creates
375
+ * data properties — while a literal would be re-read by the JavaScript
376
+ * parser: a backtick or a `${` in a value would end the literal, and
377
+ * `__proto__:` in a literal is a PROTOTYPE assignment, not a key.
378
+ *
379
+ * @param control: the armed control (paths and pinned identities).
380
+ *
381
+ * @returns
382
+ * string: the generated plain-ESM JavaScript source of the controller spec.
383
+ */
384
+ export function freezeControllerSpecSource(control) {
385
+ const armed = {
386
+ project: control.project,
387
+ runId: control.runId,
388
+ invocationId: control.invocationId,
389
+ nonce: control.nonce,
390
+ requestPath: control.requestPath,
391
+ refusalPath: control.refusalPath,
392
+ releasePath: control.releasePath,
393
+ specPath: control.specPath,
394
+ releasePublicKey: control.releasePublicKey,
395
+ timeoutMs: control.timeoutMs,
396
+ baseEnv: control.baseEnv,
397
+ exemptEnv: control.exemptEnv,
398
+ };
399
+ return `// GENERATED by gateforge trusted supervision — the GLOBAL native
400
+ // preparation freeze controller. Never edit: the CLI pinned these exact
401
+ // bytes before the run and the signed release binds their sha256.
402
+ //
403
+ // Plain ESM JavaScript, loaded as an ES module by the runner's own
404
+ // extension rule. The imports below are therefore real ESM imports and
405
+ // must never be written as require() calls or as TypeScript-only syntax.
406
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
407
+ import { createHash } from 'node:crypto';
408
+ import nativeRunner from ${JSON.stringify(pathToFileURL(control.testModulePath).href)};
409
+ import {
410
+ canonicalFreezeJson,
411
+ projectBaselineEnv,
412
+ verifyFreezeRelease,
413
+ } from ${JSON.stringify(control.barrierModulePath)};
414
+
415
+ // The runner's own playwright/test module is CommonJS, and it assembles
416
+ // its exports at RUN time, so Node's static named-export detection finds
417
+ // none of them and a named import cannot LOAD the file at all. The
418
+ // ordinary ESM binding for a CommonJS module is its default export,
419
+ // which is that module object itself: the runner and its assertions as
420
+ // members. It is the same file, from the same install, that every body
421
+ // in this run executes against — no second module, no other version.
422
+ const { test, expect } = nativeRunner;
423
+
424
+ const ARMED = JSON.parse(${JSON.stringify(JSON.stringify(armed))});
425
+
426
+ test('gateforge global preparation freeze', async () => {
427
+ test.setTimeout(ARMED.timeoutMs + 60000);
428
+ writeFileSync(
429
+ ARMED.requestPath,
430
+ canonicalFreezeJson({
431
+ schemaVersion: 1,
432
+ project: ARMED.project,
433
+ runId: ARMED.runId,
434
+ invocationId: ARMED.invocationId,
435
+ nonce: ARMED.nonce,
436
+ requestedAt: new Date().toISOString(),
437
+ }) + '\\n',
438
+ 'utf8',
439
+ );
440
+ // Wait for EITHER outcome. A refusal is failure-only: it can end this
441
+ // wait early but never unblock it, so a permanent refusal (a
442
+ // prerequisite that sealed failed, skipped, retried or duplicated)
443
+ // fails the controller within the poll interval instead of running out
444
+ // the whole bound. A forged refusal costs the run its barrier, which
445
+ // the signed release already guarantees; a missing one costs nothing.
446
+ await expect
447
+ .poll(
448
+ () => (existsSync(ARMED.releasePath) ? 'released' : existsSync(ARMED.refusalPath) ? 'refused' : 'waiting'),
449
+ { timeout: ARMED.timeoutMs, intervals: [25] },
450
+ )
451
+ .not.toBe('waiting');
452
+ // A release document that EXISTS is always read and verified, even when
453
+ // a refusal document sits beside it. The signature is the only thing
454
+ // that can unblock a body, so a present release must produce its OWN
455
+ // verdict — an unsigned document, one signed by another key and a
456
+ // replayed document from an earlier invocation each have a distinct,
457
+ // precise reason, and consulting the refusal first would mask all three
458
+ // behind the refusal's wording. The refusal is read only when there is
459
+ // no release at all, which is the ordinary refusal path.
460
+ if (existsSync(ARMED.releasePath)) {
461
+ let release = null;
462
+ try {
463
+ release = JSON.parse(readFileSync(ARMED.releasePath, 'utf8'));
464
+ } catch (error) {
465
+ expect(false, 'the freeze release is not readable JSON: ' + String(error)).toBe(true);
466
+ }
467
+ const verified = verifyFreezeRelease(release, ARMED.releasePublicKey, {
468
+ project: ARMED.project,
469
+ runId: ARMED.runId,
470
+ invocationId: ARMED.invocationId,
471
+ nonce: ARMED.nonce,
472
+ specDigest: createHash('sha256').update(readFileSync(ARMED.specPath)).digest('hex'),
473
+ });
474
+ expect(verified.ok, verified.ok ? '' : verified.reason).toBe(true);
475
+ } else {
476
+ const refusal = JSON.parse(readFileSync(ARMED.refusalPath, 'utf8'));
477
+ throw new Error(
478
+ 'the trusted side refused the prepared candidate: ' +
479
+ (typeof refusal.reason === 'string' ? refusal.reason : 'no reason given'),
480
+ );
481
+ }
482
+ // The environment projection is the whole reason this worker exists:
483
+ // the native scheduler unions every dependency worker's produced
484
+ // environment into its dependents, so a prerequisite's assignment
485
+ // would otherwise reach every body. Assert the RESULT, not that the
486
+ // projection ran — after it, every non-exempt name must carry exactly
487
+ // the baseline value and nothing the baseline never had may remain.
488
+ // OWN membership and OWN value on both sides: a name only one side owns
489
+ // is a difference whatever an inherited lookup would return for it, and
490
+ // an inherited property is never a baseline value.
491
+ projectBaselineEnv(process.env, ARMED.baseEnv, ARMED.exemptEnv);
492
+ const exempt = new Set(ARMED.exemptEnv);
493
+ const drifted = [...new Set([...Object.keys(process.env), ...Object.keys(ARMED.baseEnv)])]
494
+ .filter((key) => !exempt.has(key))
495
+ .filter(
496
+ (key) =>
497
+ Object.hasOwn(process.env, key) !== Object.hasOwn(ARMED.baseEnv, key) ||
498
+ process.env[key] !== ARMED.baseEnv[key],
499
+ )
500
+ .sort();
501
+ expect(drifted, 'the worker environment still differs from the trusted baseline: ' + drifted.join(', ')).toEqual([]);
502
+ });
503
+ `;
504
+ }
505
+ /**
506
+ * Writes the controller spec and returns the armed barrier plus the sha256
507
+ * of the exact bytes written. The spec is GENERATED engine code and the
508
+ * only worker that performs the handshake, and its bytes are pinned in
509
+ * CLI memory for the whole run; because it is a parseable test file it is
510
+ * written OUTSIDE the repository, into a private per-run directory
511
+ * ({@link FreezeControl.specDir}), while the handshake documents stay in
512
+ * the excluded run-state subtree.
513
+ *
514
+ * @param input: the identities, paths, public key and baseline environment.
515
+ *
516
+ * @returns
517
+ * ArmedFreezeControl: the control (with absolute paths) and its spec digest.
518
+ */
519
+ export function armFreezeControl(input) {
520
+ const controlDir = join(input.stateDir, FREEZE_CONTROL_DIR);
521
+ mkdirSync(controlDir, { recursive: true, mode: 0o700 });
522
+ // Owner-only. The control directory holds the pinned public key, the
523
+ // controller's request, the CLI's signed release and the refusal
524
+ // document; nothing else on this host has a reason to read them, and
525
+ // the default 0o755/0o644 would publish the baseline allowlisted
526
+ // variables the generated spec embeds. The private signing key is
527
+ // NEVER written here — it stays in the CLI process's memory.
528
+ chmodSync(controlDir, 0o700);
529
+ // The spec is a TEST file, so it cannot live inside the repository: a
530
+ // consumer configuration whose `testDir` covers the repo root collects
531
+ // every parseable file below it, and would enumerate the engine's own
532
+ // controller as a candidate case — through the engine's `--list` as
533
+ // well as through the owner's own `npx playwright test`. A fresh
534
+ // `mkdtemp` directory under the host temporary directory keeps it out
535
+ // of the candidate entirely and gives every invocation its own, so
536
+ // one run can never read another's spec.
537
+ //
538
+ // Resolved through `realpath` because a temporary directory is
539
+ // commonly a symlink (`/tmp` → `/private/tmp`): the runner reports
540
+ // the spec under the `testDir` it was handed, and both the reporter's
541
+ // exclusion and the CLI's own digest checks compare resolved paths.
542
+ const specDir = realpathSync(mkdtempSync(join(tmpdir(), 'gateforge-freeze-')));
543
+ // `mkdtempSync` already creates the directory 0700; the mode is
544
+ // asserted rather than assumed, so the guarantee does not rest on a
545
+ // default this module does not control.
546
+ chmodSync(specDir, 0o700);
547
+ const control = {
548
+ project: FREEZE_CONTROLLER_PROJECT,
549
+ runId: input.runId,
550
+ invocationId: input.invocationId,
551
+ nonce: input.nonce,
552
+ controlDir,
553
+ specDir,
554
+ specPath: join(specDir, FREEZE_CONTROL_SPEC_FILE),
555
+ requestPath: join(controlDir, FREEZE_REQUEST_FILE),
556
+ releasePath: join(controlDir, FREEZE_RELEASE_FILE),
557
+ refusalPath: join(controlDir, FREEZE_REFUSAL_FILE),
558
+ testModulePath: input.testModulePath,
559
+ barrierModulePath: barrierModulePath(),
560
+ releasePublicKey: input.releasePublicKey,
561
+ timeoutMs: input.timeoutMs,
562
+ baseEnv: { ...input.baseEnv },
563
+ exemptEnv: [...FREEZE_ENV_EXEMPT],
564
+ };
565
+ // A previous invocation's handshake must not be visible to this one: a
566
+ // stale request would be served before this run's controller ever asks,
567
+ // and a stale release or refusal would be read by a controller that
568
+ // never asked. The spec has no such history — it was just generated
569
+ // into a directory this invocation created.
570
+ for (const path of [control.requestPath, control.releasePath, control.refusalPath]) {
571
+ rmSync(path, { force: true });
572
+ }
573
+ const source = freezeControllerSpecSource(control);
574
+ try {
575
+ writeFileSync(control.specPath, source, { encoding: 'utf8', mode: 0o600 });
576
+ }
577
+ catch (error) {
578
+ // An armed barrier that never got its spec is not a barrier: leave
579
+ // no directory behind for a run that is about to fail anyway.
580
+ rmSync(specDir, { recursive: true, force: true });
581
+ throw error;
582
+ }
583
+ return {
584
+ control,
585
+ specDigest: createHash('sha256').update(source).digest('hex'),
586
+ };
587
+ }
588
+ /**
589
+ * Removes the private per-run directory holding the generated controller
590
+ * spec, and with it the generated engine file itself.
591
+ *
592
+ * The CLI calls this once the spec has been read for the LAST time — the
593
+ * final pinned-digest check — and on every path that never reaches that
594
+ * check, so no generated spec outlives the run that produced it and no
595
+ * consumer enumeration can ever see one. Idempotent: a second call, or a
596
+ * call for a directory something else already removed, removes nothing.
597
+ *
598
+ * @param control: the armed control.
599
+ */
600
+ export function removeFreezeSpecDir(control) {
601
+ rmSync(control.specDir, { recursive: true, force: true });
602
+ }
603
+ /**
604
+ * Writes the trusted side's REFUSAL document for one handshake. It is
605
+ * failure-only by construction: no code path in the controller treats
606
+ * its presence as permission to continue, so a refusal can only shorten a
607
+ * wait, never satisfy one.
608
+ *
609
+ * @param control: the armed control.
610
+ * @param reason: the plain refusal text the controller surfaces.
611
+ */
612
+ export function writeFreezeRefusal(control, reason) {
613
+ writeFileSync(control.refusalPath, `${canonicalFreezeJson({
614
+ schemaVersion: 1,
615
+ project: control.project,
616
+ runId: control.runId,
617
+ invocationId: control.invocationId,
618
+ nonce: control.nonce,
619
+ reason,
620
+ })}\n`, { encoding: 'utf8', mode: 0o600 });
621
+ }
622
+ /**
623
+ * Reads the controller's request document, or null when it is absent or
624
+ * unusable. An absent request is ordinary (the controller has not asked
625
+ * yet); a MALFORMED one is a refusal the trusted side reports.
626
+ *
627
+ * @param path: absolute request document path.
628
+ *
629
+ * @returns
630
+ * FreezeRequest | null: the parsed request, or null.
631
+ */
632
+ export function readFreezeRequest(path) {
633
+ let raw;
634
+ try {
635
+ raw = readFileSync(path, 'utf8');
636
+ }
637
+ catch {
638
+ return null;
639
+ }
640
+ let parsed;
641
+ try {
642
+ parsed = JSON.parse(raw);
643
+ }
644
+ catch {
645
+ return null;
646
+ }
647
+ if (typeof parsed !== 'object' || parsed === null)
648
+ return null;
649
+ const row = parsed;
650
+ if (row['schemaVersion'] !== 1)
651
+ return null;
652
+ // Every identity is NARROWED, not asserted: a document the controller
653
+ // wrote (or anything else sharing its writable path) is untrusted input,
654
+ // so a missing or non-string identity makes the whole request unusable
655
+ // rather than a field of `unknown` slipping into the frozen handshake.
656
+ const identity = (key) => {
657
+ const value = row[key];
658
+ return typeof value === 'string' && value.length > 0 ? value : null;
659
+ };
660
+ const project = identity('project');
661
+ const runId = identity('runId');
662
+ const invocationId = identity('invocationId');
663
+ const nonce = identity('nonce');
664
+ if (project === null || runId === null || invocationId === null || nonce === null)
665
+ return null;
666
+ return {
667
+ schemaVersion: 1,
668
+ project,
669
+ runId,
670
+ invocationId,
671
+ nonce,
672
+ requestedAt: typeof row['requestedAt'] === 'string' ? row['requestedAt'] : '',
673
+ };
674
+ }
675
+ /**
676
+ * Rewrites the planned project scopes into the freeze-aware shape the
677
+ * synthesized config emits.
678
+ *
679
+ * The rules, in the order they matter:
680
+ *
681
+ * - The engine's controller project is appended as one more scope. It
682
+ collects its generated spec from its OWN `testDir` — the private
683
+ per-run directory outside the repository the spec was written into,
684
+ which the consumer's own configuration can never collect — and
685
+ depends on the FULL upstream prerequisite closure the trusted CLI
686
+ classified, so the runner's own scheduler runs every prerequisite,
687
+ then the controller, then any body.
688
+ * - A PREREQUISITE keeps its captured edges exactly as they were. Its
689
+ rows are the native cases that must execute genuinely and exactly
690
+ once, and re-ordering them would change what the run proves.
691
+ * - Every other project gets the controller as its FIRST dependency,
692
+ followed by its own captured edges in their original emitted order.
693
+ Placement is not an ordering statement — the runner's phase scheduler
694
+ is topological (`runner/tasks.js`: a project joins the earliest phase
695
+ in which every dependency is already processed), so a body runs after
696
+ the controller either way. Placement IS an environment-merge
697
+ statement: a project's extra environment is the union over
698
+ `project.deps` in emitted order (`tasks.js:341-347`), later entries
699
+ winning, so where the controller lands decides what a body starts
700
+ with. FIRST is the contract's placement, emitted explicitly rather
701
+ than by inserting into a sorted array (a sort would move the
702
+ controller behind any prerequisite that sorts before it);
703
+ `trusted-config.ts` re-asserts it when it emits the final list. Whether
704
+ FIRST or LAST is what the environment projection actually needs is
705
+ under live measurement by the native environment probe; this is the
706
+ contract's ordering until that measurement says otherwise.
707
+ * @param control: the armed controller (its project name, dir and spec).
708
+ * @param scopes: the planned project scopes, unchanged in every other way.
709
+ * @param prerequisiteProjects: the CLI's upstream-closure classification.
710
+ *
711
+ * @returns
712
+ * ProjectScope[]: the scopes to synthesize — the planned scopes in
713
+ * place, plus the controller appended.
714
+ */
715
+ export function freezeProjectScopes(control, scopes, prerequisiteProjects) {
716
+ const defined = new Set(scopes.map((scope) => scope.name));
717
+ const prerequisites = new Set(prerequisiteProjects.filter((name) => defined.has(name) && name !== control.project));
718
+ return [
719
+ ...scopes.map((scope) => prerequisites.has(scope.name) || scope.name === control.project
720
+ ? scope
721
+ : {
722
+ ...scope,
723
+ dependencies: [
724
+ control.project,
725
+ ...(scope.dependencies ?? []).filter((name) => name !== control.project),
726
+ ],
727
+ }),
728
+ {
729
+ name: control.project,
730
+ files: [FREEZE_CONTROL_SPEC_FILE],
731
+ // The controller collects from the private directory the spec was
732
+ // written into — never from the state directory, which no longer
733
+ // holds it, and never from the repository root.
734
+ testDir: control.specDir,
735
+ dependencies: [...prerequisites].sort(),
736
+ },
737
+ ];
738
+ }
739
+ //# sourceMappingURL=prepare-barrier.js.map