@abloh/repository 1.0.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.
@@ -0,0 +1,4631 @@
1
+ import { RepoReader, PackageManifestReading, MatrixLeg, MatrixLegs, FailFastReading, SetupCandidateTrigger, SetupWorkflowCaller, SetupCandidateGate, NativeVocabulary, SetupFailFast, SetupCandidateExclusion, Refusal, YamlQuote, CoverageProviderChoice, MatrixValue, WorkflowNodePin, SetupScriptStep, ValueOrigin, CommandScriptHop, AngularTestTarget, PreparedTestCommand, InitFailureClass, InitQuestionKey, EnvironmentConfig, TargetSelectionKind, EvidenceOutputId, resolvePreparedTestCommand, MeasurementPlan, PackageExclusion } from '@abloh/core';
2
+ export { SETUP_STEP_ACTION, SETUP_STEP_IF, SETUP_STEP_JOB_STATUS, SETUP_STEP_NAME, setupStepIf } from '@abloh/core';
3
+ import { JasmineSuite, ProofRunner, StrykerMutationRunner } from '@abloh/measure';
4
+
5
+ /**
6
+ * WHERE THIS MODULE'S BYTES COME FROM.
7
+ *
8
+ * NOTHING HERE OPENS A FILE ANY MORE (the captain's ruling of 2026-09-05, and `ci-recipe.ts` before
9
+ * it). `abloh init` reads a checkout on the maintainer's machine and a GitHub App install has to
10
+ * read the same repository over GitHub's contents API with no checkout at all, and the DECIDING in
11
+ * between - which package, which runner, which manager - must be one implementation, because two
12
+ * would drift about what abloh writes into somebody else's repository.
13
+ * `packages/core/src/repo-reader.ts` states the interface and why it is synchronous.
14
+ *
15
+ * IT CARRIES A ROOT AS WELL AS A READER, because this module's own vocabulary is ABSOLUTE paths and
16
+ * always has been: every exported function takes a `repoDir` or a `dir`, every caller passes one,
17
+ * and `TargetDetection` reports `workDir` as an absolute path. Rewriting all of that to be
18
+ * repository-relative would be a second change on top of this one, in the module every other part
19
+ * of onboarding calls. So the access holds the root, the helpers below turn an absolute path back
20
+ * into the repository-relative one the reader speaks, and no signature outside this file moves.
21
+ *
22
+ * A PATH OUTSIDE THE ROOT READS AS ABSENT, which is the same answer the reader gives for the same
23
+ * reason. Detection walks UP from the measured package towards the repository root and stops there,
24
+ * so a path above it was already out of bounds; what changes is that the bound is now enforced by
25
+ * one function rather than remembered at each of thirty-three call sites.
26
+ */
27
+ interface RepoAccess {
28
+ /** The repository root, canonical, and what this module's own walks compare against. */
29
+ root: string;
30
+ /**
31
+ * EVERY ABSOLUTE SPELLING OF THAT ROOT, canonical first.
32
+ *
33
+ * A ROOT HAS TWO SPELLINGS ON ANY MACHINE WHERE A TEMP PATH IS A SYMLINK, which is every macOS
34
+ * one: `/var/folders/...` and `/private/var/folders/...` are the same directory, and this module
35
+ * meets both in one call. The walk in `detectPackageManagerContext` compares CANONICAL paths, so
36
+ * the root has to be canonical; every caller builds its absolute paths by joining onto the
37
+ * `repoDir` it was HANDED, which is whichever spelling that caller had. A single root would turn
38
+ * one of those two into "outside the repository", and the answer would be a repository with no
39
+ * `package.json` in it - which is what it was, for the whole of `detect.test.ts`, before this
40
+ * field existed.
41
+ *
42
+ * The same trap `packages/aster/src/roots.ts` records one engine over, where a single-string root
43
+ * matched nothing and every line map came back empty with no error anywhere.
44
+ */
45
+ roots: readonly string[];
46
+ reader: RepoReader;
47
+ /**
48
+ * The canonical absolute form of a path, as this road can see it.
49
+ *
50
+ * ON DISK THIS IS `realpathSync` AND IT IS LOAD-BEARING: `detectPackageManagerContext` walks from
51
+ * the measured package up to the repository root by comparing paths, and on macOS `/tmp` is a
52
+ * symlink to `/private/tmp`, so a root taken one way and a working directory taken the other
53
+ * never meet. Over a reader with no disk there are no links, so it is `resolve`.
54
+ */
55
+ realpath(path: string): string;
56
+ }
57
+ /** The access `abloh init` and every existing caller get: a checkout on this machine. */
58
+ declare function diskRepoAccess(repoDir: string): RepoAccess;
59
+ /**
60
+ * The repository-relative path an absolute one names, or null when it names nothing in here.
61
+ *
62
+ * `relative` first and `normalizeRepoPath` second: the first turns an absolute path into a
63
+ * repository-relative one, the second refuses the `..` that says it left. Both are needed - a bare
64
+ * `relative` answers `../x` cheerfully, and the reader would then refuse it silently, which is the
65
+ * same outcome by luck rather than by rule.
66
+ */
67
+ declare function repoPath(ctx: RepoAccess, absolute: string): string | null;
68
+ /** Is there anything at all here - a file or a directory? */
69
+ declare function accessExists(ctx: RepoAccess, absolute: string): boolean;
70
+ /** One file's UTF-8 text, or null when it is not there or is not a plain file. */
71
+ declare function accessText(ctx: RepoAccess, absolute: string): string | null;
72
+ /**
73
+ * ONE PACKAGE MANIFEST, THROUGH THE ONE READER (`@abloh/core`'s `package-manifest.ts`).
74
+ *
75
+ * EVERY MANIFEST THIS PACKAGE READS COMES THROUGH HERE, because the thirty-odd sites that each
76
+ * held their own `JSON.parse` disagreed about a file npm reads without complaint - a leading
77
+ * byte-order mark - and the reader that happened to meet it decided whether a repository could be
78
+ * onboarded at all. The refusal it composes names the file, which is what the caller then either
79
+ * raises or records beside the one directory it is dropping.
80
+ *
81
+ * THE PATH IT NAMES IS THE REPOSITORY-RELATIVE ONE, never the absolute one this module speaks
82
+ * internally: a sentence a maintainer reads has to name a file in their own repository, and an
83
+ * absolute path names a directory on whichever machine ran the draft.
84
+ */
85
+ declare function accessManifest(ctx: RepoAccess, absolute: string): PackageManifestReading;
86
+ /** What is at this path, with a symlink kept apart from an absence. See `RepoReader.entryKind`. */
87
+ declare function accessKind(ctx: RepoAccess, absolute: string): "file" | "directory" | "other" | "absent";
88
+ /** Immediate child directory NAMES of a path, or none when it is absent or unreadable. */
89
+ declare function accessSubdirectories(ctx: RepoAccess, absolute: string): string[];
90
+
91
+ /**
92
+ * WHICH REPOSITORIES ACTUALLY HAVE A BROWSER LANE, read from what they committed rather than from
93
+ * what they called a script.
94
+ *
95
+ * THE MEASUREMENT THAT PUT THIS FILE HERE. Round 5's wall census (`wall-census.md`, mechanism M17)
96
+ * reported "the repository's CI has a browser lane abloh does not measure" on three repositories -
97
+ * `kaitranntt/ccs`, `storybookjs/storybook`, `vitejs/vite`. Reading all three workflows and
98
+ * manifests, the sentence was right about two of them and wrong about one, and it described the
99
+ * wrong thing on the two it was right about:
100
+ *
101
+ * | repository | what M17 quoted | what is actually there |
102
+ * |------------|----------------------------------------------|-----------------------------------|
103
+ * | ccs | `bun run test:e2e` | `bun test tests/e2e/` - a CLI |
104
+ * | | | suite. No browser anywhere: no |
105
+ * | | | playwright, no cypress, no |
106
+ * | | | @vitest/browser in any manifest. |
107
+ * | storybook | `yarn exec playwright install chromium` | an INSTALL step. The lane is |
108
+ * | | | `vitest run`, whose project list |
109
+ * | | | includes a browser-mode project. |
110
+ * | vite | `pnpm playwright install chromium` | an INSTALL step. The lanes are |
111
+ * | | | `vitest run -c vitest.config.e2e` |
112
+ * | | | driving playwright as a library. |
113
+ *
114
+ * TWO SEPARATE DEFECTS, and this file exists to remove both. `ccs` was matched by SCRIPT NAME -
115
+ * `ci-recipe.ts`'s `BROWSER_SCRIPT_NAMES` reads `test:e2e` and concludes browser - which is exactly
116
+ * the failure mode `docs/lessons/verifying-rules.md` records: a rule written from assumption,
117
+ * running in bulk, never checked against the data. And on the other two the quoted command is the
118
+ * step that INSTALLS a browser, which is a fact about the setup phase and not a lane at all.
119
+ *
120
+ * SO NOTHING HERE READS A NAME. Every kind below requires a DECLARATION - a dependency in a
121
+ * manifest, a `browser` block in a vitest config, a runner binary in an argv - and where the
122
+ * evidence is absent this module returns null and the caller says nothing. That is the safe
123
+ * direction on both sides: a repository with no browser is never told it has one, and a repository
124
+ * with a browser abloh cannot yet measure gets a refusal that names its lane rather than a heads-up
125
+ * that names a script.
126
+ */
127
+
128
+ /**
129
+ * The kinds of browser lane, closed, and each is a different thing to build.
130
+ *
131
+ * `vitest-browser` and `playwright-test` are the two phase-1 kinds: in both, a runner abloh already
132
+ * drives launches the browser through an interface abloh can point at a browser it placed. The other
133
+ * two are named rather than lumped under "unsupported", because a refusal that names the lane is
134
+ * what tells a customer whether to wait or to declare a different test command.
135
+ */
136
+ type BrowserLaneKind =
137
+ /** `@vitest/browser` with a playwright provider: vitest starts the server and drives the page. */
138
+ "vitest-browser"
139
+ /** The Playwright Test runner itself - `playwright test`. */
140
+ | "playwright-test"
141
+ /** A node-mode suite that imports playwright or puppeteer and drives a browser in its own code. */
142
+ | "playwright-library"
143
+ /** Cypress, which brings its own runner, its own server contract and its own binary cache. */
144
+ | "cypress";
145
+ /** What was read to reach a verdict, by path, so a customer can go and look at the same line. */
146
+ interface BrowserLaneEvidence {
147
+ /** Repository-relative path of the file the fact was read from. */
148
+ readonly file: string;
149
+ /** The fact itself, in the file's own vocabulary. */
150
+ readonly fact: string;
151
+ }
152
+ interface BrowserLane {
153
+ readonly kind: BrowserLaneKind;
154
+ /**
155
+ * Can abloh measure this lane today?
156
+ *
157
+ * A FIELD RATHER THAN A LOOKUP AT EVERY CALL SITE, because "which kinds ship" is the thing that
158
+ * changes when the next phase lands, and a `kind === "vitest-browser"` written in four places is
159
+ * four places to forget.
160
+ */
161
+ readonly measurable: boolean;
162
+ /** Every fact behind the verdict. Never empty - a kind with no evidence is not returned at all. */
163
+ readonly evidence: readonly BrowserLaneEvidence[];
164
+ }
165
+ /**
166
+ * IS THIS COMMAND A BROWSER INSTALL rather than a browser lane, and the distinction M17 collapsed.
167
+ *
168
+ * `pnpm playwright install chromium` places a binary. It runs in the phase that HAS a network, it
169
+ * runs once, and it measures nothing. Reading it as "a browser lane abloh does not measure" is what
170
+ * produced the census sentence on `storybook` and `vite`, both of whose actual lanes are ordinary
171
+ * vitest invocations. Told apart here so the caller can do the right thing with each: an install
172
+ * step belongs in the setup phase, a lane belongs in the measurement.
173
+ */
174
+ declare function isBrowserInstallCommand(command: string): boolean;
175
+ /**
176
+ * WHAT KIND OF BROWSER LANE THIS REPOSITORY HAS, or null when it has none.
177
+ *
178
+ * ORDER IS SIGNIFICANT and it is specificity order, exactly as `admission-remedy.ts` orders its
179
+ * rules. A repository with `@vitest/browser` also has `playwright` in its tree, because that is what
180
+ * the provider drives; reading the weaker fact first would file `storybookjs/storybook` as a
181
+ * playwright-library lane and refuse a lane abloh can measure.
182
+ *
183
+ * THE MANIFEST IS NECESSARY AND, FOR THE VITEST KIND, NOT SUFFICIENT. `@vitest/browser` in a
184
+ * manifest with no config enabling it is a dependency somebody added and did not switch on, which is
185
+ * true of any repository mid-migration. Both facts are required and both are returned as evidence.
186
+ */
187
+ declare function readBrowserLane(repoDir: string, ctx?: RepoAccess): BrowserLane | null;
188
+
189
+ /**
190
+ * THE ENVIRONMENT ONE WORKFLOW STEP RUNS IN, RESOLVED THE WAY GITHUB RESOLVES IT.
191
+ *
192
+ * WHY THIS EXISTS (the 2026-09-11 detection audit, findings E1, E2, E3 and S1). The CI reader used
193
+ * to collect a job's `env:` and the `env:` of a few step kinds into ONE array and let the last push
194
+ * win. Four separate defects came out of that one shape, and every one of them was measured on a
195
+ * real repository:
196
+ *
197
+ * - The WORKFLOW's own `env:` was never read at all. `cheeriojs/cheerio`'s `FORCE_COLOR`,
198
+ * `sveltejs/svelte`'s `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD`, `swagger-api/swagger-ui`'s
199
+ * `CYPRESS_CACHE_FOLDER` and `TanStack/query`'s `NX_CLOUD_ACCESS_TOKEN` each sit at workflow
200
+ * level, which GitHub applies to every job as a default, and abloh read none of them.
201
+ * - INSTALL, BUILD and TEST were one list, so a value declared for one execution silently
202
+ * replaced the value declared for another. `babel/babel` declares `BABEL_ENV: test` on the step
203
+ * that runs its suite and `BABEL_ENV: test-legacy` on the step that builds its artifacts in a
204
+ * different job; the artifacts job was read second and `test-legacy` became the value abloh gave
205
+ * the SUITE. `babel.config.ts` selects different compilation for the two.
206
+ * - A step that re-declared a name the job had already set did not replace it when the step's own
207
+ * value was an expression: the job's stale literal stayed, and the question that would have
208
+ * asked about the expression was suppressed by it.
209
+ * - Every `${{ … }}` was "computed, nothing to write", including a direct read of a matrix value
210
+ * the maintainer had ALREADY answered. `prettier/prettier` and `Fission-AI/OpenSpec` were asked
211
+ * again for values their own answers settle.
212
+ *
213
+ * WHAT THIS MODULE IS. Two functions and no reader of its own. {@link layerEnvironment} applies
214
+ * GitHub's scope rule to a list of `env:` mappings, and {@link readWorkflowExpressions} resolves the
215
+ * handful of expression forms whose answer is already in hand. Nothing here opens a file, runs a
216
+ * command or evaluates an expression grammar - see {@link readWorkflowExpressions} for exactly which
217
+ * forms are answered and why the rest stay unanswered on purpose.
218
+ *
219
+ * IMPORT-FREE BUT FOR THE MATRIX VOCABULARY, so the CI reader, the runtime reading and any later
220
+ * caller all resolve one reference the same way rather than each spelling a regular expression.
221
+ */
222
+
223
+ /**
224
+ * HOW NARROW ONE `env:` MAPPING IS, which is the whole of GitHub's precedence rule.
225
+ *
226
+ * The workflow's block is a default for every job, a job's block is a default for every step of that
227
+ * job, and a step's block applies to that step. Later overrides earlier, per NAME.
228
+ * See https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#env-context.
229
+ */
230
+ type WorkflowEnvironmentLevel = "workflow" | "job" | "step";
231
+ /** One `env:` mapping, as `NAME -> raw workflow text`, with how narrow it is. */
232
+ interface WorkflowEnvironmentScope {
233
+ level: WorkflowEnvironmentLevel;
234
+ entries: ReadonlyArray<{
235
+ name: string;
236
+ text: string;
237
+ }>;
238
+ }
239
+ /** One name's effective declaration: the narrowest mapping that set it, whole. */
240
+ interface WorkflowEnvironmentEntry {
241
+ name: string;
242
+ /** The workflow's own text, unclassified and unresolved. */
243
+ text: string;
244
+ /** Which mapping this text came from. */
245
+ level: WorkflowEnvironmentLevel;
246
+ }
247
+ /**
248
+ * THE EFFECTIVE ENVIRONMENT FOR ONE EXECUTION, from the mappings that apply to it.
249
+ *
250
+ * A LATER SCOPE REPLACES AN EARLIER ONE WHOLE, and "whole" is the load-bearing word. The defect this
251
+ * replaces kept the earlier entry whenever the later one was something it could not turn into a
252
+ * literal, so a job's `APP_MODE: test` survived a step's `APP_MODE: ${{ matrix.mode }}` and was
253
+ * offered as this step's value. A workflow that re-declares a name has said the earlier value does
254
+ * not apply here, whatever the new one turns out to be; classification happens after this and never
255
+ * decides which declaration is in force.
256
+ *
257
+ * Order is the order the caller passes, so a caller that hands them narrowest-first gets the
258
+ * narrowest-loses answer, which is not GitHub's. The one caller hands them widest-first.
259
+ */
260
+ declare function layerEnvironment(scopes: readonly WorkflowEnvironmentScope[]): WorkflowEnvironmentEntry[];
261
+ /**
262
+ * The facts a reference can be resolved AGAINST, all of them already established elsewhere.
263
+ *
264
+ * Every field is optional and an absent field resolves nothing: this module never derives an answer,
265
+ * it only substitutes one somebody else already has.
266
+ */
267
+ interface WorkflowExpressionFacts {
268
+ /**
269
+ * The matrix leg this derivation is for, when the maintainer answered one.
270
+ *
271
+ * A dimension the leg does not name stays unresolved. That is the same restraint
272
+ * `resolveNodeMajor` already applies: an unanswered matrix is an honest question, and guessing a
273
+ * cell would describe a different execution from the one abloh's step runs on.
274
+ */
275
+ matrix?: MatrixLeg | null;
276
+ /**
277
+ * Literal values already resolved from WIDER scopes, for `${{ env.NAME }}`.
278
+ *
279
+ * Wider only. A step's own mapping is not visible to itself here, so a self-reference stays
280
+ * unresolved rather than reading whichever entry happened to be processed first.
281
+ */
282
+ env?: ReadonlyMap<string, string> | undefined;
283
+ /**
284
+ * Where a run reaches each declared service, for `${{ job.services.<name>.ports[<n>] }}`.
285
+ *
286
+ * A workflow that stands up two of one database reads its own ports back this way, because GitHub
287
+ * gives each service a port of its own and only the run knows which. `backstage/backstage` builds
288
+ * all four of its database connection strings out of these references, and reading none of them
289
+ * left a job whose whole purpose is those databases with no address for any of them. Abloh assigns
290
+ * the ports itself (`service-ports.ts`) and states them in `abloh.yml`, so this is a reference to a
291
+ * value the drafting already settled rather than a prediction about a run.
292
+ */
293
+ servicePorts?: ReadonlyMap<string, number> | undefined;
294
+ }
295
+ /** What one workflow value says once every answerable reference in it has been substituted. */
296
+ interface WorkflowExpressionReading {
297
+ /** The value with every answerable reference replaced. Equal to the input when none was. */
298
+ text: string;
299
+ /** Does this value read something out of the caller's secret store? */
300
+ hasSecret: boolean;
301
+ /** Every reference left unanswered, verbatim and in order. */
302
+ unresolved: readonly string[];
303
+ }
304
+ /** Does this value read the caller's secret store anywhere in it? */
305
+ declare function referencesSecret(text: string): boolean;
306
+ /** Does this value carry any workflow reference at all? */
307
+ declare function referencesExpression(text: string): boolean;
308
+ /**
309
+ * SUBSTITUTE THE REFERENCES WHOSE ANSWER IS ALREADY IN HAND, AND NOTHING ELSE.
310
+ *
311
+ * THREE FORMS ARE ANSWERED and the choice of those three is the whole design. Each one reads a fact
312
+ * that is settled before this derivation runs, so substituting it states what the maintainer already
313
+ * said rather than predicting a run:
314
+ *
315
+ * - `matrix.<dimension>` against the leg the maintainer answered. `prettier/prettier` answers
316
+ * `ENABLE_CODE_COVERAGE=true` and `FULL_TEST=true`; `Fission-AI/OpenSpec` answers
317
+ * `vitest_workers=4`. Continuing to describe those as values abloh cannot know is the defect.
318
+ * - `env.<NAME>` against a literal a wider scope already set. `swagger-api/swagger-ui` reads its
319
+ * own `CYPRESS_CACHE_FOLDER` back in a cache path.
320
+ * - A secret reference is not substituted - it is REPORTED. Nothing here ever copies a value out
321
+ * of somebody's secret store into a file abloh writes.
322
+ *
323
+ * EVERYTHING ELSE STAYS UNANSWERED, and that is deliberate rather than unfinished. `github.sha`,
324
+ * `runner.os`, `steps.x.outputs.y`, `hashFiles(...)`, `a && b || c` - each is decided by the run
325
+ * itself, so there is no literal for a committed file and the honest product is a question naming
326
+ * the reference. This module is a lookup, never an expression grammar: what it cannot look up it
327
+ * hands back whole so the caller can say so in the workflow's own words.
328
+ *
329
+ * A value is resolved only when EVERY reference in it is. One unanswered reference in a connection
330
+ * string makes the whole string unanswered, because half a value is not a value.
331
+ */
332
+ declare function readWorkflowExpressions(text: string, facts?: WorkflowExpressionFacts): WorkflowExpressionReading;
333
+
334
+ /** One derived setup command, with the workflow job it was read out of for its receipt. */
335
+ interface CiSetupCommand {
336
+ /** The command exactly as the workflow declares it, normalized to one line. */
337
+ command: string;
338
+ /** `.github/workflows/ci.yml::build` - what the receipt in `abloh.yml` names. */
339
+ source: string;
340
+ /**
341
+ * THE LITERAL VALUES CI GIVES THIS COMMAND, which are this command's and not the suite's.
342
+ *
343
+ * WHY IT IS HERE RATHER THAN POOLED INTO `environmentValues` (the 2026-09-11 detection audit, E2).
344
+ * `environment.environmentValues` reaches the measured suite and reaches no setup step: the cold
345
+ * lane runs each step from the ambient environment and the sealed image runs each step as its own
346
+ * `sh -c`. So a build's values pooled into that key arrive where the build is not and replace the
347
+ * suite's own values where the suite is. `babel/babel` is the measured case, in both directions
348
+ * at once: its suite was handed `BABEL_ENV=test-legacy` and its build was handed nothing.
349
+ *
350
+ * A SECRET IS NOT HERE. A name with no literal is declared under `environment.requiredVariables`
351
+ * and supplied from the caller's own environment, which every step already inherits.
352
+ */
353
+ env: ReadonlyArray<{
354
+ name: string;
355
+ value: string;
356
+ }>;
357
+ }
358
+ /**
359
+ * One OS package read out of an `apt-get install` line in the repository's own workflow.
360
+ *
361
+ * `version` is null when the workflow installed the package unpinned, which is what almost every
362
+ * workflow does - CI reinstalls from the archive on every run and does not care which build it gets.
363
+ * A recipe does care (`system-packages.ts`), so an unpinned entry is REPORTED and never written:
364
+ * see the notice in `derive` below, which names the package and the command that produces its pin.
365
+ */
366
+ interface CiSystemPackage {
367
+ name: string;
368
+ version: string | null;
369
+ /** `.github/workflows/ci.yml::test` - what the receipt in `abloh.yml` names. */
370
+ source: string;
371
+ }
372
+ /**
373
+ * One literal, non-secret `NAME: value` pair read out of an `env:` block in the customer's workflow.
374
+ *
375
+ * WHAT MAKES IT LITERAL, and it is the whole of the test: the workflow wrote the value as text.
376
+ * `${{ secrets.X }}`, `${{ github.sha }}` and `$HOME` are not text - they are things the workflow
377
+ * runner evaluates - and none of them reaches this type. See {@link CiRequiredVariable} for the half
378
+ * that carries a NAME instead, and `config.ts` at `environmentValues` for why the two are separate
379
+ * keys rather than one key with two meanings.
380
+ */
381
+ interface CiEnvironmentValue {
382
+ name: string;
383
+ value: string;
384
+ /** `.github/workflows/ci.yml::test` - what the receipt in `abloh.yml` names. */
385
+ source: string;
386
+ }
387
+ /**
388
+ * One environment-variable NAME the customer's workflow supplies from its secret store.
389
+ *
390
+ * NO VALUE TRAVELS WITH IT, ever, and that is the point of the split ruled on 2026-08-25. A
391
+ * `${{ secrets.DATABASE_URL }}` in a workflow is a name whose value lives in GitHub's secret store;
392
+ * `init` can read the name and must never invent the value, so what it writes is
393
+ * `environment.requiredVariables: [DATABASE_URL]` - the key whose value is injected at run time and
394
+ * masked out of every captured line (`secret-scrub.ts`). A literal that merely LOOKS like a
395
+ * credential lands here too, by shape rather than by name, because a value that is a secret does not
396
+ * become non-secret by having been committed to a workflow.
397
+ */
398
+ interface CiRequiredVariable {
399
+ name: string;
400
+ source: string;
401
+ /** `secret` when the workflow named a secret; `credential-shape` when a literal looked like one. */
402
+ why: "secret" | "credential-shape";
403
+ }
404
+ /**
405
+ * One backing service the chosen job declares under its own `services:` block, WHOLE.
406
+ *
407
+ * THE DECLARATION IS THE SOURCE. Everything abloh needs to stand this service up is in the block
408
+ * the customer already wrote: the key names it, the image says what it is, `env:` says what
409
+ * credentials it starts with, `options:` says how to know it is ready, and `ports:` says which port
410
+ * it listens on. This reader used to take the image string and drop the other four, which is why
411
+ * abloh started a database with credentials that appeared in no file the customer had ever seen.
412
+ *
413
+ * THE TAG IS STILL MUTABLE AND STILL REFUSED downstream: `environment.services` takes a
414
+ * digest-pinned reference (`config.ts`: `immutableImageProblem`), so `init` resolves the tag the
415
+ * workflow named to the digest it points at today and writes that. What changed is that the digest
416
+ * comes from the registry rather than from a hand-reviewed table of two service kinds.
417
+ */
418
+ interface CiService {
419
+ /** The workflow's own key for this service - `db`, `postgres`, `cache`. Becomes the alias. */
420
+ name: string;
421
+ /** The image reference exactly as declared, tag and all. `init` resolves it to a digest. */
422
+ image: string;
423
+ /** The declared `env:`, verbatim; these are the credentials the suite will connect with. */
424
+ env: ReadonlyArray<{
425
+ name: string;
426
+ value: string;
427
+ }>;
428
+ /** The `--health-cmd` the declared `options:` carries, or null when it declares none. */
429
+ healthCommand: string | null;
430
+ /** The container-side port the declared `ports:` names, or null when it names none. */
431
+ port: number | null;
432
+ /**
433
+ * WHERE A RUN REACHES THIS SERVICE, which is not always the port it listens on.
434
+ *
435
+ * Each service stands in a namespace of its own and a forwarder in the namespace the suite runs in
436
+ * carries this port to that one, so two services listening on 5432 both answer - on two different
437
+ * ports, which is what GitHub does by giving each its own port on the runner. `service-ports.ts`
438
+ * is the assignment and this is its answer for this service. Null exactly when {@link port} is.
439
+ */
440
+ localhostPort: number | null;
441
+ source: string;
442
+ }
443
+ /**
444
+ * A service the chosen job declares that abloh will not stand up, and the sentence saying why.
445
+ *
446
+ * NAMED, NOT SILENT, and the reason is the half that matters: "abloh has no preset for mysql" was
447
+ * a statement about abloh, and every one of these is a statement about the declaration, which is
448
+ * the thing the customer can change.
449
+ */
450
+ interface CiUnsupportedService {
451
+ /** The workflow's own key, so the customer knows which block to open. */
452
+ name: string;
453
+ /** One sentence: what abloh could not use, and what would make it usable. */
454
+ reason: string;
455
+ }
456
+ /**
457
+ * WHAT THE CHOSEN JOB SAYS ITS NODE IS, VERBATIM, WITH THE SENTENCE THAT SAYS WHERE IT CAME FROM.
458
+ *
459
+ * IT USED TO BE A MAJOR AND THAT WAS THE DEFECT. This carried `major: number`, read off the leading
460
+ * digits of whatever the workflow wrote and clamped to the newest image abloh had reviewed, so
461
+ * `24.16.0` arrived as `24`, `26` arrived as `24`, and `lts/*` arrived as nothing at all. The
462
+ * reading is now the repository's own text and the CHOOSING happens once, in `node-declaration.ts`,
463
+ * where the publisher's release index and the repository's `engines.node` are both in hand.
464
+ *
465
+ * MORE THAN ONE, BECAUSE A MATRIX IS MORE THAN ONE. `[26, 22]` is two versions CI proves the suite
466
+ * green on and either is an honest answer, so both travel, strongest first, and the chooser takes
467
+ * the first its requirements admit.
468
+ */
469
+ interface CiNodeVersion {
470
+ /** Every Node this job declares, strongest first, exactly as the workflow wrote it. */
471
+ specs: readonly string[];
472
+ evidence: string;
473
+ }
474
+ /**
475
+ * ONE EXECUTION A JOB DECLARES: what it runs, where it sits, and the environment it runs in.
476
+ *
477
+ * THE THREE FIELDS ARE THE THREE THINGS THE OLD READING LOST. A job used to reach `derive` as a
478
+ * list of build commands and one flat environment array, so an installation's value, a build's and
479
+ * the suite's were one dictionary whose last writer won, and a build could not say whether it ran
480
+ * before the suite or after it. Both cost real repositories a wrong answer:
481
+ *
482
+ * - `babel/babel`'s suite was given its build job's `BABEL_ENV`, which selects a different
483
+ * compilation from the one the suite's own step declares.
484
+ * - `pmndrs/valtio` and `pmndrs/zustand` each run `pnpm run build` AFTER their suite, with a
485
+ * comment saying the step exists to test the build, and each was given that build as
486
+ * preparation to run BEFORE the suite.
487
+ */
488
+ interface CiJobStep {
489
+ /**
490
+ * What abloh made of the command.
491
+ *
492
+ * `unclassified` is a step abloh declined to translate that declares an `env:` block of its own.
493
+ * It is carried for that block and for nothing else - it never becomes preparation and it is
494
+ * never read as the suite. `backstage/backstage` is why it exists: its whole job is built around
495
+ * `yarn backstage-cli repo test`, which names no runner this module knows, and the four database
496
+ * connection values that job exists for sit on that one step and reached nothing at all.
497
+ */
498
+ kind: "install" | "build" | "test" | "unclassified";
499
+ /** The command, exactly as the workflow declares it, normalized to one line. */
500
+ command: string;
501
+ /** The job-level `steps:` index this command belongs to, or null for a format with no list. */
502
+ index: number | null;
503
+ /**
504
+ * Had a step of this job already run the suite when this one was reached?
505
+ *
506
+ * WHAT IT DECIDES. Preparation is what runs BEFORE the suite; a build after it is the job moving
507
+ * on to check something else, and moving that build in front of the suite runs work the suite
508
+ * never needed against a tree it was not measured in. The reading is the classifier's own and
509
+ * never a step's name - the same rule {@link Candidate.lastTestStepIndex} is closed by.
510
+ */
511
+ afterTests: boolean;
512
+ /**
513
+ * Had this job finished running its suite and moved on to something else before this step?
514
+ *
515
+ * NOT THE SAME QUESTION AS {@link afterTests}, and `swagger-api/swagger-ui` is the shape that
516
+ * separates them: it runs `npm run test:unit`, then `npm run build`, then `npm run test:artifact`,
517
+ * and the third is a check ON the build rather than more of the suite. The suite's own environment
518
+ * is the first step's; the third step declares none and reading it as the suite dropped the
519
+ * `CI: true` the suite really runs with. This is `lastTestStepIndex`'s own closing rule, recorded
520
+ * per step so every reader takes the same answer.
521
+ */
522
+ afterSuite: boolean;
523
+ /**
524
+ * THE EFFECTIVE ENVIRONMENT FOR THIS COMMAND: the workflow's `env:`, then the job's, then this
525
+ * step's own, later replacing earlier per name.
526
+ *
527
+ * Raw workflow text. `derive` classifies it into the literal half and the injected half, and it
528
+ * does that once per execution rather than once per job.
529
+ */
530
+ env: readonly WorkflowEnvironmentEntry[];
531
+ }
532
+ /**
533
+ * ONE STEP OF THE CUSTOMER'S OWN JOB THAT RUNS THEIR SUITE.
534
+ *
535
+ * READ, NEVER ANSWERED. See {@link CiBorrowJob.testSteps}: everything here is a fact out of the
536
+ * file, and whether this step runs is a question about the leg abloh pins.
537
+ */
538
+ interface CiJobTestStep {
539
+ /** Where the `jobs.<id>.steps` entry this step came out of sits, counting from zero. */
540
+ index: number;
541
+ /** That entry's own `name:`, or null. What a maintainer sees on their checks page. */
542
+ name: string | null;
543
+ /** Every `if:` gating it, outermost first. See {@link Step.conditions}. */
544
+ conditions: readonly string[];
545
+ }
546
+ /**
547
+ * ONE JOB IN THE CUSTOMER'S OWN CI, AS THE BORROW LANE NEEDS IT.
548
+ *
549
+ * Every field is a fact read out of their workflow, and none of them is a decision: which file,
550
+ * which job id inside it, what that job does, and whether it fans out. The decision - is this job
551
+ * the one to append a step to - belongs to `setup-job.ts`, which reads these.
552
+ */
553
+ interface CiBorrowJob {
554
+ /** Repo-relative path of the workflow file, `.github/workflows/ci.yml`. */
555
+ file: string;
556
+ /** The key under `jobs:`, which is what a `steps:` list hangs off. */
557
+ jobId: string;
558
+ /** The job's own `name:`, or null. What a maintainer sees on their checks page. */
559
+ jobName: string | null;
560
+ /** Does this job install this package's dependencies? */
561
+ installs: boolean;
562
+ /** Does it run a build step abloh could read? */
563
+ builds: boolean;
564
+ /** Does it run a suite abloh recognized? */
565
+ tests: boolean;
566
+ /**
567
+ * WHERE THE STEP THAT RUNS THE SUITE SITS IN `jobs.<id>.steps`, or null when no step does.
568
+ *
569
+ * WHAT IT IS FOR, AND WHY IT IS THE READER'S ANSWER RATHER THAN THE PLACER'S (the fresh-ten
570
+ * launch-evidence run, 2026-08-31, bug 6). Abloh's step used to be appended to the END of the
571
+ * job. `swagger-api/swagger-ui` runs `Install dependencies` -> `Lint` -> `Run all tests` ->
572
+ * `Build SwaggerUI` -> `Test build artifacts`, and its build writes into `dist/`, which that
573
+ * repository TRACKS. So the placement guaranteed the condition the preflight refuses, and it
574
+ * refused on both cycles: Abloh stood after a build and then objected to the build's output.
575
+ *
576
+ * THE RULE IS "BEHIND THEIR TEST STEP" AND IT NEEDS NO CLASSIFIER OF ITS OWN. Everything the
577
+ * suite needed has already happened by the time the suite has run - a step cannot depend on one
578
+ * that runs after it - so the tree at that point is exactly the tree their suite ran in, which is
579
+ * the tree Abloh is there to measure. Everything after it is by definition not needed to run the
580
+ * suite. It is NOT a scan for build-sounding step names: the same run put Abloh on `prettier`'s
581
+ * LINT job by ranking names, and that mistake is not repeated one layer down.
582
+ *
583
+ * THE LAST SUITE STEP WHILE THE JOB IS STILL RUNNING ITS SUITE. A job that shards its suite over
584
+ * two steps has not finished running it until the second has run. A job that BUILDS after its
585
+ * suite has run has moved on to work the suite did not need, and swagger-ui is exactly that shape
586
+ * - `Run all tests`, `Build SwaggerUI`, `Test build artifacts` - so the third step is not an
587
+ * anchor: standing behind it is standing behind the build. See `suiteClosed` in `readJob`.
588
+ *
589
+ * NULL MEANS "APPEND AT THE END", which is the old behaviour and the only honest answer for a job
590
+ * whose suite Abloh could not locate - a declared `setup.job` may name a job that runs no suite
591
+ * Abloh recognized, and that declaration outranks this reading.
592
+ */
593
+ lastTestStepIndex: number | null;
594
+ /**
595
+ * EVERY SUITE STEP OF THIS JOB, in file order, each with the `if:` chain that decides whether it
596
+ * runs.
597
+ *
598
+ * WHY THE LIST AND NOT JUST {@link lastTestStepIndex} (census run 4, `prettier/prettier`, F8; run
599
+ * 33613921638). `prettier`'s test job declares TWO suite steps and an `include:` leg decides which
600
+ * of them runs - `Run Tests` on `if: ${{ !matrix.ENABLE_CODE_COVERAGE }}` and `Run Tests
601
+ * (coverage)` on `if: ${{ matrix.ENABLE_CODE_COVERAGE }}`. Abloh pinned its step to the leg
602
+ * carrying that boolean and anchored on the first suite step, which is the one that leg SKIPS, so
603
+ * it stood between the two and measured a tree whose suite the maintainer's job had not run.
604
+ * `lastTestStepIndex` cannot express that: it is one index, decided before any leg is known.
605
+ *
606
+ * THE `if:` IS CARRIED AND NOT ANSWERED HERE, exactly as {@link Candidate.gateIf} is. Which of
607
+ * these runs is a question about the LEG, and the leg is settled a layer up in `setup-job.ts`;
608
+ * this reader states what the file says and `setupStepAnchor` answers it.
609
+ */
610
+ testSteps: readonly CiJobTestStep[];
611
+ /**
612
+ * The matrix dimensions this job fans out over, `[]` when it is one job.
613
+ *
614
+ * NAMES ONLY, NEVER THE LEGS. What a caller has to decide is whether "this job" is one build or
615
+ * twelve, and the dimension names are what say so; enumerating the legs would invite a caller to
616
+ * pick one, and picking one is the silent guess the "needs your edit" state exists to refuse.
617
+ */
618
+ matrixDimensions: readonly string[];
619
+ /**
620
+ * THE JOB DECLARES A MATRIX THIS READER COULD NOT READ AT ALL, so `[]` above is not "one build".
621
+ *
622
+ * See `SetupCandidateJob.matrixUnreadable` in `@abloh/core`, which carries the whole account: a
623
+ * `matrix: ${{ fromJSON(...) }}` has no dimension name in the file, so there is no leg to ask for
624
+ * and `setupCandidateExclusion` refuses the job by name rather than letting an unconditional step
625
+ * be appended to a job that fans out.
626
+ */
627
+ matrixUnreadable: boolean;
628
+ /**
629
+ * THE LEGS EACH DIMENSION OFFERS, so a maintainer can be told which answers exist.
630
+ *
631
+ * WHY THIS EXISTS AND WHY IT IS NOT A CONTRADICTION OF {@link matrixDimensions} (borrow-coverage
632
+ * audit, 2026-08-30). The dimension names alone were the right answer for as long as there was no
633
+ * key to name a leg in: enumerating the legs would have invited a caller to pick one, and picking
634
+ * one silently is the guess the "needs your edit" state refuses. `setup.matrixLeg` changes what
635
+ * the enumeration is FOR. The refusal now asks the maintainer for a leg, and a question that lists
636
+ * no possible answers is a question they have to go and read their own workflow to answer.
637
+ *
638
+ * ABLOH STILL PICKS NOTHING. The list is printed and no entry is proposed, exactly as the refusal
639
+ * proposes no `setup.job` for a tie.
640
+ *
641
+ * A DIMENSION WHOSE VALUES CANNOT BE READ MAPS TO AN EMPTY LIST - an expression, a list of
642
+ * objects, a `fromJSON` - and empty means "abloh could not enumerate this", never "there are
643
+ * none". A declaration is admitted against an unreadable dimension for that reason.
644
+ */
645
+ matrixLegs: MatrixLegs;
646
+ /**
647
+ * EVERY LEG THIS JOB ACTUALLY RUNS, spelled out, or null when abloh could not enumerate them all.
648
+ *
649
+ * WHY A SECOND SHAPE BESIDE {@link matrixLegs} (desk check of the fresh ten, 2026-08-31).
650
+ * `matrixLegs` answers "which values may I write for this dimension" and it is a list PER
651
+ * DIMENSION, so the answers it offers are the cross product of those lists. GitHub's matrix is not
652
+ * always a cross product: an `include:` entry adds one leg with its own values, and
653
+ * `sveltejs/svelte` writes its whole matrix that way - five legs over `node-version` and `os` that
654
+ * no product of two lists describes. Offering the cross product alone would offer legs the
655
+ * workflow does not have, and `docs/lessons/a-remedy-is-a-promise.md` is the whole argument
656
+ * against a remedy nobody checked.
657
+ *
658
+ * NULL IS "NOTHING WAS READ" AND NEVER "NO LEGS", on the same rule as {@link runsOn}. A dimension
659
+ * written as `${{ fromJSON(...) }}`, legs that are objects rather than scalars, or a fan-out wider
660
+ * than {@link MAX_MATRIX_COMBINATIONS} all answer null, and every consumer treats that as the
661
+ * behaviour it had before this field existed.
662
+ */
663
+ matrixCombinations: readonly MatrixLeg[] | null;
664
+ /**
665
+ * WHAT `strategy.fail-fast` SAYS ABOUT THIS JOB.
666
+ *
667
+ * IT IS NOT A REASON TO REFUSE THE JOB and it is read for exactly one thing: whether a leg going
668
+ * red cancels the leg abloh's step is riding. `packages/core/src/setup-fail-fast.ts` holds the
669
+ * whole rule, the copy and the run that measured what it costs.
670
+ *
671
+ * `default` IS AN ANSWER, NOT AN ABSENCE. A job that declares no `fail-fast` is a job whose legs
672
+ * cancel each other, because that is GitHub's default - which is the whole of finding F4.
673
+ */
674
+ failFast: FailFastReading;
675
+ /** The job's own `runs-on:` label, or null where it declares one abloh cannot read. */
676
+ runsOn: string | null;
677
+ /**
678
+ * THE `runs-on:` EXPRESSION, VERBATIM, when the job writes one instead of a label.
679
+ *
680
+ * WHAT IT IS FOR. `runs-on: ${{ matrix.os }}` is the shape every matrix over operating systems
681
+ * writes, and `runsOn` is correctly null for it - nothing in the file says which runner the job
682
+ * gets. A PINNED LEG says it: with `os: ubuntu-latest` declared, that expression has one value,
683
+ * and `setupRunnerExclusion` can then answer the question it otherwise had to stay silent on. That
684
+ * matters in exactly the direction safety runs: `electron/asar`'s first leg is macOS, which has no
685
+ * Docker daemon, and admitting a leg without resolving the runner would put the step there.
686
+ *
687
+ * NULL WHEN THE FILE NAMES A LABEL, which is what `runsOn` already carries.
688
+ */
689
+ runsOnExpression: string | null;
690
+ /**
691
+ * The `pull_request:` block of the workflow this job lives in, verbatim.
692
+ *
693
+ * NULL FOR A FORMAT THAT HAS NO SUCH BLOCK - `.travis.yml` and `.circleci/config.yml` are read on
694
+ * identical terms here and declare their triggers somewhere this reader does not go - AND FOR A
695
+ * GITHUB WORKFLOW THAT DECLARES NO `pull_request` TRIGGER AT ALL.
696
+ *
697
+ * THAT SECOND CASE IS WHY {@link triggers} EXISTS (census run 7, F2). This doc used to say a
698
+ * GitHub workflow "always carries one, empty lists and all", so a null told `setupCandidateExclusion`
699
+ * that nothing had been read. It does not: `on: workflow_call` answers null here, and the
700
+ * exclusion then admitted a workflow GitHub does not start on a pull request as though it were a
701
+ * workflow with no filter. Ask {@link triggers} for what the file declares; this field stays what
702
+ * it always was, the filters on a `pull_request` trigger that IS declared.
703
+ */
704
+ trigger: SetupCandidateTrigger | null;
705
+ /**
706
+ * The `on:` keys of the workflow this job lives in, verbatim, or null for a non-GitHub format.
707
+ *
708
+ * The three questions this repository asks of them - is the workflow callable, does it start on a
709
+ * pull request, is its `pull_request` trigger narrowed - are all `setupCandidateExclusion`'s, in
710
+ * `@abloh/core`, where the rest of that policy is. Nothing is judged here.
711
+ */
712
+ triggers: readonly string[] | null;
713
+ /**
714
+ * Every job in this repository whose `uses:` names the workflow this job lives in, or null for a
715
+ * non-GitHub format.
716
+ *
717
+ * Read verbatim, with each caller's own `on:` keys and `pull_request:` block beside it. Which of
718
+ * them GitHub starts on a pull request, and whether abloh's publish job may go there, is
719
+ * `setupPublishHost`'s judgement in `@abloh/core`. Nothing is judged here.
720
+ */
721
+ callers: readonly SetupWorkflowCaller[] | null;
722
+ /** Every `if:` between a pull request and this job running: its own, then its `needs:` chain's. */
723
+ gates: readonly SetupCandidateGate[];
724
+ /** The ranking score, so a caller can see a clear winner and a tie for what they are. */
725
+ score: number;
726
+ }
727
+ /**
728
+ * A JOB THAT CALLS A WORKFLOW ABLOH COULD NOT READ, so it was never a candidate for anything.
729
+ *
730
+ * WHY IT IS A FACT AND NOT A SENTENCE (captain's ruling, 2026-08-31). `stylelint/stylelint`'s
731
+ * `ci.yml` has five jobs and three are calls into `stylelint/.github`, a different repository. The
732
+ * one carrying its real jest matrix is `ci.yml::test`, and it was skipped with no record anywhere -
733
+ * so the reading walled on "no pull-request job runs a test command Abloh recognizes" while the job
734
+ * that runs the suite sat two lines above it in the same file. Disclosure alone is not the fix: the
735
+ * DECISION has to change when one of these looks like the suite, which means the decision layer
736
+ * needs the jobs rather than a paragraph about them.
737
+ */
738
+ interface CiUnreadableJobCall {
739
+ /** `.github/workflows/ci.yml::test`, the spelling `setup.job` and every refusal use. */
740
+ job: string;
741
+ /** The `uses:` value, verbatim, because the refusal quotes the maintainer's own line. */
742
+ uses: string;
743
+ /** Why abloh could not read it, in the words the setup pull request prints. */
744
+ reason: string;
745
+ /**
746
+ * DOES ITS OWN NAME READ LIKE THE JOB THAT RUNS THE SUITE?
747
+ *
748
+ * A GUESS, AND SAID TO BE ONE. Nothing abloh can read says what a called workflow does, so the
749
+ * only evidence is the job's id and name - which is exactly the evidence `scoreCandidate` already
750
+ * ranks readable jobs on. It is asked with the SAME two lists, so "does this look like the front
751
+ * door" has one answer in this file rather than two.
752
+ */
753
+ looksLikeSuite: boolean;
754
+ }
755
+ /** The one command the chosen job runs its suite with, or the named reason there is not one. */
756
+ interface CiTestCommand {
757
+ command: string;
758
+ /** `.github/workflows/ci.yml::test` - what the receipt in `abloh.yml` names. */
759
+ source: string;
760
+ }
761
+ /**
762
+ * What `init` gets back. A refusal is not an error: most repositories are refused, and the refusal
763
+ * is the product - it names the one thing a customer can declare by hand to get past it.
764
+ */
765
+ /**
766
+ * WHAT `init` LEARNED ABOUT THIS REPOSITORY'S BROWSER LANE, and what it should write because of it.
767
+ *
768
+ * A PROJECTION OF `browser-lane.ts` PLUS THE ONE FACT ONLY THE CI WALK HAS: which step of the
769
+ * customer's own workflow installs the browser. Those are two different readings of two different
770
+ * files, and joining them here is what lets `init` write both halves of a working recipe - the
771
+ * declaration that puts a browser posture on the run, and the setup step that puts a browser in the
772
+ * image while the network is still up.
773
+ */
774
+ interface CiBrowserLane {
775
+ /** Which framework drives the browser. See `BrowserLaneKind`. */
776
+ kind: BrowserLaneKind;
777
+ /** Can abloh measure this kind today? False kinds get a refusal from the registry, not a key. */
778
+ measurable: boolean;
779
+ /** What `environment.browser` should say, or null when the lane is one abloh does not measure. */
780
+ declaration: "chromium" | null;
781
+ /**
782
+ * The step in the customer's own CI that places the browser, verbatim, or null when their CI has
783
+ * none - a vitest browser-mode repository whose contributors run `playwright install` by hand has
784
+ * a real lane and no step to copy.
785
+ */
786
+ install: string | null;
787
+ /** Whether that step is one literal command the schema accepts, so `init` may write it as one. */
788
+ installIsLiteral: boolean;
789
+ /** Every file and fact behind the verdict, so a customer can go and read the same lines. */
790
+ evidence: readonly BrowserLaneEvidence[];
791
+ }
792
+ interface CiRecipe {
793
+ /** Empty whenever `refusal` is set, and often empty when it is not: plenty of CI has no build. */
794
+ setupCommands: readonly CiSetupCommand[];
795
+ /**
796
+ * THE LITERAL VALUES CI GIVES THE STEP THAT INSTALLS THIS PROJECT.
797
+ *
798
+ * The third of the three environments a job keeps apart, beside {@link CiSetupCommand.env} and
799
+ * {@link environmentValues}. The COMMAND abloh installs with is derived from the lockfile rather
800
+ * than from CI, so it is abloh's; what CI gives that step is the customer's, and it is rendered
801
+ * with the install step in the setup script because nothing else delivers a value there.
802
+ *
803
+ * A secret is not here. It is declared by name under {@link requiredVariables}, and the caller's
804
+ * own environment supplies it to every step.
805
+ */
806
+ installEnvironment: ReadonlyArray<{
807
+ name: string;
808
+ value: string;
809
+ }>;
810
+ /**
811
+ * The OS packages the chosen job's own `apt-get install` lines name, pinned ones and unpinned.
812
+ *
813
+ * WHY THE UNPINNED ONES ARE HERE AND NOT DROPPED. `init` writes only the pinned entries into
814
+ * `environment.systemPackages`, because the schema refuses an unpinned one and it is right to. But
815
+ * "your CI installs libcairo2-dev and Abloh wrote nothing" is precisely the sentence a customer
816
+ * needs to see, and it can only be said by something that read the name. So the list carries both
817
+ * and the caller decides: one becomes a key, the other becomes a notice naming the key.
818
+ */
819
+ systemPackages: readonly CiSystemPackage[];
820
+ /**
821
+ * THE PATHS THE CHOSEN JOB'S OWN CACHE STEPS NAME, exactly as the workflow spells them.
822
+ *
823
+ * NOT `generatedFiles` AND NOT A GUESS AT IT. What a cache step says is "this path is worth
824
+ * carrying between runs", which is TRUE of a dependency store, a compiler cache and a browser
825
+ * download as well as of something the run writes into the checkout - so this is the READING and
826
+ * the caller decides which of them is repository dirt, because that question needs a git this
827
+ * module does not touch.
828
+ *
829
+ * WHY IT IS READ AT ALL (census run 3, and the captain's note of 2026-09-01).
830
+ * `environment.generatedFiles` ABORTS a run when a prepared checkout ends dirty in a path nobody
831
+ * declared, and its only derivation is an OBSERVATION of the install - so on every `--no-prepare`
832
+ * init the key is empty and nothing would ever fill it. `swagger-ui/swagger-ui` was refused for
833
+ * exactly that: its run writes `cypress/`, and its own workflow names that directory in a step
834
+ * called "Cache Node Modules and Cypress binary". The file was already parsed.
835
+ */
836
+ cachedPaths: readonly string[];
837
+ /**
838
+ * The literal `NAME: value` pairs the chosen job sets, deduplicated by name, sorted.
839
+ *
840
+ * READ FROM THE JOB AND FROM ITS STEPS, because a workflow puts them in both places and a suite
841
+ * does not care which. A step-level value wins over a job-level one of the same name, which is
842
+ * what the workflow runner itself does.
843
+ */
844
+ environmentValues: readonly CiEnvironmentValue[];
845
+ /** Named values the chosen job computes, which need an answer rather than a guessed literal. */
846
+ unresolvedEnvironmentValues?: readonly {
847
+ name: string;
848
+ source: string;
849
+ reason: string;
850
+ }[];
851
+ /** Services read from the job that could not be carried into the proof environment. */
852
+ unresolvedServices?: readonly {
853
+ name: string;
854
+ source: string;
855
+ reason: string;
856
+ }[];
857
+ /** The names the chosen job supplies from secrets, or whose literal looked like a credential. */
858
+ requiredVariables: readonly CiRequiredVariable[];
859
+ /** The postgres and redis services the chosen job declares. At most one of each. */
860
+ services: readonly CiService[];
861
+ /** Null when CI declares no readable Node version; `init`'s own image selection then stands. */
862
+ node: CiNodeVersion | null;
863
+ /**
864
+ * The command the chosen job runs the suite with, or null when the job runs it more than one way
865
+ * and no single one is the front door.
866
+ *
867
+ * WHO USES IT, AND WHEN. `init` prefers its OWN detection wherever the repository declares a
868
+ * `scripts.test`, because that is the same declaration CI is usually invoking and because a config
869
+ * command that disagrees with detection demotes the runner to the generic adapter
870
+ * (`detect.ts: bindPreparedTestCommand`), costing diff coverage and per-test attribution. This one
871
+ * is used exactly where detection has nothing to read and would otherwise SYNTHESIZE a command -
872
+ * `socketio/socket.io`'s root manifest declares no scripts at all, detection guessed
873
+ * `npx --no-install mocha`, and that guess finds no test files in a root that has no test
874
+ * directory. A declaration from the repository's own CI beats a guess; it does not beat a
875
+ * declaration.
876
+ *
877
+ * IT DOES BEAT A DECLARATION THE PRODUCT HAS ALREADY REFUSED (konva, 2026-08-26). Where `init`'s
878
+ * own end-to-end door has diagnosed `scripts.test` as unrunnable in the sealed environment and
879
+ * found no unit stage inside it, that script is not a declaration Abloh can act on, and this one
880
+ * is taken instead. `init-cmd.ts` holds the condition.
881
+ */
882
+ testCommand: CiTestCommand | null;
883
+ /** `ci.yml::test`, `.travis.yml::script`, or null when nothing was chosen. */
884
+ job: string | null;
885
+ /**
886
+ * THE CHOSEN JOB AS FACTS RATHER THAN AS A STRING, for the borrow lane.
887
+ *
888
+ * WHY THIS IS HERE (Kenneth's onboarding-flip decision, 2026-08-28). `job` above is a receipt: it
889
+ * names, for a human reading `abloh.yml`, which of their files a derived line was read out of.
890
+ * The setup PR needs something different - it has to APPEND ONE STEP to that job, in that file,
891
+ * and it cannot do that from `"ci.yml::test"` without splitting a string somebody else composed
892
+ * and hoping the halves are a path and a job id. Two readings of one decision is exactly the
893
+ * shape `measurement-plan.ts` was written to end, one layer down.
894
+ *
895
+ * IT IS ALSO WHAT SAYS THE JOB IS BORROWABLE AT ALL. A job the step can ride has to install, and
896
+ * has to run the suite; `builds` is the third fact and is deliberately not required, because
897
+ * plenty of readable CI has no build step and borrowing its tree is still the right lane.
898
+ *
899
+ * NULL WHENEVER `job` IS NULL, and null on a non-GitHub file: `.travis.yml` and CircleCI declare
900
+ * suites abloh reads for their commands, and neither has a `jobs.<id>.steps` list to append a
901
+ * GitHub Action to. Those repositories take the fallback road, which is what it is for.
902
+ */
903
+ borrow: CiBorrowJob | null;
904
+ /**
905
+ * THE JOBS THAT SCORED WITHIN REACH OF THE CHOSEN ONE, and it is what makes ambiguity sayable.
906
+ *
907
+ * The design record's own caveat: "Job selection in large matrices (Storybook-scale CI) is the new
908
+ * hard sub-problem and gets its own 'needs your edit' state rather than a silent guess." A silent
909
+ * guess is what a single `job` field forces, because a caller holding one name cannot tell a clear
910
+ * winner from a coin toss. These are the rivals, in score order, so the caller can.
911
+ */
912
+ borrowRivals: readonly CiBorrowJob[];
913
+ /**
914
+ * EVERY JOB THE WALK READ IN THE TIER THAT ANSWERED, whether or not it scored.
915
+ *
916
+ * WHAT IT IS FOR, AND THE ONE THING IT IS NOT FOR (borrow-coverage audit, 2026-08-30). It is the
917
+ * list a `setup.job` DECLARATION resolves against, and nothing else reads it. Abloh's own choice
918
+ * still comes from {@link borrow} and {@link borrowRivals} - jobs that declare a suite this module
919
+ * recognized - because "which job is the front door" is a question the reading answers.
920
+ *
921
+ * A MAINTAINER ANSWERS A DIFFERENT QUESTION. They are not ranking; they are stating which job
922
+ * their verdict comes from, and a job whose suite runs through a repo-local binary or whose CI
923
+ * runs no suite at all is still a job that installs and builds - which is the whole of what the
924
+ * borrow lane needs from it. So the declaration resolves against everything read, and the facts
925
+ * that would stop the step working anyway - the runner, the trigger, the gates, the install - are
926
+ * checked in `setup-job.ts` exactly as they are for a job abloh picked itself.
927
+ *
928
+ * EMPTY WHEN THE WALK DERIVED NOTHING. A recipe that refused before it reached a job offers no
929
+ * list, which is the same answer it has always given a pin it could not resolve.
930
+ */
931
+ declarableJobs: readonly CiBorrowJob[];
932
+ /**
933
+ * THE RUNNER IMAGE THE CHOSEN JOB DECLARES, read from its own `runs-on:`.
934
+ *
935
+ * `runs-on: ubuntu-24.04` is not a preference. It names a documented, versioned image
936
+ * (`actions/runner-images`) whose contents are published, so a repository that writes it has said
937
+ * - precisely, in a file abloh already reads - which distribution its CI's package names belong
938
+ * to. That is what this is FOR: the OS package names above were written in that distribution's
939
+ * vocabulary, and the proof image is Debian, and Ubuntu 24.04 renamed a large set of libraries at
940
+ * once for its 64-bit `time_t` transition. Without this, a workflow that installs `libasound2t64`
941
+ * hands the proof image a name apt cannot find.
942
+ *
943
+ * IT IS NOT LICENCE TO PREINSTALL THE RUNNER IMAGE. What the declaration is allowed to change is
944
+ * how a name is SPELLED, never how many names there are. Null for windows, macos and a
945
+ * self-hosted label, whose contents are private and therefore undeclared.
946
+ */
947
+ runnerImage: {
948
+ label: string;
949
+ vocabulary: NativeVocabulary;
950
+ } | null;
951
+ /**
952
+ * THE BROWSER LANE THIS REPOSITORY HAS, read from what it committed, or null when it has none.
953
+ *
954
+ * NULL IS THE ORDINARY ANSWER AND ALSO THE INTERESTING ONE. Until 2026-08-29 a browser step in a
955
+ * job produced one sentence for every repository - "a browser lane Abloh does not measure" - and
956
+ * round 5's wall census (M17) measured that sentence on three repositories, of which one has no
957
+ * browser anywhere: `kaitranntt/ccs`'s lane is `bun test tests/e2e/`, matched by the SCRIPT NAME
958
+ * `test:e2e` and by nothing else. `browser-lane.ts` reads manifests and configs instead, so a
959
+ * repository with no browser now gets null here and no sentence at all.
960
+ */
961
+ browserLane: CiBrowserLane | null;
962
+ /**
963
+ * THE JOBS ABLOH COULD NOT READ AT ALL, because they call a workflow rather than running steps.
964
+ *
965
+ * NOT A SUBSET OF ANYTHING ABOVE. A job with a `uses:` has no steps list, so it is not a
966
+ * candidate, not a rival and not declarable - it is simply absent from every other list here, and
967
+ * absent is what made `stylelint/stylelint` wall on a sentence about a job it never saw. Empty is
968
+ * the ordinary answer and it is a statement: this repository's jobs were all readable.
969
+ */
970
+ unreadableJobCalls: readonly CiUnreadableJobCall[];
971
+ /** One sentence naming why nothing was derived, or null when something was. */
972
+ refusal: string | null;
973
+ /**
974
+ * Things a customer should know that did NOT stop the derivation - a build whose CI step also
975
+ * sets environment values Abloh has no key for, a build scoped to one workspace package.
976
+ */
977
+ notices: readonly string[];
978
+ }
979
+ type AptReading = {
980
+ ok: true;
981
+ packages: Array<{
982
+ name: string;
983
+ version: string | null;
984
+ }>;
985
+ } | {
986
+ ok: false;
987
+ why: string;
988
+ } | null;
989
+ /**
990
+ * READ AN apt STEP, OR SAY IT IS NOT ONE.
991
+ *
992
+ * Returns null when the step is not of this class, so the caller falls through to the ordinary
993
+ * system-command refusal unchanged. Returns a refusal when it IS an apt step this module will not
994
+ * translate, because "this looked like your system dependencies and I would not read it" is worth
995
+ * saying - it is the sentence that tells a customer to write `environment.systemPackages` by hand.
996
+ */
997
+ declare function readAptStep(command: string): AptReading;
998
+ /**
999
+ * The repo-defined build class: `make -j build-standalone-ci`, `just build`, `./scripts/build.sh`.
1000
+ *
1001
+ * Returns null when the command is not of this class at all, so the caller falls through to the
1002
+ * package-manager rules. Returns a refusal when it IS of this class and fails one of the
1003
+ * constraints, because "this looked like your build and I would not run it" is worth saying.
1004
+ */
1005
+ declare function classifyRepoDefinedBuild(command: string, reader: RepoReader): {
1006
+ ok: true;
1007
+ why?: undefined;
1008
+ } | {
1009
+ ok: false;
1010
+ why: string;
1011
+ } | null;
1012
+ /** The three `env:` blocks that can supply a `node-version:` expression, innermost last. */
1013
+ interface NodeEnvironment {
1014
+ workflow: Record<string, string>;
1015
+ job: Record<string, string>;
1016
+ step: Record<string, string>;
1017
+ }
1018
+ /**
1019
+ * WHAT THE JOB DECLARES, AS IT DECLARES IT.
1020
+ *
1021
+ * NO CLAMP AND NO MAJOR. This returned `{ major }` and cut every version above the newest reviewed
1022
+ * image down to it, which made `prettier/prettier`'s `26` into `24` under a receipt calling it
1023
+ * CI's own Node, and it read `24.16.0` as `24`, discarding a choice `statelyai/xstate` explains in
1024
+ * its own CI action. Whether an image exists for a version is the RESOLVER's question and it is
1025
+ * asked with the publisher's index in hand; this one's job is to say what the repository wrote.
1026
+ *
1027
+ * @param leg the maintainer's `setup.matrixLeg`, when they have named one for this job. See the
1028
+ * matrix branch below: it is the difference between "which cells does CI prove green" and "which
1029
+ * cell does Abloh's step actually run on".
1030
+ */
1031
+ declare function readCiNodeDeclaration(raw: string | null, matrix: unknown, reader: RepoReader, leg?: MatrixLeg | null | undefined, env?: NodeEnvironment): CiNodeVersion | null;
1032
+ /**
1033
+ * DO TWO JOB REFERENCES NAME THE SAME JOB, whichever spelling each of them uses?
1034
+ *
1035
+ * EXPORTED BECAUSE THE TWO SPELLINGS ARE REAL AND CROSS A SEAM. `CiRecipe.job` is the SHORT form -
1036
+ * `ci.yml::unit`, the basename, which is what this file's own receipts have always printed - and
1037
+ * `setupJobReference` is the LONG one, `.github/workflows/ci.yml::unit`, which is what `setup.job`
1038
+ * takes and what the setup PR appends a step through. Anything comparing the recipe's answer with
1039
+ * the decision's is comparing those two, and a string equality between them is false on every
1040
+ * repository - which would make a reconciliation either never fire or always fire.
1041
+ *
1042
+ * MATCHED ON THE TWO HALVES rather than on the string, so a path that merely ends in the same
1043
+ * characters cannot resolve. `pinMatches` above is the same rule and reads it from here.
1044
+ */
1045
+ declare function sameJobReference(left: string, right: string): boolean;
1046
+ /**
1047
+ * A RECIPE THAT READ NOTHING, with the reason it read nothing.
1048
+ *
1049
+ * FOR THE LANES THAT NEVER CALL THE READER AT ALL - the Python lane returns before it - so a caller
1050
+ * downstream holds a recipe object rather than a null it has to branch on. The refusal is what the
1051
+ * borrow lane quotes when it answers "no job to borrow", so it has to be a sentence rather than an
1052
+ * empty string: "abloh read nothing and will not say why" is the shape this module refuses to have.
1053
+ */
1054
+ declare function emptyCiRecipe(refusal: string): CiRecipe;
1055
+ /**
1056
+ * READ THIS REPOSITORY'S CI AND RETURN THE LINES `init` MAY WRITE.
1057
+ *
1058
+ * Never called anywhere but `init`. See the file header: deriving at run time would move what Abloh
1059
+ * runs from declared to inferred and would let a fork's workflow edit change it.
1060
+ */
1061
+ declare function deriveCiRecipe(input: {
1062
+ repoDir: string;
1063
+ /**
1064
+ * READ THE REPOSITORY THROUGH THIS instead of off the disk under `repoDir`.
1065
+ *
1066
+ * The GitHub App door derives this same recipe with no checkout at all, over bytes fetched from
1067
+ * the contents API. It was accepted by the private `derive` below and not declared here, so the
1068
+ * one caller that needs it could not name it - see that parameter for the argument.
1069
+ */
1070
+ reader?: RepoReader;
1071
+ workDir?: string;
1072
+ /**
1073
+ * DERIVE FROM THIS JOB, `file::jobId`, rather than from the one the ranking would choose.
1074
+ *
1075
+ * WHY A PIN CHANGES THE WHOLE RECIPE AND NOT JUST THE STEP PLACEMENT. Everything in this object is
1076
+ * read out of ONE job: the build steps that become `.abloh/setup.sh`, the test command, the
1077
+ * services, the environment values, the runner image. So "ride `ci.yml::checks` instead" is a
1078
+ * different CONTRACT, not the same contract in a different place - and a pin that moved only the
1079
+ * step would leave abloh borrowing one job while executing the other job's build, which is the
1080
+ * failure the pin exists to escape.
1081
+ *
1082
+ * WHO SETS IT. `abloh.yml`'s own `setup.job`, when a maintainer has declared one, and the
1083
+ * self-healing loop, when a trial has just proved the current job cannot carry it
1084
+ * (`packages/core/src/setup-healing.ts`).
1085
+ *
1086
+ * A PIN THAT NAMES NO JOB THIS WALK READ IS IGNORED, not refused. `setup-job.ts` is the module
1087
+ * that answers a maintainer about an unresolvable `setup.job`, with the jobs it did read in hand;
1088
+ * refusing here would take `init` down over a key that has its own refusal one layer up.
1089
+ */
1090
+ job?: string | undefined;
1091
+ /**
1092
+ * DERIVE FROM THIS LEG of the job's matrix, `abloh.yml`'s own `setup.matrixLeg`.
1093
+ *
1094
+ * WHY THE LEG BELONGS TO THE DERIVATION AND NOT ONLY TO THE PLACEMENT (corpus rehearsal pass 2,
1095
+ * 2026-08-30, finding 2). `setup.job` and `setup.matrixLeg` are ONE answer - the job says which
1096
+ * build, the leg says which of that build's cells - and `setupStepIf` puts Abloh's step behind an
1097
+ * `if:` that is true on that cell alone. A recipe derived without it describes a DIFFERENT cell
1098
+ * than the one the step runs on, and the first thing that goes wrong is the runtime: the sealed
1099
+ * measurement image takes its Node from `ci.node.major`, so a maintainer who pinned `node: 22`
1100
+ * was measured on Node 24.
1101
+ *
1102
+ * A LEG THIS WALK'S CHOSEN JOB DOES NOT OFFER CHANGES NOTHING. Every reader of it below checks
1103
+ * the dimension and the value against the matrix in hand, so a leg belonging to another job -
1104
+ * which `setup-job.ts` refuses one layer up, by name and with the legs listed - cannot quietly
1105
+ * redirect a derivation here.
1106
+ */
1107
+ leg?: MatrixLeg | undefined;
1108
+ }): CiRecipe;
1109
+ /**
1110
+ * ONE PACKAGE-MANAGER VERSION THIS REPOSITORY'S OWN CI DECLARES, and the job that says so.
1111
+ *
1112
+ * WHY IT IS READ (the 2026-09-11 detection audits, I2 and P1). The pin offer started from the tool
1113
+ * installed on the laptop `abloh init` was typed on, and then edited the repository's own workflows
1114
+ * to agree with it. `react-hook-form` already says pnpm 11.7.0 inside a local action, `0no-co/gql.tada`
1115
+ * already says 11.3.0 in its release workflow, and both were offered this machine's 10.34.5 instead.
1116
+ * A version a repository has written down is a decision somebody made; a version this machine
1117
+ * happens to carry is an accident of who typed the command.
1118
+ *
1119
+ * `declared` IS THE FILE'S OWN TEXT AND {@link exact} SAYS WHETHER IT NAMES A RELEASE. `version: 11`
1120
+ * is a real declaration and is not a pin: `packageManager` accepts `X.Y.Z` alone, so a major can
1121
+ * agree with an exact version or disagree with it, and can never become one.
1122
+ */
1123
+ interface CiManagerVersion {
1124
+ pm: PackageManagerName;
1125
+ /** As the workflow writes it: `11.7.0`, `11`, `8`. */
1126
+ declared: string;
1127
+ /** True when {@link declared} is the exact `X.Y.Z` shape `packageManager` accepts. */
1128
+ exact: boolean;
1129
+ /** `.github/workflows/build-test.yml::build`, the job it was read out of. */
1130
+ source: string;
1131
+ /** `pnpm/action-setup` input `version`, or `corepack`, for saying where it was read. */
1132
+ by: "action-input" | "corepack" | "tool-versions";
1133
+ }
1134
+ /**
1135
+ * THE INSTALL STEP ONE OF THIS REPOSITORY'S OWN CI JOBS RUNS, KEPT WHOLE.
1136
+ *
1137
+ * WHAT THIS REPLACED, AND WHY A NAME WAS NOT ENOUGH (the 2026-09-11 detection audits, I1, I3 and L1).
1138
+ * The reading before it kept two fields - the manager's NAME and the job - and threw the rest of the
1139
+ * step away. Three separate defects came out of that one loss, and each of them is a field here:
1140
+ *
1141
+ * - THE ARGUMENTS. `spliit-app/spliit` installs with `npm ci --ignore-scripts` because its
1142
+ * `postinstall` runs `prisma migrate deploy` against a database that does not exist yet, and its
1143
+ * own workflow says so in a comment. abloh composed `npm ci --no-audit --no-fund` from the
1144
+ * manager name, which runs that migration. `graphql/graphql-js` and `goniszewski/grimoire`
1145
+ * suppress scripts the same way; `web-infra-dev/garfish` excludes two workspace packages.
1146
+ * - THE DIRECTORY. `grimoire` installs its root with npm and its `daemon` with bun, and the reading
1147
+ * combined the two into one repository-wide pair - so a directory whose CI names one manager
1148
+ * outright was described to its maintainer as a guess.
1149
+ * - THE VERSION. See {@link CiManagerVersion}.
1150
+ *
1151
+ * {@link command} IS NULL WHERE THE STEP IS NOT ONE COMMAND, and that is a different answer from
1152
+ * finding nothing. `ruvnet/ruflo` installs through a retry loop - a `until ... npm ci ... done` shell
1153
+ * program - which says which manager installs which directory and is not a line abloh may execute.
1154
+ * So the manager and the directory are read and the prefill falls back to the standard form, which
1155
+ * is exactly what a record with a name in it and no command means.
1156
+ */
1157
+ interface CiInstallStep {
1158
+ pm: PackageManagerName;
1159
+ /**
1160
+ * The command line, verbatim, with every argument the workflow wrote - or null where the step is
1161
+ * a shell program rather than one command.
1162
+ *
1163
+ * VERBATIM IS THE POINT. Nothing here is normalized, re-ordered, or given a flag of abloh's: the
1164
+ * whole class of defect this record exists for is abloh composing a command that differs from the
1165
+ * one the maintainer already proved runs on a checkout of this repository.
1166
+ */
1167
+ command: string | null;
1168
+ /** The step's own `env:`, literal values only. A value the workflow computes is not carried. */
1169
+ env: Readonly<Record<string, string>>;
1170
+ /** Repo-relative with forward slashes, `.` at the root: step `working-directory`, else the job's. */
1171
+ directory: string;
1172
+ /** `.github/workflows/ci.yml::checks` - the job the step was read out of. */
1173
+ source: string;
1174
+ /** Every package-manager version that job declares for {@link pm}, in the order they were read. */
1175
+ versions: readonly CiManagerVersion[];
1176
+ }
1177
+ /**
1178
+ * EVERY INSTALL STEP THIS REPOSITORY'S OWN CI RUNS, whole, in workflow order.
1179
+ *
1180
+ * WHY THIS ONE MAY BE CALLED OUTSIDE `init`, when {@link deriveCiRecipe} may not. The file header's
1181
+ * rule is about DERIVING A COMMAND FOR A RUN TO EXECUTE: a line read out of a workflow and turned
1182
+ * into what abloh runs on a pull request would move the measurement from declared to inferred, and
1183
+ * would let a fork's workflow edit change it. Two of this function's three readers are tie-breaks
1184
+ * between files the repository has already committed - which lockfile owns a directory, and which
1185
+ * version a pin should name - and the third is `abloh init`, which is the one door where reading CI
1186
+ * and writing a line a person then ratifies is the whole point. Nothing here is executed by a run.
1187
+ *
1188
+ * IT IS DELIBERATELY NOT SCOPED TO THE CHOSEN TEST JOB. A repository whose CI is refused for a
1189
+ * browser, a toolchain or an unreadable test step still installs the way it installs, and that fact
1190
+ * is worth just as much when the rest of the recipe is unreadable.
1191
+ */
1192
+ declare function ciInstallSteps(repoDir: string, reader?: RepoReader): readonly CiInstallStep[];
1193
+ /**
1194
+ * EVERY PACKAGE-MANAGER VERSION THIS REPOSITORY'S CI DECLARES, in every workflow, in file order.
1195
+ *
1196
+ * REPOSITORY-WIDE AND NOT SCOPED TO A TIER OR A DIRECTORY, and that is what the field it feeds is.
1197
+ * `packageManager` is one line in one manifest and `pnpm/action-setup` reads it in EVERY job that
1198
+ * runs - so a pin has to agree with every declaration in the repository, not with the ones in the
1199
+ * jobs that happen to run on a pull request. `reconcileCiDeclarations` in `package-manager-pin.ts`
1200
+ * already walks the whole directory for exactly that reason: reconciling only the ridden file would
1201
+ * leave the pin breaking a release job nobody looked at.
1202
+ *
1203
+ * IT COST A REAL ANSWER TO SCOPE IT (the 2026-09-11 audits' P1). `0no-co/gql.tada` declares pnpm
1204
+ * major 11 in the three workflows that run on a pull request and the exact `11.3.0` in the release
1205
+ * workflow, which runs on a push - so a pull-request-tier reading finds no release anywhere and asks
1206
+ * for a version the repository has already chosen. `ant-design/ant-design-mobile` is the same shape
1207
+ * with the opposite consequence: its `size-limit.yml` says pnpm 8 where six other workflows say 7,
1208
+ * and a reading that could not see it would call the repository agreed when it is not.
1209
+ */
1210
+ declare function ciManagerVersions(repoDir: string, reader?: RepoReader): readonly CiManagerVersion[];
1211
+ /**
1212
+ * THE MANAGER FAMILIES THIS REPOSITORY'S CI INSTALLS ONE DIRECTORY WITH, distinct and in file order.
1213
+ *
1214
+ * The tie-break `detect.ts` asks at a directory carrying more than one family of lockfile. It is
1215
+ * asked OF A DIRECTORY since the 2026-09-11 audits' L1: `goniszewski/grimoire` installs its root
1216
+ * with npm and its `daemon` with bun, and a repository-wide answer read those two as a repository
1217
+ * that had not chosen - so a root whose own CI names npm outright was described to its maintainer
1218
+ * as an assumption.
1219
+ *
1220
+ * A NULL DIRECTORY IS THE SECOND QUESTION, and the caller asks it only where the first answered
1221
+ * nothing: what does this repository install with ANYWHERE. `ruvnet/ruflo`'s own install step is two
1222
+ * subshells that each `cd` into a bridge package, so nothing in its CI installs the root - and the
1223
+ * two lockfiles the root carries still have to be decided between. The answer carries the directory
1224
+ * it was read from, because a receipt that says "your CI installs with npm" over evidence taken from
1225
+ * a subdirectory is the misplaced source this record was written to end.
1226
+ */
1227
+ declare function ciInstallersFor(steps: readonly CiInstallStep[],
1228
+ /** The repo-relative directory being decided, or null to ask what the repository installs at all. */
1229
+ directory: string | null): readonly {
1230
+ pm: PackageManagerName;
1231
+ source: string;
1232
+ directory: string;
1233
+ }[];
1234
+ type PackageManagerName = "npm" | "pnpm" | "yarn" | "bun";
1235
+ /** Exported for the tests that hold the refusal wording to its ruling. */
1236
+ declare const CI_RECIPE_REFUSALS: {
1237
+ readonly browser: "browser-driven suites are not supported in the sealed run - keep them in your own CI";
1238
+ readonly noWorkflows: "no .github/workflows directory";
1239
+ readonly noPullRequestWorkflow: "no workflow runs on pull_request, so your CI declares no build Abloh can read";
1240
+ readonly noTestJob: "no pull-request job runs a test command Abloh recognizes";
1241
+ readonly noTestJobAnywhere: "no job in your CI runs a test command Abloh recognizes";
1242
+ };
1243
+ /**
1244
+ * Is this refusal worth a customer's attention, or is it just "there was nothing here to read"?
1245
+ *
1246
+ * THE DISTINCTION IS THE WHOLE VALUE OF PRINTING REFUSALS AT ALL. "Your CI test job needs a
1247
+ * browser" tells somebody why their suite will not go green and what to do instead. "No
1248
+ * .github/workflows directory" tells a repository with no GitHub Actions something it already
1249
+ * knows, on every `init`, forever - and a notice that fires on the majority of repositories is the
1250
+ * one people learn to skip past, taking the useful ones with it.
1251
+ */
1252
+ declare function ciRefusalIsActionable(refusal: string | null): boolean;
1253
+ /** Exported for the unit tests that exercise one rule at a time rather than a whole repository. */
1254
+ declare const __testing: {
1255
+ classifyRepoDefinedBuild: typeof classifyRepoDefinedBuild;
1256
+ readAptStep: typeof readAptStep;
1257
+ readCiNodeDeclaration: typeof readCiNodeDeclaration;
1258
+ };
1259
+
1260
+ /**
1261
+ * WHICH OF THE CUSTOMER'S OWN CI JOBS THE ABLOH STEP RIDES, AND WHEN ABLOH REFUSES TO DECIDE.
1262
+ *
1263
+ * WHAT CHANGED (Kenneth's onboarding-flip decision, 2026-08-28). The old default wrote a STANDALONE
1264
+ * workflow: a fresh job on a blank runner, which builds nothing, so abloh had to rebuild the whole
1265
+ * project from a recipe it had guessed. The measured cost of that lane is 1 of 6 repositories
1266
+ * surviving first contact with zero edits. The new default appends ONE step to the job the customer
1267
+ * already gates their own pull requests on, so their steps do the build and abloh borrows the
1268
+ * result. The measured ceiling of the borrow lane is 23 of 29 repositories having a build to ride.
1269
+ *
1270
+ * SO THE WHOLE PROBLEM MOVES TO ONE QUESTION: which job. This module answers it, and its most
1271
+ * important answer is the one where it does not.
1272
+ *
1273
+ * NEVER A SILENT GUESS. The design record's own caveat: "Job selection in large matrices
1274
+ * (Storybook-scale CI) is the new hard sub-problem and gets its own 'needs your edit' state rather
1275
+ * than a silent guess." A guess here is uniquely expensive, because every later stage then argues
1276
+ * about the wrong build - the trial's baseline runs a suite the maintainer does not gate on, and a
1277
+ * green setup check would mean something other than what it says. Two shapes get the "needs your
1278
+ * edit" state:
1279
+ *
1280
+ * A TIE. Two or more jobs install, build and test, and the ranking cannot separate them.
1281
+ * A MATRIX. The chosen job fans out, so "the job" is several builds and abloh cannot say which
1282
+ * leg's tree it would be borrowing.
1283
+ *
1284
+ * Both name `abloh.yml` key `setup.job`, and that key EXISTS - `config.ts` parses it in the same
1285
+ * change that added these refusals. A remedy whose edit cannot change the refused state is the
1286
+ * defect the 2026-08-28 census counted, and shipping the refusal before the key would have been it.
1287
+ *
1288
+ * AND A CONFIDENT READING IS NOT A LICENCE TO STAY SILENT (captain's ruling, 2026-08-31, off the
1289
+ * fresh-ten run). A tie was never the only way to ride the wrong job. `prettier/prettier`'s ranking
1290
+ * had a clear WINNER, the winner was a LINT job, and the step went there without a word - four of
1291
+ * ten repositories measured a job that ran no tests. So a `borrow` outcome now carries a QUESTION
1292
+ * too: the jobs the choice was made between, chosen first, whenever there was more than one. It is
1293
+ * not a refusal and it changes no unattended road - the ranking still picks and still rides - it is
1294
+ * the set a terminal gets asked about before the draft is written.
1295
+ *
1296
+ * WHAT THIS DOES NOT DO. It reads nothing off disk and starts nothing: `ci-recipe.ts` has already
1297
+ * read the workflows under its own bounds and its own refusals, and a second reading here would be
1298
+ * a second answer to "what does this repository's CI declare". Everything below is a decision over
1299
+ * facts that reader produced.
1300
+ */
1301
+
1302
+ /**
1303
+ * One value per matrix dimension, as `abloh.yml`'s `setup.matrixLeg` spells it.
1304
+ *
1305
+ * EACH VALUE KEEPS ITS TYPE, and `matrix-value.ts` in core is the whole reason: the condition
1306
+ * `setup-step.ts` writes compares a YAML boolean bare and everything else quoted, so a leg flattened
1307
+ * to text is one the renderer cannot spell correctly any more.
1308
+ */
1309
+ type SetupMatrixLeg = MatrixLeg;
1310
+ /**
1311
+ * WHAT THE COMMITTED `setup:` BLOCK SAID, INCLUDING "SOMETHING ABLOH COULD NOT READ".
1312
+ *
1313
+ * `unreadable` IS THE FIELD THAT DID NOT EXIST (blind-maintainer report, `electron/asar`,
1314
+ * 2026-08-30). A pin that failed to parse used to come back as an empty object - indistinguishable
1315
+ * from a repository that had never answered - so the product asked its one question twice and
1316
+ * behaved, both times, as though nothing had been written. It travels as a value rather than as a
1317
+ * throw because the caller is `abloh init`, which has a refusal channel and no error channel here.
1318
+ */
1319
+ interface SetupPin {
1320
+ job?: string;
1321
+ leg?: SetupMatrixLeg;
1322
+ /**
1323
+ * `setup.failFast`: what the maintainer already told abloh to do about a job that cancels its legs.
1324
+ *
1325
+ * IT RIDES WITH THE JOB AND THE LEG BECAUSE IT IS THE SAME STATEMENT. All three are answers about
1326
+ * one job, and a caller holding two of them without the third would re-ask a question that has
1327
+ * been answered - which is the whole of what `setup.job` and `setup.matrixLeg` already refuse.
1328
+ */
1329
+ failFast?: SetupFailFast;
1330
+ unreadable?: {
1331
+ key: string;
1332
+ problem: string;
1333
+ };
1334
+ }
1335
+ /**
1336
+ * `setup.job` out of the repository's committed `abloh.yml`, or undefined.
1337
+ *
1338
+ * READ OFF DISK, and that is correct HERE and would be wrong on the run path. This is `init`,
1339
+ * looking at the working tree in front of the maintainer; a pull-request run loads its policy from
1340
+ * the trusted merge-base commit precisely so a later commit cannot change what runs, and nothing in
1341
+ * this module is ever called from there (`measurement-plan-resolver.ts` states the same split).
1342
+ *
1343
+ * AN UNREADABLE POLICY ANSWERS NOTHING rather than throwing. `abloh run` fails a malformed file
1344
+ * with its own precise error, and job selection has never been the surface that reports one.
1345
+ */
1346
+ declare function declaredSetupJob(repoDir: string): string | undefined;
1347
+ /**
1348
+ * `setup.job` AND `setup.matrixLeg` TOGETHER, because they are one answer.
1349
+ *
1350
+ * A maintainer who names a leg has answered a question about a job, and reading the two through
1351
+ * separate doors would let a caller hold one without the other - which is how a leg would end up
1352
+ * being applied to a job nobody declared, or a declared matrix job would meet the fan-out refusal
1353
+ * again with the answer sitting in the file beside it.
1354
+ */
1355
+ declare function declaredSetupPin(repoDir: string, ctx?: RepoAccess): SetupPin;
1356
+ /**
1357
+ * THE PIN THIS RUN IS ABOUT, out of the two places one can arrive from, and never out of both.
1358
+ *
1359
+ * TWO SOURCES. `caller` is what this invocation was HANDED - the control plane's healing loop, or
1360
+ * an `abloh init --answers-file` document written by a census or an agent. `committed` is the
1361
+ * `setup:` block already in the repository's `abloh.yml`.
1362
+ *
1363
+ * THE CALLER OUTRANKS THE FILE, whole, because it is the newer statement of the same fact: a loop
1364
+ * that has just proved the committed job cannot carry the trial is not asking to re-derive it, and
1365
+ * a census handing over the answer a maintainer would have written is standing in for that
1366
+ * maintainer.
1367
+ *
1368
+ * AND IT OUTRANKS IT AS ONE ANSWER, not key by key. Taking the caller's job and the file's leg would
1369
+ * pin a leg of one job onto another, which `matrixLegProblem` would then refuse for reasons nobody
1370
+ * could act on. NAMING EITHER KEY IS SPEAKING: a caller that names only a leg - which is what a
1371
+ * cross-product matrix asks for, and is the whole of the teaser census's finding 2 - has stated the
1372
+ * answer for whichever job abloh goes on to choose, and reading the file's job underneath it would
1373
+ * be the same mix one direction later.
1374
+ */
1375
+ declare function resolveSetupPin(caller: {
1376
+ job?: string;
1377
+ matrixLeg?: SetupMatrixLeg;
1378
+ failFast?: SetupFailFast;
1379
+ }, committed: SetupPin): SetupPin;
1380
+ /**
1381
+ * WHAT `abloh init` DOES ABOUT THE STEP, in three outcomes and no fourth.
1382
+ *
1383
+ * `borrow` is the default lane. `needs-your-edit` is the honest stop - the contract is still
1384
+ * drafted and the setup PR is still opened, because the PR body IS where a maintainer answers this,
1385
+ * and refusing to open it would put the question somewhere they have to go and find. `fallback` is
1386
+ * the demoted old lane: the standalone workflow and the rebuild recipe, which are now what happens
1387
+ * when there is no build to borrow rather than what happens to everybody.
1388
+ */
1389
+ type SetupJobDecision = SetupJobOutcome & {
1390
+ /**
1391
+ * THE JOBS A `setup.job` DECLARATION WOULD BE ADMITTED FOR, `file::job`, on every outcome.
1392
+ *
1393
+ * ON THE DECISION AND NOT RECOMPUTED BY THE PAGE (census cycle 2 retry, `vitejs/vite`,
1394
+ * 2026-08-31), on the same argument `answerable` makes above and on `runSetupFlow`'s own rule that
1395
+ * it is handed a decision rather than a recipe. The refusal prints this list and the setup pull
1396
+ * request offers it; a second computation would be the first place the two could come to name
1397
+ * different jobs, and a maintainer would be offered one that the next run refuses.
1398
+ *
1399
+ * See {@link declarableSetupJobs}. Empty means no declaration works here, which is a thing the
1400
+ * fallback road says out loud rather than leaving as an absence.
1401
+ */
1402
+ offers: readonly string[];
1403
+ };
1404
+ /**
1405
+ * A QUESTION ABOUT WHICH JOB, WITH THE ONLY VALUES ITS ANSWER MAY TAKE.
1406
+ *
1407
+ * ONE TYPE FOR BOTH ROADS SINCE 2026-08-31, because since the captain's ruling below there are two:
1408
+ * the tie abloh could not settle, and the choice abloh settled but did not have the right to settle
1409
+ * silently. The prompt asks them with one function and the difference between them is the
1410
+ * RECOMMENDATION, not the shape of the answer.
1411
+ *
1412
+ * THE FIRST CANDIDATE IS THE JOB ABLOH WOULD RIDE. On a tie that is the higher-scoring of two jobs
1413
+ * the ranking calls equal, so nothing is recommended; on a settled choice it is a real winner and
1414
+ * the prompt says so. Either way it is first, so a maintainer who agrees with abloh types `1`.
1415
+ */
1416
+ interface SetupJobQuestion {
1417
+ key: "setup.job";
1418
+ /** `<file>::<jobId>` for the job abloh would ride, then every other job it could ride instead. */
1419
+ candidates: readonly string[];
1420
+ /**
1421
+ * THE ONE CLAUSE THAT FLAGS A CANDIDATE, PER REFERENCE, and absent for a candidate with nothing
1422
+ * to flag.
1423
+ *
1424
+ * WHY THE LIST CARRIES IT FROM THE FIRST PASS (Kenneth's ruling of 2026-09-02, census run 4 F4).
1425
+ * A matrix job with no `fail-fast: false` cancels every leg the moment one fails, abloh's leg
1426
+ * included, and `sveltejs/svelte` lost a nearly-finished measurement to exactly that. The
1427
+ * maintainer is the one who knows whether that matters, and they are already standing at this
1428
+ * question choosing between these jobs - so the fact goes on the line they are choosing from
1429
+ * rather than in a sentence after the choice is made.
1430
+ *
1431
+ * COMPUTED WITH THE CANDIDATES AND NEVER BY THE PROMPT, on this file's standing rule: the decision
1432
+ * owns the answer set, and a second reading in the door is where the flag and the question that
1433
+ * follows it would come to disagree about which jobs cancel.
1434
+ */
1435
+ notes: Readonly<Record<string, string>>;
1436
+ }
1437
+ /**
1438
+ * WHAT ABLOH DOES ABOUT THE RIDDEN JOB'S `fail-fast`, decided once and read by everything after.
1439
+ *
1440
+ * IT IS ON THE DECISION FOR THE SAME REASON THE LEG IS. The step writer needs to know whether to
1441
+ * write a line into the customer's `strategy:` block, the contract renderer needs to know what to
1442
+ * record, and the door needs to know whether there is a question at all. Three readings of one
1443
+ * workflow node is three answers the first time any of them learns something.
1444
+ *
1445
+ * `packages/core/src/setup-fail-fast.ts` holds the rule, the copy and the run it was measured on.
1446
+ */
1447
+ interface SetupFailFastDecision {
1448
+ /** Does a failing leg of this job cancel the leg abloh's step rides? */
1449
+ cancelsLegs: boolean;
1450
+ /** May abloh write `fail-fast: false` into it, or is there a value of theirs in the way? */
1451
+ editable: boolean;
1452
+ /** The maintainer's standing answer, or null when nobody has answered. */
1453
+ answer: SetupFailFast | null;
1454
+ /**
1455
+ * WRITE THE LINE, and this is the only field the step writer reads.
1456
+ *
1457
+ * BOTH HALVES, ASKED HERE. An `add` answer on a job abloh may not edit writes nothing, and a
1458
+ * caller that tested the answer alone would replace a `fail-fast:` the maintainer typed. One
1459
+ * field means the flow cannot ask half the question.
1460
+ */
1461
+ write: boolean;
1462
+ /** What a maintainer is told about it, or null when there is nothing to say. */
1463
+ notice: string | null;
1464
+ }
1465
+ type SetupJobOutcome = {
1466
+ kind: "borrow";
1467
+ job: CiBorrowJob;
1468
+ declared: boolean;
1469
+ excluded: readonly SetupCandidateExclusion[];
1470
+ /**
1471
+ * THE JOBS THIS REPOSITORY COULD HAVE RIDDEN INSTEAD, or null when there was no choice.
1472
+ *
1473
+ * WHY A SETTLED DECISION CARRIES A QUESTION AT ALL (captain's ruling, 2026-08-31, off the
1474
+ * fresh-ten run). The ranking is a reading of what a job DOES - it installs, it builds, it runs
1475
+ * a suite abloh recognized - and on `prettier/prettier` that reading has a clear winner: the
1476
+ * LINT job. The step went there, silently, because nothing was tied. Four of the ten
1477
+ * repositories measured a job that ran no tests, and every wrong desk-check prediction in that
1478
+ * run was a job-selection prediction.
1479
+ *
1480
+ * THE MAINTAINER KNOWS THIS INSTANTLY. It is the check that goes red when they break
1481
+ * something. Five seconds of typing against a wrong pick costing a full CI cycle and a verdict
1482
+ * that means nothing is not a close call, so the rule is: more than one candidate, ASK.
1483
+ *
1484
+ * NULL WHERE THERE IS NOTHING TO ASK. One candidate is not a choice, and a repository whose
1485
+ * maintainer already declared `setup.job` has answered - re-asking would put the question to
1486
+ * somebody who wrote the answer down.
1487
+ *
1488
+ * EVERY VALUE IN IT IS ONE {@link doorSetupJobs} ADMITS, which is what stops the prompt
1489
+ * offering a job the next run's declaration branch refuses.
1490
+ */
1491
+ answerable: SetupJobQuestion | null;
1492
+ /**
1493
+ * WHAT ABLOH DOES ABOUT THIS JOB CANCELLING ITS OWN LEGS. See {@link SetupFailFastDecision}.
1494
+ *
1495
+ * ONLY ON `borrow`, and that is the shape rather than an omission. Every other outcome has no
1496
+ * job abloh is about to ride - a fan-out places no step until a leg is named, a fallback rides
1497
+ * a workflow of abloh's own - so there is nothing for the question to be about and nothing for
1498
+ * the step writer to act on. The flag on the job LIST is a different thing and rides on
1499
+ * {@link SetupJobQuestion.notes}, which every road that asks about jobs carries.
1500
+ */
1501
+ failFast: SetupFailFastDecision;
1502
+ /**
1503
+ * THE RIDE IS HELD ONLY UNTIL THE TRIAL ANSWERS.
1504
+ *
1505
+ * TRUE WHERE NO JOB IN THIS CI RUNS A SUITE ABLOH RECOGNIZED (the captain's ride-and-prove
1506
+ * ruling, 2026-09-04). `spliit-app/spliit`'s one pull-request job installs and then runs
1507
+ * types, lint and formatting, so the borrow road used to be closed to it - and what the road
1508
+ * needs from somebody else's job is a prepared tree, which that job produces. So the job that
1509
+ * installs is ridden, and the setup trial runs abloh's own derived test command in it once.
1510
+ * The ride is kept only if the baseline there is green and observed.
1511
+ *
1512
+ * NOTHING IN THIS MODULE ACTS ON IT, AND THAT IS THE POINT. A prediction about whether a
1513
+ * suite will run in a tree nobody has run it in is exactly the guess the ruling forbids. The
1514
+ * decision to keep or abandon is `decideSetupHealing`'s, taken from a finished trial, and it
1515
+ * reads the job's own `tests` fact rather than this flag - see `setupRideIsProvisional`. What
1516
+ * this field is for is the surfaces: a terminal and a setup pull request body saying that
1517
+ * abloh rode a job whose green does not currently mean a suite passed.
1518
+ *
1519
+ * FALSE ON A DECLARED JOB, whatever it runs. A maintainer naming `setup.job` has stated which
1520
+ * job their verdict comes from, and a mechanism that abandoned their answer after one trial
1521
+ * would be overriding the one statement in the file that was never abloh's to discard.
1522
+ */
1523
+ provisional: boolean;
1524
+ /**
1525
+ * THE MATRIX LEG THE STEP IS PINNED TO, or null when the job is one build.
1526
+ *
1527
+ * IT IS PART OF THE DECISION AND NOT A DETAIL OF IT. The step that gets written carries an
1528
+ * `if:` that is true on this leg alone, and the contract in `abloh.yml` states it, so a caller
1529
+ * that held the job without the leg would place a step that runs on every leg - which is the
1530
+ * fan-out this whole key exists to end.
1531
+ */
1532
+ leg: SetupMatrixLeg | null;
1533
+ } | {
1534
+ kind: "needs-your-edit";
1535
+ /**
1536
+ * THE JOB THE QUESTION IS ABOUT, or null when the question is about the contract itself.
1537
+ *
1538
+ * NULL IS NEW AND IT IS NOT A DEFAULT (blind-maintainer report, `electron/asar`, 2026-08-30).
1539
+ * `setup-contract-unreadable` is raised over a `setup:` block abloh could not parse, which can
1540
+ * happen in a repository whose CI abloh could not read either - there is then no job to name,
1541
+ * and naming one anyway would be inventing the half of the sentence that is missing.
1542
+ */
1543
+ job: CiBorrowJob | null;
1544
+ refusal: Refusal;
1545
+ excluded: readonly SetupCandidateExclusion[];
1546
+ /**
1547
+ * MAY THE ONE STEP GO IN WHILE THE MAINTAINER ANSWERS?
1548
+ *
1549
+ * TRUE FOR A TIE AND FALSE FOR A MATRIX, and the difference is not a preference (2026-08-29,
1550
+ * peer implementation review defect 7).
1551
+ *
1552
+ * A TIE IS ONE JOB. Abloh picked the higher-scoring of two, the step runs once, one trial
1553
+ * reports, and the maintainer's answer redirects it if the other was meant. Withholding the
1554
+ * edit there would cost them a whole round trip to learn something a trial can tell them now.
1555
+ *
1556
+ * A MATRIX IS SEVERAL BUILDS SHARING ONE JOB ID. Appending the step fans it out over every
1557
+ * leg: several trials, several runtime-derived plans, and - because GitHub's `GITHUB_JOB` is
1558
+ * the job KEY and is identical on every leg - reports that no reader can tell apart, racing
1559
+ * into one setup row. The last one to land wins, which makes "the trial said" a statement
1560
+ * about scheduling. Nothing downstream can recover that, so the edit waits.
1561
+ */
1562
+ placeStep: boolean;
1563
+ /**
1564
+ * WHAT AN ANSWER MAY BE, STATED BY THE DECISION THAT COULD NOT SETTLE IT.
1565
+ *
1566
+ * WHY IT IS ON THE DECISION AND NOT RECOMPUTED BY THE ASKER (Kenneth's ruling of 2026-08-30:
1567
+ * a foreseeable question is asked upfront). The refusal already carries these values as facts,
1568
+ * because a remedy that names `setup.job` without naming the jobs is a key with no answer in
1569
+ * it. A door that asked the same question would have to rank the candidates and filter the
1570
+ * legs a second time, and the first time those two rankings disagreed the maintainer would be
1571
+ * offered an answer this function then refuses - the remedy-that-cannot-work defect, one
1572
+ * layer in. So the set is computed once, here, and the refusal and the prompt read the same
1573
+ * one.
1574
+ *
1575
+ * THE TWO SHAPES ARE THE TWO KEYS, and they are not interchangeable: a declaration answers
1576
+ * "which job" and cannot answer "which leg" (`decideSetupJob` refuses a declared matrix job
1577
+ * for exactly that reason), so the prompt has to know which of the two it is asking about.
1578
+ *
1579
+ * NULL IS THE UNREADABLE-DECLARATION CASE, and it is an explicit null rather than an optional
1580
+ * key so a new return site cannot forget to decide: the maintainer already answered, abloh
1581
+ * could not read what they wrote, and a door that re-asked would overwrite a written
1582
+ * declaration instead of sending them to fix it. The refusal's own located remedy is the
1583
+ * whole of what that case offers.
1584
+ */
1585
+ answerable: SetupJobQuestion | {
1586
+ key: "setup.matrixLeg";
1587
+ /** The job the leg is a leg OF, `<file>::<jobId>`. */
1588
+ job: string;
1589
+ /** Per dimension, the values abloh could ride. See {@link ridableLegs}. */
1590
+ legs: MatrixLegs;
1591
+ } | null;
1592
+ } | {
1593
+ kind: "fallback";
1594
+ refusal: Refusal;
1595
+ excluded: readonly SetupCandidateExclusion[];
1596
+ /**
1597
+ * EVERY JOB OF THEIRS THAT INSTALLS AND WAS NOT RIDDEN, WITH WHY, in score order.
1598
+ *
1599
+ * THE FALLBACK QUESTION SAYS WHY PER JOB (the captain's ruling of 2026-09-10, over "abloh
1600
+ * did not detect"). `abloh init` used to open this road with "No job in your CI runs on pull
1601
+ * requests", which was false of `fastify/fastify` - `ci.yml::test-unit` installs, runs the
1602
+ * suite and runs on every pull request - and said nothing a maintainer could check against
1603
+ * their own file. What they can check is a list: this job, this reason, in the sentences the
1604
+ * setup pull request prints under "Jobs Abloh did not ride, and why".
1605
+ *
1606
+ * INSTALLING JOBS ONLY. A lint or labeler job that installs nothing was never a candidate and
1607
+ * is not what a maintainer means by "my test job"; naming it would pad the list with jobs the
1608
+ * answer could never be. Computed with the decision, on this file's standing rule, so the
1609
+ * prompt and the pull request cannot name different jobs or give one job two reasons.
1610
+ */
1611
+ notRidden: readonly SetupCandidateExclusion[];
1612
+ };
1613
+ /**
1614
+ * DECIDE.
1615
+ *
1616
+ * The order is the order of authority: what the maintainer declared, then whether there is anything
1617
+ * to borrow at all, then whether abloh's own reading is unambiguous.
1618
+ */
1619
+ declare function decideSetupJob(input: {
1620
+ recipe: CiRecipe;
1621
+ /** `abloh.yml`'s `setup.job`, when the repository already declares one. */
1622
+ declaredJob?: string | undefined;
1623
+ /**
1624
+ * `abloh.yml`'s `setup.matrixLeg`, when the repository already declares one.
1625
+ *
1626
+ * IT ANSWERS THE FAN-OUT AND NOTHING ELSE. A leg on a job that is one build is not an error and
1627
+ * not a preference - `matrixLegProblem` refuses it, because a workflow with no matrix declares no
1628
+ * dimension the leg could name, and a key that quietly did nothing would be worse than one that
1629
+ * says so.
1630
+ */
1631
+ declaredLeg?: SetupMatrixLeg | undefined;
1632
+ /**
1633
+ * `abloh.yml` HAD A `setup:` BLOCK AND ABLOH COULD NOT READ IT.
1634
+ *
1635
+ * Handed in from `declaredSetupPin`'s own reading, and it outranks everything below: a decision
1636
+ * taken over an answer that was discarded is a decision taken against the maintainer's stated
1637
+ * intent, and it is what asked `electron/asar` the fan-out question twice.
1638
+ */
1639
+ declaredUnreadable?: {
1640
+ key: string;
1641
+ problem: string;
1642
+ } | undefined;
1643
+ /**
1644
+ * `abloh.yml`'s `setup.failFast`, when the maintainer has answered that question.
1645
+ *
1646
+ * IT DECIDES NOTHING ABOUT WHICH JOB and is read for one thing: whether the step writer adds
1647
+ * `fail-fast: false` beside its own step, and whether the door still has a question to ask. An
1648
+ * answer of `keep` is therefore load-bearing rather than a no-op - it is what stops abloh asking
1649
+ * again on every re-run of `init`.
1650
+ */
1651
+ declaredFailFast?: SetupFailFast | undefined;
1652
+ /**
1653
+ * WHAT THIS REPOSITORY CALLS ITS MAINLINE, when the caller resolved it.
1654
+ *
1655
+ * Handed in rather than looked up, on this module's own rule: it reads nothing off disk. `init`
1656
+ * has the clone and already asks git this question to pick the setup pull request's base, so the
1657
+ * value is in hand at the moment this runs. Absent falls back to core's three names.
1658
+ */
1659
+ defaultBranch?: string | null;
1660
+ /**
1661
+ * `environment.runtimeImage` AS THIS SAME RUN WROTE IT, so the worked example agrees with it.
1662
+ *
1663
+ * WHY A DECISION ABOUT JOBS TAKES AN IMAGE (the fresh-ten launch-evidence run, 2026-08-31, bug 4).
1664
+ * It changes no decision and it is not read by any branch below: what it reaches is the FACT SET
1665
+ * of the two matrix refusals, where `matrixLegShape` in core turns it into which leg the copyable
1666
+ * block names. The setup pull request writes the image and shows the example, a few sections
1667
+ * apart, and until now each chose a Node without reading the other - `moment/luxon` was pinned
1668
+ * Node 24 and shown a leg on Node 20. A maintainer who copies the block is then measured in an
1669
+ * image a major away from the build abloh borrows.
1670
+ *
1671
+ * IT TRAVELS IN THE FACTS RATHER THAN AT RENDER TIME, because a refusal is rendered twice: on the
1672
+ * pull request body when it is raised, and on the sticky comment the control plane posts from the
1673
+ * trial artifact later. A shape chosen at render time would be chosen once with the pin and once
1674
+ * without, which is this same defect between two of abloh's own surfaces.
1675
+ *
1676
+ * ABSENT IS "THE CALLER HAS NO PIN", and the example falls back to the first leg the workflow
1677
+ * lists, which is what it always was.
1678
+ */
1679
+ runtimeImage?: string | null;
1680
+ }): SetupJobDecision;
1681
+ /**
1682
+ * EVERY JOB A `setup.job` DECLARATION WOULD BE ADMITTED FOR, `file::job`, IN ONE EDIT.
1683
+ *
1684
+ * WHAT IT IS FOR (blind-maintainer census cycle 2 retry, `vitejs/vite`, 2026-08-31). Every
1685
+ * `setup-no-usable-job` refusal names `setup.job` as the way back onto the borrow road, and until
1686
+ * this existed none of them could say which value works. vite's reader was told its suite job could
1687
+ * not carry the check and pointed at a key; the only value that key accepts for that repository is
1688
+ * `.github/workflows/ci.yml::lint`, three jobs further down the same file, and no surface named it.
1689
+ * A key whose every obvious value refuses the reader again is the remedy-that-cannot-work defect
1690
+ * with a round trip in front of it.
1691
+ *
1692
+ * THE SET IS `declarableJobs` AND NOT `borrowable`, on the same argument `findDeclared` makes: a
1693
+ * declaration resolves against every job the walk read, because "which job is the front door" is
1694
+ * abloh's guess and a maintainer is not guessing. `ci.yml::lint` runs no suite abloh recognized and
1695
+ * is a perfectly good tree to borrow.
1696
+ *
1697
+ * ONE EDIT, WHICH IS WHY A MATRIX JOB IS NOT OFFERED. Naming a job that fans out lands the reader on
1698
+ * the leg question, so the offer would be half an answer presented as a whole one. A repository
1699
+ * whose only ridable jobs fan out therefore offers nothing here, and the refusal says so - which is
1700
+ * the fallback truth rather than a door with a second door behind it.
1701
+ *
1702
+ * SCORE ORDER, THEN THE REFERENCE, exactly as {@link filterSetupCandidates} sorts, because two
1703
+ * orderings of one list is two answers to "which job would abloh take".
1704
+ */
1705
+ declare function declarableSetupJobs(recipe: CiRecipe, options?: {
1706
+ defaultBranch?: string | null;
1707
+ }): readonly string[];
1708
+ /**
1709
+ * EVERY JOB THE UPFRONT DOOR MAY OFFER, `file::job`, IN SCORE ORDER.
1710
+ *
1711
+ * WHY IT IS NOT {@link declarableSetupJobs} (captain's ruling, 2026-08-31, and it is the difference
1712
+ * between the rule working on `prettier/prettier` and not). That list answers a question a FILE
1713
+ * asks: what can somebody write in `abloh.yml`, on their own, in one edit, and be on the borrow road
1714
+ * afterwards. A job that fans out fails that test - naming it lands the reader on the leg question -
1715
+ * so it is left out on purpose.
1716
+ *
1717
+ * A TERMINAL IS NOT A FILE. The person at the door can be asked the second question the moment the
1718
+ * first is answered, and `init` does exactly that: an answer naming a matrix job reopens the door on
1719
+ * `setup.matrixLeg`. Holding those jobs back here would be the rule refusing to offer prettier the
1720
+ * only jobs it runs its suite in - every one of `dev-test.yml::test`, `prod-test.yml::test` and
1721
+ * `dev-package-test.yml::test` fans out - which is the exact wrong pick the ruling is about.
1722
+ *
1723
+ * A FAN-OUT WITH NO RIDABLE LEG IS STILL NOT OFFERED. {@link unaskableFanOut} is the same reading
1724
+ * the refusal road uses, so a job whose every leg lands on a runner abloh cannot work on is absent
1725
+ * here rather than offered and then refused one question later.
1726
+ */
1727
+ declare function doorSetupJobs(recipe: CiRecipe, options?: {
1728
+ defaultBranch?: string | null;
1729
+ }): readonly string[];
1730
+ /**
1731
+ * `.github/workflows/ci.yml::unit`, the one spelling `setup.job` and every refusal use.
1732
+ *
1733
+ * IT DELEGATES TO CORE, which is where the spelling moved when self-healing job selection landed:
1734
+ * the healing ledger, the exhaustion refusal and this module all name jobs, and the control plane
1735
+ * cannot import this file. Two `${file}::${jobId}` templates is two places for a separator to
1736
+ * change, and the `setup.job` a maintainer copies out of one has to be the one the other admits.
1737
+ */
1738
+ declare function setupJobReference(job: CiBorrowJob): string;
1739
+ /**
1740
+ * EVERY JOB THIS REPOSITORY OFFERS THE SETUP TRIAL, in the order the healing loop will ride them.
1741
+ *
1742
+ * WHAT IT IS FOR. `decideSetupJob` answers "which job first"; this answers "and then which", which
1743
+ * is the question `setup-healing.ts` asks after a trial fails at the borrow or wire-in stage. The
1744
+ * ORDER is core's and the FACTS are the CI reader's - this function is only the join, so that the
1745
+ * service (which reads the same list over its own discovery port) and the CLI cannot end up trying
1746
+ * a customer's jobs in two different orders.
1747
+ *
1748
+ * THE CHOSEN JOB IS IN IT. It is the first candidate rather than a thing beside the list: the ledger
1749
+ * counts it against the cap, and a list that omitted it would let the loop ride four jobs on a
1750
+ * budget of three.
1751
+ */
1752
+ declare function setupJobCandidates(recipe: CiRecipe,
1753
+ /** What this repository calls its mainline, when the caller resolved it. */
1754
+ options?: {
1755
+ defaultBranch?: string | null;
1756
+ }): readonly CiBorrowJob[];
1757
+
1758
+ /**
1759
+ * A RUNTIME THE GITHUB RUNNER DOES NOT SHIP, and the line in the customer's own file that says so.
1760
+ *
1761
+ * DECLARED HERE AND RE-EXPORTED BY ITS READER. `runtime-needs.ts` is the module that FINDS these,
1762
+ * by reading a repository's scripts, and it stayed in the CLI. The shape travels with the renderer
1763
+ * that writes it into a workflow, so the two doors cannot disagree about the field while agreeing
1764
+ * about the file.
1765
+ */
1766
+ interface RuntimeNeed {
1767
+ runtime: "bun" | "deno";
1768
+ /** The customer's own file and the string in it, for the comment written above the key. */
1769
+ evidence: string;
1770
+ }
1771
+ /**
1772
+ * THE BUILD COMMAND A MUTATION RUN PAYS FOR, with the candidates when more than one could be it.
1773
+ *
1774
+ * Declared here for the same reason {@link RuntimeNeed} is: `init-questions.ts` asks about it and
1775
+ * this file writes it, and the field is the thing they have to agree on.
1776
+ */
1777
+ interface RebuildDraftValue {
1778
+ value: string | null;
1779
+ evidence: string;
1780
+ /** The declared build scripts, when more than one could be the answer. Empty otherwise. */
1781
+ candidates: readonly string[];
1782
+ /** The package manager whose `run` would reach one of them, for the example. */
1783
+ manager: string;
1784
+ }
1785
+ /**
1786
+ * One OS package line as it will appear in the file, with the receipt that goes above it.
1787
+ *
1788
+ * `source` is a `.github/workflows/ci.yml::test` for a package the repository's own CI declared,
1789
+ * and the customer's own failing suite line for one the closure loop discovered. Both are
1790
+ * receipts, and the file must never carry a package whose comment cannot say where it came from.
1791
+ */
1792
+ interface ConfigSystemPackage {
1793
+ name: string;
1794
+ /**
1795
+ * Null for a package the customer named without a version, which the setup script installs
1796
+ * unpinned (removal R2). Everything abloh DERIVED carries a version, and everything the closure
1797
+ * loop discovered carries one too, because it reads the archive before writing it.
1798
+ */
1799
+ version: string | null;
1800
+ source: string;
1801
+ }
1802
+ interface ConfigInput {
1803
+ /**
1804
+ * THE QUOTE CHARACTER FOR THE ONE CLASS OF SCALAR THAT CANNOT BE WRITTEN PLAIN.
1805
+ *
1806
+ * Every value this file writes is spelled plain wherever YAML reads it back unchanged, which is
1807
+ * what makes it survive `prettier --check` under any quote setting (`yaml-scalar.ts`). What is
1808
+ * left is a string that would otherwise parse as a number - a matrix leg like `node: "26"` - and
1809
+ * that one has no quote-neutral spelling, so it takes the character the repository's own
1810
+ * formatter config names. Absent is `"`, which is what every repository configuring nothing gets
1811
+ * and what this file wrote before the plain-scalar rule existed.
1812
+ */
1813
+ yamlQuote?: YamlQuote;
1814
+ subdir: string | null;
1815
+ /**
1816
+ * WHAT A RUN DOES ABOUT A SUITE THAT IS ALREADY RED, when the maintainer answered that question.
1817
+ *
1818
+ * Absent, or `quarantine`, writes nothing: that is what a file with no `flaky:` key already means.
1819
+ * See `configFlakyLines`, and `askRedBaselineDoor` for the one place this is answered.
1820
+ */
1821
+ flaky?: "quarantine" | "strict";
1822
+ /**
1823
+ * THE ANSWER TO "MAY ABLOH INSTALL THE COVERAGE PROVIDER", when there is one.
1824
+ *
1825
+ * Absent writes no line, which is what `install` already means - see
1826
+ * {@link EnvironmentConfig.coverageProvider}. It is written for BOTH answers all the same when
1827
+ * somebody gave one, because a recorded answer is the difference between a maintainer who chose
1828
+ * the default and one who was never asked.
1829
+ */
1830
+ coverageProvider?: CoverageProviderChoice;
1831
+ /**
1832
+ * WHERE THIS REPOSITORY'S SETUP SCRIPT LIVES, when it is not where `init` puts one.
1833
+ *
1834
+ * Absent means {@link SETUP_SCRIPT_PATH}, which is every repository `init` created. A repository
1835
+ * being RECONFIGURED may declare its own path in `environment.setup`, and that path is the one
1836
+ * every cold run already executes - so a re-run reads its steps from there and writes them back
1837
+ * there. Re-pointing the key at our default would leave their file on disk, unread, with nothing
1838
+ * saying so.
1839
+ */
1840
+ setupPath?: string;
1841
+ /**
1842
+ * THE INSTALL COMMAND RESOLVES RATHER THAN INSTALLING A FROZEN SET, because this repository will
1843
+ * not carry a lockfile (census run 7 F3).
1844
+ *
1845
+ * It decides the install step's own description alone - {@link STEP_WHAT.installUnlocked} - and
1846
+ * writes no `abloh.yml` key: the command and the evidence beside it already carry the fact into
1847
+ * the file, and a key nobody answers is a key nobody edits. Absent is the ordinary frozen install,
1848
+ * which is every repository that commits a lockfile.
1849
+ */
1850
+ installUnlocked?: boolean;
1851
+ /**
1852
+ * `.github/workflows/ci.yml::unit` - the customer's own job the Abloh step rides, or null.
1853
+ *
1854
+ * WHY THE CONTRACT CARRIES IT. The borrow lane appends one step to one of their jobs, and
1855
+ * self-healing job selection moves that step between jobs until a trial proves one
1856
+ * (`packages/core/src/setup-healing.ts`). The winning job is then a fact that has to survive, and
1857
+ * the tree is the only place that does: the receipt a green trial earns is a digest over this
1858
+ * file, so a contract naming no job would bind evidence to a measurement whose most consequential
1859
+ * input is unstated. It is also the key a maintainer edits to move the step by hand, which means
1860
+ * abloh writing it and them changing it are one mechanism rather than two.
1861
+ *
1862
+ * NULL ON THE FALLBACK ROAD AND ON AN AMBIGUOUS READ. Abloh's own workflow rides no job of theirs,
1863
+ * and a tie is the one case where the whole design says abloh will not pick - writing a key for
1864
+ * either would commit a guess the surrounding copy promises not to make.
1865
+ */
1866
+ setupJob?: string | null;
1867
+ /**
1868
+ * THE MATRIX LEG {@link setupJob} RIDES, when the job that won fans out.
1869
+ *
1870
+ * SAME RULE AS THE JOB AND FOR THE SAME REASON. The step abloh appends carries an `if:` that is
1871
+ * true on this leg alone, so which leg was measured is as consequential an input as which job, and
1872
+ * the receipt is a digest over the file that has to state it.
1873
+ */
1874
+ setupMatrixLeg?: SetupMatrixLeg | null;
1875
+ /**
1876
+ * WHAT THE MAINTAINER ANSWERED ABOUT THE RIDDEN JOB CANCELLING ITS OWN MATRIX LEGS.
1877
+ *
1878
+ * NOT THE SAME RULE AS THE JOB AND THE LEG, and the difference is who said it. Those two are
1879
+ * abloh's derivation and are written only where abloh proved them; this is the maintainer's own
1880
+ * answer to a question abloh asked, so it is written wherever they gave one - the same exception
1881
+ * `configSetupJobLines` already makes for a leg they named.
1882
+ */
1883
+ setupFailFast?: SetupFailFast | null;
1884
+ /**
1885
+ * WHAT ABLOH COULD NOT READ WHILE PLACING ITS STEP INSIDE {@link setupJob}, one sentence each.
1886
+ *
1887
+ * See `configSetupJobLines` and `setup-step-anchor.ts`. It is a fact about the job the contract
1888
+ * names, so it travels with the job rather than being re-derived by whoever renders the file.
1889
+ */
1890
+ setupStepUnreadableConditions?: readonly string[];
1891
+ /**
1892
+ * WHAT THE LAST TWO QUESTIONS IN THE WALK ARE ABOUT - the job abloh rides and its matrix.
1893
+ *
1894
+ * Not `abloh.yml` values in themselves: `setupJob` above is the key, and this is the pair of facts
1895
+ * the PROMPT needs, which are the receipt and the legs. Same reason `boundRunner` sits beside
1896
+ * `runner` one screen up - the walk's prompts are derived from this object and a prompt has to
1897
+ * show the answer the run reaches rather than the key the file happens to carry.
1898
+ */
1899
+ setupQuestions?: SetupDraftValues;
1900
+ /**
1901
+ * THE PROOF IMAGE THIS CONTRACT PINS, or null when the drafting road could not resolve one.
1902
+ *
1903
+ * `init` ALWAYS RESOLVES ONE, by pulling an image whose Node major matches what the repository
1904
+ * declares, so on that road this is always a string. The App door has no machine to pull with, and
1905
+ * writing the reviewed DEFAULT instead would pin Node 22 into a repository that declares Node 24 -
1906
+ * a wrong answer nothing downstream could see. Null writes no key, which is what a repository that
1907
+ * pins nothing already means: the run resolves the default at run time and the maintainer is asked.
1908
+ * `receiptLines` already skips a null value, so the renderer needs no branch for it.
1909
+ */
1910
+ runtimeImage: string | null;
1911
+ /**
1912
+ * WHICH NODE THE GENERATED WORKFLOW PINS, resolved from the repository's own declaration.
1913
+ *
1914
+ * Here rather than re-derived inside `renderWorkflow` because the draft has to be able to SHOW the
1915
+ * workflow before it writes it, and a value read twice from a tree that may have changed between
1916
+ * the two reads is two answers. See {@link workflowNodePin} for the preference order and for the
1917
+ * `shardeum/shardeum` row that made it a ruling.
1918
+ */
1919
+ workflowNode: WorkflowNodePin | null;
1920
+ /**
1921
+ * Extra runtimes the proof image must carry beyond node, each with the file that asked for it.
1922
+ * Empty for almost every repository; see `runtime-needs.ts` for the two that are not.
1923
+ */
1924
+ runtimes: readonly RuntimeNeed[];
1925
+ /**
1926
+ * The browser this repository's suite drives, with the files that said so, or null for the great
1927
+ * majority that drive none.
1928
+ *
1929
+ * DRAFTED FROM TWO DECLARATIONS AND NEVER FROM A NAME. `browser-lane.ts` requires a vitest config
1930
+ * that enables browser mode AND a manifest that depends on `@vitest/browser`; round 5's census
1931
+ * (M17) is what made both facts mandatory, having reported a browser lane on `kaitranntt/ccs`
1932
+ * whose whole evidence was a script called `test:e2e`.
1933
+ */
1934
+ browser: {
1935
+ value: "chromium";
1936
+ evidence: readonly string[];
1937
+ } | null;
1938
+ testCommand: string | null;
1939
+ /**
1940
+ * THE RUNNER THE FILE DECLARES, or null when the file leaves detection to answer.
1941
+ *
1942
+ * WRITTEN ONLY WHERE IT SAYS SOMETHING (audit F2, and the same restraint every other derived key
1943
+ * here follows). A repository whose command names its runner unambiguously gets no `runner:` line:
1944
+ * the key would restate what `bindPreparedTestCommand` reads off that command anyway, and a file
1945
+ * full of lines that change nothing is a file nobody reviews. What earns the line is a repository
1946
+ * where abloh DISCARDED a candidate - pino's five-stage chain - or where the maintainer answered
1947
+ * the prompt themselves.
1948
+ */
1949
+ runner: string | null;
1950
+ /**
1951
+ * THE RUNNER THE RUN WILL BIND, whether or not the file declares one.
1952
+ *
1953
+ * NOT AN `abloh.yml` VALUE, and it is here for the same reason `sealedPromptEvidence` and
1954
+ * `testCommandCostNote` are: the walk's prompts are derived from this object, and the prompt for
1955
+ * this key has to show the answer the run reaches rather than the key the file happens to carry.
1956
+ * A repository whose command names vitest shows `vitest` and writes no line, which is the true
1957
+ * pair; showing the file's null there would be an empty prompt on a repository with no question.
1958
+ *
1959
+ * `"command"` IS THE ONE VALUE THAT MEANS THE PROMPT IS EMPTY - see `ScalarDraftValues.runner`.
1960
+ */
1961
+ boundRunner: string;
1962
+ /** Did abloh discard a candidate runner here? Decides the prompt's weight and nothing else. */
1963
+ runnerContested: boolean;
1964
+ installCommand: string | null;
1965
+ /**
1966
+ * The build steps this repository's own CI runs between install and test, each with the workflow
1967
+ * job it was read out of. Empty for the repositories whose CI declares no build, which is most of
1968
+ * them, and empty whenever the CI could not be read - see `ci-recipe.ts` for why a refusal there
1969
+ * is the product rather than a failure.
1970
+ */
1971
+ setupCommands: readonly CiSetupCommand[];
1972
+ /**
1973
+ * The literal values this repository's own CI gives the step that installs it.
1974
+ *
1975
+ * Rendered with the install step in the setup script, because that is the only thing that
1976
+ * delivers a value there. See {@link CiRecipe.installEnvironment}.
1977
+ */
1978
+ installEnvironment: ReadonlyArray<{
1979
+ name: string;
1980
+ value: string;
1981
+ }>;
1982
+ /**
1983
+ * The PINNED OS packages this repository's own CI installs, each with the workflow job it came
1984
+ * from. Empty for nearly every repository, and empty for a repository whose CI installs system
1985
+ * libraries WITHOUT pinning them - which is nearly all of the ones that install any. The unpinned
1986
+ * case is reported as a notice naming the packages and the pin, never written; `ci-recipe.ts`
1987
+ * carries why an unpinned entry cannot be a recipe line.
1988
+ */
1989
+ systemPackages: readonly ConfigSystemPackage[];
1990
+ sealedTestCommand: string | null;
1991
+ /**
1992
+ * THE COMPILE THIS REPOSITORY PAYS PER PLANTED MUTANT, or null for the repositories - nearly all
1993
+ * of them - whose tests are about the files they change.
1994
+ *
1995
+ * PRESENT WITH A NULL `value` IS ITS OWN STATE and is not the same as absent: it means abloh found
1996
+ * that the tests read compiled output and found more than one declared build script, so the file
1997
+ * carries the finding, names the candidates and leaves the key out. A run of such a repository
1998
+ * still reports false survivors until somebody answers, and a file that said nothing about it
1999
+ * would leave them no reason to look.
2000
+ */
2001
+ rebuild: RebuildDraftValue | null;
2002
+ installDirectory: string;
2003
+ identityFiles: readonly string[];
2004
+ /**
2005
+ * Files this repository's own test boot writes into the checkout, each with the evidence for it.
2006
+ * See `framework-run-outputs.ts`: without the declaration such a run measures correctly and is
2007
+ * then refused for having changed the checkout.
2008
+ */
2009
+ generatedFiles: readonly {
2010
+ path: string;
2011
+ evidence: string;
2012
+ }[];
2013
+ /**
2014
+ * TRACKED files this repository's own install rewrites, each with the evidence for it.
2015
+ *
2016
+ * DERIVED AND NEVER ASKED. The reading is `observed-install-outputs.ts`: abloh runs the
2017
+ * repository's own install once in a throwaway checkout of HEAD and asks git which COMMITTED
2018
+ * files came back different. That is a fact about their install rather than a preference, so
2019
+ * there is no prompt for it - what a maintainer does with the line is read it in the draft, check
2020
+ * it against their own `prepare` script, and delete it if it is wrong.
2021
+ *
2022
+ * A TEST FILE IS NEVER DRAFTED HERE, because `rewritten-files.ts` refuses one at the run and a
2023
+ * key init wrote that the run then refuses is the promise this product has already paid for
2024
+ * twice.
2025
+ */
2026
+ rewrittenFiles: readonly {
2027
+ path: string;
2028
+ evidence: string;
2029
+ }[];
2030
+ /**
2031
+ * Literal, NON-SECRET `NAME: value` pairs this repository's own CI declares, each with the
2032
+ * workflow line it came from.
2033
+ *
2034
+ * EMPTY UNTIL SOMETHING FILLS IT. `init`'s own derivation - package.json, lockfile, Dockerfile -
2035
+ * has no workflow reader in it, so nothing here comes from this file today. It is the shape the
2036
+ * CI-recipe translator writes into, and it is separate from `requiredVariables` for the reason
2037
+ * `config.ts` gives at the key: a value here is committed and a name there is injected, and the
2038
+ * customer has to be able to see which of the two they are getting.
2039
+ */
2040
+ environmentValues: readonly {
2041
+ name: string;
2042
+ value: string;
2043
+ evidence: string;
2044
+ }[];
2045
+ /**
2046
+ * Environment-variable NAMES the suite needs and this file must never carry values for, each with
2047
+ * the workflow line that asked for it.
2048
+ *
2049
+ * The other half of the 2026-08-25 split. A name here is injected from the caller's own
2050
+ * environment at run time and masked out of every captured line; a value under
2051
+ * `environmentValues` is committed and disclosed to everyone who can read the repository. The
2052
+ * translator decides which of the two a workflow line is (`ci-recipe.ts`), and the customer sees
2053
+ * both lists before either is written.
2054
+ */
2055
+ requiredVariables: readonly {
2056
+ name: string;
2057
+ evidence: string;
2058
+ }[];
2059
+ /**
2060
+ * The backing services the proof container stands up, exactly as the repository declared them.
2061
+ *
2062
+ * `spelling` is the prompt's spelling of the same entry - `db=postgres:16` - and reaches the file
2063
+ * nowhere: what the file carries is the name, the pinned image, the declared environment and the
2064
+ * readiness gate. It travels here so the walk and the rendered block cannot disagree about which
2065
+ * services a draft has.
2066
+ */
2067
+ services: readonly DraftService[];
2068
+ coverageNote?: string | null;
2069
+ /** One sentence per derived line, naming the customer's own file it came from. */
2070
+ evidence: {
2071
+ runtimeImage: string;
2072
+ installCommand: string;
2073
+ testCommand: string;
2074
+ /**
2075
+ * WHY THIS RUNNER AND NOT ANOTHER, which is the half the audit found missing entirely.
2076
+ *
2077
+ * A receipt naming only the source - `scripts.test in package.json` - says where the value came
2078
+ * from and not what abloh DECIDED. On pino it would have been true and useless: the jest leg is
2079
+ * in `scripts.test`, and so is the borp stage abloh skipped. `rejectedRunnerSentence` composes
2080
+ * the second half where there is one, for the file and the prompt alike, so an unattended init
2081
+ * discloses exactly what an interactive one does.
2082
+ */
2083
+ runner: string;
2084
+ /** Written into the file only when {@link sealedNote} says so. */
2085
+ sealedTestCommand: string;
2086
+ };
2087
+ /**
2088
+ * The `sealedTestCommand` receipt as the WALK says it, which is shorter than the file's.
2089
+ *
2090
+ * The file explains an absent key to a reviewer, permanently. The prompt is one line read once,
2091
+ * with its weight beside it already saying what enter costs. See `sealedSuggestion`.
2092
+ */
2093
+ sealedPromptEvidence: string;
2094
+ /**
2095
+ * WHAT A GENERIC-ADAPTER DEMOTION COSTS, appended to the `testCommand` receipt in BOTH the file
2096
+ * and the prompt, or null for the repositories that are not demoted - which is nearly all of them.
2097
+ *
2098
+ * IT USED TO BE THE PROMPT'S ALONE, and that is what made it unreachable: the prompt is walked
2099
+ * only by a human, and the census, an agent, a control plane and `--yes` all write the file
2100
+ * without one. See {@link genericAdapterCost} for the measured cost of that, and
2101
+ * {@link testCommandReceipt} for the one composition both surfaces read.
2102
+ */
2103
+ testCommandCostNote?: string | null;
2104
+ /**
2105
+ * Is an absent `sealedTestCommand` the right answer for this repository, or merely the one nothing
2106
+ * could be derived? Decides the prompt's weight and nothing else. See `sealedSuggestion`.
2107
+ */
2108
+ sealedTestCommandNeeded?: boolean;
2109
+ /**
2110
+ * THE EXAMPLE THE EMPTY `sealedTestCommand` PROMPT ENDS WITH, spelled for THIS repository's runner.
2111
+ *
2112
+ * Null where there is no runner to name - a task-runner root, whose absent key is by design. The
2113
+ * walk used to print a constant vitest command at every repository, which is the
2114
+ * audit's Finding 16: the one line a customer copies the spelling from named the wrong runner on
2115
+ * every jest, mocha and bun repository there is.
2116
+ */
2117
+ sealedPromptExample?: string | null;
2118
+ /**
2119
+ * The `sealedTestCommand` comment, or null to write none.
2120
+ *
2121
+ * A key absent because nothing calls for it needs no explanation in the file. A key absent while
2122
+ * the repository's script really does gate its runner needs one, because that absence is a live
2123
+ * cause of every proposed test being rejected and nothing else would say so.
2124
+ */
2125
+ sealedNote: string | null;
2126
+ }
2127
+ /**
2128
+ * The part `init` alone can work out: where the project is and how to rebuild it.
2129
+ *
2130
+ * Kept separate from the settings above because this is the half that gets APPENDED to a policy
2131
+ * file somebody already has. `installCommand` is the load-bearing line — the fix loop refuses to
2132
+ * start without it, before the first model call, so a run with no install command produces findings
2133
+ * and no proposed tests.
2134
+ *
2135
+ * EVERY DERIVED LINE CARRIES ITS EVIDENCE as a comment above it. A value a machine worked out and a
2136
+ * value somebody typed look identical in YAML, and the whole point of a drafted file is that the
2137
+ * customer can check the working. Naming the file it came from - `pnpm-lock.yaml`, the FROM line in
2138
+ * `docker/Dockerfile` - is what makes "is this right?" a question they can answer in ten seconds
2139
+ * rather than a question about Abloh.
2140
+ */
2141
+ /**
2142
+ * Wrap one evidence sentence into `#` comment lines at the given indent, without breaking words.
2143
+ *
2144
+ * The width is 108 rather than 96 because of Kenneth's compact ruling (2026-08-23): every receipt
2145
+ * below is written to fit on ONE line, and a narrower wrap would turn a one-sentence receipt back
2146
+ * into a two-line paragraph purely by folding it. The wrap still exists because some receipts name
2147
+ * a customer's own path, and a deep monorepo path has no length we get to choose.
2148
+ */
2149
+ declare function commentLines(text: string, indent: string, width?: number): string[];
2150
+ /**
2151
+ * The `environment:` block as it will appear in the customer's file.
2152
+ *
2153
+ * EXPORTED for its tests and for the CI-recipe hook: what this renders is what gets committed, so
2154
+ * the lines are worth asserting on directly rather than through a whole `init` run.
2155
+ */
2156
+ declare function renderEnvironmentBlock(input: ConfigInput): string[];
2157
+ /**
2158
+ * The whole created file. The coverage note LEADS when it exists (Kenneth, 2026-08-24: it is the
2159
+ * one line that tells a customer their changed lines cannot be attested yet, and it was buried
2160
+ * mid-file). The environment block keeps the note for the EXTENDED path, where an existing file's
2161
+ * lines cannot be moved and the appended block is the only place the note can go.
2162
+ */
2163
+ declare function renderConfig(input: ConfigInput): string;
2164
+ /**
2165
+ * THE WORKFLOW, WRITTEN RATHER THAN COPIED.
2166
+ *
2167
+ * It used to be the customer's job: the setup panel printed the file and they pasted it into a path
2168
+ * they had to create. That is the step people got wrong — not the Abloh line, which is three lines,
2169
+ * but the build and install steps above it, which have to match the job their tests already pass in.
2170
+ *
2171
+ * `init` has just worked out the install command for the environment block, so it is the one place
2172
+ * that knows them. Writing the file here means the only thing a customer types is one command.
2173
+ *
2174
+ * THE PIN IS A COMMIT, NOT A TAG, and it is baked into this CLI. A copy of `abloh init` installed
2175
+ * months ago therefore writes a months-old Action. `npx` fetches the newest CLI, which is the common
2176
+ * path, but a cached or version-pinned one does not — the file it writes looks perfectly correct and
2177
+ * runs an old Action with no signal. Reading the recommended pin from the control plane at run time,
2178
+ * falling back to the constant when offline, is the fix; it is not built yet.
2179
+ *
2180
+ * The file's body and its pin are `@abloh/core`'s, for the same reason the policy header is: the
2181
+ * website's setup panel prints the same workflow, and a second copy here is a second thing to
2182
+ * remember to change on a release.
2183
+ */
2184
+ declare function renderWorkflow(input: ConfigInput): string;
2185
+ /**
2186
+ * The `testCommand` receipt, composed ONCE for the file and the prompt.
2187
+ *
2188
+ * They were two compositions of the same two strings, in two functions, and only one of them ran on
2189
+ * an unattended init. A reader who is told at a prompt what a value costs and then finds no trace of
2190
+ * it in the file they commit has been told twice that two different things are true.
2191
+ */
2192
+ declare function testCommandReceipt(input: {
2193
+ evidence: string;
2194
+ cost?: string | null;
2195
+ }): string;
2196
+ /**
2197
+ * One service on a draft: what the repository declared, with its tag pinned.
2198
+ *
2199
+ * `spelling` is the prompt's line and nothing else. `image` is the only field abloh added, and
2200
+ * `evidence` is the sentence saying which file the rest came from.
2201
+ */
2202
+ interface DraftService {
2203
+ name: string;
2204
+ spelling: string;
2205
+ image: string;
2206
+ env: readonly {
2207
+ name: string;
2208
+ value: string;
2209
+ }[];
2210
+ healthCommand: string | null;
2211
+ healthPort: number | null;
2212
+ /** Where a run reaches it, when that is not the port it listens on. See `service-ports.ts`. */
2213
+ localhostPort?: number | null;
2214
+ source: string;
2215
+ evidence: string;
2216
+ }
2217
+ /**
2218
+ * The one comment line that opens every `environment:` block this command writes.
2219
+ *
2220
+ * A constant because a re-run has to RECOGNIZE it: the block is replaced from its own `environment:`
2221
+ * line down, and a header left stranded above the replacement appears twice in the customer's file.
2222
+ */
2223
+ declare const ENVIRONMENT_BLOCK_HEADER = "# Rebuild recipe for the proof container. Comments name the source file.";
2224
+ /**
2225
+ * WHAT ABLOH READ ABOUT THE JOB ITS STEP RIDES, as the contract will state it.
2226
+ *
2227
+ * Declared here for the reason {@link RuntimeNeed} and {@link RebuildDraftValue} are: the walk in
2228
+ * `init-questions.ts` asks about these values and this file writes them, so the shape is the thing
2229
+ * those two have to agree on rather than a type one of them owns.
2230
+ */
2231
+ interface SetupDraftValues {
2232
+ job: {
2233
+ value: string | null;
2234
+ evidence: string;
2235
+ } | null;
2236
+ legs: readonly {
2237
+ dimension: string;
2238
+ offered: readonly MatrixValue[];
2239
+ evidence: string;
2240
+ }[];
2241
+ }
2242
+ /**
2243
+ * THE FOUR STEP DESCRIPTIONS `init` ITSELF WRITES, and the key each one is generated from.
2244
+ *
2245
+ * They are constants because a re-run has to be able to tell ITS OWN steps from the customer's. A
2246
+ * step whose description is not one of these four was written by a person or by their coding agent,
2247
+ * and a regeneration carries it through untouched (see {@link mergeSetupSteps}). Spelling one of
2248
+ * these differently here and in the merge would silently turn every customer's install step into a
2249
+ * step abloh no longer recognizes, so there is one spelling.
2250
+ */
2251
+ declare const STEP_WHAT: {
2252
+ readonly systemPackages: "OS packages your suite needs, pinned";
2253
+ readonly corepack: "put the package manager on PATH, for your own lifecycle scripts";
2254
+ readonly install: "dependencies, from your lockfile";
2255
+ /**
2256
+ * THE SAME STEP ON A REPOSITORY THAT CARRIES NO LOCKFILE (census run 7 F3, 2026-09-06).
2257
+ *
2258
+ * A SECOND SPELLING RATHER THAN A REWORD, on the rule above: `install`'s exact text is what a
2259
+ * re-run recognizes as its own step, so changing it would orphan every install step already
2260
+ * committed. Both are install steps to {@link initStepRole} and to {@link isInstallStep}, which is
2261
+ * what every reader asks instead of comparing against one of them.
2262
+ *
2263
+ * IT EXISTS BECAUSE THE OTHER ONE WOULD BE FALSE. `fastify/fastify` and `pinojs/pino` both
2264
+ * `.gitignore` their lockfile, so the drafted step resolves rather than installing frozen, and a
2265
+ * comment saying "from your lockfile" above a command that has none is a claim the file cannot
2266
+ * back. The `source` beside it names the ignore rule.
2267
+ */
2268
+ readonly installUnlocked: "dependencies, resolved - your repository commits no lockfile";
2269
+ readonly build: "your build";
2270
+ };
2271
+ /** Whether a step is the install step `init` writes, in EITHER of its two spellings. */
2272
+ declare function isInstallStep(what: string): boolean;
2273
+ /**
2274
+ * WHO WROTE A STEP: abloh where its description is one {@link STEP_WHAT} carries, the maintainer
2275
+ * otherwise.
2276
+ *
2277
+ * IT IS THE MERGE'S OWN READING AND NOT A SECOND ONE. {@link mergeSetupSteps} already treats those
2278
+ * four descriptions as abloh's own and carries everything else through untouched, so a refusal that
2279
+ * asked a different question would name a different author from the one the re-run acts on.
2280
+ *
2281
+ * WHY A RUN NEEDS IT (census run 8's F1, and the ownership audit's finding 1). A cold run executes
2282
+ * this file and `setup-step-failed` declares an abloh arm that no producer supplied, so a step abloh
2283
+ * composed from the maintainer's own CI failed and was filed against the maintainer. It fails
2284
+ * towards the customer: a description this does not recognise is theirs, which is the owner that
2285
+ * refusal has always carried.
2286
+ */
2287
+ declare function setupStepOrigin(step: {
2288
+ readonly what: string;
2289
+ }): ValueOrigin;
2290
+ /** Which environment key a step was generated from, or null for a step `init` did not write. */
2291
+ declare function initStepRole(what: string): "systemPackages" | "installCommand" | "setupCommands" | null;
2292
+ /**
2293
+ * THE SETUP SCRIPT'S STEPS, from the same derivation that used to fill four config keys.
2294
+ *
2295
+ * WHAT CHANGED ON 2026-08-26, and what did not. The reading is unchanged: the same lockfile, the
2296
+ * same `packageManager` field, the same workflow job, the same answers a customer gave at the walk.
2297
+ * What changed is where the answer LANDS. It used to be config that abloh re-derived into an image
2298
+ * recipe at every run; it is now a file in the customer's repository that a run executes.
2299
+ *
2300
+ * THE ORDER IS THE ORDER THINGS HAPPEN IN, and it is the order the image already built them in:
2301
+ * system libraries first, because a native module compiles DURING the install and a header that
2302
+ * arrives afterwards arrives too late; then corepack, because a package manager has to be on PATH
2303
+ * before the repository's own lifecycle scripts call it; then the frozen install; then whatever the
2304
+ * repository's own CI builds between installing and testing.
2305
+ *
2306
+ * EVERY STEP NAMES ITS SOURCE, which is the whole reason a customer can ratify this file in ten
2307
+ * seconds rather than take it on trust.
2308
+ */
2309
+ /**
2310
+ * THE ONE SETUP STEP THAT INSTALLS OS PACKAGES, or null when this repository declares none.
2311
+ *
2312
+ * SPLIT OUT BECAUSE IT IS REBUILT AFTER THE FILE IS WRITTEN (the captain's ruling of 2026-09-10).
2313
+ * `abloh init` now writes and commits the contract BEFORE it builds the proof image, so a library
2314
+ * the suite proves it needs arrives after the script is on disk - and the amend that puts it there
2315
+ * has to compose exactly the step this function composes, or the file a maintainer ratified and the
2316
+ * file a run executes would be two different renderings of one list.
2317
+ *
2318
+ * IT READS NOTHING BUT THE LIST, which is what makes that safe: the step's text is a function of the
2319
+ * pinned packages alone.
2320
+ */
2321
+ declare function systemPackagesStep(pinned: readonly ConfigSystemPackage[]): SetupScriptStep | null;
2322
+ declare function setupScriptSteps(input: ConfigInput): SetupScriptStep[];
2323
+
2324
+ /**
2325
+ * WHERE AN ANSWER IS APPLIED, because three of them are not values in the contract at all.
2326
+ *
2327
+ * `contract` answers change the drafted `abloh.yml`. `discovery` answers move what the whole
2328
+ * contract is DERIVED FROM, so they go back through the drafting rather than being written over its
2329
+ * result: the job to ride and the leg of its matrix decide which job the recipe, the services and
2330
+ * the environment values are read out of, and the package to measure decides which manifest the
2331
+ * runner and the test command are read out of. A value written over the rendered file would leave
2332
+ * every sibling line derived from the old one, which is the shape AGENTS.md's self-healing row
2333
+ * already states - "a pin re-derives the WHOLE recipe".
2334
+ */
2335
+ type SetupAnswerChannel = "contract" | "discovery";
2336
+ /** How a maintainer's TEXT becomes the shape the key takes. */
2337
+ type SetupAnswerShape =
2338
+ /** One value, written as typed. */
2339
+ {
2340
+ kind: "scalar";
2341
+ }
2342
+ /** Comma-separated entries, blank entries dropped. */
2343
+ | {
2344
+ kind: "list";
2345
+ }
2346
+ /** `&&`-separated commands, which is how a build reads top to bottom. */
2347
+ | {
2348
+ kind: "commands";
2349
+ }
2350
+ /** Comma-separated `name=version`, the version optional. */
2351
+ | {
2352
+ kind: "packages";
2353
+ }
2354
+ /** Comma-separated `NAME=value`. */
2355
+ | {
2356
+ kind: "pairs";
2357
+ }
2358
+ /** Comma-separated names. */
2359
+ | {
2360
+ kind: "names";
2361
+ }
2362
+ /**
2363
+ * Comma-separated `name=image:port`, the image immutable-pinned and the port its readiness gate.
2364
+ *
2365
+ * THE WHOLE ENTRY, because the contract holds no half of one. `abloh init` accepts `db=postgres:16`
2366
+ * and then resolves the digest and the exposed port off a registry, which is a machine reading and
2367
+ * not a file one; this door has no machine, so what it takes is what the file will hold. A value
2368
+ * it could not judge would be a form that writes a line the loader refuses one command later.
2369
+ */
2370
+ | {
2371
+ kind: "services";
2372
+ };
2373
+ interface SetupQuestionDescriptor {
2374
+ /** The `abloh.yml` key the answer is written under. The form and the file agree by construction. */
2375
+ key: string;
2376
+ /** What is being asked, in one line. */
2377
+ ask: string;
2378
+ /** Why abloh is asking, where the run has said nothing more specific. */
2379
+ why: string;
2380
+ /** The values the contract accepts, where they are a closed set. Empty is a free field. */
2381
+ candidates: readonly string[];
2382
+ required: boolean;
2383
+ /**
2384
+ * ASKED OF EVERY REPOSITORY, because no reader can establish this value at all.
2385
+ *
2386
+ * The rest are values discovery either settles or believes irrelevant, so they are hidden until a
2387
+ * stopped stage names one.
2388
+ */
2389
+ unreadable?: true;
2390
+ shape: SetupAnswerShape;
2391
+ channel: SetupAnswerChannel;
2392
+ /**
2393
+ * HOW AN ACCEPTED VALUE CHANGES THE DRAFTED CONTRACT.
2394
+ *
2395
+ * Absent exactly for the `discovery` channel, whose answers move what the contract is derived
2396
+ * FROM rather than a line in it. Every `contract` descriptor has one, and
2397
+ * `scripts/setup-answer-application.test.ts` is what keeps that true.
2398
+ */
2399
+ apply?: (draft: ConfigInput, answer: string) => ConfigInput;
2400
+ }
2401
+ /** The entries in a list answer, blanks dropped. One grammar, read by both lanes. */
2402
+ declare function splitAnswerList(shape: SetupAnswerShape, raw: string): string[];
2403
+ /**
2404
+ * THE VALUE THE KEY IS SET TO, out of what the maintainer typed.
2405
+ *
2406
+ * The shape decides the JavaScript value; `judgeConfigKeyValue` decides whether the key may hold it.
2407
+ */
2408
+ declare function readAnswerValue(descriptor: SetupQuestionDescriptor, raw: string): unknown;
2409
+ /** What is wrong with this answer to this question, or null. The one door both lanes ask. */
2410
+ declare function setupAnswerProblem(key: string, raw: string): string | null;
2411
+ /**
2412
+ * THE ANSWERS APPLIED TO ONE DRAFTED CONTRACT, in declaration order.
2413
+ *
2414
+ * An answer for a key with no descriptor is not applied and not silently swallowed either - the
2415
+ * caller admitted it or refused it before this ran, through {@link setupAnswerProblem}. This
2416
+ * function is the second half of that door and never a second door.
2417
+ */
2418
+ declare function applyAnswersToContract(draft: ConfigInput, answers: Readonly<Record<string, string>>): ConfigInput;
2419
+ /** WHAT ABLOH SAYS ABOUT A VALUE SOMEBODY TYPED, on every receipt line an answer wrote. */
2420
+ declare const ANSWERED_EVIDENCE = "you answered this when you set abloh up";
2421
+ /**
2422
+ * ---------------------------------------------------------------------------------------------
2423
+ * THE TABLE.
2424
+ * ---------------------------------------------------------------------------------------------
2425
+ *
2426
+ * Every key here is one `abloh init` already asks about - `apps/cli/src/init-question-map.ts` is
2427
+ * that inventory and `scripts/setup-question-placement.test.ts` pins the two together - because
2428
+ * there is ONE fixed list of questions and both doors onto one contract ask from it.
2429
+ */
2430
+ declare const SETUP_QUESTION_DESCRIPTORS: Readonly<Record<string, SetupQuestionDescriptor>>;
2431
+ /**
2432
+ * EVERY KEY AN ANSWER OR AN EDIT MAY NAME, derived rather than listed.
2433
+ *
2434
+ * A key not here is not offered as a field, rather than being accepted and dropped - which is the
2435
+ * whole of F1 said as one sentence.
2436
+ */
2437
+ declare const ANSWERABLE_SETUP_KEYS: readonly string[];
2438
+ /** The descriptors whose answers change the drafted contract, in declaration order. */
2439
+ declare function contractDescriptors(): readonly SetupQuestionDescriptor[];
2440
+ /** The package a maintainer asked abloh to measure, or undefined. Read before the derivation. */
2441
+ declare function answeredTargetDirectory(answers: Readonly<Record<string, string>>): string | undefined;
2442
+
2443
+ /**
2444
+ * HOW THIS MODULE GETS BYTES, since the captain's ruling of 2026-09-05.
2445
+ *
2446
+ * It opened files itself until then, which put a checkout in the middle of a decision the service
2447
+ * has to make over GitHub's contents API with no checkout at all - see `packages/core/src/repo-reader.ts`.
2448
+ * The caller supplies the reading and the deciding stays here, so both doors decide alike.
2449
+ *
2450
+ * The parameter takes an ABSOLUTE path and answers text or nothing, because that is the vocabulary
2451
+ * `detect.ts` speaks and this module is called from nowhere else. It defaults to `node:fs` so every
2452
+ * other caller of {@link denoLockfilePath} is unchanged.
2453
+ */
2454
+ type ReadTextAt = (absolutePath: string) => string | null;
2455
+ /** The two names deno itself looks for, in deno's own precedence order. */
2456
+ declare const DENO_CONFIG_FILES: readonly ["deno.json", "deno.jsonc"];
2457
+ /** Deno's lockfile. Present far less often than a node lockfile - see {@link denoLockfilePath}. */
2458
+ declare const DENO_LOCKFILE = "deno.lock";
2459
+ interface DenoProject {
2460
+ /** Which of {@link DENO_CONFIG_FILES} declared it, repository-relative to the project directory. */
2461
+ configFile: string;
2462
+ /** The body of `tasks.test`, verbatim, or null when the project declares no test task. */
2463
+ testTask: string | null;
2464
+ /**
2465
+ * The single `deno test` stage of that task, verbatim, when the task has exactly one and is a
2466
+ * plain `&&` chain. Null when the task is composed some other way, names deno in zero or more
2467
+ * than one stage, or is not a `deno test` at all - in which case the run falls back to
2468
+ * `deno task test`, which is always correct and merely opaque.
2469
+ */
2470
+ testInvocation: string | null;
2471
+ }
2472
+ /**
2473
+ * The one `deno test` stage of a task body, or null when there is not exactly one to take.
2474
+ *
2475
+ * Mirrors `runnerInvocation` in `detect.ts` stage for stage, including its refusals: anything that
2476
+ * is not a plain `&&` chain is left alone, because the fallback (`deno task test`) reproduces the
2477
+ * whole task and a partial reading of it would not.
2478
+ */
2479
+ declare function denoTestInvocation(taskBody: string | null): string | null;
2480
+ /**
2481
+ * Read a directory's deno declaration, or null when it declares nothing this run can act on.
2482
+ *
2483
+ * A `deno.json` with no `tasks.test` yields null rather than a project with a null task: deno's own
2484
+ * `deno test` discovers `*_test.ts` and friends by convention, so such a repository MIGHT be
2485
+ * testable - but nobody wrote down that it is, and inventing `deno test` for a config file that
2486
+ * exists only to set `nodeModulesDir` would claim a suite the author never declared. The honest
2487
+ * answer stays `TargetNotFoundError`, exactly as an npm package with no `test` script gets.
2488
+ */
2489
+ declare function readDenoProject(dir: string, readText?: ReadTextAt): DenoProject | null;
2490
+ /** The deno lockfile a directory commits, or null. Absence is what refuses a frozen install. */
2491
+ declare function denoLockfilePath(dir: string, readText?: ReadTextAt): string | null;
2492
+ /**
2493
+ * Does a `package.json` test script run deno's own runner?
2494
+ *
2495
+ * The HYBRID row, and it is the one shape this answers for. A repository whose `scripts.test` is
2496
+ * `deno test -A` classified as the generic `command` runner before 2026-08-18, which cost it Layer
2497
+ * 0, per-test attribution and the whole v2 engine over a spelling: it is the same suite a pure deno
2498
+ * repository declares, written in the other file. Mirrors `usesBun` in `classifyPackageManifest`,
2499
+ * which exists for exactly the same reason one runtime over.
2500
+ */
2501
+ declare function scriptRunsDenoTest(testScript: string | null): boolean;
2502
+
2503
+ /**
2504
+ * Workspace AGGREGATORS - turbo, lage and nx - and the one monorepo shape whose packages are not
2505
+ * declared in a manifest at all.
2506
+ *
2507
+ * TWO FACTS, ONE FILE, because they are the two halves of the same question. A repository whose
2508
+ * root `scripts.test` is `turbo run test` has not told us how to run a suite; it has told us that
2509
+ * every package runs its own. So the aggregator's name is a routing fact (this root is not a
2510
+ * runner - go and measure the packages), and for the Nx flavour that declares no `workspaces` the
2511
+ * package list has to come from somewhere other than the root manifest.
2512
+ *
2513
+ * WHY THE AGGREGATOR IS NOT A RUNNER, and why leaving it to classify as the generic `command`
2514
+ * adapter was wrong in two directions:
2515
+ *
2516
+ * 1. it MEASURES THE WHOLE REPOSITORY. `npm test` at the root of a Turborepo runs every
2517
+ * package's suite through one process tree, so Stryker's command runner re-runs all of them
2518
+ * per mutant and diff coverage has no single provider to read - the exact shape the multi-suite
2519
+ * fanout work refused for `vitest a && vitest b`, one level up: packages compose the way
2520
+ * suites do.
2521
+ * 2. it can be READ AS A RUNNER BY ACCIDENT. `runnerNamesInScript` looks for a runner binary
2522
+ * anywhere in the script, and `turbo run test --filter=./packages/jest` ends in the bare token
2523
+ * `jest` behind a path separator, which is exactly the shape that function admits. That
2524
+ * repository would have classified as a direct jest project and had jest's coverage flags
2525
+ * appended to a turbo invocation.
2526
+ *
2527
+ * So an aggregator invocation is recognised BEFORE any runner name is looked for, and a package
2528
+ * whose test script is one classifies as no runner at all - the same treatment `npm-run-all`
2529
+ * already gets in {@link classifyPackageManifest}, for the same reason.
2530
+ *
2531
+ * PACKAGE-BASED MONOREPOS ARE UNAFFECTED BY DESIGN. A root that declares `workspaces` (or a
2532
+ * `pnpm-workspace.yaml`) already rides per-package composition, whatever aggregator its scripts
2533
+ * name - the aggregator is ignored, and that is the correct outcome, not an oversight. What is new
2534
+ * here is the INTEGRATED Nx flavour, which declares no `workspaces` field at all: its projects are
2535
+ * declared one `project.json` at a time and the root carries `nx.json`. Without a reader for that
2536
+ * shape the selector found no workspace definition, skipped, and the run fell back to measuring the
2537
+ * aggregator command at the root.
2538
+ *
2539
+ * THE THIRD WORKING DIRECTORY, added 2026-08-16. An integrated-Nx project declares its suite through
2540
+ * an executor Nx runs FROM THE WORKSPACE ROOT against a root-relative config, and per-package
2541
+ * composition had only two positions for a working directory - the repository root, measuring the
2542
+ * repository, or a package's own directory, measuring that package. So those projects were honest
2543
+ * excluded rows. {@link WorkspaceRootSuite} is the third position: the runner runs where the
2544
+ * declaration says it runs, and the measurement is scoped to the project directory. It is keyed on
2545
+ * that structural fact and never on the name of the tool that produced it.
2546
+ *
2547
+ * Everything here is fail-closed. A script that is not exactly one aggregator invocation is not an
2548
+ * aggregator, an `nx.json` that does not parse yields no projects, and a project whose test target
2549
+ * this file cannot drive is named as unmeasurable rather than guessed at.
2550
+ */
2551
+ /** The three workspace task runners. `nx` covers both flavours; the flavour is a separate fact. */
2552
+ type AggregatorTool = "turbo" | "lage" | "nx";
2553
+ interface AggregatorInvocation {
2554
+ tool: AggregatorTool;
2555
+ /**
2556
+ * The task names the invocation selects, when they are stated positionally or through nx's
2557
+ * `-t`/`--target`. Empty when the invocation selects its tasks some other way (an nx
2558
+ * `run-many` with no target reads its own default) - the tool is still the routing fact.
2559
+ */
2560
+ tasks: string[];
2561
+ }
2562
+ /**
2563
+ * The aggregator a test script invokes, or null when it invokes none.
2564
+ *
2565
+ * ONE STAGE ONLY, deliberately. A script that composes an aggregator with anything else
2566
+ * (`nx run-many -t lint && nx run-many -t test`, `turbo run test | tee log`) is left to the
2567
+ * existing fanout and gate machinery, which already reads chains; claiming the aggregator for a
2568
+ * composed script would take a routing decision on a script this file has not read whole.
2569
+ */
2570
+ declare function aggregatorInvocation(testScript: string | null): AggregatorInvocation | null;
2571
+ /**
2572
+ * The tasks half of an aggregator invocation, as one printable phrase for a refusal message.
2573
+ *
2574
+ * An invocation that names no task reads its selection from configuration this file does not
2575
+ * parse, and saying so is more honest than naming a task nobody read.
2576
+ *
2577
+ * COMMAS ONCE THERE ARE MORE THAN TWO, because this sentence became reachable for a long list on
2578
+ * 2026-09-01: it was only ever produced for a directly-written `turbo run test` until the aliased
2579
+ * root started reaching it, and TanStack/query's run-many names eight tasks. Eight joined by "and"
2580
+ * is not a sentence anybody reads.
2581
+ */
2582
+ declare function aggregatorTaskPhrase(invocation: AggregatorInvocation): string;
2583
+ /** One integrated-Nx project: its directory and what its own `project.json` says about testing. */
2584
+ interface NxProject {
2585
+ /** Repository-relative project directory, never "" (the workspace root is not a project). */
2586
+ directory: string;
2587
+ /** The project's own name, when it declares one. */
2588
+ name: string | null;
2589
+ /** What the project's `test` target is, once read. */
2590
+ test: NxTestTarget;
2591
+ }
2592
+ type NxTestTarget = {
2593
+ state: "runner";
2594
+ runner: string;
2595
+ command: string;
2596
+ } | ({
2597
+ state: "workspace-root";
2598
+ } & WorkspaceRootSuite) | {
2599
+ state: "unmeasurable";
2600
+ reason: string;
2601
+ remedy: string;
2602
+ };
2603
+ /**
2604
+ * A suite whose runner runs FROM THE WORKSPACE ROOT against a root-relative config, while the
2605
+ * measurement it produces belongs to one project directory.
2606
+ *
2607
+ * THE STRUCTURAL FACT, and it is deliberately not "this repository uses Nx". Two positions used to
2608
+ * be the whole vocabulary - a suite runs at the repository root and measures the repository, or a
2609
+ * suite runs inside a package and measures that package. This is the third: the working directory
2610
+ * and the measured directory are DIFFERENT, and the declaration says which is which. Nx's official
2611
+ * executors are the first shape that produces it; nothing below reads the string "nx" to decide.
2612
+ *
2613
+ * Every field is what the run needs to act on it, and each is a fact somebody wrote down:
2614
+ * - `runner` is a runner the existing adapters already drive; an executor that maps to none stays
2615
+ * an excluded row, which is the boundary this mode does not widen.
2616
+ * - `configFile` is the workspace-root-relative config the declaration names. Never guessed: a
2617
+ * target that names none is excluded, because running a root-found config against one project
2618
+ * would measure a suite nobody selected.
2619
+ * - `directory` is the measured project, which is what scopes coverage, mutation and attribution.
2620
+ * - `declaredBy` is the executor that said so, carried for the log and the artifact.
2621
+ */
2622
+ interface WorkspaceRootSuite {
2623
+ runner: "jest" | "vitest";
2624
+ /** Workspace-root-relative config file the runner is pointed at. */
2625
+ configFile: string;
2626
+ /** Repository-relative directory whose measurement this suite produces. */
2627
+ directory: string;
2628
+ /** The declaration this was read from, e.g. `@nx/jest:jest`. */
2629
+ declaredBy: string;
2630
+ /** The runner invocation, run from the workspace root. */
2631
+ command: string;
2632
+ }
2633
+ /**
2634
+ * Read one project's `test` target into the answers per-package composition can act on.
2635
+ *
2636
+ * A `run-commands` target whose command is one literal runner invocation AND whose `cwd` is the
2637
+ * project's own directory is drivable exactly as a package script is - it is the same invocation,
2638
+ * written in a different file. Everything else is named.
2639
+ */
2640
+ declare function classifyNxTestTarget(projectJson: unknown, directory: string): NxTestTarget;
2641
+ /**
2642
+ * Enumerate an integrated-Nx workspace's projects from the files a tree listing already has.
2643
+ *
2644
+ * READ THROUGH A SEAM, on purpose. Selection reads the TRUSTED merge-base tree and `abloh prepare`
2645
+ * reads the working tree; both must enumerate identically or they desync on the same repository -
2646
+ * the same rule `classifyPackageManifest` carries for manifests. So this function takes the path
2647
+ * list and a reader and never touches the filesystem itself.
2648
+ *
2649
+ * @param paths every repository-relative path in the tree
2650
+ * @param readFile the bytes of one path, or null when absent/unreadable
2651
+ */
2652
+ declare function enumerateNxProjects(paths: readonly string[], readFile: (path: string) => string | null, limit: number): NxProject[];
2653
+
2654
+ /**
2655
+ * The tool that installs a repository's dependencies.
2656
+ *
2657
+ * `deno` and `rush` joined the four node package managers on 2026-08-18, and neither is a node
2658
+ * package manager in the ordinary sense - which is the point. This union is what selects the frozen
2659
+ * install command and what `preparation.ts` matches a lockfile against, and BOTH of those refused a
2660
+ * fifth value outright (`no reviewed sandbox install command exists for package manager X`), so a
2661
+ * repository installing through either tool could not be prepared at all.
2662
+ *
2663
+ * - `deno` caches from `deno.lock` with `deno install --frozen --entrypoint .`, into a baked cache.
2664
+ * - `rush` installs from `common/config/**\/pnpm-lock.yaml` with a COMMITTED bootstrap script,
2665
+ * which then installs its own pinned pnpm. The lockfile is pnpm's; the command is Rush's, and
2666
+ * `rush.json` pins both versions - so it is Rush that owns the install, not pnpm.
2667
+ */
2668
+ type PackageManager = "npm" | "pnpm" | "yarn" | "bun" | "deno" | "rush";
2669
+ type Runner = "jest" | "vitest" | "mocha" | "node-test" | "ava" | "tap" | "jasmine" | "bun" | "deno" | "angular-karma" | "angular-vitest" | "command";
2670
+ interface CoverageChildSuite {
2671
+ /**
2672
+ * Exact package.json script name this suite came from, when it came from one. Absent for a suite
2673
+ * the script INVOKES INLINE (`vitest run -c a.ts && vitest run -c b.ts`), which has no script name
2674
+ * to give - `testCommand` is what names such a suite to an operator.
2675
+ */
2676
+ script?: string;
2677
+ /**
2678
+ * Native runner named by that leaf script. All children in one plan use the same runner.
2679
+ *
2680
+ * bun and deno are excluded for the same structural reason and not for a capability one: a fanout
2681
+ * child is a leaf of an `npm-run-all`, `concurrently` or `&&` chain declared in a package.json,
2682
+ * and neither runtime's suites are declared there. A deno project's tasks live in `deno.json`,
2683
+ * which has no wrapper vocabulary at all. `NATIVE_COVERAGE_RUNNERS` below is the runtime set and
2684
+ * this type is its compile-time twin; the two must not drift.
2685
+ *
2686
+ * The two Angular runners are excluded on the same reasoning one step further: a child suite is a
2687
+ * leaf SCRIPT naming its runner, and an Angular suite is never named by a script - it is named by
2688
+ * a workspace file, per project, and `angular.json` says nothing about which of a wrapper's
2689
+ * stages runs which project. A repository whose wrapper fans out over `ng test` stages keeps the
2690
+ * bounded command treatment rather than getting a child plan built on a guess.
2691
+ */
2692
+ runner: Exclude<Runner, "command" | "bun" | "deno" | "angular-karma" | "angular-vitest">;
2693
+ /** Package-manager invocation that preserves the leaf script's config, environment and selectors. */
2694
+ testCommand: string;
2695
+ }
2696
+ interface TargetDetection {
2697
+ /** package manager detected from the lockfile */
2698
+ pm: PackageManager;
2699
+ /** test runner: an installed vitest/jest dep, else the command fallback */
2700
+ runner: Runner;
2701
+ /** direct-runner invocation (bypasses lint in `npm test`), or the repo's own `test` script */
2702
+ testCommand: string;
2703
+ /**
2704
+ * Set when `testCommand` carries an appended `--watch=false --watchAll=false` that the repository
2705
+ * did not write.
2706
+ *
2707
+ * A rewritten test command is a thing the customer is owed an explanation for, so the fact
2708
+ * travels rather than being inferrable only by diffing strings: `abloh init` turns it into the
2709
+ * evidence line beside `environment.testCommand` in the file they ratify. Absent - never false -
2710
+ * for every repository whose script exits on its own, which is nearly all of them.
2711
+ */
2712
+ watchModeNormalized?: true;
2713
+ /**
2714
+ * The one stage of that script which runs the test runner, when the script is a gated `&&` chain.
2715
+ * The mutation engine falls back to it after Stryker's initial run fails, and nowhere else; see
2716
+ * {@link runnerStageCommand}. Undefined when there is nothing separable to fall back to.
2717
+ */
2718
+ runnerStageCommand?: string;
2719
+ /**
2720
+ * The stages that same script runs BEFORE the runner stage, in the author's order, when running
2721
+ * one stage alone would otherwise skip work the suite needs; see {@link preRunnerStageCommands}.
2722
+ * Absent whenever {@link runnerStageCommand} is absent, and whenever the chain has no earlier
2723
+ * stage of its own.
2724
+ */
2725
+ preRunnerStageCommands?: readonly string[];
2726
+ /**
2727
+ * The repository's OWN runner invocation, taken from `scripts.test` with nothing but a package-
2728
+ * runner prefix removed, for the runners whose suite or configuration is named by the invocation
2729
+ * rather than found by the runner itself. Forwarded to the per-test plugins and to the mutation
2730
+ * engine's jest and mocha blocks; see {@link runnerInvocation}. Undefined when the script does
2731
+ * not name the runner unambiguously.
2732
+ */
2733
+ runnerInvocation?: string;
2734
+ /** vitest `test.dir`, read from the repo's vitest/vite config (undefined when unset) */
2735
+ vitestDir?: string;
2736
+ /**
2737
+ * Where the repo's own jasmine invocation says its specs are, forwarded to the mutation engine.
2738
+ * Undefined when the repo keeps its config where jasmine's own fallback already looks, which is
2739
+ * the only case that needs no help; see {@link detectJasmineSuite}.
2740
+ */
2741
+ jasmineSuite?: JasmineSuite;
2742
+ /**
2743
+ * The `angular.json` test target behind `ng test`, for the two Angular runners and nothing else.
2744
+ *
2745
+ * Forwarded rather than re-read because three consumers need it and each would otherwise parse the
2746
+ * workspace file again with its own rules: the mutation engine needs the PROJECT name and the
2747
+ * BUILDER (the Stryker karma config, and the sandbox rewrite the unit-test builder needs), diff coverage
2748
+ * needs the project name to ask for coverage, and the sealed v2 command needs it to run one spec.
2749
+ * Undefined for every non-Angular repository.
2750
+ */
2751
+ angularTarget?: AngularTestTarget;
2752
+ /** relative subdir holding the testable package.json, or null for the repo root */
2753
+ subdir: string | null;
2754
+ /**
2755
+ * Absolute directory stryker/triage run in.
2756
+ *
2757
+ * `repoDir/subdir` for every ordinary package, and `repoDir` at the root. The ONE shape where it
2758
+ * is neither is {@link workspaceRootSuite}: there the suite is declared to run from the workspace
2759
+ * root while `subdir` stays the package being measured, so the two facts separate. Anything
2760
+ * deriving a path prefix from this must derive it from `relative(repoDir, workDir)` and not from
2761
+ * `subdir` - they are equal in every other case, and only one of them is the stryker cwd.
2762
+ */
2763
+ workDir: string;
2764
+ /**
2765
+ * Set when this package's suite is declared to run FROM THE WORKSPACE ROOT against a
2766
+ * root-relative config, rather than from the package's own directory.
2767
+ *
2768
+ * WHAT IT CHANGES, and it is only ever these three things: `workDir` becomes the repository root,
2769
+ * `testCommand` is the invocation the declaration names, and the measurement stays scoped to
2770
+ * `subdir`. Coverage, mutation and per-test attribution are all still one package's.
2771
+ */
2772
+ workspaceRootSuite?: WorkspaceRootSuite;
2773
+ /**
2774
+ * A runner recognized from the script rather than from an installed dependency (currently "bun",
2775
+ * whose test runner is built into the binary and so has nothing in `node_modules` to find).
2776
+ *
2777
+ * The artifact discloses WHICH runner was identified instead of a generic "command". It used to
2778
+ * mean "identified but undrivable", and for bun that stopped being true on 2026-08-16: mutation
2779
+ * now runs through `@abloh/stryker-bun-runner`, one test at a time. What remains disabled for bun
2780
+ * is diff coverage, for a reason that is bun's rather than ours - see the bun row in
2781
+ * `capability-registry.ts` and the copy in `BUN_DIFF_COVERAGE_NOTICE`.
2782
+ */
2783
+ identifiedRunner?: string;
2784
+ /**
2785
+ * Statically resolved native coverage plan for a bounded npm-run-all aggregate. The baseline
2786
+ * still executes `testCommand` unchanged; this plan exists only to collect truthful diff-coverage
2787
+ * coverage from every selected child with that child's native provider.
2788
+ */
2789
+ coverageChildren?: CoverageChildSuite[];
2790
+ /** Preserve the wrapper's declared scheduling semantics during native coverage collection. */
2791
+ coverageExecution?: "parallel" | "sequential";
2792
+ /**
2793
+ * Why a script that launches MORE THAN ONE suite got no coverage plan, in the operator's words.
2794
+ *
2795
+ * A fanout that cannot be merged is EXCLUDED AND SAID SO, never measured in part: the standing
2796
+ * principle, and the reason this field exists rather than the run reporting the generic "no
2797
+ * coverage provider for the command runner", which names neither the suites nor what would fix it.
2798
+ */
2799
+ coverageExclusion?: string;
2800
+ /**
2801
+ * The workspace task runner this package's `test` script delegates to, when it delegates to one.
2802
+ *
2803
+ * Reported rather than inferred: `turbo run test` names no suite, so the honest identity of such
2804
+ * a root is "an aggregator", and a run that measures it is measuring every package at once. The
2805
+ * field exists so the log and the artifact can say WHICH tool, and so per-package composition's
2806
+ * refusals can name what the root actually is.
2807
+ */
2808
+ workspaceAggregator?: AggregatorTool;
2809
+ /**
2810
+ * The aggregator TASK this package's suite was read from, when the package declares no `test`
2811
+ * script and the root's aggregator named a task it does declare.
2812
+ *
2813
+ * Reported for the same reason `workspaceAggregator` is, and for one more: `abloh init` has to
2814
+ * know whether the runner it is looking at came from something the REPOSITORY wrote down or from
2815
+ * a guess off an installed dependency, because a CI-derived `testCommand` is drafted over the
2816
+ * second and must never be drafted over the first. Absent for every package whose declaration is
2817
+ * `scripts.test`.
2818
+ */
2819
+ aggregatorTask?: string;
2820
+ /**
2821
+ * The build this package must run BEFORE its suite can see a change to its source, when its tests
2822
+ * read compiled output rather than source.
2823
+ *
2824
+ * WHAT IT IS FOR, and it is one thing: mutation. Stryker writes a mutant into `src/*.ts`, and a
2825
+ * project whose jest config roots at `lib-commonjs/` runs tests that never load it - so the mutant
2826
+ * is invisible, the suite is green, and the verdict is SURVIVED for a mutant the suite would kill.
2827
+ * Measured on rushstack's `libraries/node-core-library`, 2026-08-18: 10 passed / 0 failed unbuilt,
2828
+ * 9 passed / 1 failed rebuilt. Every mutant a false survivor, and the finding handed to a customer
2829
+ * would say their tests notice nothing when they notice everything.
2830
+ *
2831
+ * KENNETH RULED ON 2026-08-18 that abloh pays the compile
2832
+ * (`abloh-deno-rush-decision-compile-then-test-mutation-rebuild`), so this travels to the mutation
2833
+ * engine and becomes the first half of every mutant's cycle. Undefined for every ordinary package,
2834
+ * whose tests read the source they are about.
2835
+ *
2836
+ * IT IS NOT USED BY DIFF COVERAGE OR THE BASELINE. Both run the suite as the repository runs it, against
2837
+ * whatever the checkout already contains; only mutation changes a source file underneath them.
2838
+ *
2839
+ * IT IS NOT A RUSH FIELD ANY MORE. Until 2026-09-01 the Rush probe was the only thing that set it,
2840
+ * which is the ruling shipped by example: every other repository of the same shape got the false
2841
+ * survivors and had no key to correct them with (audit F8). `readsCompiledOutput` in `@abloh/core`
2842
+ * is the general reading, `environment.rebuildCommand` is the declaration that outranks it, and
2843
+ * Rush is now one case rather than a branch beside it.
2844
+ */
2845
+ rebuildCommand?: string;
2846
+ /**
2847
+ * WHAT SAID THIS REPOSITORY'S TESTS READ COMPILED OUTPUT, in one line naming both files.
2848
+ *
2849
+ * PRESENT WHENEVER THE SHAPE WAS DETECTED, INCLUDING WITH NO COMMAND BESIDE IT, and that pairing
2850
+ * is the point: a detected shape whose build script could not be picked out is exactly the case
2851
+ * `abloh init` has to ASK about, and a reading with no receipt is a question with no evidence in
2852
+ * it. Absent for every repository whose suite is about the files it changes.
2853
+ */
2854
+ rebuildEvidence?: string;
2855
+ /**
2856
+ * The declared build scripts, when more than one could be the answer and nothing ranks them.
2857
+ *
2858
+ * TWO BUILD SCRIPTS ARE TWO DEFENSIBLE ANSWERS to "which one writes the directory the tests read",
2859
+ * so neither is picked - the same posture `setup.job` takes on a tie. Present only alongside an
2860
+ * absent {@link rebuildCommand}.
2861
+ */
2862
+ rebuildCandidates?: readonly string[];
2863
+ /**
2864
+ * The directory this package's suite reads instead of its source, repo-relative.
2865
+ *
2866
+ * IT IS A FACT ON A CUSTOMER SENTENCE and that is the whole of what it is for: a run that finds
2867
+ * this shape and has no command to run names the directory, so the maintainer reads "your tests
2868
+ * import from lib/" rather than "your tests import built output".
2869
+ */
2870
+ rebuildOutputDirectory?: string;
2871
+ }
2872
+ /**
2873
+ * The runner a DECLARED test command names as its own binary, or null when it names none.
2874
+ *
2875
+ * DECLARATION-FIRST, and this is the function that makes the phrase mean something. A command a
2876
+ * human wrote down - `--test-command` or `environment.testCommand` - is a statement about how this
2877
+ * suite runs. When it names a runner directly, that name is a FACT about the command the run will
2878
+ * execute, not an inference from anything else in the tree, so nothing in the tree may outrank it.
2879
+ *
2880
+ * IT READS ONE ARGV AND NOTHING ELSE. A prepared command has already been through
2881
+ * `parsePreparedTestCommand`, so it is one literal command with no shell composition - there is no
2882
+ * chain to disambiguate and no second stage to lose. A package-manager invocation (`npm test`,
2883
+ * `pnpm run x`) names a SCRIPT here and not a runner, deliberately: the answer to what that script
2884
+ * runs is in the manifest, which this reading does not hold. {@link bindDeclaredCommand} is the one
2885
+ * that does, and it is what every caller deciding an ADAPTER asks.
2886
+ *
2887
+ * `node <path-to-runner>` IS A RUNNER, and until 2026-08-31 it was not - which made the one remedy
2888
+ * this product offers a demotion in disguise. See {@link nodeInvokedRunner} and the note on
2889
+ * {@link bindPreparedTestCommand} about the trap that header records as closed.
2890
+ */
2891
+ declare function declaredRunner(argv: readonly string[]): Runner | null;
2892
+ /**
2893
+ * {@link declaredRunner}, plus the argv WITHOUT the tokens that only stand in front of the binary.
2894
+ *
2895
+ * The stripped form is what a derived command must be rebuilt from: `npx jest --ci` and `jest --ci`
2896
+ * name the same invocation, and re-prefixing the first would produce `npx --no-install npx jest`.
2897
+ */
2898
+ declare function declaredRunnerInvocation(argv: readonly string[]): {
2899
+ runner: Runner;
2900
+ argv: readonly string[];
2901
+ } | null;
2902
+ /** What a declared command turned out to be, once the manifest was allowed to answer. */
2903
+ interface DeclaredBinding {
2904
+ /** The runner that will measure it. */
2905
+ runner: Runner;
2906
+ /**
2907
+ * The script text the command reached that runner THROUGH, or null when the command named the
2908
+ * runner itself. This is what every reading of a script - the stage split, the `--config` a
2909
+ * plugin needs, a jasmine spec list - has to be taken from, because for a hopped command the
2910
+ * command itself says none of it.
2911
+ */
2912
+ script: string | null;
2913
+ /** The script NAME of the last hop, for the lifecycle hooks the manager runs around it. */
2914
+ scriptName: string | null;
2915
+ /** The hops taken, outermost first. Empty when the command named its runner directly. */
2916
+ hops: readonly CommandScriptHop[];
2917
+ }
2918
+ /**
2919
+ * THE RUNNER A DECLARED COMMAND WILL BE MEASURED BY, reading the manifest when the command points
2920
+ * into it. This is the one function every surface that decides an ADAPTER asks.
2921
+ *
2922
+ * WHY IT EXISTS, and it is the single largest defect of the 2026-08-31 ten-repository run
2923
+ * (`data/abloh-ten-run-2/report.md` §6.1). abloh printed, to five of ten maintainers:
2924
+ *
2925
+ * the baseline will report 0 executed tests ... Naming one runner directly here is what
2926
+ * changes that
2927
+ *
2928
+ * Every one of them did exactly that, out of their own `package.json`, and five were refused anyway,
2929
+ * because the reader behind that sentence was {@link declaredRunner} - which takes the FIRST token
2930
+ * and asks whether it is a runner. Measured over `jotai`'s frozen commit, one
2931
+ * `abloh init --answers-file` at a time: `vitest run` and `npx vitest run` bound;
2932
+ * `corepack pnpm vitest run` and `corepack pnpm run test:spec`, where `test:spec` is literally
2933
+ * `vitest run`, did not. The two spellings that worked are the two abloh never writes - its own
2934
+ * derived command on every pnpm and yarn repository in that population is `corepack pnpm test` - so
2935
+ * a maintainer correcting abloh's command, in abloh's own idiom, was refused. Six of ten rows died
2936
+ * there, and 243 seconds of green test execution were counted as zero.
2937
+ *
2938
+ * THE SCRIPT IS CLASSIFIED THE WAY EVERY OTHER SCRIPT IS. Following the hop is `resolveCommandPlan`
2939
+ * in `@abloh/core`; deciding what the script it lands on IS is `classifyPackageManifest`, called
2940
+ * here on the same script table with that text substituted for `test`. That is deliberate and it is
2941
+ * the whole difference between this and a second reading: `eslint . && vitest run` names vitest and
2942
+ * IS measurable behind its gate, `vitest run -c unit && vitest run -c int` names vitest and is a
2943
+ * fanout that gets no runner identity, and `turbo run test` is an aggregator - and every one of
2944
+ * those three answers already existed. A hop that answered them again, its own way, would be the
2945
+ * drift `command-plan.ts` was built to end.
2946
+ *
2947
+ * FLAGS THAT SURVIVE THE HOPS. abloh's reporter flags are appended to the command it executes, and
2948
+ * they have to arrive at the runner or the baseline observes nothing - the state this whole reading
2949
+ * exists to leave. One hop is safe on every manager: `withRunnerArgs` puts npm's documented `--` in
2950
+ * front where npm needs it, and pnpm >=10 and yarn forward verbatim. A SECOND hop is not, and
2951
+ * `aliasHopFamily` is the measurement of exactly that - pnpm <=9 and npm consume the tokens at the
2952
+ * second hop. Binding a runner whose flags will be eaten would trade an honest refusal for a silent
2953
+ * zero, so it is not done.
2954
+ */
2955
+ declare function bindDeclaredCommand(argv: readonly string[], context?: DeclaredCommandContext): DeclaredBinding | null;
2956
+ /**
2957
+ * Bind an explicit prepared command to the adapter that will measure it.
2958
+ *
2959
+ * A specialized adapter may add runner-specific coverage flags and Stryker may invoke its own
2960
+ * runner plugin, so baseline, coverage capability and mutation must all be describing the SAME
2961
+ * suite. The invariant is that the adapter and the command agree; it is not that the command and
2962
+ * `scripts.test` agree.
2963
+ *
2964
+ * SELECTION FOLLOWS THE COMMAND THAT ACTUALLY RUNS. Kenneth's admission-first order of 2026-08-27,
2965
+ * closing D1 of `data/abloh-real-bugs-study-5/report.md` - four repositories at admission (§4) and
2966
+ * the proposal stage's only measured wall (§5.2). Until then this function demoted any prepared
2967
+ * command that differed from detection's to the generic adapter, which reads the invariant one term
2968
+ * too wide: the prepared command is what this run will execute, so it, and not the script detection
2969
+ * first read, is what the adapter has to agree with.
2970
+ *
2971
+ * WHAT THAT COST WHILE IT READ THE OTHER WAY. `electron/asar`'s `scripts.test` is
2972
+ * `yarn lint && yarn vitest run && xvfb-maybe electron …`, so detection selected vitest from the
2973
+ * script and the baseline ran the whole gated chain: `0 executed test(s) … red true` on a suite that
2974
+ * is 11 files and 190 tests green by hand. The maintainer then wrote the one thing that failure
2975
+ * invites - `testCommand: npx --no-install vitest run` - and the run answered `test command differs
2976
+ * from the command that selected vitest; using the generic command adapter`, which has no reporter
2977
+ * at all: `0 executed test(s)` a second time, from a command that is a bare vitest invocation. The
2978
+ * report's own sentence: "Correcting the command costs the runner adapter; leaving it costs the
2979
+ * baseline. There is no third answer." This is the third answer, and it was always the honest one -
2980
+ * the demotion was never protecting against a mixed measurement, because a command NAMING vitest
2981
+ * and vitest's own adapter describe the same suite.
2982
+ *
2983
+ * THE THREE OUTCOMES, and each is a fact about `prepared` alone:
2984
+ *
2985
+ * - it names a runner ⇒ THAT runner's adapter, whether or not detection agreed. A declaration
2986
+ * naming a DIFFERENT runner is not two statements to reconcile: one of them is a guess read off
2987
+ * a script that this run will not execute, and the other is the binary about to be spawned.
2988
+ * - it names no runner and restates detection's own command ⇒ nothing changed, so nothing moves.
2989
+ * - it names no runner and is something else - `./run-tests.sh` - ⇒ the generic command adapter,
2990
+ * because what executes is genuinely opaque. That is the ONLY demotion left, and
2991
+ * `identifiedRunner` still discloses what the script had said.
2992
+ *
2993
+ * `npm run test:ci` LEFT THAT THIRD ROW ON 2026-09-01, and it is why `context` exists. It is not
2994
+ * opaque: the script is in the manifest, it is the same manifest detection reads, and following it
2995
+ * is the difference between measuring a suite and reporting zero executed tests over it. Six of the
2996
+ * ten rows of `data/abloh-ten-run-2/report.md` died on that reading being one token wide - each of
2997
+ * them a maintainer who had just been told "Naming one runner directly here is what changes that"
2998
+ * and had done exactly that. §6.1 carries the six spellings and which two of them bound.
2999
+ *
3000
+ * WHAT IS DROPPED WITH THE COMMAND, in every branch that changes it: the fanout coverage plan and
3001
+ * the watch-mode correction, both readings of `scripts.test` that a declared command replaces.
3002
+ *
3003
+ * WHAT IS NO LONGER DROPPED BLINDLY IS THE RUNNER-STAGE SPLIT. For a command that names its runner
3004
+ * directly the old sentence holds exactly - `pnpm exec vitest run` IS one stage - so the split is
3005
+ * still dropped there. For a command that reached its runner THROUGH a script, the script is a
3006
+ * script like any other: it can gate the runner behind a linter, and `baselineCommand`'s whole
3007
+ * reason for taking a stage is that appending reporter flags to `lint && jest` puts them on the
3008
+ * linter. So the split is RECOMPUTED from the resolved script rather than discarded, by the same
3009
+ * two functions detection calls on `scripts.test`.
3010
+ *
3011
+ * AND A SCRIPT THAT RUNS THE SUITE TWICE STILL NAMES NO RUNNER, which is the fail-closed rule
3012
+ * `classifyPackageManifest` states for `scripts.test` and which a hop must not be able to walk
3013
+ * around: `vitest run -c unit && vitest run -c int` names vitest once and launches two suites, and
3014
+ * measuring one of them silently is the defect that rule exists for.
3015
+ */
3016
+ declare function bindPreparedTestCommand(detected: TargetDetection, prepared: PreparedTestCommand, context?: DeclaredCommandContext, ctx?: RepoAccess): TargetDetection;
3017
+ /**
3018
+ * THE RUNNER THE MAINTAINER WROTE DOWN, APPLIED - `environment.runner`, and nothing else reaches it.
3019
+ *
3020
+ * WHY IT IS A SEPARATE FUNCTION FROM {@link bindPreparedTestCommand} rather than a fourth outcome
3021
+ * inside it. That function answers "what does the command that will execute name", which is a
3022
+ * reading. This answers "what did a person state", which is not - and folding a declaration into a
3023
+ * reading is how the two would come to disagree about which of them wins. They run in that order,
3024
+ * here and only here (`measurement-plan-resolver.ts`), so a declaration is applied to whatever the
3025
+ * command reading produced and never the other way round.
3026
+ *
3027
+ * IT MOVES THE ADAPTER AND NOTHING ELSE. The command is untouched, which is the whole point: a
3028
+ * repository whose command is right and whose runner abloh read wrong - `pinojs/pino`, whose
3029
+ * five-stage chain abloh reduced to a one-test `jest test/jest` leg - needs one of those corrected
3030
+ * and not both. What that leaves open is a declared runner whose reporter flags the command in force
3031
+ * cannot carry, and the baseline is what closes it: `baselineObservedNoTests` refuses a green run
3032
+ * that observed no test rather than scoring over none.
3033
+ *
3034
+ * WHAT TRAVELS WITH THE RUNNER AND WHAT DOES NOT, on exactly the terms `bindPreparedTestCommand`
3035
+ * sets for its own declared branch. `vitestDir` and `jasmineSuite` belong to a runner and are
3036
+ * re-read for the one now named; `angularTarget` is read from `angular.json` and survives only for
3037
+ * the two Angular runners, because a project name attached to a run of something else is a claim
3038
+ * about a file that run never opens; `runnerInvocation` survives only for the runners that select
3039
+ * their specs or their config from it, which is the same list detection reads it for.
3040
+ *
3041
+ * A DECLARATION THAT AGREES WITH THE READING CHANGES NOTHING, and returns the same object, so the
3042
+ * commonest case - a maintainer ratifying what abloh detected - cannot cost a re-read.
3043
+ */
3044
+ declare function applyDeclaredRunner(detected: TargetDetection, declared: string | undefined, ctx?: RepoAccess): TargetDetection;
3045
+ /**
3046
+ * What a caller holds ABOUT THE REPOSITORY that a declared command needs in order to be read whole.
3047
+ *
3048
+ * One optional argument rather than two: a caller either has the target package's manifest or it
3049
+ * does not, and the two fields are always read out of the same one. Absent, every reading below
3050
+ * behaves exactly as it did before scripts could be followed - `abloh run --test-command` from a
3051
+ * directory with no manifest is still a valid, if narrower, question.
3052
+ */
3053
+ interface DeclaredCommandContext {
3054
+ /** `scripts` from the target package's `package.json`. */
3055
+ scripts: Record<string, string>;
3056
+ /**
3057
+ * The manifest itself, for {@link aliasHopFamily} - which decides whether abloh's own appended
3058
+ * reporter flags survive more than one package-manager hop.
3059
+ */
3060
+ manifest?: unknown;
3061
+ }
3062
+ interface PackageManagerContext {
3063
+ pm: PackageManager;
3064
+ /** Canonical directory that owns the selected lockfile or packageManager declaration. */
3065
+ ownerDir: string;
3066
+ /** Lockfiles from the selected manager family at ownerDir. Empty means no frozen install exists. */
3067
+ lockfiles: string[];
3068
+ source: "lockfile" | "packageManager" | "default";
3069
+ version: string | null;
3070
+ /**
3071
+ * How a level carrying MORE THAN ONE lockfile family was decided, or absent when no level did.
3072
+ *
3073
+ * Present so `init` can put the decision in the receipt beside `environment.installCommand`,
3074
+ * where the customer ratifies it and can flip it in the same breath. A choice nobody was told
3075
+ * about would be the refusal's failure mode wearing a different face.
3076
+ */
3077
+ lockfileChoice?: LockfileChoice;
3078
+ }
3079
+ /** The decision made at a directory holding two families of lockfile. See {@link chooseAmongLockfiles}. */
3080
+ interface LockfileChoice {
3081
+ /** Every competing lockfile, in the walk's own order, e.g. `["package-lock.json", "bun.lock"]`. */
3082
+ competing: readonly string[];
3083
+ /** The lockfiles NOT selected. What a customer edits their way towards if the choice is wrong. */
3084
+ rejected: readonly string[];
3085
+ /**
3086
+ * One manager per competing FAMILY, in this walk's own order. Not index-aligned with
3087
+ * {@link competing}, which is per FILE - npm alone can contribute two of those.
3088
+ *
3089
+ * Carried rather than re-derived from the file names, because the name-to-manager table lives
3090
+ * here and a second copy of it in `init` would be a second answer to which manager
3091
+ * `npm-shrinkwrap.json` means.
3092
+ */
3093
+ families: readonly PackageManager[];
3094
+ /**
3095
+ * Which rung of the precedence answered.
3096
+ *
3097
+ * `customer` and `undecided` are the two halves of the ruflo ruling (Kenneth, 2026-08-26). A pair
3098
+ * nothing in the repository decides is no longer a refusal: `undecided` means one was taken
3099
+ * PROVISIONALLY so that `init` could draft a file and ask, and `customer` means the answer came
3100
+ * back. Only `init` ever sees `undecided` - every measuring path still refuses, because a run
3101
+ * that silently installs with the other half of a pair measures the wrong tree.
3102
+ */
3103
+ by: "packageManager" | "ci" | "conservative-default" | "customer" | "undecided";
3104
+ /**
3105
+ * Why, with no subject: "your CI installs with bun (.github/workflows/ci.yml::test)". Kept
3106
+ * subjectless so the receipt beside `environment.installCommand`, which has already named the
3107
+ * selected lockfile, does not name it twice.
3108
+ */
3109
+ because: string;
3110
+ /** The whole sentence: what was chosen, over what, and why. */
3111
+ evidence: string;
3112
+ }
3113
+ /**
3114
+ * Resolve the package manager from the selected package outward to the Git root.
3115
+ *
3116
+ * A child lock owns that package. An ancestor lock owns it only when no nearer level has a lock.
3117
+ * Mixed lockfile families at one level are decided by {@link chooseAmongLockfiles} - never by a
3118
+ * priority rule of ours, and still refused where nothing in the repository decides them. With no
3119
+ * lock anywhere, an exact packageManager declaration selects the host command but deliberately
3120
+ * yields no frozen install recipe.
3121
+ */
3122
+ /**
3123
+ * HOW A CALLER WANTS A TWO-FAMILY DIRECTORY HANDLED, and what it already knows about one.
3124
+ *
3125
+ * Both fields are `init`'s and nothing else passes them. The default is the behaviour every
3126
+ * measuring path has always had: a pair nothing decides is refused rather than guessed at.
3127
+ */
3128
+ interface PackageManagerResolution {
3129
+ /** The family the customer chose at the prompt, where two competed and nothing else decided. */
3130
+ chosen?: PackageManager;
3131
+ /**
3132
+ * `refuse` - the default, and every path that measures - or `provisional`, which takes one so a
3133
+ * file can be drafted and the question can be asked over it. See {@link chooseAmongLockfiles}.
3134
+ */
3135
+ undecided?: "refuse" | "provisional";
3136
+ }
3137
+ declare function detectPackageManagerContext(repoDir: string, workDir?: string, resolution?: PackageManagerResolution, ctx?: RepoAccess): PackageManagerContext;
3138
+ /**
3139
+ * THE NAME THE RUN DRIVES THIS RUNNER BY, or `command` when it has none.
3140
+ *
3141
+ * Every runner the capability registry marks `named-runner` passes through under its own name; the
3142
+ * rest fall back to the repository's own command. Both halves still measure - the fallback runs the
3143
+ * whole suite for every mutant and reads its exit status, which is a verdict - and the difference is
3144
+ * what else can be asked. A named runner's report says WHICH test failed, so a baseline can
3145
+ * quarantine one test rather than a file, and its arguments can ask for particular test files, so a
3146
+ * mutant can be judged by the tests that cover it instead of by the suite.
3147
+ *
3148
+ * NOTHING IS LEFT ON THE FALLBACK EXCEPT deno AND pytest, as of 2026-08-16, and the registry row is
3149
+ * what says so rather than a list here.
3150
+ *
3151
+ * IT USED TO BE READ AS "does a Stryker plugin exist", which is what the column was called until
3152
+ * 2026-08-31 and what its return type is still spelled after. The plugins no longer run; the name
3153
+ * they gave this function outlives them until the packages themselves go.
3154
+ */
3155
+ declare function engineRunner(runner: Runner): StrykerMutationRunner | "command";
3156
+ /** pnpm-lock ⇒ pnpm, yarn.lock ⇒ yarn, else npm (spike detectPm). */
3157
+ declare function detectPackageManager(dir: string): PackageManager;
3158
+ interface PkgProbe {
3159
+ dir: string;
3160
+ /** a runner we can drive DIRECTLY (fast path); null means fall back to the project's own script */
3161
+ runner: "jest" | "vitest" | "mocha" | "node-test" | "ava" | "tap" | "jasmine" | "bun" | "deno" | "angular-karma" | "angular-vitest" | null;
3162
+ testScript: string | null;
3163
+ /** a runner identified from the script rather than a dependency (e.g. "bun") - never guessed at */
3164
+ note: string | null;
3165
+ scripts: Record<string, string>;
3166
+ /** the workspace task runner this package's test script delegates to, when it delegates */
3167
+ aggregator: AggregatorTool | null;
3168
+ /**
3169
+ * The TASK NAMES that delegation selects, empty when it names none. Beside the tool because the
3170
+ * two are one reading, and because a reader that re-derives them from `testScript` gets a
3171
+ * different answer on an ALIASED root - which is what left `suiteFanoutPlan` silent about the one
3172
+ * repository shape the sentence exists for. See {@link ManifestClassification.aggregatorTasks}.
3173
+ */
3174
+ aggregatorTasks: readonly string[];
3175
+ /** The one of those tasks this package's suite was read from; null for an ordinary package. */
3176
+ aggregatorTask: string | null;
3177
+ /**
3178
+ * The `angular.json` test target this package's suite is, when it is an Angular one. Carried
3179
+ * because everything downstream needs facts the script cannot give: which PROJECT `ng test` must
3180
+ * be given, and which BUILDER is behind it - the mutation config, the sealed command and the
3181
+ * coverage lane each branch on one of the two. Null for every non-Angular package.
3182
+ */
3183
+ angular: AngularTestTarget | null;
3184
+ /**
3185
+ * This package's own literal test invocation when it is declared somewhere other than
3186
+ * `scripts.test` — today that is an integrated-Nx `project.json` run-commands target. Null for
3187
+ * every ordinary package, whose declaration IS `scripts.test`.
3188
+ */
3189
+ declaredCommand: string | null;
3190
+ /**
3191
+ * Set when {@link declaredCommand} is declared to run from the WORKSPACE ROOT rather than from
3192
+ * `dir`. Null for every package whose suite runs where the package lives.
3193
+ */
3194
+ workspaceRootSuite: WorkspaceRootSuite | null;
3195
+ }
3196
+ /**
3197
+ * The runners a `package.json` script names by writing the runner's own binary name.
3198
+ *
3199
+ * ONE LIST, IN `@abloh/core`, DERIVED FROM THE CAPABILITY REGISTRY. Four sites used to type this
3200
+ * list out - here, `TEST_BINARIES` and `DIRECT_RUNNER` in `ci-recipe.ts`, and `LEAF_RUNNERS` in
3201
+ * `workspace-aggregator.ts` - and they disagreed: init omitted jasmine from one of its two copies,
3202
+ * so `abloh init` could reject a jasmine command `abloh run` measures perfectly (audit F49).
3203
+ */
3204
+ type ScriptRunner = string;
3205
+ /**
3206
+ * How many times a script INVOKES ONE NAMED RUNNER as a suite. {@link suiteStages} is the general
3207
+ * question and carries the measurement; this is the per-runner form, used to tell a leaf that runs
3208
+ * one suite from a leaf that is itself a chain.
3209
+ *
3210
+ * The split is deliberately wider than the `&&` chains the plan builder admits (`;`, `||`, `|`, `&`
3211
+ * count too). Recognising a fanout is the safety half of this answer and must not depend on the
3212
+ * composition being one we can also reproduce; a `jest a; jest b` script is still two suites.
3213
+ */
3214
+ declare function runnerSuiteInvocations(testScript: string, runner: ScriptRunner | "node-test"): number;
3215
+ /**
3216
+ * THE ONE RULE FOR "does this invoke node's built-in test runner", used by every reader that asks.
3217
+ *
3218
+ * BOTH HALVES OF IT ARE `@abloh/core`'s NOW - the wrapper prefixes this file used to strip in its own
3219
+ * `withoutCommandPrefix`, and the flag-not-adjacent reading. Six places used to answer this question
3220
+ * with three different patterns, and `abloh init` still had a fourth as late as this mechanism (audit
3221
+ * F49): it recognized only `node --test`, while a TypeScript repository writes `node --import tsx
3222
+ * --test`. See `planCommand` for the measurement and for why `tsx` counts and `borp` does not.
3223
+ */
3224
+ declare function argvInvokesNodeTest(argv: readonly string[]): boolean;
3225
+ /** {@link argvInvokesNodeTest} for ONE command segment that has not been split yet. */
3226
+ declare function invokesNodeTest(command: string): boolean;
3227
+ /**
3228
+ * How many stages of a script RUN A SUITE, counting the ones that reach a suite through a package
3229
+ * script as well as the ones that name a runner directly.
3230
+ *
3231
+ * THE DEFECT THIS EXISTS FOR, measured 2026-08-16 on a real vitest 4.1.10 install. A script that
3232
+ * runs two suites of ONE runner:
3233
+ *
3234
+ * "test": "vitest run -c vitest.unit.ts && vitest run -c vitest.integration.ts"
3235
+ *
3236
+ * named vitest exactly ONCE as far as {@link runnerNamesInScript} was concerned, because that
3237
+ * function answers which runner NAMES appear, not how many suites are launched. So the repository
3238
+ * classified as a direct vitest project. Diff coverage then appended its coverage flags to the run's test
3239
+ * command, and `npm test -- --coverage ...` appends to the END of the script string, which put every
3240
+ * flag on the LAST stage alone. The unit suite ran, uninstrumented, and its coverage was never
3241
+ * collected: a source function exercised by the unit suite came back with ZERO hits on every line
3242
+ * while the customer's own `npm test` covers it completely. That is silent coverage loss inside a
3243
+ * signed certificate, the worst class, and it was silent in BOTH directions - the sealed v2 engine
3244
+ * synthesized `npx --no-install vitest run` for the same repository, which is neither config, and
3245
+ * Stryker drove its vitest plugin against the runner's default discovery rather than either suite.
3246
+ *
3247
+ * The alias half is not a refinement, it is the same defect wearing a different composition:
3248
+ *
3249
+ * "test": "vitest run && npm run test:integration" // test:integration: "jest"
3250
+ *
3251
+ * names vitest once and jest not at all, so that package classified as a direct vitest project too,
3252
+ * and the appended flags landed on the jest stage. Two suites is two suites however the author wrote
3253
+ * the second one.
3254
+ *
3255
+ * So the count, not the name set, decides. More than one suite stage means the script is a FANOUT:
3256
+ * it is not one drivable runner, and {@link suiteFanoutPlan} decides what of it can be measured.
3257
+ *
3258
+ * IT IS `scriptRuns`'s LIST SINCE 2026-09-11, and the widening is the point. The hand-rolled split
3259
+ * and one-level alias hop above it counted 1 for `iamkun/dayjs` (nine jest executions, eight of them
3260
+ * behind a `cross-env TZ=…` prefix this could not see through), 1 for `immerjs/immer` (a second
3261
+ * vitest execution inside a chained alias body) and 0 for `electron/asar` (a second vitest execution
3262
+ * under `xvfb-maybe electron`). Each of those is exactly the appended-flag defect recorded above,
3263
+ * measured on three more repositories.
3264
+ *
3265
+ * WHAT IT COUNTS IS UNCHANGED: a stage running a suite abloh would MEASURE. A browser suite is not
3266
+ * one - `vitest run && playwright test` has always been a direct vitest project - and neither is a
3267
+ * runner abloh cannot drive, which is what keeps `pinojs/pino`'s borp stage from demoting a
3268
+ * repository whose jest stage abloh measures perfectly well.
3269
+ */
3270
+ declare function suiteStages(testScript: string, scripts?: Record<string, string>): number;
3271
+ /**
3272
+ * The stages a repository's `test` script runs BEFORE its runner stage, as commands, in the
3273
+ * author's order. Empty whenever there is nothing before it.
3274
+ *
3275
+ * WHY THEY HAVE TO RUN. The baseline measures one stage of a chained script - {@link
3276
+ * baselineCommand} in `@abloh/measure` carries the defect that made that necessary. Running that
3277
+ * stage alone silently skips whatever the earlier stages did, and for a repository whose first
3278
+ * stage GENERATES a file the suite imports, what it skips is the suite. Measured on
3279
+ * All-Hands-AI/OpenHands at `10d2285dd`, whose script is `npm run make-i18n && vitest run`
3280
+ * (`data/abloh-timeout-coverage-diagnosis/report.md` §2.3): with the first stage dropped, 339 of
3281
+ * 600 test files fail on `Failed to resolve import "#/i18n/declaration"` and no coverage report is
3282
+ * written at all; run it first and the identical command passes 600 files and writes the report.
3283
+ *
3284
+ * WHY RUNNING THEM IS SAFE HERE AND NOT UNDER MUTATION. These stages are the reason
3285
+ * {@link runnerStageCommand} exists: a linter or a typechecker in front of the runner fails on
3286
+ * Stryker's instrumented source, so the mutation engine strips them and must keep stripping them.
3287
+ * The baseline runs the UNTOUCHED tree - the same tree the repository's own `npm test` runs on - so
3288
+ * a gate that passes for its author passes here, and a gate that does not was never the baseline's
3289
+ * question. That asymmetry is the whole rule: prerequisite and gate are indistinguishable from the
3290
+ * text, and on unmutated source it does not matter which one a stage is.
3291
+ *
3292
+ * TAKEN VERBATIM, THEN MADE RESOLVABLE. A stage is the author's own command, so its flags, config
3293
+ * files and selectors survive. The one adjustment is the same one the runner stage gets: inside
3294
+ * `npm test` a bare `tsc` resolves out of `node_modules/.bin`, which npm puts on PATH and a direct
3295
+ * spawn does not, so a stage that is not already a package manager or a runtime is invoked through
3296
+ * `npx --no-install` - which resolves from that same tree and never reaches the network.
3297
+ *
3298
+ * Stages AFTER the runner are deliberately not returned: nothing the runner needs can be produced
3299
+ * by a command the author wrote to run once the tests were already over.
3300
+ */
3301
+ declare function preRunnerStageCommands(testScript: string, runner: Runner, scripts?: Record<string, string>): string[];
3302
+ /**
3303
+ * The one stage of a repository's `test` script that runs the test RUNNER, as a command, or null
3304
+ * when the script has no separable stage.
3305
+ *
3306
+ * WHAT IT IS FOR. Runners with no Stryker plugin - ava above all - go through Stryker's generic
3307
+ * command runner, which executes the repository's own `scripts.test` once per mutant. When that
3308
+ * script GATES the runner (`xo && ava && tsd`, `tsc --noEmit && jest`, `posttest: npm run lint`),
3309
+ * the gate is handed Stryker's own instrumented source and fails on it. Measured on
3310
+ * sindresorhus/p-map 3f153f1: 276 lint errors, EVERY ONE of them on a file Stryker had rewritten -
3311
+ * 274 style errors on the instrumented `index.js`, plus the `@ts-nocheck` Stryker wrote into two
3312
+ * `.d.ts` files, which that linter bans. `npm test` on the untouched tree passes.
3313
+ *
3314
+ * Instrumented source cannot pass a style linter, by construction. So a repository whose test
3315
+ * script gates the runner cannot be mutation-tested at all through the command runner, however
3316
+ * healthy its suite is, and the failure names the linter rather than the cause.
3317
+ *
3318
+ * THE AUTHOR'S OWN INVOCATION, MINUS THE GATE. This deliberately does not synthesize a bare
3319
+ * `npx ava`: it returns the stage the author wrote, so a `--config`, a project selector or a spec
3320
+ * path they put there is preserved. `sealed-test-command.ts` explains at length why a synthesized
3321
+ * invocation is a guess for ava, tap and node:test; taking the stage verbatim is not a guess.
3322
+ *
3323
+ * A GATE IS NOT ALWAYS IN THE SCRIPT. npm runs `pretest` before `scripts.test` and `posttest`
3324
+ * after it, automatically, on every `npm test` - so a repository can gate its runner without its
3325
+ * `test` script containing a single `&&`. mapbox/supercluster `8a97f6a` is exactly that shape: its
3326
+ * `test` is a bare `node --test`, its `pretest` is a linter, and the whole repository was
3327
+ * unmeasurable because this function read one key. It died in Stryker's initial run naming
3328
+ * `@stylistic/indent` on instrumented source, with no retry line in the log at all, and this was
3329
+ * read as node:test being unreachable rather than as a gate (measured 2026-08-16).
3330
+ *
3331
+ * A hook is therefore what makes a ONE-STAGE script separable: running the stage directly is what
3332
+ * skips the hooks, because Stryker's command runner executes the string given here rather than
3333
+ * `npm test`. The chain rules below are unchanged for a script that has stages of its own.
3334
+ *
3335
+ * IT DECLINES far more often than it answers, and that is the point - the caller only reaches for
3336
+ * it after the full script has already failed:
3337
+ * - a one-stage script with no `pretest`/`posttest` hook has nothing to strip (a bare `ava`
3338
+ * returns null, not `npx ava`)
3339
+ * - anything that is not a plain `&&` chain (`;`, `|`, `||`, redirection, substitution, a
3340
+ * subshell) is left alone, because the command runner passes this string to a shell
3341
+ * - a chain naming the runner in zero stages, or in more than one, is ambiguous and declines
3342
+ *
3343
+ * @param lifecycleHooks the non-empty `pretest`/`posttest` script bodies npm would run around this
3344
+ * script; their CONTENT is never parsed, only their existence, because a hook that is a
3345
+ * prerequisite rather than a gate is indistinguishable from one that is - and this answer is only
3346
+ * ever reached for after the full script has already failed, so neither can regress a run.
3347
+ */
3348
+ declare function runnerStageCommand(testScript: string, runner: Runner, lifecycleHooks?: readonly string[], scripts?: Record<string, string>): string | null;
3349
+ /**
3350
+ * The same stage with a PACKAGE-RUNNER PREFIX removed: `npx jest --config test/ut/jest.config.cjs`
3351
+ * becomes `jest --config test/ut/jest.config.cjs`.
3352
+ *
3353
+ * WHY IT EXISTS. `apache/echarts` writes its whole suite as `npx jest --config
3354
+ * test/ut/jest.config.cjs`, and a matcher that compares the stage's FIRST token to the runner's
3355
+ * name reads no invocation there at all - so the config file the repository names is lost, the
3356
+ * mutation run drives jest under its defaults, and the run dies before a test executes (measured
3357
+ * 2026-08-24, `report.md` §3). A package runner is a way of RESOLVING the binary, not a different
3358
+ * command, so removing it changes nothing about what runs.
3359
+ *
3360
+ * IT STRIPS RESOLVERS AND NOTHING ELSE, which is why it is not `withoutCommandPrefix`. That
3361
+ * function answers a different question for a DECLARED argv and also drops `NODE_ENV=test`, `env`
3362
+ * and `cross-env` - and an environment assignment is part of what the author runs, not a way of
3363
+ * finding it. `runnerStageCommand` re-spawns the stage as argv with no shell, so a stage whose env
3364
+ * this dropped would run a different suite: `xo && NODE_ENV=test ava` is declined on purpose, and
3365
+ * `detect.test.ts` pins that.
3366
+ *
3367
+ * WHAT IT LEAVES ALONE, and each is a case where the prefix is not merely a resolver:
3368
+ *
3369
+ * - `npx` carrying a flag not listed below. `-p`/`--package` installs and runs something ELSE
3370
+ * under the same name, so the stage is handed back as written and then reads as naming no
3371
+ * runner rather than as naming this one.
3372
+ * - a BARE `yarn <name>` or `pnpm <name>` WITH NO SCRIPT TABLE TO CHECK IT AGAINST. Both managers
3373
+ * run a package SCRIPT of that name when one exists and a binary only when one does not, so
3374
+ * `yarn jest` is ambiguous from the string alone - `yarn exec jest` is not, and is the spelling
3375
+ * that is always stripped.
3376
+ *
3377
+ * THE AMBIGUITY IS RESOLVABLE WHEN THE MANIFEST IS IN HAND, and round 5a's D1 is what made that
3378
+ * worth resolving. `electron/asar`'s script is `yarn lint && yarn vitest run && xvfb-maybe
3379
+ * electron …` and its manifest declares no script called `vitest`, so `yarn vitest run` can only
3380
+ * be the binary - but the stage read as naming no runner, the whole split declined, and the
3381
+ * baseline ran the entire gated chain for `0 executed test(s) … red true` on a suite that is 190
3382
+ * tests green (`data/abloh-real-bugs-study-5/findings.md` F1, cycle 1). Given `scripts`, a name
3383
+ * that is NOT a script in it is a binary by yarn's and pnpm's own resolution order; without the
3384
+ * table nothing changes and the stage is still handed back as written. `npm` is not in this
3385
+ * branch at all: npm has no bare-script shorthand, so `npm jest` never runs a script.
3386
+ */
3387
+ declare function withoutPackageRunnerPrefix(stage: string, scripts?: Record<string, string>): string;
3388
+ /**
3389
+ * The stage of `scripts.test` that INVOKES the runner, whether or not the script is a chain.
3390
+ *
3391
+ * {@link runnerStageCommand} answers a different question — what to retry with after a gated script
3392
+ * has already failed — and so it deliberately declines a single-stage script with no lifecycle hook,
3393
+ * which has nothing to strip. The per-test plugins need something else: `node --test` and ava select
3394
+ * their SPECS from the invocation, so a repository whose whole script is
3395
+ * `node --test 'test/*.test.js'` is naming its suite there, and a plugin that never sees it falls
3396
+ * back to the runner's default discovery.
3397
+ *
3398
+ * Measured on mourner/suncalc, whose script is exactly that: the default patterns also match
3399
+ * `test/fetch-truth.js`, a helper that fetches over the network, and running it once per mutant took
3400
+ * the run from seconds to 102. The author already said which files are the suite.
3401
+ */
3402
+ declare function runnerInvocation(testScript: string | null, runner: Runner, scripts?: Record<string, string>): string | null;
3403
+ /**
3404
+ * The suite a repository's own jasmine invocation names — a config file, or spec globs.
3405
+ *
3406
+ * WHY THIS IS READ AT ALL. `JasmineSuite` in `@abloh/measure` carries the mechanism: the Stryker
3407
+ * jasmine-runner asks jasmine to load a config file, and jasmine's fallback tries exactly the three
3408
+ * paths above and reads neither `--config=` nor `JASMINE_CONFIG_PATH`. A repository that points
3409
+ * jasmine anywhere else runs zero specs under mutation while its own suite is perfectly green, and
3410
+ * the run dies `No tests were executed` — measured on all three jasmine repositories the corpus
3411
+ * holds.
3412
+ *
3413
+ * IT READS THE AUTHOR'S COMMAND, IT DOES NOT SYNTHESIZE ONE. Every rule below mirrors jasmine's own
3414
+ * argument parser (`jasmine/lib/command.js`, `parseOptions`) rather than guessing:
3415
+ *
3416
+ * - jasmine spells EVERY option `--name=value`; there is no space-separated form. So a token that
3417
+ * does not start with `-` is a spec path, which is what makes the two shapes separable at all.
3418
+ * - `FOO=bar` AFTER the binary is not an environment assignment. jasmine's `isEnvironmentVariable`
3419
+ * recognises the shape and silently DROPS it, which is why xlsx-populate's
3420
+ * `jasmine JASMINE_CONFIG_PATH=test/unit/jasmine.json` runs no specs when that repository runs
3421
+ * it itself. Reading it as a config path here would measure a suite the customer does not run.
3422
+ * Before the binary it is a real shell assignment, and jasmine's CLI does read it there.
3423
+ * - `--` ends jasmine's own arguments, exactly as its parser does.
3424
+ *
3425
+ * IT DECLINES rather than guesses: a script that is not a plain `&&` chain, or that names jasmine in
3426
+ * zero or more than one stage, yields undefined and the run behaves exactly as it does today.
3427
+ */
3428
+ declare function jasmineSuiteFromScript(testScript: string): JasmineSuite | undefined;
3429
+ /**
3430
+ * The jasmine suite to hand the mutation engine for one package directory, or nothing to hand it.
3431
+ *
3432
+ * The one thing this adds to reading the script is the case where BOTH are true: the author names
3433
+ * spec paths positionally AND keeps a config where jasmine's fallback already looks. Jasmine loads
3434
+ * that config for its helpers, requires and `jsLoader` and only then narrows to the named paths, and
3435
+ * a generated config carrying the paths alone would silently drop all three. So that repository is
3436
+ * left to the fallback, which measures the config's own spec set - a superset of what the author
3437
+ * named, which can only add kills, never invent them.
3438
+ */
3439
+ declare function detectJasmineSuite(dir: string, testScript: string | null, ctx?: RepoAccess): JasmineSuite | undefined;
3440
+ /**
3441
+ * A manifest that EXISTS but cannot be read.
3442
+ *
3443
+ * Distinct from a directory with no `package.json`, which is a legitimate absence and yields null.
3444
+ * A malformed manifest used to throw a bare `SyntaxError`, which the CLI caught, logged to stderr,
3445
+ * and continued from — leaving `runner: "command"` and an artifact reporting `not-run: no-runner`,
3446
+ * rendered as "no test runner detected". That is a claim about the repository. The truth was that
3447
+ * we could not read its manifest, and the repository may have a perfectly good suite.
3448
+ */
3449
+ declare class TargetDetectionError extends Error {
3450
+ /**
3451
+ * THE REGISTRY REFUSAL THIS SENTENCE WAS COMPOSED FROM, where there was one.
3452
+ *
3453
+ * `cli-failure.ts`'s `declaredRefusalOf` is the one reader: every class it recognises that
3454
+ * composes from the registry sets this, so a caller that wants the code, the owner and the next
3455
+ * action rather than the sentence gets them. Optional, and stays optional - most of this file's
3456
+ * refusals compose their own sentence and the record says so rather than inventing a code.
3457
+ */
3458
+ readonly refusal: Refusal | undefined;
3459
+ constructor(message: string, refusal?: Refusal);
3460
+ }
3461
+ /**
3462
+ * WHY NO TARGET WAS FOUND, as a closed code rather than a sentence.
3463
+ *
3464
+ * WHAT THIS FIXES (external refusal review, rank 12). All three refusals below were composed,
3465
+ * printed on the terminal, and then thrown away: `index.ts` caught the error, logged the sentence,
3466
+ * and continued with no detection, at which point the run PERSISTED
3467
+ * `baseline: skipped - nothing to baseline (no runner detected / empty scope)` and
3468
+ * `Diff coverage: not applicable - no test runner detected`. A customer whose `target.directory`
3469
+ * pointed one directory too high, and a repository abloh genuinely cannot measure, produced the
3470
+ * same recorded claim - and that claim was about the REPOSITORY, on a run where the repository was
3471
+ * fine and the configuration was one line out.
3472
+ *
3473
+ * The code and the remedy travel with the error so the artifact records which of the three
3474
+ * happened, and every surface reading the artifact stops having to guess.
3475
+ */
3476
+ type TargetNotFoundReason =
3477
+ /** `--subdir` or `target.directory` names a directory that holds no package.json */
3478
+ "configured-directory-has-no-package"
3479
+ /** nothing under the repository declares a test runner or a test script */
3480
+ | "no-testable-package"
3481
+ /** a workspace root with no test script of its own, which abloh will not guess one for */
3482
+ | "workspace-root-declares-no-test-script"
3483
+ /**
3484
+ * two package-manager lockfile families in one directory and nothing in the repository deciding.
3485
+ *
3486
+ * WHY IT JOINED THIS UNION (round 5 v4 census, M9 - `ruvnet/ruflo`). It threw a bare
3487
+ * `TargetDetectionError` carrying one sentence - "conflicting package-manager lockfiles in
3488
+ * /tmp/...: package-lock.json, pnpm-lock.yaml" - and no next action of any kind. A sentence with
3489
+ * no remedy is not a refusal, it is a wall, and this one was reached AFTER `abloh init` had
3490
+ * already drafted a whole file for the same repository: init asks the customer which family is
3491
+ * theirs, and every measuring path threw the answer away and refused again.
3492
+ */
3493
+ | "conflicting-lockfiles";
3494
+ declare class TargetNotFoundError extends Error {
3495
+ /** Which of the three, as a code an artifact can carry. */
3496
+ readonly reason: TargetNotFoundReason;
3497
+ /** The one thing to do next, on its own so a surface can render it apart from the explanation. */
3498
+ readonly remedy: string;
3499
+ constructor(reason: TargetNotFoundReason, message: string, remedy: string);
3500
+ }
3501
+ /** The runner/test-script facts of one parsed package.json, independent of where it was read from. */
3502
+ interface ManifestClassification {
3503
+ runner: PkgProbe["runner"];
3504
+ testScript: string | null;
3505
+ note: string | null;
3506
+ scripts: Record<string, string>;
3507
+ /** The workspace task runner this manifest's `test` script delegates to, when it delegates. */
3508
+ aggregator: AggregatorTool | null;
3509
+ /**
3510
+ * The TASK NAMES that aggregator invocation selects, empty when it selects them some other way.
3511
+ *
3512
+ * Carried beside the tool because it is the other half of the same sentence, and because the
3513
+ * tool alone could not answer the question the packages have: `nx run-many --targets=test:lib`
3514
+ * says which SCRIPT KEY every package behind it declares its suite under. Read at the ROOT and
3515
+ * handed down; see {@link rootAggregatorTasks}.
3516
+ */
3517
+ aggregatorTasks: readonly string[];
3518
+ /**
3519
+ * The aggregator task {@link testScript} was read from, when this package declares no `test`
3520
+ * script of its own and the root's aggregator named a task it does declare. Null everywhere
3521
+ * else, including for every package whose declaration is `scripts.test`.
3522
+ */
3523
+ aggregatorTask: string | null;
3524
+ /**
3525
+ * Why the aggregator's tasks did not resolve to ONE suite here, when more than one of them is a
3526
+ * suite. Null when nothing tied - a package that simply declares no runner is already refused in
3527
+ * those words by every caller, and inventing a second sentence for it would say the same thing
3528
+ * twice.
3529
+ */
3530
+ aggregatorTaskRefusal: string | null;
3531
+ }
3532
+ /**
3533
+ * The task names a ROOT manifest's aggregator selects, ready to hand to each package.
3534
+ *
3535
+ * ONE DERIVATION, because detection, selection and `abloh prepare` all need it and a package
3536
+ * classified from a different list than the one selection used is the desync `classifyWithAliasHop`
3537
+ * exists to prevent. The hop is made here for the same reason it is made there: TanStack/query's
3538
+ * root `test` is `pnpm run test:ci` and the aggregator is one script further in.
3539
+ */
3540
+ declare function rootAggregatorTasks(rootManifest: any): readonly string[];
3541
+ /**
3542
+ * Classify a parsed package.json without touching the filesystem.
3543
+ *
3544
+ * Split out of {@link probePkg} so workspace target selection can classify a manifest read from a
3545
+ * trusted git TREE (merge base) with byte-identical semantics to the working-tree probe — the two
3546
+ * must never disagree about what counts as a direct runner.
3547
+ *
3548
+ * `aggregatorTasks` is the ROOT's answer to "which script key do the packages here use", read once
3549
+ * by {@link rootAggregatorTasks} and handed down. Empty is the ordinary case and changes nothing.
3550
+ */
3551
+ declare function classifyPackageManifest(pkg: any, aggregatorTasks?: readonly string[]): ManifestClassification;
3552
+ /**
3553
+ * Alias-hop admissibility. Appended reporter/coverage flags must survive TWO package-manager
3554
+ * hops (outer `pnpm test` -> inner `pnpm test:spec` -> runner). Measured live: pnpm >=10 and
3555
+ * yarn forward post-script tokens verbatim through both hops; pnpm <=9 and npm consume them at
3556
+ * the second hop. So the hop is admissible ONLY under a pinned pnpm>=10 or yarn root, and only
3557
+ * for a same-family alias (an `npm run x` alias inside a pnpm repo re-creates the silent hop).
3558
+ */
3559
+ declare function aliasHopFamily(rootManifest: any): "pnpm" | "yarn" | null;
3560
+ /**
3561
+ * Classify a manifest, resolving ONE pure single-script alias when the gate admits it. PURE
3562
+ * means the entire test script is one package-manager invocation of one other script in the
3563
+ * SAME manifest — chains, globs, env prefixes, and cross-family calls never hop. Selection
3564
+ * (workspace-packages.ts) and detection MUST share this rule or they desync on the same repo.
3565
+ */
3566
+ declare function classifyWithAliasHop(manifest: any, family: "pnpm" | "yarn" | null, aggregatorTasks?: readonly string[]): ManifestClassification;
3567
+ /**
3568
+ * Classify ONE package directory's manifest exactly as measurement will, alias hop included.
3569
+ *
3570
+ * EXPORTED FOR `abloh prepare`, WHICH IS FINDING F28. Prepare enumerates workspace packages to
3571
+ * decide what to stage a coverage provider for, and it called `classifyPackageManifest` directly -
3572
+ * the classifier WITHOUT the alias hop - while measurement resolves the same package through
3573
+ * {@link classifyWithAliasHop}. So a package whose `test` script is `pnpm run test:unit` and whose
3574
+ * `test:unit` is vitest was MEASURED as vitest and OMITTED from staging, which fails preparation or
3575
+ * loses that package's coverage for a reason nothing in the output names.
3576
+ *
3577
+ * The family comes from the repository root, which is where the pin that admits the hop is declared
3578
+ * and is exactly what {@link detectTarget} reads.
3579
+ */
3580
+ declare function classifyPackageDirectory(repoDir: string, dir: string, ctx?: RepoAccess): ManifestClassification | null;
3581
+ /**
3582
+ * What {@link bindPreparedTestCommand} needs about the package a declared command runs in.
3583
+ *
3584
+ * THE SCRIPTS ARE THE TARGET PACKAGE'S and the alias-hop manifest is THE ROOT'S, and the two are
3585
+ * deliberately different files. `npm run test:ci` names a script in the manifest beside the suite,
3586
+ * which under `--subdir` is not the repository root; the `packageManager` pin that decides whether
3587
+ * a second hop carries abloh's flags is declared once, at the root, which is where `detectTarget`
3588
+ * and `classifyPackageDirectory` both already read it from.
3589
+ *
3590
+ * An unreadable or absent manifest yields empty scripts rather than throwing: a declared command
3591
+ * that names no script is a perfectly ordinary thing to run from a directory with no `package.json`,
3592
+ * and the reading it gets is the narrower one it always had.
3593
+ */
3594
+ declare function declaredCommandContext(repoDir: string, subdir: string | null, ctx?: RepoAccess): DeclaredCommandContext;
3595
+ /**
3596
+ * Read `test.dir` out of the repo's vitest (or vite) config. Heuristic, dependency-free:
3597
+ * find the first `dir:` string literal inside the `test: { ... }` block. Stryker's
3598
+ * vitest-runner forwards `vitest.dir` as a CLI-level override, so an unset value would
3599
+ * clobber the repo's own config (the redux layout) — we forward the repo's value instead.
3600
+ */
3601
+ declare function detectVitestDir(dir: string, ctx?: RepoAccess): string | undefined;
3602
+ /**
3603
+ * `test.dir` out of one config's source, read from INSIDE the `test` block and nowhere else.
3604
+ *
3605
+ * The defect this replaces was `/test\s*:\s*\{[\s\S]*?\bdir\s*:\s*…/`. `[\s\S]*?` is lazy but
3606
+ * unbounded: it does not stop at the block's closing brace, so the first `dir:` ANYWHERE later in
3607
+ * the file was taken as vitest's. Measured on a config whose `build` block carries a `dir`, the
3608
+ * answer was `OOPS-not-a-test-dir` - and a wrong `dir` means vitest finds no tests, which surfaces
3609
+ * as "No tests were executed" and kills the mutation run before a single mutant exists.
3610
+ *
3611
+ * The block is delimited by COUNTING BRACES, and when it cannot be delimited - an unbalanced file,
3612
+ * a `test` key that is not an object literal - this refuses to answer rather than reading past it.
3613
+ * Stryker's own default is a better answer than a confident wrong one.
3614
+ */
3615
+ declare function vitestDirIn(source: string): string | undefined;
3616
+ /**
3617
+ * Detect how to test `repoDir` (optionally a subdir holding the testable package.json).
3618
+ * Prefers a dir whose package.json declares a runner; falls back to any dir with a `test`
3619
+ * script (command runner). Throws when nothing testable is found.
3620
+ */
3621
+ /**
3622
+ * The path prefix between REPOSITORY-relative paths and the paths spoken by the tools that run in
3623
+ * `workDir` - Stryker's `mutate` entries, and the file paths its report comes back with.
3624
+ *
3625
+ * NOT THE SAME QUESTION AS "which package is measured", and this function exists because the two
3626
+ * used to be one string. They are equal for every ordinary package, where the tools run inside the
3627
+ * package they measure, and they diverge for exactly one shape: a {@link TargetDetection.workspaceRootSuite}
3628
+ * runs the tools at the repository root while measuring one package, so their paths are already
3629
+ * repository-relative and stripping the package prefix would name files that do not exist.
3630
+ *
3631
+ * The MEASUREMENT scope stays `subdir` in both modes, and the runner-relative prefix diff coverage uses
3632
+ * stays `subdir` too - a jest `rootDir` and a vitest `root` are the project's, whatever directory
3633
+ * the process was started in.
3634
+ */
3635
+ declare function workDirPrefix(detection: Pick<TargetDetection, "subdir" | "workspaceRootSuite">): string | null;
3636
+ /**
3637
+ * Why the CLASSIC layer cannot drive ONE test of this runner, or null when it can.
3638
+ *
3639
+ * THE CAPABILITY THIS NAMES, precisely. `ProofRunner` in `@abloh/measure` is the set of runners the
3640
+ * v1 fix loop and the deep-audit container can execute one test file at a time and read a per-test
3641
+ * report from, and every member of it has a Stryker plugin or a hand-written runner command in
3642
+ * `runner.ts`. deno has neither: Stryker ships no deno plugin, so its mutation runs whole-suite
3643
+ * through the command runner, and `runner.ts` is the frozen benchmark control arm that may not gain
3644
+ * a deno arm on the way past.
3645
+ *
3646
+ * IT IS NOT A STATEMENT ABOUT DENO'S RUNNER. `deno test --filter '/(?:^|\s)name$/' --reporter=junit`
3647
+ * selects exactly one test and names it in a machine-readable report - measured 2026-08-18 - and the
3648
+ * SEALED v2 engine drives deno that way on the shipped path. So the sentence says which layer, not
3649
+ * "unsupported", because a reader who is told the latter will conclude the wrong thing about the v2
3650
+ * results in the same artifact.
3651
+ */
3652
+ declare function classicPerTestRunner(runner: Runner): runner is ProofRunner;
3653
+ declare function classicPerTestRefusal(runner: Runner): string | null;
3654
+ /**
3655
+ * THE PACKAGES A WORKSPACE ROOT COULD BE POINTED AT, read off disk.
3656
+ *
3657
+ * WHAT WAS BROKEN WITHOUT IT. Both refusals below describe a repository whose suites live in its
3658
+ * workspace packages, and neither one named a single package - so a customer or an agent standing
3659
+ * at a workspace root was told what was absent and never told what was present, on a repository
3660
+ * where the answer is a directory listing this codebase already knows how to produce. The
3661
+ * fresh-clone refusal named no remedy at all.
3662
+ *
3663
+ * A CANDIDATE IS A PACKAGE THAT DECLARES A SUITE, not merely a package. The enumeration is the
3664
+ * workspace declaration's own (`enumerateWorkspacePackagesOnDisk` - pnpm-workspace.yaml,
3665
+ * `workspaces`, `rush.json`, `nx.json`), and the filter is the same test `detectTarget` applies to
3666
+ * a `--subdir`: a runner or a `test` script. A package with neither is not somewhere the customer
3667
+ * could point this command, so naming it would be naming a wrong answer.
3668
+ */
3669
+ declare function testableWorkspacePackages(repoDir: string, refusals?: string[], ctx?: RepoAccess): string[];
3670
+ declare function detectTarget(repoDir: string, subdir?: string | null, resolution?: PackageManagerResolution, ctx?: RepoAccess): TargetDetection;
3671
+
3672
+ /**
3673
+ * A REFUSAL FROM `init`, CARRYING ITS OWN CATEGORY.
3674
+ *
3675
+ * WHY THE CLASS IS ON THE ERROR. `init` reports one outcome category at the end of every run
3676
+ * (`@abloh/core`'s `init-telemetry.ts`), and the failure half of that category has to be the SAME
3677
+ * vocabulary as the refusal the customer just read on their terminal - otherwise there are two
3678
+ * answers to "why did this init not finish" and only one of them is in front of the person it
3679
+ * happened to.
3680
+ *
3681
+ * The alternative was a table of regular expressions over the refusal text, and it is the wrong
3682
+ * shape for a reason worth stating: a refusal message is COPY. It gets rewritten - this file's own
3683
+ * neighbours have been rewritten twice this month - and the day somebody improves a sentence, every
3684
+ * event it raised silently changes bucket while the code that raised it did not move. A class on
3685
+ * the throw cannot drift from the throw.
3686
+ *
3687
+ * NOTHING ELSE CHANGES. The message is the message; `initRefusal` is `new Error` with one extra
3688
+ * field, so every existing reader - the `catch` in `index.ts`, every test that asserts on the
3689
+ * sentence - sees exactly what it saw before.
3690
+ */
3691
+
3692
+ declare class InitRefusal extends Error {
3693
+ readonly failureClass: InitFailureClass;
3694
+ /**
3695
+ * THE WALK KEY THIS REFUSAL IS ABOUT, where the throw site knows one the class cannot.
3696
+ *
3697
+ * Null on almost every throw, and that is right: the class already implies the key for a refusal
3698
+ * that can only ever be about one - `no-reviewed-image` is `runtimeImage` wherever it is raised,
3699
+ * and `init-refusal-shape.ts` holds that mapping in one table rather than repeating it at forty
3700
+ * throw sites. This field is for the refusals where the class covers SEVERAL keys, which today is
3701
+ * the answers document: `invalid-answers-file` is about whichever key the document got wrong, and
3702
+ * a fix depends on knowing which.
3703
+ */
3704
+ readonly questionKey: InitQuestionKey | null;
3705
+ /**
3706
+ * THIS REFUSAL'S OWN REMEDY, where the class table's is not true of this instance.
3707
+ *
3708
+ * Null on almost every throw, and that is still right: a per-class remedy is what stops forty
3709
+ * throw sites carrying forty drifting copies of the same sentence. But a CLASS is not always one
3710
+ * situation, and `repository-too-large` is where that broke (external refusal review, rank 15).
3711
+ * Its four throw sites all tell the human to pass `--environment-image`, because the scan they
3712
+ * abandon is the Dockerfile scan and an image is what makes it unnecessary. The class table told
3713
+ * the MACHINE to pass `--subdir`, which narrows nothing: the scan walks the repository root
3714
+ * whatever `--subdir` says. A human and an agent reading the same refusal were sent to different
3715
+ * options, and only one of them could work.
3716
+ *
3717
+ * So a throw site that knows better than its class says so here, and `init-refusal-shape.ts`
3718
+ * prefers it. One refusal instance, one remedy, whoever is reading.
3719
+ */
3720
+ readonly remedy: string | null;
3721
+ constructor(failureClass: InitFailureClass, message: string, questionKey?: InitQuestionKey | null, remedy?: string | null);
3722
+ }
3723
+ /** The throw site's own spelling: `throw initRefusal("no-reviewed-image", "...")`. */
3724
+ declare function initRefusal(failureClass: InitFailureClass, message: string, questionKey?: InitQuestionKey | null, remedy?: string | null): InitRefusal;
3725
+ /** The remedy an error itself carries, or null to fall back to its class. */
3726
+ declare function refusalRemedyOf(error: unknown): string | null;
3727
+ /**
3728
+ * The class an error ends up counted under.
3729
+ *
3730
+ * `unclassified` for anything init did not raise itself - a filesystem error, a bug. Losing those
3731
+ * would make the one number that says "something is wrong that we have not named" always zero.
3732
+ */
3733
+ declare function initFailureClassOf(error: unknown): InitFailureClass;
3734
+
3735
+ /**
3736
+ * THE ONE FUNCTION THAT DECIDES WHAT A TARGET'S MEASUREMENT IS.
3737
+ *
3738
+ * `init`, `prepare`, `run` and publication each used to reconstruct the same plan from the same
3739
+ * tree, separately, and the structural review of 2026-08-28 found that the disagreements between
3740
+ * those reconstructions are where abloh's measured failures live (§7.3, §7.5). `vitejs/vite` is the
3741
+ * clearest one: `abloh prepare` read the manifests alone and answered "nothing to prepare", `abloh
3742
+ * run` read `abloh.yml` first and measured vitest 4.1.11, and the refusal the maintainer got named
3743
+ * the command that had just told them there was nothing to do.
3744
+ *
3745
+ * SO THE SEQUENCE LIVES HERE, ONCE:
3746
+ *
3747
+ * policy declaration -> detectTarget -> resolvePreparedTestCommand -> bindPreparedTestCommand
3748
+ * -> applyDeclaredRunner
3749
+ *
3750
+ * and every surface calls this rather than something that resembles it. A second copy of that
3751
+ * sequence anywhere in the product is the divergence coming back.
3752
+ *
3753
+ * WHAT THIS PHASE DOES NOT DO, and the restraint is the point. It admits nothing, gates nothing, and
3754
+ * changes no external behaviour - same generated files, same terminal output, same published
3755
+ * payloads. §11.1: "no candidate receives admitted status until the common executor validates it."
3756
+ * The executor is phase 2.
3757
+ *
3758
+ * WHAT IT DELIBERATELY DOES NOT REACH FOR. It does no network, starts no container, and runs no
3759
+ * install. Two of the plan's fields are therefore OBSERVATIONS the caller supplies rather than facts
3760
+ * this module can discover: {@link ResolveMeasurementPlanInput.provider}, which needs the coverage
3761
+ * adapter the caller has already prepared, and the proof image's DIGEST, which needs a registry.
3762
+ * Both are optional and both are stated-absent rather than guessed - a plan that invented a provider
3763
+ * identity would be the `prepare` defect again, one layer down.
3764
+ */
3765
+
3766
+ /**
3767
+ * The declaration `abloh.yml` makes about the suite, read once.
3768
+ *
3769
+ * EVERY FIELD, because the run reads every one and only reading some is what put prepare and the run
3770
+ * on different packages. `target.directory` names the package; `environment.testCommand` names the
3771
+ * command and OUTRANKS the manifest, so a declared `vitest run` over a `scripts.test` of
3772
+ * `run-s test-unit test-serve` measures with vitest whatever the script says; `environment.runner`
3773
+ * names the adapter and outranks what that command was read as naming.
3774
+ *
3775
+ * AN INVALID POLICY ANSWERS NOTHING. `abloh run` fails it with its own precise error, and neither
3776
+ * this resolver nor `prepare` has ever been the surface that reports a malformed policy.
3777
+ */
3778
+ interface PolicyDeclaration {
3779
+ directory: string | null;
3780
+ testCommand: string | undefined;
3781
+ /**
3782
+ * `environment.runner`, the runner the maintainer wrote down.
3783
+ *
3784
+ * READ HERE BECAUSE THE RUN MUST NOT RE-DERIVE IT (audit F2). Nineteen detection facts were
3785
+ * re-worked-out from the tree on every run and none was recorded, so a repository abloh read
3786
+ * wrong at setup was read wrong again for ever. This is the recorded half, and `resolveTarget`
3787
+ * applies it after the command reading, which is the only order in which a statement can outrank
3788
+ * a guess.
3789
+ */
3790
+ runner: string | undefined;
3791
+ }
3792
+ /**
3793
+ * WHAT THE WORKING TREE'S OWN `abloh.yml` DECLARES, or nothing when it declares none.
3794
+ *
3795
+ * THE PARSE IS STILL `loadConfig`, WHICH OPENS THE FILE ITSELF, and that is the one reading in this
3796
+ * module the reader does not cover. It is bounded rather than open: the access decides whether
3797
+ * there is a file to read at all, so a road with no checkout answers "no declaration" and stops,
3798
+ * which is the right answer for the door this reader exists for - a repository being onboarded has
3799
+ * no `abloh.yml` yet, that being the whole point of onboarding it. Giving `loadConfig` a text
3800
+ * argument is the fix when a road needs one, and it is a change to core's parser rather than here.
3801
+ */
3802
+ declare function policyDeclaration(repoDir: string, ctx?: RepoAccess): PolicyDeclaration;
3803
+ /**
3804
+ * WHO ANSWERS THE DECLARED QUESTIONS - and this is a SECURITY distinction, not a convenience.
3805
+ *
3806
+ * `from-disk` reads the repository's own `abloh.yml`, which is what `init` and `prepare` want: they
3807
+ * are looking at the working tree in front of the maintainer.
3808
+ *
3809
+ * `supplied` is the RUN's, and it must never be made to re-read the working tree. A pull-request run
3810
+ * loads its policy from the TRUSTED merge-base commit precisely so that a later commit cannot change
3811
+ * what privileged infrastructure executes by editing a file. Handing this resolver a `repoDir` and
3812
+ * letting it open `abloh.yml` would hand that decision back to the pull request.
3813
+ */
3814
+ type PlanDeclaration = {
3815
+ source: "from-disk";
3816
+ } | {
3817
+ source: "supplied";
3818
+ directory: string | null;
3819
+ testCommand: string | undefined;
3820
+ /**
3821
+ * The trusted environment block, for every plan field that comes from the policy rather than
3822
+ * from the tree: the setup script, the services, the required variable NAMES, the declared
3823
+ * runtimes and system packages, the generated outputs.
3824
+ *
3825
+ * OMITTING IT MEANS THE CALLER HOLDS NONE, and the plan then carries the empty answer. It does
3826
+ * NOT fall back to reading `abloh.yml` off disk: a caller that supplied a declaration did so
3827
+ * because the working tree is not the authority for it, and a silent disk fallback for the
3828
+ * other half of the same file would be exactly the hole this discriminator exists to close.
3829
+ */
3830
+ environment?: EnvironmentConfig;
3831
+ };
3832
+ interface ResolveMeasurementPlanInput {
3833
+ /** Repository root, already resolved through `resolveRepositoryContext`. */
3834
+ repoDir: string;
3835
+ /**
3836
+ * The package to measure, when a caller has already chosen one.
3837
+ *
3838
+ * `--subdir` on `run` and `prepare`, the auto-selected package on a pull request, the workspace
3839
+ * package `init` picked. Null lets `target.directory` answer, and null again lets detection walk.
3840
+ */
3841
+ subdir?: string | null;
3842
+ /** `--test-command`, which outranks the policy and the manifest both. */
3843
+ cliTestCommand?: string | undefined;
3844
+ /** How the directory was chosen, so the plan records a stated value rather than an inference. */
3845
+ selection?: TargetSelectionKind;
3846
+ /** See {@link TargetResolutionInput.access}. Omitted is a checkout at `repoDir`. */
3847
+ access?: RepoAccess;
3848
+ /**
3849
+ * The coverage adapter the caller has already resolved, when it has one.
3850
+ *
3851
+ * SUPPLIED RATHER THAN LOOKED UP. Resolving it means reading `node_modules` and possibly abloh's
3852
+ * provider cache, which is I/O this module has no business doing on `init`'s path - and the run
3853
+ * has already done it by the time it wants a plan.
3854
+ */
3855
+ provider?: {
3856
+ name: string;
3857
+ source: "project" | "runtime" | "abloh-bundled" | "abloh-cache";
3858
+ version: string | null;
3859
+ } | null;
3860
+ /** Why no provider resolved, in the customer's words. Ignored when a provider is supplied. */
3861
+ providerReason?: string | null;
3862
+ /** The proof image ref in force, from `--environment-image` or the policy default. */
3863
+ imageRef?: string | null;
3864
+ imageSource?: "customer" | "abloh-default" | null;
3865
+ /** Which owned outputs this target's configuration is expected to produce. */
3866
+ expectedEvidence?: readonly EvidenceOutputId[];
3867
+ /**
3868
+ * Detection already performed by the caller, so a run does not detect twice.
3869
+ *
3870
+ * THE RUN SUPPLIES THIS. `index.ts` establishes detection inside a try/catch that turns two
3871
+ * distinct failures into two distinct artifact records, and re-detecting here would either
3872
+ * duplicate that handling or lose it. What matters for the seam is that both paths call the SAME
3873
+ * three functions in the SAME order, which {@link resolveTargetDetection} is.
3874
+ */
3875
+ detection?: TargetDetection;
3876
+ /** Where the declared directory and command come from; see {@link PlanDeclaration}. */
3877
+ declaration?: PlanDeclaration;
3878
+ }
3879
+ /**
3880
+ * DETECTION, BOUND TO THE DECLARED COMMAND - the exact three calls, in the exact order.
3881
+ *
3882
+ * Exported because `prepare` and the run both need the resolved {@link TargetDetection} itself and
3883
+ * not only the plan built over it. It is the smallest thing both can share, and sharing it is what
3884
+ * stops them resolving different packages in the same tree.
3885
+ */
3886
+ interface TargetResolutionInput {
3887
+ repoDir: string;
3888
+ /**
3889
+ * The package to measure.
3890
+ *
3891
+ * `undefined` and `null` ARE DIFFERENT ANSWERS, and conflating them would change what the run
3892
+ * measures. `undefined` means nobody has chosen, so the declaration's `target.directory` answers.
3893
+ * An explicit `null` means the caller HAS chosen, and chose the repository root - which is what
3894
+ * the run passes, because `effectiveSubdir` has already folded the declared directory into its
3895
+ * own `opts.subdir` several hundred lines earlier.
3896
+ */
3897
+ subdir?: string | null;
3898
+ cliTestCommand?: string | undefined;
3899
+ declaration?: PlanDeclaration;
3900
+ /**
3901
+ * WHERE THIS REPOSITORY'S BYTES COME FROM, when they do not come from a checkout.
3902
+ *
3903
+ * Omitted is a checkout at `repoDir`, which is every caller in the CLI. It is supplied by the
3904
+ * control plane, which reads the same repository over GitHub's contents API and has no checkout
3905
+ * at all - see `packages/core/src/repo-reader.ts` for why the deciding in between must be one
3906
+ * implementation rather than two.
3907
+ */
3908
+ access?: RepoAccess;
3909
+ }
3910
+ /**
3911
+ * BOTH HALVES OF THE RESOLUTION, so a caller that needs the raw detection as well as the bound one
3912
+ * gets them from a single pass.
3913
+ *
3914
+ * The run needs all three: `detected` to say whether the declared command outranked the manifest's
3915
+ * runner, `command` to build the environment contract, and `detection` to measure with. It used to
3916
+ * write those three calls out itself, which is one of the two copies of this sequence the
3917
+ * structural review counted.
3918
+ */
3919
+ interface TargetResolution {
3920
+ /** What the tree alone says, before any declared command is applied. */
3921
+ detected: TargetDetection;
3922
+ /** The command that will execute, and where it came from. */
3923
+ command: ReturnType<typeof resolvePreparedTestCommand>;
3924
+ /** Detection re-bound to that command - what a measurement actually uses. */
3925
+ detection: TargetDetection;
3926
+ }
3927
+ declare function resolveTarget(input: TargetResolutionInput): TargetResolution;
3928
+ /** The bound detection alone, for the callers that need nothing else. */
3929
+ declare function resolveTargetDetection(input: TargetResolutionInput): TargetDetection;
3930
+ /**
3931
+ * RESOLVE ONE TARGET'S PLAN.
3932
+ *
3933
+ * Every field is decided here and nowhere else. A consumer that finds itself re-deriving one has
3934
+ * found the seam this object exists to close, and the fix is to add the field rather than to
3935
+ * recompute it locally.
3936
+ */
3937
+ declare function resolveMeasurementPlan(input: ResolveMeasurementPlanInput): MeasurementPlan;
3938
+
3939
+ /** One Node version a repository states, exactly as it states it, and where it was read. */
3940
+ interface NodeDeclaration {
3941
+ /** The text verbatim: `16`, `24.16.0`, `v26`, `lts/*`, `latest`. Never normalised. */
3942
+ spec: string;
3943
+ /** Where it came from, in the receipt's own words. */
3944
+ evidence: string;
3945
+ /**
3946
+ * The repo-relative file that states it, separately from the sentence about it.
3947
+ *
3948
+ * The generated workflow needs the path on its own: `actions/setup-node` reads `.nvmrc`,
3949
+ * `.node-version`, `.tool-versions` and `package.json` itself, and pointing it at the
3950
+ * repository's own file is what stops the check's Node and the repository's Node from drifting
3951
+ * apart. Null for a declaration that lives in a CI file rather than in a version file.
3952
+ */
3953
+ path: string | null;
3954
+ }
3955
+ /** One `engines.node` range, kept apart from every other one in the package chain. */
3956
+ interface NodeRequirement {
3957
+ range: string;
3958
+ /** The repo-relative manifest that states it. */
3959
+ source: string;
3960
+ }
3961
+ /** One entry of the publisher's release index, as `nodejs.org/dist/index.json` states it. */
3962
+ interface NodeRelease {
3963
+ /** `26.8.2`, with no leading `v`. */
3964
+ version: string;
3965
+ /** The LTS codename, or false for a release that is not on an LTS line. */
3966
+ lts: string | false;
3967
+ }
3968
+ /**
3969
+ * What one declaration's text says, before anything is looked up.
3970
+ *
3971
+ * `unreadable` is a real answer and never a refusal: a `.nvmrc` reading `lts/-1` is a form this
3972
+ * product does not resolve, and the honest response is to say so and let the next declaration or
3973
+ * the host answer - which is what the receipt already did for it.
3974
+ */
3975
+ type NodeSpecReading = {
3976
+ kind: "exact";
3977
+ version: string;
3978
+ }
3979
+ /** A major or a major.minor, as the semver range it names and the words for it. */
3980
+ | {
3981
+ kind: "family";
3982
+ range: string;
3983
+ }
3984
+ /** `latest`, `current`, `node` - the newest release there is. */
3985
+ | {
3986
+ kind: "newest";
3987
+ }
3988
+ /** `lts`, `lts/*`, `lts/latest` - the newest release on the newest LTS line. */
3989
+ | {
3990
+ kind: "newest-lts";
3991
+ }
3992
+ /** `lts/jod` - the newest release on the line with that codename. */
3993
+ | {
3994
+ kind: "lts-line";
3995
+ name: string;
3996
+ } | {
3997
+ kind: "unreadable";
3998
+ };
3999
+ /**
4000
+ * The one grammar every declaration form goes through, whichever file or workflow stated it.
4001
+ *
4002
+ * THE ALIASES ARE ASKED BEFORE THE RANGE and the order is load-bearing: `semver` reads `latest` as
4003
+ * no range at all, but it would happily read a future alias as one, and an alias resolved by
4004
+ * arithmetic rather than by the publisher's own index is exactly the guess this module refuses.
4005
+ *
4006
+ * THE FAMILY IS `semver`'S OWN READING and not a pattern of ours. A `node-version` is written every
4007
+ * way a range can be written - `16`, `22.14`, `24.x`, `^20`, `>=18`, `18 || 20` - and
4008
+ * `actions/setup-node` resolves all of them with semver, so a second implementation here would be a
4009
+ * second answer to a question that already has one. `moment/luxon` writes `24.x`, which a
4010
+ * digits-only reader called unreadable.
4011
+ */
4012
+ declare function readNodeSpec(text: string): NodeSpecReading;
4013
+ /**
4014
+ * Every published version a spec admits, newest first.
4015
+ *
4016
+ * An alias names exactly one, because that is what an alias IS: `lts/*` on the day `init` runs is
4017
+ * one release, and the digest the image resolver then pins is what freezes it. A family names
4018
+ * every release inside it, so the newest is taken once the requirements have had their say.
4019
+ */
4020
+ declare function nodeSpecVersions(reading: NodeSpecReading, releases: readonly NodeRelease[]): string[];
4021
+ /** Why one declaration produced no version this repository could be measured on. */
4022
+ type NodeDeclarationProblem =
4023
+ /** Abloh has no reading for the form it is written in. Disclosed, never refused. */
4024
+ {
4025
+ kind: "unreadable";
4026
+ }
4027
+ /** It reads correctly and names a Node the publisher has never released. */
4028
+ | {
4029
+ kind: "never-published";
4030
+ }
4031
+ /** It reads correctly and this repository's own `engines.node` excludes every version of it. */
4032
+ | {
4033
+ kind: "excluded";
4034
+ by: readonly NodeRequirement[];
4035
+ };
4036
+ interface NodeDeclarationRejection {
4037
+ declaration: NodeDeclaration;
4038
+ problem: NodeDeclarationProblem;
4039
+ }
4040
+ type NodeChoice = {
4041
+ kind: "chosen";
4042
+ /** The concrete published version the image is resolved from. */
4043
+ version: string;
4044
+ major: number;
4045
+ /** The declaration it came from, or null when the repository declares none. */
4046
+ declaration: NodeDeclaration | null;
4047
+ /** Declarations passed over on the way here, each with the reason, for the receipt. */
4048
+ rejected: readonly NodeDeclarationRejection[];
4049
+ }
4050
+ /** No published Node satisfies every `engines.node` in this repository, whatever is declared. */
4051
+ | {
4052
+ kind: "requirements-conflict";
4053
+ requirements: readonly NodeRequirement[];
4054
+ }
4055
+ /** The requirements admit a Node and not one declaration lands inside them. */
4056
+ | {
4057
+ kind: "declarations-unsatisfiable";
4058
+ rejected: readonly NodeDeclarationRejection[];
4059
+ };
4060
+ /**
4061
+ * THE ONE CHOICE, AND THE ORDER IS THE WHOLE RULE.
4062
+ *
4063
+ * Declarations arrive strongest first - a version file before a CI job, because a version file is a
4064
+ * single deliberate line every contributor's tooling reads while a CI `node-version` is frequently
4065
+ * one cell of a matrix. Each is offered its published versions, each version is tested against
4066
+ * EVERY requirement separately, and the first declaration with a survivor wins at its own newest.
4067
+ *
4068
+ * WHAT REFUSES, AND WHAT DOES NOT. A form abloh cannot read is skipped and carried on `rejected` so
4069
+ * the receipt can name the line it could not read - that is a disclosure, and it is what `lts/-1`
4070
+ * has always got. A declaration abloh CAN read that no admitted Node satisfies is a contradiction
4071
+ * inside the repository, and there is no honest image for it: picking one anyway is the defect this
4072
+ * function replaces. The two are told apart so a repository is never refused over a spelling.
4073
+ */
4074
+ declare function chooseNodeVersion(input: {
4075
+ /** Strongest first. */
4076
+ declarations: readonly NodeDeclaration[];
4077
+ requirements: readonly NodeRequirement[];
4078
+ releases: readonly NodeRelease[];
4079
+ /** The major to prefer when the repository declares nothing at all. */
4080
+ preferredMajor: number | null;
4081
+ }): NodeChoice;
4082
+ /** The sentence a receipt or a notice uses for one declaration abloh passed over. */
4083
+ declare function nodeDeclarationProblemReason(rejection: NodeDeclarationRejection): string;
4084
+
4085
+ /**
4086
+ * THE CLI's HALF OF THE SHARED READER: a checkout on this machine.
4087
+ *
4088
+ * `packages/core/src/repo-reader.ts` states why the interface exists and why it is synchronous.
4089
+ * This is the implementation `abloh init` hands to the drafting functions; the control plane hands
4090
+ * them one over bytes it fetched from GitHub, and the deciding in between is the same code.
4091
+ *
4092
+ * ---------------------------------------------------------------------------------------------
4093
+ * IT KEEPS EVERY REFUSAL THE CALLERS USED TO MAKE FOR THEMSELVES.
4094
+ * ---------------------------------------------------------------------------------------------
4095
+ *
4096
+ * The three call sites this replaces did not simply read files. They checked containment against
4097
+ * the checkout root before opening anything, because a `uses: ./../../x` and a `node-version-file`
4098
+ * are repository-author-controlled text; they refused SYMLINKS, because a symlink in a stranger's
4099
+ * repository points wherever its author decided; and they refused anything that was not a plain
4100
+ * file. Every one of those refusals is here instead of there, which is the point of moving them:
4101
+ * a reader that only refused on some paths would be a reader every future caller had to remember
4102
+ * to check around.
4103
+ *
4104
+ * CONTAINMENT IS CHECKED TWICE ON PURPOSE. `normalizeRepoPath` refuses `..` and absolute paths
4105
+ * before a path is joined, and the PARENT of the join is resolved and checked against the resolved
4106
+ * root afterwards. The first catches the ordinary case; the second catches the one the first cannot
4107
+ * see, which is a SYMLINKED DIRECTORY partway down a path that is itself perfectly relative.
4108
+ *
4109
+ * WHAT THE SYMLINK RULE NARROWS, stated because it is a product-visible change rather than a
4110
+ * refactor. `detect.ts` used to reach its manifests with bare `existsSync`/`readFileSync`, which
4111
+ * FOLLOW a link, so a repository whose `package.json` is a symlink was read; through this reader it
4112
+ * is absent. A symlinked DIRECTORY on the way to a file is a different case and still works, because
4113
+ * the check below is "does this resolve inside the repository" rather than "is there a link in this
4114
+ * path" - which is what a monorepo linking one package into another needs. What is refused is a link
4115
+ * that leaves, and a link AS the file, on the ground that its target is chosen by the author of the
4116
+ * repository being drafted for.
4117
+ *
4118
+ * The second half said this and did not do it until 2026-09-05: the join was compared against the
4119
+ * root as TEXT, so `elsewhere/secret.json` was inside the repository whatever `elsewhere` pointed
4120
+ * at, and refusing a symlink at the leaf never reaches one in the middle. `repo-reader-disk.test.ts`
4121
+ * is what found it, which is the argument for a reader having a test of its own rather than being
4122
+ * covered only through its callers.
4123
+ */
4124
+ declare function diskRepoReader(repoDir: string): RepoReader;
4125
+
4126
+ /** The declaration at a Rush repository's root. Its presence IS what makes a repository one. */
4127
+ declare const RUSH_CONFIG_FILE = "rush.json";
4128
+ /**
4129
+ * The script name a Rush project spells its test phase with, and the ordinary one it may also have.
4130
+ *
4131
+ * `rush test` is a PHASED command defined in `common/config/rush/command-line.json`, and phases map
4132
+ * onto `_phase:<name>` scripts. A project that declares a plain `test` has said the same thing in
4133
+ * the spelling every other monorepo uses, so both are read and the ordinary one wins - if an author
4134
+ * wrote both, the one every other tool would run is the one they meant.
4135
+ */
4136
+ declare const RUSH_TEST_SCRIPTS: readonly ["test", "_phase:test"];
4137
+ /** Where a Rush repository keeps the files that decide what an install produces. */
4138
+ declare const RUSH_CONFIG_DIRECTORY = "common/config/rush";
4139
+ declare const RUSH_SUBSPACE_DIRECTORY = "common/config/subspaces";
4140
+ declare const RUSH_LOCKFILE_NAME = "pnpm-lock.yaml";
4141
+ declare const RUSH_REPO_STATE_NAME = "repo-state.json";
4142
+ /**
4143
+ * The COMMITTED bootstrap that installs the exact Rush a repository pins, then installs with it.
4144
+ *
4145
+ * `node common/scripts/install-run-rush.js install` and nothing shorter. `rush install` alone
4146
+ * requires a globally installed Rush of a compatible version, which a sealed image has no reason to
4147
+ * carry; the committed script reads `rushVersion` out of `rush.json`, fetches exactly that Rush, and
4148
+ * that Rush then installs exactly the pnpm `pnpmVersion` names. So the version pinning is the
4149
+ * repository's own, all the way down, and abloh adds none of it.
4150
+ *
4151
+ * Measured on rushstack 2026-08-18: `Rush install finished successfully. (2 minutes 17.9 seconds)`,
4152
+ * 193 projects, from the committed lockfile.
4153
+ */
4154
+ declare const RUSH_INSTALL_COMMAND = "node common/scripts/install-run-rush.js install";
4155
+ /** The path of the bootstrap the command above runs, so its ABSENCE can be reported as one. */
4156
+ declare const RUSH_INSTALL_SCRIPT = "common/scripts/install-run-rush.js";
4157
+ /** One project a Rush root declares, and what its own manifest says about building and testing. */
4158
+ interface RushProject {
4159
+ /** Repository-relative project directory, exactly as `rush.json` spells it. */
4160
+ directory: string;
4161
+ /** The package name `rush.json` pairs with that folder. */
4162
+ name: string;
4163
+ /** What this project's `test` phase is, once its manifest has been read. */
4164
+ test: RushTestTarget;
4165
+ }
4166
+ type RushTestTarget =
4167
+ /** A literal command, run in the project's own directory, exactly as a package script would be. */
4168
+ {
4169
+ state: "command";
4170
+ script: string;
4171
+ command: string;
4172
+ } | {
4173
+ state: "unmeasurable";
4174
+ reason: string;
4175
+ remedy: string;
4176
+ };
4177
+ /**
4178
+ * Read one Rush project's manifest into the answer per-package composition can act on.
4179
+ *
4180
+ * The command is taken VERBATIM and run in the project's own directory, which is the whole reason
4181
+ * Rush is drivable where integrated Nx was not: there is no workspace-root cwd problem to solve,
4182
+ * because `heft run --only test` is what the author runs, where they run it.
4183
+ *
4184
+ * Everything it declines, it declines by name. A shell-composed command is not one suite; a project
4185
+ * with no test phase declares no suite at all.
4186
+ */
4187
+ declare function classifyRushTestScript(manifest: unknown, directory: string): RushTestTarget;
4188
+ /**
4189
+ * Enumerate a Rush workspace's projects from `rush.json` and the manifests it points at.
4190
+ *
4191
+ * READ THROUGH A SEAM, for the reason `enumerateNxProjects` states: selection reads the TRUSTED
4192
+ * merge-base tree and `abloh prepare` reads the working tree, and the two desyncing on one
4193
+ * repository is the failure this shares with manifest classification. So this takes a reader and
4194
+ * never touches the filesystem.
4195
+ *
4196
+ * @param readFile the bytes of one repository-relative path, or null when absent/unreadable
4197
+ */
4198
+ declare function enumerateRushProjects(readFile: (path: string) => string | null, limit: number): RushProject[];
4199
+ /**
4200
+ * The identity files a Rush install's outcome depends on, in the order a reader should see them.
4201
+ *
4202
+ * THREE FACTS, NOT ONE LOCKFILE, and each is load-bearing:
4203
+ *
4204
+ * - `rush.json` pins the Rush version AND the pnpm version, so the same lockfile installed by two
4205
+ * Rush versions is two installs.
4206
+ * - the pnpm lockfile is the dependency set, and it lives per SUBSPACE. rushstack declares two
4207
+ * (`default` and `build-tests-subspace`), so a single path would name one of them and miss the
4208
+ * other; every one found is an identity file.
4209
+ * - `repo-state.json` is Rush's own record that the lockfile has not been hand-edited
4210
+ * (`pnpmShrinkwrapHash`, `preferredVersionsHash`), and Rush verifies it BEFORE installing. It is
4211
+ * the one file in this list whose whole purpose is to make the install deterministic, which is
4212
+ * exactly what an identity file is for.
4213
+ *
4214
+ * @param exists whether a repository-relative path is present
4215
+ * @param subdirectories the immediate child directory names of a repository-relative directory
4216
+ */
4217
+ declare function rushIdentityFiles(input: {
4218
+ exists: (path: string) => boolean;
4219
+ subdirectories: (path: string) => readonly string[];
4220
+ }): string[];
4221
+ /** Every lockfile a Rush install reads, for the install-recipe's "there is a frozen set" check. */
4222
+ declare function rushLockfiles(input: {
4223
+ exists: (path: string) => boolean;
4224
+ subdirectories: (path: string) => readonly string[];
4225
+ }): string[];
4226
+ /**
4227
+ * The node version range a Rush repository ENFORCES on its host, or null when it declares none.
4228
+ *
4229
+ * A GATE NO OTHER JS REPOSITORY SHAPE IMPOSES, and it is a hard error rather than a warning:
4230
+ *
4231
+ * ERROR: Your dev environment is running Node.js version v24.7.0 which does not meet the
4232
+ * requirements for building this repository. (The rush.json configuration requires
4233
+ * nodeSupportedVersionRange=">=20.9.0 <21.0.0 || >=22.12.0 <23.0.0 || ...")
4234
+ *
4235
+ * Rush refuses to install at all. It cuts both ways, and the second way is the useful one: it is a
4236
+ * DECLARATION abloh can read to validate the base image before a build, which no other shape offers.
4237
+ * Reported rather than acted on here - the caller decides whether an image satisfies it.
4238
+ */
4239
+ declare function rushNodeVersionRange(rushJsonText: string | null): string | null;
4240
+
4241
+ /**
4242
+ * ONE FACT ABOUT THE JOB THAT THE TRIAL WILL NEED AND THE JOB DOES NOT DECLARE.
4243
+ *
4244
+ * NOT A REFUSAL. Nothing here stops the step being appended or the setup PR being opened; the trial
4245
+ * runs and either it mattered or it did not. What this exists to prevent is a maintainer meeting
4246
+ * the consequence with no idea it was foreseeable - the same complaint the whole flip is about, one
4247
+ * layer down.
4248
+ */
4249
+ interface SetupStepPrecondition {
4250
+ /** What sort of thing is missing, in the words the setup PR body prints. */
4251
+ kind: "workflow-step";
4252
+ /** The file the fix goes in, always one the setup PR touches. */
4253
+ file: string;
4254
+ /** The key inside it, in the workflow's own spelling. */
4255
+ key: string;
4256
+ /** What the trial needs, in one sentence. */
4257
+ need: string;
4258
+ }
4259
+ /**
4260
+ * WHAT ABLOH DID ABOUT THE ATTESTATION JOB, so the setup PR body can say it in one sentence.
4261
+ *
4262
+ * A reviewer is being asked to accept a NEW JOB in their workflow, which is a bigger thing than a
4263
+ * step and the one part of this change that costs them a runner. It is never left to be inferred
4264
+ * from an indentation level: the body names the job, what it runs, and the single permission it
4265
+ * holds.
4266
+ */
4267
+ type AttestationJob =
4268
+ /** The job was written into the file, and depends on the job the measuring step rides. */
4269
+ {
4270
+ kind: "written";
4271
+ jobId: string;
4272
+ needs: string;
4273
+ key: string;
4274
+ file: string;
4275
+ }
4276
+ /** A job of ours by that id was already in the file, so nothing was written. */
4277
+ | {
4278
+ kind: "already-there";
4279
+ jobId: string;
4280
+ key: string;
4281
+ file: string;
4282
+ };
4283
+ /**
4284
+ * THE SECOND FILE THIS PLACEMENT EDITS, when abloh's publish job may not go where its step went.
4285
+ *
4286
+ * WHY THERE IS A SECOND FILE AT ALL (census run 7, F2, part two). A workflow declaring
4287
+ * `workflow_call` has its jobs' permissions capped by whatever calls it, so the publish job cannot
4288
+ * live there - but the STEP asks for no permission and rides that job perfectly well. So the step
4289
+ * goes into the callee and the job into a caller GitHub starts itself. `setupPublishHost` in
4290
+ * `@abloh/core` decides which file that is, and the caller of this function hands it in.
4291
+ */
4292
+ interface SetupPublishPlacement {
4293
+ /** The workflow the publish job was written into, repo-relative. */
4294
+ file: string;
4295
+ /** That file as it is on disk now. */
4296
+ before: string;
4297
+ /** That file with the job in it. Identical to `before` when the job was already there. */
4298
+ after: string;
4299
+ /** The unified diff a reviewer reads, empty when nothing was written. */
4300
+ diff: string;
4301
+ }
4302
+ interface SetupStepPlacement {
4303
+ /** The workflow file, repo-relative. */
4304
+ file: string;
4305
+ /** The job the step was appended to. */
4306
+ jobId: string;
4307
+ /**
4308
+ * WHICH OF THE JOB'S OWN STEPS ABLOH'S STEP WENT IN BEHIND, or null when it went in at the end.
4309
+ *
4310
+ * A FACT THE SETUP PULL REQUEST STATES rather than one a reviewer has to count lines to find.
4311
+ * The whole point of the change is that Abloh no longer stands at the end of somebody's job, and
4312
+ * a reviewer reading "one step appended" over a diff in the MIDDLE of their steps list would be
4313
+ * reading a sentence that no longer matches the edit.
4314
+ */
4315
+ afterStepIndex: number | null;
4316
+ /** The file as it is on disk now. */
4317
+ before: string;
4318
+ /** The file with both insertions. Identical to `before` when there was nothing left to add. */
4319
+ after: string;
4320
+ /** True when this job already ends in an Abloh step, so no step was appended. */
4321
+ alreadyThere: boolean;
4322
+ /** The unified diff a reviewer reads, empty when nothing was written. */
4323
+ diff: string;
4324
+ /** The job that holds the identity, and what abloh did about it. */
4325
+ attestationJob: AttestationJob;
4326
+ /**
4327
+ * THE EDIT TO THE FILE THE PUBLISH JOB WENT INTO, or null when that was this file.
4328
+ *
4329
+ * NULL IS THE ORDINARY BORROW and not an omission: {@link after} already carries both insertions
4330
+ * there. A non-null value is the split road, where {@link after} carries the step alone and this
4331
+ * carries the job - two files, both of which the setup pull request lists.
4332
+ */
4333
+ publish: SetupPublishPlacement | null;
4334
+ /**
4335
+ * DID ABLOH WRITE `fail-fast: false` INTO THE RIDDEN JOB'S `strategy:` BLOCK?
4336
+ *
4337
+ * A FACT THE SETUP PULL REQUEST STATES rather than one a reviewer has to find in the diff. It is
4338
+ * the one edit abloh makes that is not about abloh: it changes how the maintainer's OWN legs
4339
+ * behave when one of them fails, so a reviewer has to be told it happened and why, in the same
4340
+ * sentence that says one step was appended.
4341
+ *
4342
+ * FALSE WHERE THE ANSWER WAS `keep`, where the job does not fan out, where it already declares
4343
+ * `fail-fast: false`, and where it declares a `fail-fast` of the maintainer's that abloh will not
4344
+ * write over. `setup-fail-fast.ts` holds all four.
4345
+ */
4346
+ failFastWritten: boolean;
4347
+ /** What the trial will need that this job does not declare. */
4348
+ preconditions: readonly SetupStepPrecondition[];
4349
+ }
4350
+
4351
+ /**
4352
+ * APPEND THE STEP.
4353
+ *
4354
+ * `before` is the file's exact bytes and the caller owns reading them; this function opens nothing,
4355
+ * so the same code runs over a fixture, over a scratch repository and over a real checkout.
4356
+ */
4357
+ declare function placeSetupStep(input: {
4358
+ file: string;
4359
+ jobId: string;
4360
+ before: string;
4361
+ /**
4362
+ * THE MATRIX LEG THIS STEP IS PINNED TO, or absent when the job is one build.
4363
+ *
4364
+ * It only changes the step's `if:`. See {@link setupStepIf}: a matrix has one steps list, so a leg
4365
+ * is a condition rather than a second place to write.
4366
+ */
4367
+ leg?: MatrixLeg | null;
4368
+ /**
4369
+ * THE INDEX OF THE STEP THAT RUNS THEIR SUITE, so abloh's step goes in immediately behind it.
4370
+ *
4371
+ * WHY THE PLACEMENT MOVED (the fresh-ten launch-evidence run, 2026-08-31, bug 6). Appending at
4372
+ * the END of the job put abloh after `swagger-api/swagger-ui`'s `Build SwaggerUI` step, which
4373
+ * writes into a `dist/` that repository TRACKS - so abloh stood after a build and then refused
4374
+ * the job for the build's output, on both cycles, and the maintainer moved nothing.
4375
+ *
4376
+ * BEHIND THE SUITE IS THE RIGHT PLACE AND IT NEEDS NO GUESS. Everything the suite needed has
4377
+ * already run by the time it has run; everything after it is by definition not needed to run it.
4378
+ * The value comes from {@link CiBorrowJob.lastTestStepIndex} - the reader's own classification of
4379
+ * the step, which is how the job was chosen in the first place - and never from a scan of step
4380
+ * names for build-sounding words.
4381
+ *
4382
+ * ABSENT OR NULL APPENDS AT THE END, which is the old behaviour and the honest answer for a job
4383
+ * whose suite abloh could not locate: a declared `setup.job` need not run one at all.
4384
+ */
4385
+ afterStepIndex?: number | null;
4386
+ /**
4387
+ * WRITE `fail-fast: false` INTO THIS JOB'S `strategy:` BLOCK, in the same edit as the step.
4388
+ *
4389
+ * THE CALLER DECIDES AND THIS FUNCTION DOES NOT RE-READ THE WORKFLOW. `SetupJobDecision.failFast`
4390
+ * has already asked all four questions - does the job fan out, does it already opt out, did the
4391
+ * maintainer write a `fail-fast` of their own, and what did they answer - and a second reading
4392
+ * here would be a second answer to the one question `setup-fail-fast.ts` owns.
4393
+ *
4394
+ * IT IS STILL REFUSED WHERE THE FILE CANNOT CARRY IT. A `strategy:` written as a one-line flow
4395
+ * mapping has no block to insert into, and this module does not rewrite a line the maintainer
4396
+ * wrote - so the step goes in and the line does not, which {@link SetupStepPlacement.failFastWritten}
4397
+ * reports rather than claiming an edit that did not happen.
4398
+ */
4399
+ failFast?: boolean;
4400
+ /**
4401
+ * THE `name:` OF THE STEP `afterStepIndex` POINTS AT, so the comment can name it.
4402
+ *
4403
+ * WHY THE COMMENT NAMES IT (census run 4, `prettier/prettier`, F8). The line abloh writes into
4404
+ * somebody else's workflow says where its step sits, and on a job with two suite steps under two
4405
+ * `if:` conditions "the step that runs your suite" is a claim about WHICH of them - the claim
4406
+ * that was false on prettier. A name is checkable by the person reading the diff; a description
4407
+ * is not. Absent falls back to the unnamed wording, which claims nothing it cannot show.
4408
+ */
4409
+ afterStepName?: string | null;
4410
+ /**
4411
+ * THE FILE ABLOH'S PUBLISH JOB GOES INTO, when it may not go into the one the step goes into.
4412
+ *
4413
+ * THE CALLER DECIDES AND THIS FUNCTION DOES NOT RE-READ ANY WORKFLOW. `setupPublishHost` in
4414
+ * `@abloh/core` is the one place the question is answered - callable file, is there a caller
4415
+ * GitHub starts on a pull request - and a second reading here would be a second answer to it.
4416
+ *
4417
+ * `jobId` IS THE CALLER'S OWN JOB, the one whose `uses:` names the workflow the step went into,
4418
+ * so abloh's job `needs:` the job that runs the step and reads the artifact it left on the same
4419
+ * run. `before` is that file's exact bytes, on the same terms as {@link before}: this function
4420
+ * opens nothing.
4421
+ *
4422
+ * ABSENT IS THE ORDINARY BORROW, where the job goes in beside the step exactly as it always has.
4423
+ */
4424
+ publishInto?: {
4425
+ file: string;
4426
+ jobId: string;
4427
+ before: string;
4428
+ } | null;
4429
+ }): SetupStepPlacement | Refusal;
4430
+
4431
+ /**
4432
+ * WHICH OF THE MAINTAINER'S SUITE STEPS ABLOH'S STEP GOES BEHIND, ON THE LEG IT PINNED.
4433
+ *
4434
+ * WHAT WAS BROKEN (census run 4, `prettier/prettier`, F8; run 33613921638 on the fork).
4435
+ * `prettier`'s `.github/workflows/dev-test.yml::test` declares two suite steps and an `include:`
4436
+ * leg decides which one runs:
4437
+ *
4438
+ * - name: Run Tests
4439
+ * if: ${{ !matrix.ENABLE_CODE_COVERAGE }}
4440
+ * run: yarn test
4441
+ * - name: Run Tests (coverage)
4442
+ * if: ${{ matrix.ENABLE_CODE_COVERAGE }}
4443
+ * run: yarn c8 yarn test
4444
+ *
4445
+ * `setup-step.ts` was handed `CiBorrowJob.lastTestStepIndex`, one number decided by the reader
4446
+ * before any leg existed, and the leg was decided separately by `setup-job.ts`. The two never
4447
+ * spoke. Abloh pinned the cell carrying `ENABLE_CODE_COVERAGE: true` and stood behind the step that
4448
+ * cell SKIPS - between the two suite steps, measuring a tree whose suite the maintainer's job had
4449
+ * not run, while the comment abloh wrote into their own file said "It sits right after the step
4450
+ * that runs your suite". Nothing could notice, because neither half knew about the other.
4451
+ *
4452
+ * THIS IS WHERE THE TWO MEET, and it is the only place either question is asked together. The
4453
+ * reader states what the file says (`CiBorrowJob.testSteps`), `setup-job.ts` settles the leg, and
4454
+ * this answers each step's own `if:` with GitHub's arithmetic - `evaluateStepConditions`, over
4455
+ * `matrix-value.ts`'s coercion, which is the same rule F7 writes conditions with.
4456
+ *
4457
+ * WHEN SEVERAL RUN, THE LAST OF THEM. A job that shards its suite over two steps has not finished
4458
+ * running it until the second has run, which is `lastTestStepIndex`'s own rule one question deeper.
4459
+ *
4460
+ * WHEN ABLOH CANNOT READ A CONDITION IT SAYS SO AND CHANGES NOTHING. A step gated on a secret, on
4461
+ * another job's output, on the event payload or on an environment value something earlier exported
4462
+ * is one no reading of the file can answer, so the anchor falls back to the last test-shaped step -
4463
+ * which is exactly where it stood before this module existed - and the uncertainty is NAMED, on the
4464
+ * job list `abloh init` prints and in the contract it writes. It is never a question: there is
4465
+ * nothing to ask a maintainer that their own `if:` does not already say.
4466
+ *
4467
+ * AND WHEN NOTHING RUNS, THE SAME FALLBACK. A leg on which every suite step is skipped is a leg
4468
+ * that runs no suite abloh recognized, and the honest anchor there is the unchanged one; the trial
4469
+ * that follows is what says so, in its own words, having measured.
4470
+ */
4471
+
4472
+ /** Where abloh's step goes, and what abloh could not read on the way to deciding. */
4473
+ interface SetupStepAnchor {
4474
+ /**
4475
+ * The `jobs.<id>.steps` index to insert behind, or null to append at the end of the job.
4476
+ *
4477
+ * Null is the honest answer for a job whose suite abloh could not locate at all - a declared
4478
+ * `setup.job` need not run one - and it is what `placeSetupStep` has always taken.
4479
+ */
4480
+ index: number | null;
4481
+ /** That step's own `name:`, or null, so the comment abloh writes can name it. */
4482
+ stepName: string | null;
4483
+ /**
4484
+ * ONE SENTENCE PER SUITE STEP WHOSE `if:` ABLOH COULD NOT ANSWER, in file order.
4485
+ *
4486
+ * Empty is the ordinary case and means the anchor is a reading rather than a fallback.
4487
+ */
4488
+ unreadable: readonly string[];
4489
+ }
4490
+ /**
4491
+ * WHAT A STEP IS CALLED WHEN IT SAYS SO, and where it is when it does not.
4492
+ *
4493
+ * THE NAME IS THE MAINTAINER'S OWN TEXT AND IT IS REDUCED BEFORE IT IS QUOTED. This label reaches
4494
+ * two surfaces and one of them is a COMMENT in the `abloh.yml` abloh writes into their repository,
4495
+ * where a newline is not a comment - a `name:` spelled as a YAML block scalar carries one, and
4496
+ * writing it through would break the file abloh is asking them to ratify. So whitespace collapses
4497
+ * to one space and the name is clipped; past the clip the label says WHERE the step is, which is
4498
+ * less than the name and is never wrong.
4499
+ */
4500
+ declare function testStepLabel(step: CiJobTestStep): string;
4501
+ /**
4502
+ * THE ONE SENTENCE THAT SAYS ABLOH COULD NOT ANSWER A STEP'S CONDITION.
4503
+ *
4504
+ * Written once because it is printed twice - on the job list a maintainer chooses from and in the
4505
+ * `abloh.yml` that outlives the terminal - and two spellings of one fact is how a reader ends up
4506
+ * unable to tell whether they are the same fact.
4507
+ */
4508
+ declare function unreadableConditionLine(step: CiJobTestStep): string;
4509
+ /**
4510
+ * DECIDE THE ANCHOR.
4511
+ *
4512
+ * @param job the reader's answer about this job: every suite step it declares, and the index the
4513
+ * placement used before any leg was consulted.
4514
+ * @param leg the leg abloh pinned, or null when the job is one build. A non-matrix job still goes
4515
+ * through here, and still gets an answer: a `matrix.<x>` reference reads as null there, which is
4516
+ * what GitHub answers too.
4517
+ */
4518
+ declare function setupStepAnchor(job: {
4519
+ readonly testSteps: readonly CiJobTestStep[];
4520
+ readonly lastTestStepIndex: number | null;
4521
+ }, leg: MatrixLeg | null | undefined): SetupStepAnchor;
4522
+
4523
+ /**
4524
+ * Refuse the run when ANY entry in the measured range touches a gitlink — added, removed, bumped,
4525
+ * or type-changed (old OR new mode 160000). `--raw` carries the modes the unified parser never
4526
+ * sees; no pathspec, so the check covers every run arm (root, --subdir, auto, multi) identically.
4527
+ */
4528
+ declare function assertNoGitlinkChanges(repoDir: string, base: string, head: string, uncommitted: boolean): void;
4529
+ interface SubmoduleRoot {
4530
+ /** Repo-relative gitlink path. */
4531
+ path: string;
4532
+ /** The exact commit the superproject's index binds for this path. */
4533
+ gitlink: string;
4534
+ /** False = admitted-as-absent: the caller never materialized it (empty dir, byte-faithful). */
4535
+ materialized: boolean;
4536
+ }
4537
+ /**
4538
+ * Admit every tracked gitlink or refuse with a named rule. The doctrine mirrors tracked
4539
+ * symlinks: a gitlink binds only a COMMIT SHA into HEAD, not the bytes at that SHA — so a
4540
+ * materialized submodule is admitted exactly when its interior provably sits AT that commit,
4541
+ * completely clean (including gitignored content: unbound bytes are not evidence), with no
4542
+ * nested gitlinks and no interior symlink escaping the submodule. Every probe interrogates the
4543
+ * SUBMODULE's own repository — never the superproject, whose view is blinded by the
4544
+ * author-writable `.gitmodules ignore` setting (measured), and whose answers a mis-anchored
4545
+ * `git -C` silently substitutes when the path is not a repository (also measured).
4546
+ */
4547
+ declare function admitSubmodules(repoDir: string): SubmoduleRoot[];
4548
+ /**
4549
+ * Materialize an admitted submodule into the worktree FROM THE OBJECT STORE at exactly the
4550
+ * gitlink SHA — never from the caller's working files. Content-addressed extraction makes
4551
+ * time-of-check/time-of-use divergence impossible: whatever happens in the caller's checkout
4552
+ * after admission, the worktree receives the bytes the superproject's commit binds, or the
4553
+ * extraction fails. The result carries no `.git`; no git command ever runs inside it. All
4554
+ * access to the submodule's gitdir is read-only (read-tree writes only the scratch index).
4555
+ */
4556
+ declare function materializeSubmodule(sourceRepoDir: string, worktreeDir: string, root: SubmoduleRoot, scratchDir: string): void;
4557
+
4558
+ type AutoTargetSelection = {
4559
+ state: "selected";
4560
+ subdir: string;
4561
+ changedFiles: number;
4562
+ runner: string;
4563
+ } | {
4564
+ /** Multi-target: measurable packages (runners may DIFFER — mixed-runner runs measure). */
4565
+ state: "multi";
4566
+ targets: string[];
4567
+ /** Per-package runner for every entry in `targets`. */
4568
+ runners: Map<string, string>;
4569
+ /** Environmental-failure packages, disclosed as rows and driving cannot-attest by name. */
4570
+ unmeasurable: Array<{
4571
+ directory: string;
4572
+ } & PackageExclusion>;
4573
+ changedFiles: number;
4574
+ } | {
4575
+ state: "skipped";
4576
+ reason: string;
4577
+ };
4578
+ /**
4579
+ * WHAT AN ENUMERATION COULD NOT SEE (junction audit WALK-12, 2026-08-28).
4580
+ *
4581
+ * Every failure in this walk collapsed into an empty list: a directory this process may not read, a
4582
+ * malformed `pnpm-workspace.yaml`, a glob shape the pattern parser has no rule for, a tree deeper
4583
+ * than the depth bound, a workspace wider than the enumeration cap. "This repository declares no
4584
+ * workspace packages" and "this walk could not finish" then read identically - and the first is
4585
+ * what every caller acted on, so a permissions problem on one directory presented as a repository
4586
+ * with nothing in it.
4587
+ *
4588
+ * Structured, because these reach a customer through several different surfaces and each writes its
4589
+ * own sentence.
4590
+ */
4591
+ interface WorkspaceEnumeration {
4592
+ /** the package directories this walk did find, sorted */
4593
+ packages: string[];
4594
+ /**
4595
+ * Why the walk is not a complete answer, one entry per cause. Empty on the ordinary repository,
4596
+ * which is every repository whose tree this process can read.
4597
+ */
4598
+ refusals: string[];
4599
+ }
4600
+ /**
4601
+ * Workspace package dirs read from the WORKING TREE — for `abloh prepare`, which runs as a
4602
+ * trusted setup step on the caller's own checkout (there is no PR boundary to defend, and the
4603
+ * installed dependency tree it must stage from only exists on disk). Measurement-time selection
4604
+ * uses {@link selectAutoTarget}, which reads the merge-base tree instead.
4605
+ */
4606
+ declare function enumerateWorkspacePackagesOnDisk(repoDir: string): string[];
4607
+ /**
4608
+ * The same enumeration, WITH WHAT IT COULD NOT SEE (junction audit WALK-12, 2026-08-28).
4609
+ *
4610
+ * Every caller that has somewhere to put a sentence should use this one; `enumerateWorkspacePackages
4611
+ * OnDisk` above stays for the callers that genuinely only want the list, and is the same walk.
4612
+ */
4613
+ declare function enumerateWorkspacePackages(repoDir: string, ctx?: RepoAccess): WorkspaceEnumeration;
4614
+ declare function selectAutoTarget(input: {
4615
+ repoDir: string;
4616
+ base: string;
4617
+ head: string;
4618
+ uncommitted: boolean;
4619
+ /** Opt-in to the homogeneous multi-target arm; without it spanning diffs refuse as before. */
4620
+ allowMulti?: boolean;
4621
+ }): AutoTargetSelection;
4622
+
4623
+ /**
4624
+ * The exact yarn version this directory commits for itself, or null when it commits none.
4625
+ *
4626
+ * `directory` is the one that owns the `yarn.lock` - the same directory `detectPackageManagerContext`
4627
+ * selected - so the release, the rc file and the lockfile are all read as one repository's answer.
4628
+ */
4629
+ declare function committedYarnVersion(directory: string, lockfileName: string, ctx?: RepoAccess): string | null;
4630
+
4631
+ export { ANSWERABLE_SETUP_KEYS, ANSWERED_EVIDENCE, type AggregatorInvocation, type AggregatorTool, type AttestationJob, type AutoTargetSelection, type BrowserLane, type BrowserLaneEvidence, type BrowserLaneKind, CI_RECIPE_REFUSALS, type CiBorrowJob, type CiBrowserLane, type CiEnvironmentValue, type CiInstallStep, type CiJobStep, type CiJobTestStep, type CiManagerVersion, type CiNodeVersion, type CiRecipe, type CiRequiredVariable, type CiService, type CiSetupCommand, type CiSystemPackage, type CiTestCommand, type CiUnreadableJobCall, type CiUnsupportedService, type ConfigInput, type ConfigSystemPackage, type CoverageChildSuite, DENO_CONFIG_FILES, DENO_LOCKFILE, type DeclaredBinding, type DeclaredCommandContext, type DenoProject, type DraftService, ENVIRONMENT_BLOCK_HEADER, InitRefusal, type LockfileChoice, type ManifestClassification, type NodeChoice, type NodeDeclaration, type NodeDeclarationProblem, type NodeDeclarationRejection, type NodeEnvironment, type NodeRelease, type NodeRequirement, type NodeSpecReading, type NxProject, type NxTestTarget, type PackageManager, type PackageManagerContext, type PackageManagerResolution, type PlanDeclaration, type PolicyDeclaration, RUSH_CONFIG_DIRECTORY, RUSH_CONFIG_FILE, RUSH_INSTALL_COMMAND, RUSH_INSTALL_SCRIPT, RUSH_LOCKFILE_NAME, RUSH_REPO_STATE_NAME, RUSH_SUBSPACE_DIRECTORY, RUSH_TEST_SCRIPTS, type ReadTextAt, type RebuildDraftValue, type RepoAccess, type ResolveMeasurementPlanInput, type Runner, type RuntimeNeed, type RushProject, type RushTestTarget, SETUP_QUESTION_DESCRIPTORS, STEP_WHAT, type SetupAnswerChannel, type SetupAnswerShape, type SetupDraftValues, type SetupFailFastDecision, type SetupJobDecision, type SetupJobQuestion, type SetupMatrixLeg, type SetupPin, type SetupPublishPlacement, type SetupQuestionDescriptor, type SetupStepAnchor, type SetupStepPlacement, type SetupStepPrecondition, type SubmoduleRoot, type TargetDetection, TargetDetectionError, TargetNotFoundError, type TargetNotFoundReason, type TargetResolution, type TargetResolutionInput, type WorkflowEnvironmentEntry, type WorkflowEnvironmentLevel, type WorkflowEnvironmentScope, type WorkflowExpressionFacts, type WorkflowExpressionReading, type WorkspaceEnumeration, type WorkspaceRootSuite, __testing, accessExists, accessKind, accessManifest, accessSubdirectories, accessText, admitSubmodules, aggregatorInvocation, aggregatorTaskPhrase, aliasHopFamily, answeredTargetDirectory, applyAnswersToContract, applyDeclaredRunner, argvInvokesNodeTest, assertNoGitlinkChanges, bindDeclaredCommand, bindPreparedTestCommand, chooseNodeVersion, ciInstallSteps, ciInstallersFor, ciManagerVersions, ciRefusalIsActionable, classicPerTestRefusal, classicPerTestRunner, classifyNxTestTarget, classifyPackageDirectory, classifyPackageManifest, classifyRushTestScript, classifyWithAliasHop, commentLines, committedYarnVersion, contractDescriptors, decideSetupJob, declarableSetupJobs, declaredCommandContext, declaredRunner, declaredRunnerInvocation, declaredSetupJob, declaredSetupPin, denoLockfilePath, denoTestInvocation, deriveCiRecipe, detectJasmineSuite, detectPackageManager, detectPackageManagerContext, detectTarget, detectVitestDir, diskRepoAccess, diskRepoReader, doorSetupJobs, emptyCiRecipe, engineRunner, enumerateNxProjects, enumerateRushProjects, enumerateWorkspacePackages, enumerateWorkspacePackagesOnDisk, initFailureClassOf, initRefusal, initStepRole, invokesNodeTest, isBrowserInstallCommand, isInstallStep, jasmineSuiteFromScript, layerEnvironment, materializeSubmodule, nodeDeclarationProblemReason, nodeSpecVersions, placeSetupStep, policyDeclaration, preRunnerStageCommands, readAnswerValue, readBrowserLane, readDenoProject, readNodeSpec, readWorkflowExpressions, referencesExpression, referencesSecret, refusalRemedyOf, renderConfig, renderEnvironmentBlock, renderWorkflow, repoPath, resolveMeasurementPlan, resolveSetupPin, resolveTarget, resolveTargetDetection, rootAggregatorTasks, runnerInvocation, runnerStageCommand, runnerSuiteInvocations, rushIdentityFiles, rushLockfiles, rushNodeVersionRange, sameJobReference, scriptRunsDenoTest, selectAutoTarget, setupAnswerProblem, setupJobCandidates, setupJobReference, setupScriptSteps, setupStepAnchor, setupStepOrigin, splitAnswerList, suiteStages, systemPackagesStep, testCommandReceipt, testStepLabel, testableWorkspacePackages, unreadableConditionLine, vitestDirIn, withoutPackageRunnerPrefix, workDirPrefix };