@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
- import { mkdir } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { build } from "esbuild";
5
- import { spawnSync } from "node:child_process";
5
+ import { createSign, randomUUID } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
8
+ import { once } from "node:events";
9
+ import { arch, availableParallelism, cpus, platform, release, totalmem } from "node:os";
10
+ import { performance } from "node:perf_hooks";
6
11
  //#region src/actions.ts
7
12
  /**
8
13
  * The canonical Node 24 family shared by factory-project workflows.
@@ -27,6 +32,32 @@ const NODE_PNPM_ACTION_FAMILY_NODE24 = {
27
32
  }
28
33
  };
29
34
  //#endregion
35
+ //#region src/candidate-lifecycle.ts
36
+ /**
37
+ * Pull-request events that can create or refresh a Factory candidate run.
38
+ *
39
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
40
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
41
+ * jobs idle until the pull request is a candidate.
42
+ */
43
+ const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
44
+ "opened",
45
+ "synchronize",
46
+ "reopened",
47
+ "ready_for_review"
48
+ ]);
49
+ /**
50
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
51
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
52
+ */
53
+ const factoryCandidateOrPushCondition = (candidateCondition) => {
54
+ return ["github.event_name == 'push'", `(${[
55
+ "github.event_name == 'pull_request'",
56
+ "github.event.pull_request.draft != true",
57
+ ...candidateCondition ? [candidateCondition] : []
58
+ ].join(" && ")})`].join(" || ");
59
+ };
60
+ //#endregion
30
61
  //#region src/bundle-alchemy-entry.ts
31
62
  /**
32
63
  * Alchemy and Effect must resolve from the *consumer's* node_modules at run
@@ -40,6 +71,50 @@ const ALCHEMY_EXTERNALS = [
40
71
  "effect/*"
41
72
  ];
42
73
  /**
74
+ * The package names behind `ALCHEMY_EXTERNALS`, derived rather than restated so
75
+ * a future external can never be guarded by only one of two lists.
76
+ */
77
+ const RESERVED_ALIAS_PACKAGES = [...new Set(ALCHEMY_EXTERNALS.map((external) => external.replace(/\/\*$/u, "").toLowerCase()))];
78
+ const reservedAliasKey = (key) => RESERVED_ALIAS_PACKAGES.find((name) => key === name || key.startsWith(`${name}/`));
79
+ /**
80
+ * Refuse an alias *key* that is itself a reserved package or one of its
81
+ * subpaths. esbuild substitutes aliases before it decides what is external, so
82
+ * such a key defeats `external` outright. Keys are compared, never resolved,
83
+ * which is what makes string matching sound here.
84
+ */
85
+ const assertAliasKeysAreAdmissible = (alias) => {
86
+ for (const key of Object.keys(alias)) {
87
+ const reserved = reservedAliasKey(key);
88
+ if (reserved) throw new Error(`bundleAlchemyEntry cannot alias "${key}": ${reserved} must stay external because its identity is shared with the consumer's runtime, and esbuild applies aliases before external matching. Point the consumer's own resolution at one copy instead.`);
89
+ }
90
+ };
91
+ /**
92
+ * Whether a bundled input lives inside a reserved package.
93
+ *
94
+ * Metafile inputs are paths esbuild resolved and normalized itself, so
95
+ * comparing whole segments here answers what was *bundled* rather than how the
96
+ * config was spelled. Segments are compared case-insensitively: on a
97
+ * case-insensitive filesystem `node_modules/Effect/…` resolves to the real
98
+ * package and the metafile keeps the caller's spelling.
99
+ */
100
+ const isReservedInput = (input) => {
101
+ const segments = input.toLowerCase().split(/[/\\]+/u);
102
+ return segments.some((segment, position) => position > 0 && segments[position - 1] === "node_modules" && RESERVED_ALIAS_PACKAGES.includes(segment));
103
+ };
104
+ /**
105
+ * Assert the externals contract against the bundle esbuild actually produced.
106
+ *
107
+ * Checking alias *values* instead was unsound by construction: `..` segments,
108
+ * symlinks, and every other spelling of the same file each need another string
109
+ * rule, and the scanner loses. The metafile records the inputs after esbuild's
110
+ * own resolution and normalization, so one check closes the whole class —
111
+ * whatever route reached an identity-sensitive package, it shows up here.
112
+ */
113
+ const assertNoReservedInputs = (inputs) => {
114
+ const offenders = inputs.filter(isReservedInput);
115
+ if (offenders.length > 0) throw new Error(`bundleAlchemyEntry refused a bundle carrying a second copy of an identity-sensitive package: ${offenders.join(", ")}. Those packages must resolve from the consumer's runtime, so nothing may pull their files into the bundle — an alias that re-exports them by bare specifier stays external and is fine.`);
116
+ };
117
+ /**
43
118
  * Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
44
119
  * `effect` external (#268).
45
120
  *
@@ -53,22 +128,26 @@ const ALCHEMY_EXTERNALS = [
53
128
  * Returns the absolute path of the file written.
54
129
  */
55
130
  const bundleAlchemyEntry = async (options) => {
131
+ if (options.alias) assertAliasKeysAreAdmissible(options.alias);
56
132
  const root = options.absWorkingDir ? path.resolve(options.absWorkingDir) : process.cwd();
57
133
  const outfile = path.resolve(root, options.outfile);
58
134
  await mkdir(path.dirname(outfile), { recursive: true });
59
- await build({
135
+ const result = await build({
60
136
  absWorkingDir: root,
61
137
  bundle: true,
62
138
  entryPoints: [path.resolve(root, options.entry)],
63
139
  external: ALCHEMY_EXTERNALS,
64
140
  format: "esm",
141
+ metafile: true,
65
142
  outfile,
66
143
  packages: options.packages ?? "external",
67
144
  platform: "node",
68
145
  sourcemap: options.sourcemap ?? false,
69
146
  target: options.target ?? "node24",
147
+ ...options.alias ? { alias: { ...options.alias } } : {},
70
148
  ...options.tsconfig ? { tsconfig: path.resolve(root, options.tsconfig) } : {}
71
149
  });
150
+ assertNoReservedInputs(Object.keys(result.metafile.inputs));
72
151
  return outfile;
73
152
  };
74
153
  //#endregion
@@ -122,9 +201,18 @@ const localPreviewStage = (options) => {
122
201
  return `local-pr-${pr}-${normalizedHead.slice(0, shaLength)}`;
123
202
  };
124
203
  //#endregion
125
- //#region src/factory-workflow.ts
204
+ //#region src/pinned-action.ts
126
205
  const PINNED_ACTION_PATTERN = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/u;
127
206
  const ACTION_TAG_PATTERN = /^v\d+(?:\.\d+){0,2}$/u;
207
+ const actionRepository = (action) => action.uses.slice(0, action.uses.indexOf("@"));
208
+ /** Shared validator for every action pin emitted by factory-ci helpers. */
209
+ const assertPinnedAction = (name, action, expectedRepository) => {
210
+ if (!PINNED_ACTION_PATTERN.test(action.uses)) throw new Error(`${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`);
211
+ if (expectedRepository && actionRepository(action) !== expectedRepository) throw new Error(`${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`);
212
+ if (!ACTION_TAG_PATTERN.test(action.tag)) throw new Error(`${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`);
213
+ };
214
+ //#endregion
215
+ //#region src/factory-workflow.ts
128
216
  const RESERVED_ACTION_NAMES = new Set([
129
217
  "checkout",
130
218
  "setupNode",
@@ -133,12 +221,6 @@ const RESERVED_ACTION_NAMES = new Set([
133
221
  const assertSingleLine = (name, value) => {
134
222
  if (value.trim() !== value || value.length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} must be a single non-empty line.`);
135
223
  };
136
- const actionRepository = (action) => action.uses.slice(0, action.uses.indexOf("@"));
137
- const assertPinnedAction = (name, action, expectedRepository) => {
138
- if (!PINNED_ACTION_PATTERN.test(action.uses)) throw new Error(`${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`);
139
- if (expectedRepository && actionRepository(action) !== expectedRepository) throw new Error(`${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`);
140
- if (!ACTION_TAG_PATTERN.test(action.tag)) throw new Error(`${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`);
141
- };
142
224
  const usesStep = (fallbackName, action) => ({
143
225
  name: fallbackName,
144
226
  uses: action.uses
@@ -155,15 +237,26 @@ const assertAdditionalActions = (actions) => {
155
237
  assertPinnedAction(name, action);
156
238
  }
157
239
  };
240
+ const checkoutInputs = (checkout) => {
241
+ if (checkout === void 0) return;
242
+ const inputs = {};
243
+ if (checkout.fetchDepth !== void 0) {
244
+ if (!Number.isSafeInteger(checkout.fetchDepth) || checkout.fetchDepth < 0) throw new Error("checkout.fetchDepth must be a non-negative integer.");
245
+ inputs["fetch-depth"] = String(checkout.fetchDepth);
246
+ }
247
+ if (checkout.ref) inputs.ref = checkout.ref;
248
+ return Object.keys(inputs).length > 0 ? inputs : void 0;
249
+ };
158
250
  const setupSteps = (family, setup = {}) => {
159
251
  const installRun = setup.install?.run ?? "pnpm install --frozen-lockfile";
160
252
  if (/(?:^|\s)--ignore-scripts(?:\s|$)/u.test(installRun)) throw new Error("The install step must not use --ignore-scripts; workspace prepare scripts build required package artifacts.");
161
253
  const { checkout, setupNode } = setup;
254
+ const checkoutWith = checkoutInputs(checkout);
162
255
  return Object.freeze([
163
256
  {
164
257
  name: checkout?.name ?? "Checkout",
165
258
  uses: family.checkout.uses,
166
- ...checkout?.ref ? { with: { ref: checkout.ref } } : {}
259
+ ...checkoutWith ? { with: checkoutWith } : {}
167
260
  },
168
261
  usesStep("Setup pnpm", family.setupPnpm),
169
262
  {
@@ -210,6 +303,95 @@ const factoryWorkflow = (options) => {
210
303
  });
211
304
  };
212
305
  //#endregion
306
+ //#region src/github-app-token.ts
307
+ /**
308
+ * Minting a GitHub App installation token: the RS256 app JWT, the optional
309
+ * installation lookup, and the token exchange (#617).
310
+ *
311
+ * Two projects had grown the same three steps independently — the factory's
312
+ * check-run publisher and paitronage's proof-comment publisher — which is the
313
+ * admitted-on-repetition bar. Only the *mechanism* lives here. Where the
314
+ * private key comes from, how the app id is configured, and what the token is
315
+ * then used for stay with each consumer: this module is handed credentials and
316
+ * returns a token.
317
+ */
318
+ /** The default request budget, matching the factory's other GitHub writes. */
319
+ const DEFAULT_TIMEOUT_MS = 5e3;
320
+ /** Nine-minute JWT lifetime, backdated a minute against runner clock skew. */
321
+ const JWT_BACKDATE_SECONDS = 60;
322
+ const JWT_LIFETIME_SECONDS = 600;
323
+ /** Carries the HTTP status so a caller can tell a retryable failure apart. */
324
+ var GitHubApiError = class extends Error {
325
+ status;
326
+ constructor(status, statusText) {
327
+ super(`GitHub API ${status} ${statusText}`);
328
+ this.name = "GitHubApiError";
329
+ this.status = status;
330
+ }
331
+ };
332
+ const base64url = (value) => Buffer.from(value).toString("base64url");
333
+ /**
334
+ * The signed app JWT GitHub accepts as `Authorization: Bearer` for the App
335
+ * endpoints. `iss` is stringified because GitHub accepts either spelling and a
336
+ * numeric app id must not depend on JSON's number formatting.
337
+ *
338
+ * The signature is produced from the key on disk and returned; the key
339
+ * material itself never leaves this call.
340
+ */
341
+ const githubAppJwt = (credentials, options = {}) => {
342
+ const nowMs = (options.now ?? Date.now)();
343
+ const issuedAt = Math.floor(nowMs / 1e3) - JWT_BACKDATE_SECONDS;
344
+ const unsigned = `${base64url(JSON.stringify({
345
+ alg: "RS256",
346
+ typ: "JWT"
347
+ }))}.${base64url(JSON.stringify({
348
+ exp: issuedAt + JWT_LIFETIME_SECONDS,
349
+ iat: issuedAt,
350
+ iss: String(credentials.appId)
351
+ }))}`;
352
+ const readKey = options.readPrivateKey ?? readFileSync;
353
+ const signer = createSign("RSA-SHA256");
354
+ signer.update(unsigned);
355
+ signer.end();
356
+ return `${unsigned}.${signer.sign(readKey(credentials.privateKeyPath), "base64url")}`;
357
+ };
358
+ const githubAppJson = async (request, url, jwt, method, timeoutMs) => {
359
+ const response = await request(url, {
360
+ headers: {
361
+ Accept: "application/vnd.github+json",
362
+ Authorization: `Bearer ${jwt}`,
363
+ "X-GitHub-Api-Version": "2022-11-28"
364
+ },
365
+ method,
366
+ signal: AbortSignal.timeout(timeoutMs)
367
+ });
368
+ if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
369
+ return await response.json();
370
+ };
371
+ /**
372
+ * Mint an installation access token for one repository.
373
+ *
374
+ * When the credentials omit `installationId`, the installation is discovered
375
+ * from the repository first — the same call every consumer had written for
376
+ * itself. Nothing is cached: the token is returned to the caller and this
377
+ * module keeps no copy.
378
+ */
379
+ const mintInstallationToken = async (input, options = {}) => {
380
+ const { credentials } = input;
381
+ const request = options.fetch ?? fetch;
382
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
383
+ const jwt = githubAppJwt(credentials, options);
384
+ let { installationId } = credentials;
385
+ if (installationId === void 0) {
386
+ const installation = await githubAppJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/installation`, jwt, "GET", timeoutMs);
387
+ if (typeof installation.id !== "number") throw new TypeError("GitHub App installation response omitted id");
388
+ installationId = installation.id;
389
+ }
390
+ const minted = await githubAppJson(request, `https://api.github.com/app/installations/${installationId}/access_tokens`, jwt, "POST", timeoutMs);
391
+ if (typeof minted.token !== "string") throw new TypeError("GitHub App token response omitted token");
392
+ return minted.token;
393
+ };
394
+ //#endregion
213
395
  //#region src/execute-alchemy-entry.ts
214
396
  /**
215
397
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
@@ -261,9 +443,9 @@ const executeAlchemyEntry = async (options) => {
261
443
  * Escaped so the emitted text carries a GitHub Actions expression rather than
262
444
  * this file carrying a JavaScript template hole.
263
445
  */
264
- const githubExpression = (expression) => `\${{ ${expression} }}`;
446
+ const githubExpression$1 = (expression) => `\${{ ${expression} }}`;
265
447
  /** A braced shell expansion that has to survive TypeScript interpolation. */
266
- const shellExpansion = (expression) => `\${${expression}}`;
448
+ const shellExpansion$1 = (expression) => `\${${expression}}`;
267
449
  /**
268
450
  * Single-quote a value for the emitted script. Every non-literal value that
269
451
  * reaches the script goes through this.
@@ -293,6 +475,13 @@ const FACTORY_PROOF_GATE_APP_ID = "4314840";
293
475
  /** Step id the guard condition refers to. */
294
476
  const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
295
477
  /**
478
+ * Human-visible name of the gate step as the Actions jobs API serves it. A
479
+ * read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
480
+ * classify a run as proof-reuse versus full fallback, so it is exported from
481
+ * exactly the module that writes it — matching on a re-typed copy would drift.
482
+ */
483
+ const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
484
+ /**
296
485
  * The shell the gate runs under, and it is a correctness requirement rather
297
486
  * than a preference.
298
487
  *
@@ -329,6 +518,8 @@ const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
329
518
  * proof that executed every command the surface requires is reusable.
330
519
  */
331
520
  const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
521
+ /** Exact Checks API URL of the proof generation selected by the gate. */
522
+ const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
332
523
  /**
333
524
  * Guard for every step the gate protects. Deliberately `!= 'true'` and not
334
525
  * `== 'false'`: an unset, empty, or garbled output must run the suite.
@@ -349,7 +540,8 @@ const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
349
540
  * - `pending` the newest generation had not completed when this job read it
350
541
  * - `failed` the newest generation records no pass
351
542
  * - `unreadable` it passed but carries no binding for this repository and head
352
- * - `incomplete` it passed but did not execute every required command
543
+ * - `incomplete` its executed plus stamp-authorized released commands do not
544
+ * cover every required command
353
545
  * - `ambiguous` two newest generations share the greatest start time
354
546
  * - `error` the gate could not reach a decision (fail open)
355
547
  *
@@ -383,6 +575,13 @@ const FACTORY_PROOF_GATE_REASONS = [
383
575
  */
384
576
  const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
385
577
  const COMMAND_IDENTITY_MAX_LENGTH = 120;
578
+ /**
579
+ * The impact-stamp interpretation the paired factory packages currently
580
+ * share. A release recorded under any other version is an unmodelled input and
581
+ * cannot subtract hosted work. This is intentionally fail-closed and moves in
582
+ * lockstep with the proof/check-payload producer.
583
+ */
584
+ const TRUSTED_IMPACT_STAMP_VERSION = 3;
386
585
  const isProofReuseCommand = (value) => {
387
586
  if (!(value && typeof value === "object")) return false;
388
587
  const entry = value;
@@ -411,8 +610,46 @@ const proofReuseRequiredCommands = (commands) => {
411
610
  return names.toSorted();
412
611
  };
413
612
  /**
613
+ * Resolve command *identities* to their `ProofReuseCommand` objects (ADR
614
+ * 0021).
615
+ *
616
+ * Selection by name is deliberately a consumer decision (proof-surfaces.ts in
617
+ * software-factory-hq, the `paitronage:verify` filter in paitronage's
618
+ * `verify.ts`): only the consumer knows which named commands a guarded
619
+ * surface requires. What both of those implementations independently
620
+ * hand-rolled is the same lookup — find each name in the profile's command
621
+ * catalog, and refuse to silently shrink the required set when a name has no
622
+ * entry. That lookup is what this function is: the mechanic, not the
623
+ * selection.
624
+ *
625
+ * Throwing at generation time (rather than returning `undefined` or an empty
626
+ * array) is deliberate: a name with no catalog entry is a mistake in the
627
+ * generator source, not a runtime condition a consumer should have to check
628
+ * for, and a required set that quietly loses an entry is exactly what makes a
629
+ * passing proof trivially "covering".
630
+ *
631
+ * `selectionLabel` names the failure, nothing else: it is not part of the
632
+ * selection this function resolves, only prose a consumer supplies for its
633
+ * own thrown error (e.g. HQ's surface name, "core" or "docs"). The message
634
+ * deliberately says "the profile's command catalog" rather than naming
635
+ * `software-factory.profile.json`: a fleet-generic library must not assume
636
+ * every consumer's catalog is that exact file, so this wording differs
637
+ * on purpose from the HQ-local message it replaced.
638
+ */
639
+ const resolveProofReuseCommands = (catalog, names, selectionLabel) => names.map((name) => {
640
+ const command = catalog.find((entry) => entry.name === name);
641
+ if (!command) {
642
+ const location = selectionLabel ? `Proof-reuse selection for the ${selectionLabel} surface names` : "Proof-reuse selection names";
643
+ throw new Error(`${location} "${name}", which the profile's command catalog does not define.`);
644
+ }
645
+ return {
646
+ command: command.command,
647
+ name: command.name
648
+ };
649
+ });
650
+ /**
414
651
  * jq program: every page of the Checks API result in, three sanitized lines
415
- * (`reason`, `mode`, missing commands) out.
652
+ * (`reason`, `mode`, uncovered commands) out.
416
653
  *
417
654
  * The input is what `gh api --paginate` actually writes: the pages
418
655
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -469,6 +706,20 @@ def startedAt: (.started_at // "") | tostring;
469
706
  def rankable:
470
707
  test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
471
708
 
709
+ def nonemptyString:
710
+ type == "string" and length > 0;
711
+
712
+ def distinctNames:
713
+ (map(.name) | length) == (map(.name) | unique | length);
714
+
715
+ def releaseAuthorized($release; $commands; $stamp):
716
+ ([ $commands[] | select(.name == $release.name) ]) as $commandRows
717
+ | ([ $stamp.targets[] | select(.name == $release.impactTarget) ]) as $targetRows
718
+ | ($commandRows | length) == 1
719
+ and $commandRows[0].impactTarget == $release.impactTarget
720
+ and ($targetRows | length) == 1
721
+ and $targetRows[0].impact == "not-affected";
722
+
472
723
  [ .[]
473
724
  | (.check_runs // [])[]
474
725
  | select(.name == $name)
@@ -476,20 +727,60 @@ def rankable:
476
727
  | { started: startedAt,
477
728
  status: (.status // ""),
478
729
  conclusion: (.conclusion // ""),
730
+ url: ((.html_url // "") | tostring),
479
731
  binding: binding }
480
732
  ] as $runs
481
733
  | ($runs | map(.started | rankable) | all) as $orderable
482
734
  | ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
483
735
  | [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
484
- | (if ($runs | length) == 0 then ["none", "", ""]
485
- elif ($orderable | not) then ["ambiguous", "", ""]
486
- elif ($generation | length) != 1 then ["ambiguous", "", ""]
736
+ | (if ($runs | length) == 0 then ["none", "", "", ""]
737
+ elif ($orderable | not) then ["ambiguous", "", "", ""]
738
+ elif ($generation | length) != 1 then ["ambiguous", "", "", ""]
487
739
  else
488
740
  $generation[0] as $run
489
741
  | (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
490
742
  | (($proof.mode // "") | tostring) as $mode
491
743
  | (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
492
- | ($required - $executed) as $missing
744
+ | ($proof | has("notRequiredCommands")) as $hasReleases
745
+ | (if $hasReleases then $proof.notRequiredCommands else [] end) as $releases
746
+ | ($proof.verificationCommands // null) as $commands
747
+ | ($proof.impactStamp // null) as $stamp
748
+ | (if ($hasReleases | not) then true
749
+ else
750
+ ($proof.proofSchemaVersion == 4)
751
+ and ($releases | type) == "array"
752
+ and ($releases | length) > 0
753
+ and ($releases | all(.[];
754
+ type == "object"
755
+ and (.name | nonemptyString)
756
+ and (.impactTarget | nonemptyString)
757
+ and (.basis | nonemptyString)))
758
+ and ($releases | distinctNames)
759
+ and ($commands | type) == "array"
760
+ and ($commands | all(.[];
761
+ type == "object"
762
+ and (.name | nonemptyString)
763
+ and ((has("impactTarget") | not) or (.impactTarget | nonemptyString))))
764
+ and ($commands | distinctNames)
765
+ and ($stamp | type) == "object"
766
+ and ($stamp.stampVersion == ${TRUSTED_IMPACT_STAMP_VERSION})
767
+ and ($stamp.basis == "target-scoped")
768
+ and (($stamp.reasons | type) == "array")
769
+ and ($stamp.reasons | all(.[]; type == "string"))
770
+ and (($stamp.unsubscribedPaths | type) == "array")
771
+ and ($stamp.unsubscribedPaths | all(.[]; type == "string"))
772
+ and (($stamp.targets | type) == "array")
773
+ and ($stamp.targets | all(.[];
774
+ type == "object"
775
+ and (.name | nonemptyString)
776
+ and (.basis | nonemptyString)
777
+ and ((.impact == "affected") or (.impact == "not-affected"))))
778
+ and ($stamp.targets | distinctNames)
779
+ and (([$releases[].name] - $executed | length) == ($releases | length))
780
+ and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
781
+ end) as $releasesValid
782
+ | (if $releasesValid then [$releases[].name] else [] end) as $released
783
+ | ($required - $executed - $released) as $missing
493
784
  | (if $run.status != "completed" then "pending"
494
785
  elif $run.conclusion != "success" then "failed"
495
786
  elif (($run.binding | type) != "object")
@@ -499,10 +790,11 @@ def rankable:
499
790
  or ($proof.repository != $repository) then "unreadable"
500
791
  elif $proof.outcome != "passed" then "failed"
501
792
  elif ($required | length) == 0 then "incomplete"
793
+ elif ($releasesValid | not) then "incomplete"
502
794
  elif ($missing | length) != 0 then "incomplete"
503
795
  else "proven"
504
796
  end) as $reason
505
- | [$reason, $mode, ($missing | join(", "))]
797
+ | [$reason, $mode, ($missing | join(", ")), $run.url]
506
798
  end)
507
799
  | map(gsub("[\\r\\n\\t]"; " "))
508
800
  | join("\n")
@@ -527,9 +819,9 @@ const CORRECTIVE_LINE = "Run `psf pr:verify` before publishing to reuse local pr
527
819
  const UNUSABLE_SELECTION_SCRIPT = String.raw`{
528
820
  printf '${FACTORY_PROOF_GATE_OUTPUT}=false\n'
529
821
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=error\n'
530
- } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"`;
822
+ } >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"`;
531
823
  /** Surface labels reach markdown, so only a plain, bounded label survives. */
532
- const safeLabel = (surface) => {
824
+ const safeLabel$1 = (surface) => {
533
825
  const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
534
826
  return cleaned.length > 0 ? cleaned : "verification";
535
827
  };
@@ -545,14 +837,15 @@ reason=error
545
837
  detail=''
546
838
  mode=''
547
839
  missing=''
840
+ source_url=''
548
841
 
549
842
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
550
843
  # default "latest" filter is ordered by completion, so a newer generation
551
844
  # that is still running can be hidden behind an older completed one — the
552
845
  # gate would then read a stale pass as current.
553
- if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
846
+ if [ -z "${shellExpansion$1("HEAD_SHA:-")}" ]; then
554
847
  detail='no pull request head SHA'
555
- elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
848
+ elif [ -z "${shellExpansion$1("GITHUB_REPOSITORY:-")}" ]; then
556
849
  detail='no repository name'
557
850
  elif ! response=$(gh api --method GET --paginate \
558
851
  "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
@@ -583,6 +876,7 @@ else
583
876
  IFS= read -r reason || :
584
877
  IFS= read -r mode || :
585
878
  IFS= read -r missing || :
879
+ IFS= read -r source_url || :
586
880
  } <<< "$finding"
587
881
  case "$reason" in
588
882
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
@@ -609,8 +903,11 @@ fi
609
903
  # line so it cannot restructure the summary it is written into.
610
904
  detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
611
905
  missing=$(printf '%s' "$missing" | tr '\n\r\t' ' ' | cut -c1-240)
906
+ if ! [[ "$source_url" =~ ^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/runs/[0-9]+$ ]]; then
907
+ source_url=''
908
+ fi
612
909
 
613
- SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
910
+ SUMMARY="${shellExpansion$1("GITHUB_STEP_SUMMARY:-/dev/null")}"
614
911
  say() { printf '%s\n' "$1" >> "$SUMMARY"; }
615
912
 
616
913
  say "## Proof reuse: $SURFACE"
@@ -621,7 +918,11 @@ case "$reason" in
621
918
  echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
622
919
  say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
623
920
  say ''
624
- say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
921
+ if [ -n "$source_url" ]; then
922
+ say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
923
+ else
924
+ say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
925
+ fi
625
926
  say "- Covers head: \`$HEAD_SHA\`"
626
927
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
627
928
  ;;
@@ -656,7 +957,7 @@ case "$reason" in
656
957
  incomplete)
657
958
  say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
658
959
  say ''
659
- say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`executedCommands\` is missing: \`$missing\`"
960
+ say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its executed commands plus stamp-authorized \`notRequiredCommands\` do not cover this surface. Missing: \`$missing\`"
660
961
  say ''
661
962
  say '${CORRECTIVE_LINE}'
662
963
  ;;
@@ -678,7 +979,7 @@ esac
678
979
  say ''
679
980
 
680
981
  if [ "$verdict" != 'true' ]; then
681
- echo "No reusable factory proof for ${shellExpansion("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
982
+ echo "No reusable factory proof for ${shellExpansion$1("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
682
983
  fi
683
984
 
684
985
  # The only write. A crash before this line leaves the output unset, the guard
@@ -687,7 +988,8 @@ fi
687
988
  printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
688
989
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
689
990
  printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
690
- } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"
991
+ printf '${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}=%s\n' "$source_url"
992
+ } >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"
691
993
  `.trim();
692
994
  /**
693
995
  * The gate script. Exported so it can be executed directly under test against
@@ -703,7 +1005,7 @@ fi
703
1005
  */
704
1006
  const factoryProofGateScript = ({ commands, surface }) => {
705
1007
  const required = proofReuseRequiredCommands(commands);
706
- return required ? gateScript(required, safeLabel(surface)) : UNUSABLE_SELECTION_SCRIPT;
1008
+ return required ? gateScript(required, safeLabel$1(surface)) : UNUSABLE_SELECTION_SCRIPT;
707
1009
  };
708
1010
  /**
709
1011
  * The step itself, structurally accepted by gagen's `step()` without adding a
@@ -721,12 +1023,12 @@ const factoryProofGateScript = ({ commands, surface }) => {
721
1023
  const factoryProofGateStep = (options) => Object.freeze({
722
1024
  continueOnError: true,
723
1025
  env: Object.freeze({
724
- GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
725
- HEAD_SHA: githubExpression("github.event.pull_request.head.sha")
1026
+ GH_TOKEN: githubExpression$1("secrets.GITHUB_TOKEN"),
1027
+ HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha")
726
1028
  }),
727
1029
  id: FACTORY_PROOF_GATE_STEP_ID,
728
1030
  if: FACTORY_PROOF_GATE_IF,
729
- name: "Check for factory proof of this head",
1031
+ name: FACTORY_PROOF_GATE_STEP_NAME,
730
1032
  run: factoryProofGateScript(options),
731
1033
  shell: FACTORY_PROOF_GATE_SHELL
732
1034
  });
@@ -754,9 +1056,1202 @@ const proofReuseCoverage = ({ commands, skipped }) => {
754
1056
  const assertProofReuseCoverage = (input) => {
755
1057
  const report = proofReuseCoverage(input);
756
1058
  if (report.covered) return report;
757
- const surface = safeLabel(input.surface ?? "");
1059
+ const surface = safeLabel$1(input.surface ?? "");
758
1060
  const problem = report.requiredCommands.length === 0 ? "selects no usable profile commands, so any passing proof would trivially cover it" : `skips work no selected profile command runs: ${report.uncovered.join(", ")}`;
759
1061
  throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
760
1062
  };
761
1063
  //#endregion
762
- export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, NODE_PNPM_ACTION_FAMILY_NODE24, assertProofReuseCoverage, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands };
1064
+ //#region src/proof-reuse-presentation.ts
1065
+ /** Generic timing and presentation steps around the proof-reuse gate (#652). */
1066
+ const githubExpression = (expression) => `\${{ ${expression} }}`;
1067
+ const shellExpansion = (expression) => `\${${expression}}`;
1068
+ const safeLabel = (surface) => {
1069
+ const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
1070
+ return cleaned.length > 0 ? cleaned : "verification";
1071
+ };
1072
+ const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
1073
+ const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
1074
+ const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME = "Record proof-reuse timing";
1075
+ const factoryProofTimingStartStep = () => Object.freeze({
1076
+ continueOnError: true,
1077
+ id: FACTORY_PROOF_TIMING_STEP_ID,
1078
+ name: FACTORY_PROOF_TIMING_START_STEP_NAME,
1079
+ run: String.raw`started_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
1080
+ printf 'started_ms=%s\n' "$started_ms" >> "$GITHUB_OUTPUT"`
1081
+ });
1082
+ const factoryProofReuseSummaryScript = ({ surface }) => {
1083
+ const label = safeLabel(surface);
1084
+ return String.raw`now_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
1085
+ ci_started_ms="${shellExpansion("CI_STARTED_MS:-")}"
1086
+
1087
+ SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
1088
+ say() { printf '%s\n' "$1" >> "$SUMMARY"; }
1089
+
1090
+ say '## ${label} timing'
1091
+ say ''
1092
+
1093
+ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
1094
+ say '- Path: merge-target full execution'
1095
+ say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
1096
+ say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
1097
+ echo "::notice title=Factory proof reuse::Proof reuse is not applicable on merge-target runs; the full ${label} suite executed."
1098
+ elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
1099
+ say '- Path: trusted local proof reused'
1100
+ if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
1101
+ say "- Reused proof: [exact source check](${shellExpansion("PROOF_SOURCE_URL")})"
1102
+ fi
1103
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
1104
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
1105
+ echo "::notice title=Factory proof reuse::Reused trusted local proof for ${label} at ${shellExpansion("PROOF_HEAD_SHA:-unknown")}."
1106
+ else
1107
+ say "- Path: full GitHub CI fallback (${shellExpansion("PROOF_REASON:-error")})"
1108
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
1109
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
1110
+ echo "::notice title=Factory proof reuse::No reusable proof for ${label}; the full hosted suite executed (${shellExpansion("PROOF_REASON:-error")})."
1111
+ fi
1112
+
1113
+ if [[ "$ci_started_ms" =~ ^[0-9]+$ ]]; then
1114
+ say "- GitHub workflow execution after the proof gate: $((now_ms - ci_started_ms)) ms"
1115
+ else
1116
+ say '- GitHub workflow execution after the proof gate: unavailable'
1117
+ fi
1118
+ say ''`;
1119
+ };
1120
+ const factoryProofReuseSummaryStep = (options) => Object.freeze({
1121
+ continueOnError: true,
1122
+ env: Object.freeze({
1123
+ CI_STARTED_MS: githubExpression(`steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`),
1124
+ PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
1125
+ PROOF_MODE: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`),
1126
+ PROOF_REASON: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_REASON_OUTPUT}`),
1127
+ PROOF_REUSED: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT}`),
1128
+ PROOF_SOURCE_URL: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}`)
1129
+ }),
1130
+ if: "always()",
1131
+ name: FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
1132
+ run: factoryProofReuseSummaryScript(options)
1133
+ });
1134
+ //#endregion
1135
+ //#region src/production-impact-workflow.ts
1136
+ const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
1137
+ const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
1138
+ const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
1139
+ const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
1140
+ const OUTPUT_NAME_PATTERN = /[^a-z0-9_]+/gu;
1141
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1142
+ /** Stable GitHub-output key for one declared target. */
1143
+ const productionImpactTargetOutput = (targetName) => {
1144
+ const normalized = targetName.toLowerCase().replaceAll(OUTPUT_NAME_PATTERN, "_").replaceAll(/^_+|_+$/gu, "");
1145
+ if (normalized.length === 0) throw new Error(`Impact target name "${targetName}" has no output-safe characters.`);
1146
+ return `target_${normalized}`;
1147
+ };
1148
+ /**
1149
+ * Generate the small factory-owned decision seam for a consumer production
1150
+ * workflow. Consumers retain jobs, deploy commands, credentials, topology,
1151
+ * and convergence checks; this artifact supplies only the decision step and
1152
+ * per-target withdrawal conditions.
1153
+ */
1154
+ const factoryProductionImpactWorkflow = (options) => {
1155
+ const targetOutputs = Object.fromEntries(options.targets.map((target) => [target, productionImpactTargetOutput(target)]));
1156
+ if (new Set(Object.values(targetOutputs)).size !== options.targets.length) throw new Error("Impact target names must map to distinct GitHub output keys.");
1157
+ const before = options.before ?? `\${{ github.event.before }}`;
1158
+ const after = options.after ?? `\${{ github.sha }}`;
1159
+ const cli = options.cli ?? "pnpm exec psf";
1160
+ const profile = options.profilePath ? ` --profile ${shellQuote(options.profilePath)}` : "";
1161
+ const decisionStep = {
1162
+ continueOnError: true,
1163
+ env: {
1164
+ FACTORY_AFTER_SHA: after,
1165
+ FACTORY_BEFORE_SHA: before
1166
+ },
1167
+ id: FACTORY_PRODUCTION_IMPACT_STEP_ID,
1168
+ name: "Classify production impact",
1169
+ run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
1170
+ };
1171
+ return Object.freeze({
1172
+ decisionJobOutputs: Object.freeze({
1173
+ basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
1174
+ decision: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} }}`,
1175
+ unsubscribed_paths: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
1176
+ ...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${output} }}`]))
1177
+ }),
1178
+ decisionStep: Object.freeze(decisionStep),
1179
+ demandedIf: (targetName, decisionJob) => {
1180
+ const output = targetOutputs[targetName];
1181
+ if (output === void 0) throw new Error(`Unknown production impact target "${targetName}".`);
1182
+ const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}`;
1183
+ const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
1184
+ return decisionJob ? `always() && (${condition})` : condition;
1185
+ },
1186
+ targetOutputs: Object.freeze(targetOutputs)
1187
+ });
1188
+ };
1189
+ //#endregion
1190
+ //#region src/push-identity-workflow.ts
1191
+ const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
1192
+ const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
1193
+ const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID = "factory_push_identity_record";
1194
+ const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID = "factory_push_identity_lookup";
1195
+ const FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID = "factory_push_identity_download";
1196
+ const FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID = "factory_push_identity_checkout";
1197
+ const FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID = "factory_push_identity";
1198
+ const ARTIFACT_MAX_BYTES = 16384;
1199
+ const ENVELOPE_MAX_BYTES = 4096;
1200
+ const expression = (value) => `\${{ ${value} }}`;
1201
+ const artifactName = (runId) => `${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-${runId}`;
1202
+ const producerScript = String.raw`identity="$RUNNER_TEMP/factory-push-identity.json"
1203
+ produced=false
1204
+ reason='producer_error'
1205
+ before=''
1206
+ event_after=''
1207
+ rm -f "$identity"
1208
+
1209
+ is_hex_sha() {
1210
+ [ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
1211
+ case "$1" in *[!0-9a-f]*) false;; *) true;; esac
1212
+ }
1213
+
1214
+ is_nonzero_sha() {
1215
+ is_hex_sha "$1" && [ "$1" != '0000000000000000000000000000000000000000' ]
1216
+ }
1217
+
1218
+ if [ "$GITHUB_EVENT_NAME" != 'push' ]; then
1219
+ reason='not_push_event'
1220
+ elif ! printf '%s' "$GITHUB_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
1221
+ reason='repository_invalid'
1222
+ elif ! printf '%s' "$GITHUB_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
1223
+ reason='run_id_invalid'
1224
+ elif ! before="$(jq -er '.before | select(type == "string")' "$GITHUB_EVENT_PATH" 2>/dev/null)"; then
1225
+ reason='event_before_missing'
1226
+ elif ! event_after="$(jq -er '.after | select(type == "string")' "$GITHUB_EVENT_PATH" 2>/dev/null)"; then
1227
+ reason='event_after_missing'
1228
+ elif ! is_hex_sha "$before"; then
1229
+ reason='event_before_invalid'
1230
+ elif ! is_nonzero_sha "$event_after" || ! is_nonzero_sha "$GITHUB_SHA"; then
1231
+ reason='event_after_invalid'
1232
+ elif [ "$event_after" != "$GITHUB_SHA" ]; then
1233
+ reason='event_after_mismatch'
1234
+ elif jq -cn \
1235
+ --argjson schemaVersion '${1}' \
1236
+ --arg repository "$GITHUB_REPOSITORY" \
1237
+ --arg runId "$GITHUB_RUN_ID" \
1238
+ --arg before "$before" \
1239
+ --arg after "$event_after" \
1240
+ '{schemaVersion: $schemaVersion, repository: $repository, runId: $runId, before: $before, after: $after}' > "$identity"; then
1241
+ produced=true
1242
+ reason='produced'
1243
+ fi
1244
+
1245
+ printf 'produced=%s\nreason=%s\n' "$produced" "$reason" >> "$GITHUB_OUTPUT"
1246
+ {
1247
+ printf '## Exact push identity producer\n\n'
1248
+ printf -- '- Status: %s\n' "$reason"
1249
+ printf -- '- Repository/run: %s / %s\n' "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID"
1250
+ if [ "$produced" = true ]; then
1251
+ printf -- '- Bound push: %s → %s\n' "$before" "$event_after"
1252
+ else
1253
+ printf -- '- Identity transport was not produced; downstream classification will refuse withdrawal.\n'
1254
+ fi
1255
+ } >> "$GITHUB_STEP_SUMMARY"`;
1256
+ const lookupScript = String.raw`status='refused'
1257
+ reason='artifact_lookup_failed'
1258
+ artifact_id=''
1259
+ matches="$RUNNER_TEMP/factory-push-identity-artifacts.tsv"
1260
+ artifact_name='${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-'"$EXPECTED_RUN_ID"
1261
+ rm -f "$matches"
1262
+
1263
+ is_sha() {
1264
+ [ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
1265
+ [ "$1" != '0000000000000000000000000000000000000000' ] &&
1266
+ case "$1" in *[!0-9a-f]*) false;; *) true;; esac
1267
+ }
1268
+
1269
+ if [ "$EXPECTED_EVENT_NAME" != 'workflow_run' ]; then
1270
+ reason='event_not_workflow_run'
1271
+ elif [ "$EXPECTED_WORKFLOW_EVENT" != 'push' ]; then
1272
+ reason='triggering_workflow_not_push'
1273
+ elif [ "$EXPECTED_CONCLUSION" != 'success' ]; then
1274
+ reason='triggering_workflow_not_successful'
1275
+ elif [ "$EXPECTED_WORKFLOW_REPOSITORY" != "$EXPECTED_REPOSITORY" ]; then
1276
+ reason='triggering_repository_mismatch'
1277
+ elif ! printf '%s' "$EXPECTED_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
1278
+ reason='triggering_repository_invalid'
1279
+ elif ! printf '%s' "$EXPECTED_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
1280
+ reason='triggering_run_id_invalid'
1281
+ elif ! is_sha "$EXPECTED_AFTER_SHA"; then
1282
+ reason='triggering_head_sha_invalid'
1283
+ elif ! gh api --paginate \
1284
+ "repos/$EXPECTED_REPOSITORY/actions/runs/$EXPECTED_RUN_ID/artifacts?per_page=100" \
1285
+ --jq ".artifacts[] | select(.name == \"$artifact_name\") | [.id, .size_in_bytes, .expired] | @tsv" \
1286
+ > "$matches" 2>/dev/null; then
1287
+ reason='artifact_lookup_failed'
1288
+ else
1289
+ count="$(awk 'END { print NR + 0 }' "$matches")"
1290
+ if [ "$count" -eq 0 ]; then
1291
+ reason='artifact_missing'
1292
+ elif [ "$count" -ne 1 ]; then
1293
+ reason='artifact_duplicate'
1294
+ else
1295
+ IFS="$(printf '\t')" read -r artifact_id artifact_size artifact_expired < "$matches" || true
1296
+ if ! printf '%s' "$artifact_id" | grep -Eq '^[1-9][0-9]*$' ||
1297
+ ! printf '%s' "$artifact_size" | grep -Eq '^[0-9]+$' ||
1298
+ { [ "$artifact_expired" != 'false' ] && [ "$artifact_expired" != 'true' ]; }; then
1299
+ reason='artifact_metadata_invalid'
1300
+ artifact_id=''
1301
+ elif [ "$artifact_expired" = 'true' ]; then
1302
+ reason='artifact_expired'
1303
+ artifact_id=''
1304
+ elif [ "$artifact_size" -gt '${ARTIFACT_MAX_BYTES}' ]; then
1305
+ reason='artifact_oversized'
1306
+ artifact_id=''
1307
+ else
1308
+ status='available'
1309
+ reason='artifact_available'
1310
+ fi
1311
+ fi
1312
+ fi
1313
+
1314
+ printf 'status=%s\nreason=%s\nartifact_id=%s\n' "$status" "$reason" "$artifact_id" >> "$GITHUB_OUTPUT"`;
1315
+ const validationScript = String.raw`disposition='refused'
1316
+ reason='validation_error'
1317
+ before=''
1318
+ after=''
1319
+ identity_directory="$RUNNER_TEMP/factory-push-identity"
1320
+ identity="$identity_directory/factory-push-identity.json"
1321
+ artifact_name='${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-'"$EXPECTED_RUN_ID"
1322
+
1323
+ is_sha() {
1324
+ [ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
1325
+ [ "$1" != '0000000000000000000000000000000000000000' ] &&
1326
+ case "$1" in *[!0-9a-f]*) false;; *) true;; esac
1327
+ }
1328
+
1329
+ if [ "$EXPECTED_EVENT_NAME" != 'workflow_run' ]; then
1330
+ reason='event_not_workflow_run'
1331
+ elif [ "$EXPECTED_WORKFLOW_EVENT" != 'push' ]; then
1332
+ reason='triggering_workflow_not_push'
1333
+ elif [ "$EXPECTED_CONCLUSION" != 'success' ]; then
1334
+ reason='triggering_workflow_not_successful'
1335
+ elif [ "$EXPECTED_WORKFLOW_REPOSITORY" != "$EXPECTED_REPOSITORY" ]; then
1336
+ reason='triggering_repository_mismatch'
1337
+ elif ! printf '%s' "$EXPECTED_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
1338
+ reason='triggering_repository_invalid'
1339
+ elif ! printf '%s' "$EXPECTED_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
1340
+ reason='triggering_run_id_invalid'
1341
+ elif ! is_sha "$EXPECTED_AFTER_SHA"; then
1342
+ reason='triggering_head_sha_invalid'
1343
+ elif [ "$LOOKUP_STATUS" != 'available' ]; then
1344
+ if [ -n "$LOOKUP_REASON" ]; then
1345
+ reason="$LOOKUP_REASON"
1346
+ else
1347
+ reason='artifact_lookup_failed'
1348
+ fi
1349
+ elif [ "$DOWNLOAD_OUTCOME" != 'success' ]; then
1350
+ reason='artifact_download_failed'
1351
+ elif [ "$CHECKOUT_OUTCOME" != 'success' ]; then
1352
+ reason='checkout_failed'
1353
+ elif [ ! -d "$identity_directory" ]; then
1354
+ reason='artifact_contents_missing'
1355
+ else
1356
+ entry_count="$(find "$identity_directory" -mindepth 1 -maxdepth 1 -print 2>/dev/null | awk 'END { print NR + 0 }')"
1357
+ if [ "$entry_count" -ne 1 ] || [ ! -f "$identity" ] || [ -L "$identity" ]; then
1358
+ reason='artifact_contents_invalid'
1359
+ else
1360
+ envelope_size="$(wc -c < "$identity" | tr -d '[:space:]')"
1361
+ if ! printf '%s' "$envelope_size" | grep -Eq '^[0-9]+$'; then
1362
+ reason='envelope_size_unreadable'
1363
+ elif [ "$envelope_size" -gt '${ENVELOPE_MAX_BYTES}' ]; then
1364
+ reason='envelope_oversized'
1365
+ elif ! jq -e --argjson schemaVersion '${1}' '
1366
+ type == "object" and
1367
+ keys == ["after", "before", "repository", "runId", "schemaVersion"] and
1368
+ .schemaVersion == $schemaVersion and
1369
+ (.repository | type == "string") and
1370
+ (.runId | type == "string" and test("^[1-9][0-9]*$")) and
1371
+ (.before | type == "string" and test("^[0-9a-f]{40}$") and (test("^0{40}$") | not)) and
1372
+ (.after | type == "string" and test("^[0-9a-f]{40}$") and (test("^0{40}$") | not))
1373
+ ' "$identity" >/dev/null 2>&1; then
1374
+ reason='envelope_malformed'
1375
+ else
1376
+ repository="$(jq -r '.repository' "$identity")"
1377
+ run_id="$(jq -r '.runId' "$identity")"
1378
+ before="$(jq -r '.before' "$identity")"
1379
+ after="$(jq -r '.after' "$identity")"
1380
+
1381
+ if [ "$repository" != "$EXPECTED_REPOSITORY" ]; then
1382
+ reason='envelope_repository_mismatch'
1383
+ elif [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
1384
+ reason='envelope_run_id_mismatch'
1385
+ elif [ "$after" != "$EXPECTED_AFTER_SHA" ]; then
1386
+ reason='envelope_after_mismatch'
1387
+ elif ! actual_head="$(git rev-parse --verify HEAD 2>/dev/null)" || [ "$actual_head" != "$EXPECTED_AFTER_SHA" ]; then
1388
+ reason='checkout_head_mismatch'
1389
+ elif ! git cat-file -e "$before^{commit}" 2>/dev/null || ! git cat-file -e "$after^{commit}" 2>/dev/null; then
1390
+ reason='envelope_commit_unreachable'
1391
+ elif [ "$before" = "$after" ]; then
1392
+ reason='envelope_commits_contradictory'
1393
+ elif ! git merge-base --is-ancestor "$before" "$after"; then
1394
+ reason='before_not_ancestor'
1395
+ else
1396
+ disposition='usable'
1397
+ reason='identity_bound'
1398
+ fi
1399
+ fi
1400
+ fi
1401
+ fi
1402
+
1403
+ if [ "$disposition" != 'usable' ]; then
1404
+ before=''
1405
+ after=''
1406
+ fi
1407
+
1408
+ provenance="$(jq -cn \
1409
+ --argjson schemaVersion '${1}' \
1410
+ --arg disposition "$disposition" \
1411
+ --arg reason "$reason" \
1412
+ --arg repository "$EXPECTED_REPOSITORY" \
1413
+ --arg runId "$EXPECTED_RUN_ID" \
1414
+ --arg expectedAfter "$EXPECTED_AFTER_SHA" \
1415
+ --arg artifactName "$artifact_name" \
1416
+ --arg artifactId "$ARTIFACT_ID" \
1417
+ --arg before "$before" \
1418
+ --arg after "$after" \
1419
+ '{schemaVersion: $schemaVersion, disposition: $disposition, reason: $reason, repository: $repository, runId: $runId, expectedAfter: $expectedAfter, artifactName: $artifactName, artifactId: (if $artifactId == "" then null else $artifactId end), before: (if $before == "" then null else $before end), after: (if $after == "" then null else $after end)}')"
1420
+
1421
+ printf 'disposition=%s\nreason=%s\nbefore=%s\nafter=%s\nprovenance=%s\n' \
1422
+ "$disposition" "$reason" "$before" "$after" "$provenance" >> "$GITHUB_OUTPUT"
1423
+ {
1424
+ printf '## Exact push identity\n\n'
1425
+ printf -- '- Disposition: %s\n' "$disposition"
1426
+ printf -- '- Reason: %s\n' "$reason"
1427
+ printf -- '- Repository/run: %s / %s\n' "$EXPECTED_REPOSITORY" "$EXPECTED_RUN_ID"
1428
+ printf -- '- Expected verified head: %s\n' "$EXPECTED_AFTER_SHA"
1429
+ if [ "$disposition" = 'usable' ]; then
1430
+ printf -- '- Bound push: %s → %s\n' "$before" "$after"
1431
+ else
1432
+ printf -- '- Production impact classification refused; every consumer-owned production target remains demanded.\n'
1433
+ fi
1434
+ printf '\nFactory telemetry provenance: %s\n' "$provenance"
1435
+ } >> "$GITHUB_STEP_SUMMARY"`;
1436
+ /**
1437
+ * Record and upload the exact push-event identity without changing the Verify
1438
+ * result when transport is unavailable. Place these steps after verification.
1439
+ */
1440
+ const factoryPushIdentityProducer = (options) => {
1441
+ assertPinnedAction("uploadArtifact", options.uploadArtifact, "actions/upload-artifact");
1442
+ const condition = options.if ? `github.event_name == 'push' && (${options.if})` : "github.event_name == 'push'";
1443
+ const name = artifactName(expression("github.run_id"));
1444
+ return Object.freeze({
1445
+ artifactName: name,
1446
+ steps: Object.freeze([{
1447
+ continueOnError: true,
1448
+ id: FACTORY_PUSH_IDENTITY_RECORD_STEP_ID,
1449
+ if: condition,
1450
+ name: "Record exact push identity",
1451
+ run: producerScript
1452
+ }, {
1453
+ continueOnError: true,
1454
+ if: `${condition} && steps.${FACTORY_PUSH_IDENTITY_RECORD_STEP_ID}.outputs.produced == 'true'`,
1455
+ name: "Upload exact push identity",
1456
+ uses: options.uploadArtifact.uses,
1457
+ with: {
1458
+ "if-no-files-found": "error",
1459
+ name,
1460
+ path: `${expression("runner.temp")}/factory-push-identity.json`,
1461
+ "retention-days": "7"
1462
+ }
1463
+ }])
1464
+ });
1465
+ };
1466
+ /**
1467
+ * Download and validate identity from exactly the triggering workflow run in
1468
+ * the current repository. Every failure becomes a typed refusal; consumers
1469
+ * retain deploy policy, credentials, commands, topology, and convergence.
1470
+ */
1471
+ const factoryPushIdentityConsumer = (options) => {
1472
+ assertPinnedAction("checkout", options.checkout, "actions/checkout");
1473
+ assertPinnedAction("downloadArtifact", options.downloadArtifact, "actions/download-artifact");
1474
+ const currentRepository = expression("github.repository");
1475
+ const triggeringRun = expression("github.event.workflow_run.id");
1476
+ const triggeringHead = expression("github.event.workflow_run.head_sha");
1477
+ const lookupOutput = (name) => expression(`steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.${name}`);
1478
+ const validateOutput = (name) => expression(`steps.${FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID}.outputs.${name}`);
1479
+ const contextEnvironment = {
1480
+ EXPECTED_AFTER_SHA: triggeringHead,
1481
+ EXPECTED_CONCLUSION: expression("github.event.workflow_run.conclusion"),
1482
+ EXPECTED_EVENT_NAME: expression("github.event_name"),
1483
+ EXPECTED_REPOSITORY: currentRepository,
1484
+ EXPECTED_RUN_ID: triggeringRun,
1485
+ EXPECTED_WORKFLOW_EVENT: expression("github.event.workflow_run.event"),
1486
+ EXPECTED_WORKFLOW_REPOSITORY: expression("github.event.workflow_run.repository.full_name")
1487
+ };
1488
+ const steps = [
1489
+ {
1490
+ continueOnError: true,
1491
+ env: {
1492
+ GH_TOKEN: expression("github.token"),
1493
+ ...contextEnvironment
1494
+ },
1495
+ id: FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID,
1496
+ name: "Resolve exact push identity artifact",
1497
+ run: lookupScript
1498
+ },
1499
+ {
1500
+ continueOnError: true,
1501
+ id: FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID,
1502
+ if: `steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.status == 'available'`,
1503
+ name: "Download exact push identity",
1504
+ uses: options.downloadArtifact.uses,
1505
+ with: {
1506
+ "artifact-ids": lookupOutput("artifact_id"),
1507
+ "github-token": expression("github.token"),
1508
+ path: `${expression("runner.temp")}/factory-push-identity`,
1509
+ repository: currentRepository,
1510
+ "run-id": triggeringRun
1511
+ }
1512
+ },
1513
+ {
1514
+ continueOnError: true,
1515
+ id: FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID,
1516
+ if: `steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.status == 'available'`,
1517
+ name: "Checkout exact verified head",
1518
+ uses: options.checkout.uses,
1519
+ with: {
1520
+ "fetch-depth": "0",
1521
+ ref: triggeringHead
1522
+ }
1523
+ },
1524
+ {
1525
+ continueOnError: true,
1526
+ env: {
1527
+ ARTIFACT_ID: lookupOutput("artifact_id"),
1528
+ CHECKOUT_OUTCOME: expression(`steps.${FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID}.outcome`),
1529
+ DOWNLOAD_OUTCOME: expression(`steps.${FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID}.outcome`),
1530
+ LOOKUP_REASON: lookupOutput("reason"),
1531
+ LOOKUP_STATUS: lookupOutput("status"),
1532
+ ...contextEnvironment
1533
+ },
1534
+ id: FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID,
1535
+ if: "always()",
1536
+ name: "Validate exact push identity",
1537
+ run: validationScript
1538
+ }
1539
+ ];
1540
+ return Object.freeze({
1541
+ outputs: Object.freeze({
1542
+ after: validateOutput("after"),
1543
+ before: validateOutput("before"),
1544
+ disposition: validateOutput("disposition"),
1545
+ provenance: validateOutput("provenance"),
1546
+ reason: validateOutput("reason")
1547
+ }),
1548
+ requiredPermissions: Object.freeze({
1549
+ actions: "read",
1550
+ contents: "read"
1551
+ }),
1552
+ steps: Object.freeze(steps),
1553
+ usableIf: `steps.${FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID}.outputs.disposition == 'usable'`
1554
+ });
1555
+ };
1556
+ //#endregion
1557
+ //#region src/vitest-profile.ts
1558
+ /**
1559
+ * Vitest suite profiling — the measurement mechanics behind a CI runner
1560
+ * comparison (#640, #647).
1561
+ *
1562
+ * A profile is a machine-readable record of N serial Vitest runs at one worker
1563
+ * count, carrying per-file and per-test timings, aggregate duration statistics,
1564
+ * and the hardware the samples actually ran on. The hardware capture is the
1565
+ * point: the #640 Depot comparison only resolved because every sample recorded
1566
+ * its `cpuModel`, which split otherwise-identical 4-CPU runs into two
1567
+ * non-overlapping populations.
1568
+ *
1569
+ * Everything a repository decides stays with the repository: worker counts,
1570
+ * sample counts, output paths, runner labels, and which suite to run at all.
1571
+ * This module owns the schema, the report parsing, the environment capture, and
1572
+ * the atomic write.
1573
+ */
1574
+ /** Schema version of the emitted profile document. */
1575
+ const VITEST_PROFILE_SCHEMA_VERSION = 1;
1576
+ /** `tool` discriminator every emitted profile carries. */
1577
+ const VITEST_PROFILE_TOOL = "factory-ci-vitest-profile";
1578
+ const RUN_TIMEOUT_MS = 15 * 6e4;
1579
+ const TERMINATION_GRACE_MS = 5e3;
1580
+ /**
1581
+ * A sample failed. The partial profile is already on disk; `exitCode` is the
1582
+ * status a caller should exit with.
1583
+ */
1584
+ var VitestProfileError = class extends Error {
1585
+ exitCode;
1586
+ constructor(message, exitCode) {
1587
+ super(message);
1588
+ this.name = "VitestProfileError";
1589
+ this.exitCode = exitCode;
1590
+ }
1591
+ };
1592
+ const durationSummary = (durations) => {
1593
+ const sorted = durations.toSorted((left, right) => left - right);
1594
+ const middle = Math.floor(sorted.length / 2);
1595
+ const median = sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : sorted[middle] ?? 0;
1596
+ return {
1597
+ maximum: sorted.at(-1) ?? 0,
1598
+ mean: durations.reduce((sum, value) => sum + value, 0) / durations.length,
1599
+ median,
1600
+ minimum: sorted[0] ?? 0
1601
+ };
1602
+ };
1603
+ const relativePath = (cwd, file) => path.relative(cwd, file).split(path.sep).join("/");
1604
+ /**
1605
+ * Fold one Vitest JSON report into a profile sample: file and test timings,
1606
+ * both sorted slowest first, plus the run's counts. A missing report (crash,
1607
+ * timeout, unwritable output) yields a sample with `reportAvailable: false`
1608
+ * rather than nothing at all.
1609
+ */
1610
+ const normalizeVitestProfileSample = (report, input) => {
1611
+ const files = (report?.testResults ?? []).map((file) => ({
1612
+ durationMs: Math.max(0, file.endTime - file.startTime),
1613
+ path: relativePath(input.cwd, file.name),
1614
+ status: file.status
1615
+ })).toSorted((left, right) => right.durationMs - left.durationMs);
1616
+ const tests = (report?.testResults ?? []).flatMap((file) => file.assertionResults.map((test) => ({
1617
+ durationMs: Math.max(0, test.duration ?? 0),
1618
+ file: relativePath(input.cwd, file.name),
1619
+ name: test.fullName,
1620
+ status: test.status
1621
+ }))).toSorted((left, right) => right.durationMs - left.durationMs);
1622
+ return {
1623
+ counts: report ? {
1624
+ failed: report.numFailedTests,
1625
+ passed: report.numPassedTests,
1626
+ pending: report.numPendingTests,
1627
+ suites: report.numTotalTestSuites,
1628
+ tests: report.numTotalTests,
1629
+ todo: report.numTodoTests
1630
+ } : null,
1631
+ durationMs: input.durationMs,
1632
+ endedAt: input.endedAt.toISOString(),
1633
+ exitCode: input.exitCode,
1634
+ failure: input.failure ?? null,
1635
+ files,
1636
+ reportAvailable: report !== null,
1637
+ sample: input.sample,
1638
+ startedAt: input.startedAt.toISOString(),
1639
+ tests
1640
+ };
1641
+ };
1642
+ const aggregateSlowFiles = (runs, limit) => {
1643
+ const durations = /* @__PURE__ */ new Map();
1644
+ for (const file of runs.flatMap((run) => run.files)) {
1645
+ const recorded = durations.get(file.path) ?? [];
1646
+ recorded.push(file.durationMs);
1647
+ durations.set(file.path, recorded);
1648
+ }
1649
+ return [...durations].map(([filePath, values]) => ({
1650
+ durationMs: durationSummary(values),
1651
+ path: filePath,
1652
+ samples: values.length
1653
+ })).toSorted((left, right) => right.durationMs.median - left.durationMs.median).slice(0, limit);
1654
+ };
1655
+ const aggregateSlowTests = (runs, limit) => {
1656
+ const timings = /* @__PURE__ */ new Map();
1657
+ for (const test of runs.flatMap((run) => run.tests)) {
1658
+ const key = `${test.file}\0${test.name}`;
1659
+ const recorded = timings.get(key) ?? {
1660
+ durations: [],
1661
+ file: test.file,
1662
+ name: test.name
1663
+ };
1664
+ recorded.durations.push(test.durationMs);
1665
+ timings.set(key, recorded);
1666
+ }
1667
+ return [...timings.values()].map(({ durations, file, name }) => ({
1668
+ durationMs: durationSummary(durations),
1669
+ file,
1670
+ name,
1671
+ samples: durations.length
1672
+ })).toSorted((left, right) => right.durationMs.median - left.durationMs.median).slice(0, limit);
1673
+ };
1674
+ const gitValue = (cwd, args) => {
1675
+ try {
1676
+ return execFileSync("git", args, {
1677
+ cwd,
1678
+ encoding: "utf-8",
1679
+ stdio: [
1680
+ "ignore",
1681
+ "pipe",
1682
+ "ignore"
1683
+ ]
1684
+ }).trim();
1685
+ } catch {
1686
+ return null;
1687
+ }
1688
+ };
1689
+ const resolveVitestPackage = (cwd) => createRequire(path.join(path.resolve(cwd), "noop.js")).resolve("vitest/package.json");
1690
+ /**
1691
+ * Record the machine and commit a profile was taken on. Vitest's version is
1692
+ * resolved from `cwd`, so it is the consumer's Vitest and not this package's.
1693
+ * Git failures degrade to `null` — an artifact from a tarball checkout is still
1694
+ * a usable measurement.
1695
+ */
1696
+ const captureVitestProfileEnvironment = async (options) => {
1697
+ const gitDirectory = options.gitDirectory ?? options.cwd;
1698
+ let vitestVersion = "unknown";
1699
+ try {
1700
+ vitestVersion = JSON.parse(await readFile(resolveVitestPackage(options.cwd), "utf-8")).version ?? "unknown";
1701
+ } catch {
1702
+ vitestVersion = "unknown";
1703
+ }
1704
+ const processors = cpus();
1705
+ const status = gitValue(gitDirectory, ["status", "--porcelain"]);
1706
+ return {
1707
+ arch: arch(),
1708
+ availableParallelism: availableParallelism(),
1709
+ cpuCount: processors.length,
1710
+ cpuModel: processors[0]?.model ?? null,
1711
+ gitDirty: status === null ? null : status.length > 0,
1712
+ gitHead: gitValue(gitDirectory, ["rev-parse", "HEAD"]),
1713
+ node: process.version,
1714
+ osRelease: release(),
1715
+ platform: platform(),
1716
+ totalMemoryBytes: totalmem(),
1717
+ vitest: vitestVersion
1718
+ };
1719
+ };
1720
+ const spawnVitest = async ({ cwd, maxWorkers, reportPath, stdio }) => {
1721
+ const vitestCli = path.join(path.dirname(resolveVitestPackage(cwd)), "vitest.mjs");
1722
+ const started = performance.now();
1723
+ const child = spawn(process.execPath, [
1724
+ vitestCli,
1725
+ "run",
1726
+ "--reporter=json",
1727
+ `--outputFile=${reportPath}`,
1728
+ `--maxWorkers=${maxWorkers}`
1729
+ ], {
1730
+ cwd,
1731
+ stdio
1732
+ });
1733
+ let timedOut = false;
1734
+ let forceKillTimer;
1735
+ const timeoutTimer = setTimeout(() => {
1736
+ timedOut = true;
1737
+ child.kill("SIGTERM");
1738
+ forceKillTimer = setTimeout(() => {
1739
+ child.kill("SIGKILL");
1740
+ }, TERMINATION_GRACE_MS);
1741
+ }, RUN_TIMEOUT_MS);
1742
+ let exitCode = 0;
1743
+ let failure = null;
1744
+ try {
1745
+ const [code, signal] = await once(child, "exit");
1746
+ exitCode = code ?? (signal ? 1 : 0);
1747
+ } catch (error) {
1748
+ exitCode = 1;
1749
+ failure = `Vitest could not start: ${error instanceof Error ? error.message : String(error)}`;
1750
+ } finally {
1751
+ clearTimeout(timeoutTimer);
1752
+ if (forceKillTimer) clearTimeout(forceKillTimer);
1753
+ }
1754
+ if (timedOut) failure = `Vitest profile sample exceeded ${RUN_TIMEOUT_MS}ms and was terminated.`;
1755
+ let report = null;
1756
+ try {
1757
+ report = JSON.parse(await readFile(reportPath, "utf-8"));
1758
+ } catch (error) {
1759
+ const reportFailure = `Vitest JSON report unavailable: ${error instanceof Error ? error.message : String(error)}`;
1760
+ failure = failure ? `${failure} ${reportFailure}` : reportFailure;
1761
+ }
1762
+ return {
1763
+ durationMs: Math.max(0, performance.now() - started),
1764
+ exitCode: timedOut ? 1 : exitCode,
1765
+ failure,
1766
+ report
1767
+ };
1768
+ };
1769
+ /**
1770
+ * Write a profile document atomically: a partial file must never be readable
1771
+ * as a complete measurement, and the profile is rewritten after every sample.
1772
+ */
1773
+ const writeVitestProfile = async (outputPath, profile) => {
1774
+ await mkdir(path.dirname(outputPath), { recursive: true });
1775
+ const temporaryPath = `${outputPath}.tmp`;
1776
+ await writeFile(temporaryPath, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
1777
+ await rename(temporaryPath, outputPath);
1778
+ };
1779
+ /**
1780
+ * Take `samples` serial Vitest runs at one worker count and persist the profile
1781
+ * after each one. Samples never overlap: concurrent runs would measure CPU and
1782
+ * I/O contention instead of the worker count under test. A failing sample
1783
+ * throws `VitestProfileError` with the partial profile already written.
1784
+ */
1785
+ const runVitestProfile = async (options, dependencies = {}) => {
1786
+ if (!Number.isSafeInteger(options.samples) || options.samples < 1) throw new Error("runVitestProfile: samples must be a positive integer.");
1787
+ const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
1788
+ const runSample = dependencies.runSample ?? spawnVitest;
1789
+ const writeResult = dependencies.writeResult ?? writeVitestProfile;
1790
+ const startedAt = now();
1791
+ const rawRoot = path.join(`${options.outputPath}.raw`, randomUUID());
1792
+ await mkdir(rawRoot, { recursive: true });
1793
+ const profile = {
1794
+ command: [
1795
+ "vitest",
1796
+ "run",
1797
+ "--reporter=json",
1798
+ `--maxWorkers=${options.maxWorkers}`
1799
+ ],
1800
+ endedAt: startedAt.toISOString(),
1801
+ environment: await captureVitestProfileEnvironment({
1802
+ cwd: options.cwd,
1803
+ gitDirectory: options.gitDirectory
1804
+ }),
1805
+ options: {
1806
+ maxWorkers: options.maxWorkers,
1807
+ samples: options.samples,
1808
+ slowLimit: options.slowLimit
1809
+ },
1810
+ rawReportDirectory: rawRoot,
1811
+ runs: [],
1812
+ schemaVersion: 1,
1813
+ startedAt: startedAt.toISOString(),
1814
+ summary: {
1815
+ durationMs: {
1816
+ maximum: 0,
1817
+ mean: 0,
1818
+ median: 0,
1819
+ minimum: 0
1820
+ },
1821
+ slowFiles: [],
1822
+ slowTests: []
1823
+ },
1824
+ tool: VITEST_PROFILE_TOOL
1825
+ };
1826
+ for (let sample = 1; sample <= options.samples; sample += 1) {
1827
+ let hookFailure;
1828
+ try {
1829
+ options.onSampleStart?.({
1830
+ maxWorkers: options.maxWorkers,
1831
+ sample,
1832
+ samples: options.samples
1833
+ });
1834
+ } catch (error) {
1835
+ hookFailure = { error };
1836
+ }
1837
+ const sampleStartedAt = now();
1838
+ const reportPath = path.join(rawRoot, `sample-${sample}.json`);
1839
+ const execution = await runSample({
1840
+ cwd: options.cwd,
1841
+ maxWorkers: options.maxWorkers,
1842
+ reportPath,
1843
+ sample,
1844
+ stdio: options.stdio ?? "inherit"
1845
+ });
1846
+ const sampleEndedAt = now();
1847
+ const normalized = normalizeVitestProfileSample(execution.report, {
1848
+ cwd: options.cwd,
1849
+ durationMs: execution.durationMs,
1850
+ endedAt: sampleEndedAt,
1851
+ exitCode: execution.exitCode,
1852
+ failure: execution.failure,
1853
+ sample,
1854
+ startedAt: sampleStartedAt
1855
+ });
1856
+ profile.runs.push(normalized);
1857
+ profile.endedAt = sampleEndedAt.toISOString();
1858
+ profile.summary = {
1859
+ durationMs: durationSummary(profile.runs.map((run) => run.durationMs)),
1860
+ slowFiles: aggregateSlowFiles(profile.runs, options.slowLimit),
1861
+ slowTests: aggregateSlowTests(profile.runs, options.slowLimit)
1862
+ };
1863
+ await writeResult(options.outputPath, profile);
1864
+ try {
1865
+ options.onSampleComplete?.({
1866
+ result: normalized,
1867
+ sample,
1868
+ samples: options.samples
1869
+ });
1870
+ } catch (error) {
1871
+ hookFailure ??= { error };
1872
+ }
1873
+ if (execution.exitCode !== 0 || execution.report?.success !== true) throw new VitestProfileError(`Vitest profile sample ${sample} failed; partial results saved.`, execution.exitCode || 1);
1874
+ if (hookFailure) throw hookFailure.error;
1875
+ }
1876
+ return profile;
1877
+ };
1878
+ //#endregion
1879
+ //#region src/vitest-profile-reader.ts
1880
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1881
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
1882
+ const durationSummaryFailure = (value, where) => {
1883
+ if (!isRecord(value)) return `${where} is not an object`;
1884
+ const missing = [
1885
+ "maximum",
1886
+ "mean",
1887
+ "median",
1888
+ "minimum"
1889
+ ].find((field) => !isFiniteNumber(value[field]));
1890
+ return missing === void 0 ? void 0 : `${where}.${missing} is not a finite number`;
1891
+ };
1892
+ const environmentFailure = (value) => {
1893
+ if (!isRecord(value)) return "environment is not an object";
1894
+ if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) return "environment.cpuModel is neither a string nor null";
1895
+ if (!isFiniteNumber(value.cpuCount)) return "environment.cpuCount is not a finite number";
1896
+ };
1897
+ /** The count fields the writer emits; every one must be a finite number. */
1898
+ const COUNT_FIELDS = [
1899
+ "failed",
1900
+ "passed",
1901
+ "pending",
1902
+ "suites",
1903
+ "tests",
1904
+ "todo"
1905
+ ];
1906
+ const runFailure = (value, index) => {
1907
+ if (!isRecord(value)) return `runs[${index}] is not an object`;
1908
+ if (!isFiniteNumber(value.durationMs)) return `runs[${index}].durationMs is not a finite number`;
1909
+ if (value.counts === null) return;
1910
+ if (!isRecord(value.counts)) return `runs[${index}].counts is neither an object nor null`;
1911
+ const { counts } = value;
1912
+ const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
1913
+ return missing === void 0 ? void 0 : `runs[${index}].counts.${missing} is not a finite number`;
1914
+ };
1915
+ const slowEntryFailure = (value, where, labelField) => {
1916
+ if (!isRecord(value)) return `${where} is not an object`;
1917
+ if (typeof value[labelField] !== "string") return `${where}.${labelField} is not a string`;
1918
+ if (labelField === "name" && typeof value.file !== "string") return `${where}.file is not a string`;
1919
+ return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
1920
+ };
1921
+ const summaryFailure = (value) => {
1922
+ if (!isRecord(value)) return "summary is not an object";
1923
+ const durations = durationSummaryFailure(value.durationMs, "summary.durationMs");
1924
+ if (durations !== void 0) return durations;
1925
+ if (!Array.isArray(value.slowFiles)) return "summary.slowFiles is not an array";
1926
+ for (const [index, entry] of value.slowFiles.entries()) {
1927
+ const failure = slowEntryFailure(entry, `summary.slowFiles[${index}]`, "path");
1928
+ if (failure !== void 0) return failure;
1929
+ }
1930
+ if (!Array.isArray(value.slowTests)) return "summary.slowTests is not an array";
1931
+ for (const [index, entry] of value.slowTests.entries()) {
1932
+ const failure = slowEntryFailure(entry, `summary.slowTests[${index}]`, "name");
1933
+ if (failure !== void 0) return failure;
1934
+ }
1935
+ };
1936
+ /**
1937
+ * Check one parsed JSON document against the profile contract the writer
1938
+ * emits. Returns the typed profile on success and the first mismatch reason
1939
+ * otherwise — never a partially-usable value.
1940
+ */
1941
+ const readVitestProfileDocument = (value) => {
1942
+ if (!isRecord(value)) return {
1943
+ kind: "unrecognized",
1944
+ reason: "the document is not an object"
1945
+ };
1946
+ if (value.tool !== "factory-ci-vitest-profile") return {
1947
+ kind: "unrecognized",
1948
+ reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`
1949
+ };
1950
+ if (value.schemaVersion !== 1) return {
1951
+ kind: "unrecognized",
1952
+ reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than 1`
1953
+ };
1954
+ if (!(Array.isArray(value.command) && value.command.every((part) => typeof part === "string"))) return {
1955
+ kind: "unrecognized",
1956
+ reason: "command is not an array of strings"
1957
+ };
1958
+ const environment = environmentFailure(value.environment);
1959
+ if (environment !== void 0) return {
1960
+ kind: "unrecognized",
1961
+ reason: environment
1962
+ };
1963
+ if (!isRecord(value.options)) return {
1964
+ kind: "unrecognized",
1965
+ reason: "options is not an object"
1966
+ };
1967
+ if (!isFiniteNumber(value.options.maxWorkers)) return {
1968
+ kind: "unrecognized",
1969
+ reason: "options.maxWorkers is not a finite number"
1970
+ };
1971
+ if (!Array.isArray(value.runs)) return {
1972
+ kind: "unrecognized",
1973
+ reason: "runs is not an array"
1974
+ };
1975
+ for (const [index, run] of value.runs.entries()) {
1976
+ const failure = runFailure(run, index);
1977
+ if (failure !== void 0) return {
1978
+ kind: "unrecognized",
1979
+ reason: failure
1980
+ };
1981
+ }
1982
+ const summary = summaryFailure(value.summary);
1983
+ if (summary !== void 0) return {
1984
+ kind: "unrecognized",
1985
+ reason: summary
1986
+ };
1987
+ return {
1988
+ kind: "profile",
1989
+ profile: value
1990
+ };
1991
+ };
1992
+ //#endregion
1993
+ //#region src/workflow-shell-lint.ts
1994
+ const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
1995
+ /**
1996
+ * GitHub evaluates `${{ }}` before bash ever sees the script, and what it
1997
+ * substitutes is not knowable here. Neutralizing each expression to one plain
1998
+ * word is what the runner's *shape* looks like: a value in argument position.
1999
+ * Leaving them in would make every workflow fail to parse; expanding them to
2000
+ * nothing would silently change quoting.
2001
+ *
2002
+ * Scanned rather than matched with a lazy regex, because `}}` occurs inside
2003
+ * Actions string literals: `format('refs/{{0}}', github.ref_name)` escapes a
2004
+ * literal brace pair that way, and stopping there would leave half an
2005
+ * expression in the script and report a parse error the runner never sees.
2006
+ */
2007
+ const neutralizeExpressions = (script) => {
2008
+ let out = "";
2009
+ let cursor = 0;
2010
+ while (cursor < script.length) {
2011
+ const start = script.indexOf("${{", cursor);
2012
+ if (start === -1) {
2013
+ out += script.slice(cursor);
2014
+ break;
2015
+ }
2016
+ out += script.slice(cursor, start);
2017
+ let scan = start + 3;
2018
+ let quote;
2019
+ let end = -1;
2020
+ while (scan < script.length) {
2021
+ const char = script[scan];
2022
+ if (quote === void 0) {
2023
+ if (char === "'" || char === "\"") quote = char;
2024
+ else if (char === "}" && script[scan + 1] === "}") {
2025
+ end = scan + 2;
2026
+ break;
2027
+ }
2028
+ } else if (char === quote) if (script[scan + 1] === quote) scan += 1;
2029
+ else quote = void 0;
2030
+ scan += 1;
2031
+ }
2032
+ if (end === -1) {
2033
+ out += script.slice(start);
2034
+ break;
2035
+ }
2036
+ out += "FACTORY_ACTIONS_EXPRESSION";
2037
+ cursor = end;
2038
+ }
2039
+ return out;
2040
+ };
2041
+ /** Strip one layer of YAML single quoting from a scalar value. */
2042
+ const unquote = (raw) => {
2043
+ const value = raw.trim();
2044
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) return value.slice(1, -1).replaceAll("''", "'");
2045
+ return value;
2046
+ };
2047
+ const indentOf = (line) => line.length - line.trimStart().length;
2048
+ /**
2049
+ * Where a mapping key sits, counting the `- ` sequence marker as indentation:
2050
+ * `- name:` and the `run:` below it are siblings in the same step even though
2051
+ * their raw columns differ by two.
2052
+ */
2053
+ const keyIndentOf = (line) => indentOf(line) + (line.trimStart().startsWith("- ") ? 2 : 0);
2054
+ /** A sibling scalar of the `run:` key under inspection, when the line is one. */
2055
+ const keyValueAt = (line, indent, key) => {
2056
+ if (keyIndentOf(line) !== indent) return;
2057
+ const rest = line.trimStart().replace(/^-\s+/u, "");
2058
+ return rest.startsWith(`${key}:`) ? rest.slice(key.length + 1) : void 0;
2059
+ };
2060
+ /**
2061
+ * Every `defaults: { run: { shell } }` in the document, with the span it
2062
+ * governs: the mapping that declares it, which is the whole workflow at the
2063
+ * top level and one job under `jobs:`.
2064
+ *
2065
+ * GitHub resolves a step's interpreter as step `shell:`, then the job's
2066
+ * default, then the workflow's, then bash. A scanner that only looked at the
2067
+ * step would call every step in a `defaults.run.shell: sh` workflow bash and
2068
+ * report a pass for scripts `sh` cannot parse — the very false negative this
2069
+ * control exists to close.
2070
+ */
2071
+ const defaultShellScopes = (lines) => {
2072
+ const scopes = [];
2073
+ for (const [index, line] of lines.entries()) {
2074
+ if (line.trim() !== "defaults:") continue;
2075
+ const depth = keyIndentOf(line);
2076
+ const shell = (() => {
2077
+ let inRun = false;
2078
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
2079
+ const candidate = lines[cursor];
2080
+ if (candidate.trim().length === 0) continue;
2081
+ if (keyIndentOf(candidate) <= depth) return;
2082
+ if (keyValueAt(candidate, depth + 2, "run") !== void 0) {
2083
+ inRun = true;
2084
+ continue;
2085
+ }
2086
+ if (keyIndentOf(candidate) <= depth + 2) {
2087
+ inRun = false;
2088
+ continue;
2089
+ }
2090
+ const value = inRun ? keyValueAt(candidate, depth + 4, "shell") : void 0;
2091
+ if (value !== void 0) return unquote(value);
2092
+ }
2093
+ })();
2094
+ if (shell === void 0) continue;
2095
+ let start = 0;
2096
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
2097
+ start = cursor;
2098
+ break;
2099
+ }
2100
+ let end = lines.length;
2101
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
2102
+ end = cursor;
2103
+ break;
2104
+ }
2105
+ scopes.push({
2106
+ depth,
2107
+ end,
2108
+ shell,
2109
+ start
2110
+ });
2111
+ }
2112
+ return scopes;
2113
+ };
2114
+ /** The innermost `defaults.run.shell` governing a line, if any. */
2115
+ const inheritedShell = (scopes, index) => scopes.filter((scope) => index >= scope.start && index < scope.end).toSorted((left, right) => right.depth - left.depth).at(0)?.shell;
2116
+ /**
2117
+ * Every `run:` block in a generated workflow, paired with the `shell:` its
2118
+ * step declares.
2119
+ *
2120
+ * Deliberately a scanner over the emitted text and not a YAML parse: this
2121
+ * package takes no dependency it does not need, and the emitted shape is one
2122
+ * generator's output, not arbitrary YAML. It reads both block scalars
2123
+ * (`run: |-`) and inline scripts.
2124
+ */
2125
+ const workflowRunBlocks = (yaml) => {
2126
+ const lines = yaml.split("\n");
2127
+ const scopes = defaultShellScopes(lines);
2128
+ const blocks = [];
2129
+ for (const [index, line] of lines.entries()) {
2130
+ const match = RUN_KEY.exec(line);
2131
+ if (match?.groups === void 0) continue;
2132
+ const keyIndent = keyIndentOf(line);
2133
+ const inline = match.groups.inline.trim();
2134
+ let script;
2135
+ let end = index;
2136
+ if (inline.startsWith(">")) throw new Error(`folded (\`run: >\`) scripts are not supported: YAML folds their line breaks into spaces, so what bash parses is not what is written. Use a literal block (\`run: |\`).`);
2137
+ if (inline.trimStart().startsWith("\"")) throw new Error(`double-quoted \`run:\` scalars are not supported: their YAML escapes would have to be decoded before bash sees them. Use a literal block (\`run: |\`) or an unquoted scalar.`);
2138
+ if (inline.startsWith("|")) {
2139
+ const body = [];
2140
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
2141
+ const candidate = lines[cursor];
2142
+ if (candidate.trim().length > 0 && indentOf(candidate) <= keyIndent) break;
2143
+ body.push(candidate);
2144
+ end = cursor;
2145
+ }
2146
+ const strip = Math.min(...body.filter((entry) => entry.trim().length > 0).map((entry) => indentOf(entry)));
2147
+ script = body.map((entry) => entry.slice(strip)).join("\n");
2148
+ } else if (inline.length > 0) script = unquote(inline);
2149
+ else continue;
2150
+ let shell;
2151
+ let step;
2152
+ const readSibling = (line_) => {
2153
+ shell ??= keyValueAt(line_, keyIndent, "shell");
2154
+ step ??= keyValueAt(line_, keyIndent, "name");
2155
+ };
2156
+ for (let cursor = index; cursor >= 0 && keyIndentOf(lines[cursor]) >= keyIndent; cursor -= 1) {
2157
+ readSibling(lines[cursor]);
2158
+ if (lines[cursor].trimStart().startsWith("- ")) break;
2159
+ }
2160
+ for (let cursor = end + 1; cursor < lines.length; cursor += 1) {
2161
+ if (keyIndentOf(lines[cursor]) < keyIndent || lines[cursor].trimStart().startsWith("- ")) break;
2162
+ readSibling(lines[cursor]);
2163
+ }
2164
+ const effectiveShell = shell === void 0 ? inheritedShell(scopes, index) : unquote(shell);
2165
+ blocks.push({
2166
+ script,
2167
+ ...effectiveShell === void 0 ? {} : { shell: effectiveShell },
2168
+ ...step === void 0 ? {} : { step: unquote(step) }
2169
+ });
2170
+ }
2171
+ return blocks;
2172
+ };
2173
+ /** `NAME=value` in a shell command template, as `env` takes them. */
2174
+ const ASSIGNMENT = /^[A-Za-z_]\w*=/u;
2175
+ /**
2176
+ * Interpreters GitHub supports that this control deliberately leaves alone.
2177
+ * Named rather than inferred, so an unfamiliar command fails loudly instead of
2178
+ * being skipped as though it had been considered.
2179
+ */
2180
+ const NON_SHELL_INTERPRETERS = new Set([
2181
+ "cmd",
2182
+ "powershell",
2183
+ "pwsh",
2184
+ "python",
2185
+ "python3"
2186
+ ]);
2187
+ /**
2188
+ * The interpreter that will parse a step's script, or `undefined` for one this
2189
+ * control leaves alone.
2190
+ *
2191
+ * A step declaring no shell gets bash: that is GitHub's default for `run:` on
2192
+ * Linux runners. A step declaring `sh` gets `sh`, because the runner runs it
2193
+ * with `sh` — whose grammar is narrower than bash's, so parsing it with bash
2194
+ * would report a pass for a script the runner cannot run. Anything else
2195
+ * (pwsh, python, cmd) is not this control's business.
2196
+ */
2197
+ const parserFor = (shell) => {
2198
+ if (shell === void 0) return ["bash"];
2199
+ const argv = [];
2200
+ let interpreter;
2201
+ for (const token of shell.trim().split(/\s+/u)) {
2202
+ if (token === "{0}") break;
2203
+ if (interpreter !== void 0) {
2204
+ argv.push(token);
2205
+ continue;
2206
+ }
2207
+ const executable = token.slice(token.lastIndexOf("/") + 1);
2208
+ if (executable === "bash" || executable === "sh") interpreter = token;
2209
+ else if (!(executable === "env" || token.startsWith("-") || ASSIGNMENT.test(token))) {
2210
+ if (NON_SHELL_INTERPRETERS.has(executable)) return;
2211
+ throw new Error(`unrecognized \`shell:\` command: ${shell}. This control parses bash and sh scripts and knowingly skips pwsh, powershell, python, and cmd; it refuses rather than guess at anything else.`);
2212
+ }
2213
+ argv.push(token);
2214
+ }
2215
+ if (interpreter === void 0) throw new Error(`unrecognized \`shell:\` command: ${shell}. No interpreter to parse the script with.`);
2216
+ return argv;
2217
+ };
2218
+ /** Every `run:` block its interpreter refuses to parse. Empty means sound. */
2219
+ const workflowShellParseFailures = (yaml) => {
2220
+ const failures = [];
2221
+ for (const block of workflowRunBlocks(yaml)) {
2222
+ const parser = parserFor(block.shell);
2223
+ if (parser === void 0) continue;
2224
+ const [executable, ...parserArgs] = parser;
2225
+ const script = neutralizeExpressions(block.script);
2226
+ try {
2227
+ execFileSync(executable, [...parserArgs, "-n"], {
2228
+ input: script,
2229
+ stdio: [
2230
+ "pipe",
2231
+ "ignore",
2232
+ "pipe"
2233
+ ]
2234
+ });
2235
+ } catch (error) {
2236
+ failures.push({
2237
+ script,
2238
+ ...block.step === void 0 ? {} : { step: block.step },
2239
+ stderr: String(error.stderr ?? error).trim()
2240
+ });
2241
+ }
2242
+ }
2243
+ return failures;
2244
+ };
2245
+ /**
2246
+ * Fail the generated-workflow lint when any embedded `run:` block is not
2247
+ * parseable bash. Call it on the YAML a generator is about to write, so the
2248
+ * defect is caught at generation rather than by the runner.
2249
+ */
2250
+ const assertWorkflowShellParses = (yaml, options) => {
2251
+ const failures = workflowShellParseFailures(yaml);
2252
+ if (failures.length === 0) return;
2253
+ const detail = failures.map((failure) => ` - ${failure.step ?? "unnamed step"}: ${failure.stderr.replaceAll("\n", "\n ")}`).join("\n");
2254
+ throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
2255
+ };
2256
+ //#endregion
2257
+ export { FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PROOF_TIMING_START_STEP_NAME, FACTORY_PROOF_TIMING_STEP_ID, FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME, FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX, FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID, FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID, FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID, FACTORY_PUSH_IDENTITY_RECORD_STEP_ID, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateOrPushCondition, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryScript, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };