@patronage/factory-ci 0.2.0 → 1.0.0-alpha.4

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
@@ -2,7 +2,9 @@ import { createRequire } from "node:module";
2
2
  import { mkdir } 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 } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ import { execFileSync, spawnSync } from "node:child_process";
6
8
  //#region src/actions.ts
7
9
  /**
8
10
  * The canonical Node 24 family shared by factory-project workflows.
@@ -40,6 +42,50 @@ const ALCHEMY_EXTERNALS = [
40
42
  "effect/*"
41
43
  ];
42
44
  /**
45
+ * The package names behind `ALCHEMY_EXTERNALS`, derived rather than restated so
46
+ * a future external can never be guarded by only one of two lists.
47
+ */
48
+ const RESERVED_ALIAS_PACKAGES = [...new Set(ALCHEMY_EXTERNALS.map((external) => external.replace(/\/\*$/u, "").toLowerCase()))];
49
+ const reservedAliasKey = (key) => RESERVED_ALIAS_PACKAGES.find((name) => key === name || key.startsWith(`${name}/`));
50
+ /**
51
+ * Refuse an alias *key* that is itself a reserved package or one of its
52
+ * subpaths. esbuild substitutes aliases before it decides what is external, so
53
+ * such a key defeats `external` outright. Keys are compared, never resolved,
54
+ * which is what makes string matching sound here.
55
+ */
56
+ const assertAliasKeysAreAdmissible = (alias) => {
57
+ for (const key of Object.keys(alias)) {
58
+ const reserved = reservedAliasKey(key);
59
+ 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.`);
60
+ }
61
+ };
62
+ /**
63
+ * Whether a bundled input lives inside a reserved package.
64
+ *
65
+ * Metafile inputs are paths esbuild resolved and normalized itself, so
66
+ * comparing whole segments here answers what was *bundled* rather than how the
67
+ * config was spelled. Segments are compared case-insensitively: on a
68
+ * case-insensitive filesystem `node_modules/Effect/…` resolves to the real
69
+ * package and the metafile keeps the caller's spelling.
70
+ */
71
+ const isReservedInput = (input) => {
72
+ const segments = input.toLowerCase().split(/[/\\]+/u);
73
+ return segments.some((segment, position) => position > 0 && segments[position - 1] === "node_modules" && RESERVED_ALIAS_PACKAGES.includes(segment));
74
+ };
75
+ /**
76
+ * Assert the externals contract against the bundle esbuild actually produced.
77
+ *
78
+ * Checking alias *values* instead was unsound by construction: `..` segments,
79
+ * symlinks, and every other spelling of the same file each need another string
80
+ * rule, and the scanner loses. The metafile records the inputs after esbuild's
81
+ * own resolution and normalization, so one check closes the whole class —
82
+ * whatever route reached an identity-sensitive package, it shows up here.
83
+ */
84
+ const assertNoReservedInputs = (inputs) => {
85
+ const offenders = inputs.filter(isReservedInput);
86
+ 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.`);
87
+ };
88
+ /**
43
89
  * Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
44
90
  * `effect` external (#268).
45
91
  *
@@ -53,22 +99,26 @@ const ALCHEMY_EXTERNALS = [
53
99
  * Returns the absolute path of the file written.
54
100
  */
55
101
  const bundleAlchemyEntry = async (options) => {
102
+ if (options.alias) assertAliasKeysAreAdmissible(options.alias);
56
103
  const root = options.absWorkingDir ? path.resolve(options.absWorkingDir) : process.cwd();
57
104
  const outfile = path.resolve(root, options.outfile);
58
105
  await mkdir(path.dirname(outfile), { recursive: true });
59
- await build({
106
+ const result = await build({
60
107
  absWorkingDir: root,
61
108
  bundle: true,
62
109
  entryPoints: [path.resolve(root, options.entry)],
63
110
  external: ALCHEMY_EXTERNALS,
64
111
  format: "esm",
112
+ metafile: true,
65
113
  outfile,
66
114
  packages: options.packages ?? "external",
67
115
  platform: "node",
68
116
  sourcemap: options.sourcemap ?? false,
69
117
  target: options.target ?? "node24",
118
+ ...options.alias ? { alias: { ...options.alias } } : {},
70
119
  ...options.tsconfig ? { tsconfig: path.resolve(root, options.tsconfig) } : {}
71
120
  });
121
+ assertNoReservedInputs(Object.keys(result.metafile.inputs));
72
122
  return outfile;
73
123
  };
74
124
  //#endregion
@@ -210,6 +260,95 @@ const factoryWorkflow = (options) => {
210
260
  });
211
261
  };
212
262
  //#endregion
263
+ //#region src/github-app-token.ts
264
+ /**
265
+ * Minting a GitHub App installation token: the RS256 app JWT, the optional
266
+ * installation lookup, and the token exchange (#617).
267
+ *
268
+ * Two projects had grown the same three steps independently — the factory's
269
+ * check-run publisher and paitronage's proof-comment publisher — which is the
270
+ * admitted-on-repetition bar. Only the *mechanism* lives here. Where the
271
+ * private key comes from, how the app id is configured, and what the token is
272
+ * then used for stay with each consumer: this module is handed credentials and
273
+ * returns a token.
274
+ */
275
+ /** The default request budget, matching the factory's other GitHub writes. */
276
+ const DEFAULT_TIMEOUT_MS = 5e3;
277
+ /** Nine-minute JWT lifetime, backdated a minute against runner clock skew. */
278
+ const JWT_BACKDATE_SECONDS = 60;
279
+ const JWT_LIFETIME_SECONDS = 600;
280
+ /** Carries the HTTP status so a caller can tell a retryable failure apart. */
281
+ var GitHubApiError = class extends Error {
282
+ status;
283
+ constructor(status, statusText) {
284
+ super(`GitHub API ${status} ${statusText}`);
285
+ this.name = "GitHubApiError";
286
+ this.status = status;
287
+ }
288
+ };
289
+ const base64url = (value) => Buffer.from(value).toString("base64url");
290
+ /**
291
+ * The signed app JWT GitHub accepts as `Authorization: Bearer` for the App
292
+ * endpoints. `iss` is stringified because GitHub accepts either spelling and a
293
+ * numeric app id must not depend on JSON's number formatting.
294
+ *
295
+ * The signature is produced from the key on disk and returned; the key
296
+ * material itself never leaves this call.
297
+ */
298
+ const githubAppJwt = (credentials, options = {}) => {
299
+ const nowMs = (options.now ?? Date.now)();
300
+ const issuedAt = Math.floor(nowMs / 1e3) - JWT_BACKDATE_SECONDS;
301
+ const unsigned = `${base64url(JSON.stringify({
302
+ alg: "RS256",
303
+ typ: "JWT"
304
+ }))}.${base64url(JSON.stringify({
305
+ exp: issuedAt + JWT_LIFETIME_SECONDS,
306
+ iat: issuedAt,
307
+ iss: String(credentials.appId)
308
+ }))}`;
309
+ const readKey = options.readPrivateKey ?? readFileSync;
310
+ const signer = createSign("RSA-SHA256");
311
+ signer.update(unsigned);
312
+ signer.end();
313
+ return `${unsigned}.${signer.sign(readKey(credentials.privateKeyPath), "base64url")}`;
314
+ };
315
+ const githubAppJson = async (request, url, jwt, method, timeoutMs) => {
316
+ const response = await request(url, {
317
+ headers: {
318
+ Accept: "application/vnd.github+json",
319
+ Authorization: `Bearer ${jwt}`,
320
+ "X-GitHub-Api-Version": "2022-11-28"
321
+ },
322
+ method,
323
+ signal: AbortSignal.timeout(timeoutMs)
324
+ });
325
+ if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
326
+ return await response.json();
327
+ };
328
+ /**
329
+ * Mint an installation access token for one repository.
330
+ *
331
+ * When the credentials omit `installationId`, the installation is discovered
332
+ * from the repository first — the same call every consumer had written for
333
+ * itself. Nothing is cached: the token is returned to the caller and this
334
+ * module keeps no copy.
335
+ */
336
+ const mintInstallationToken = async (input, options = {}) => {
337
+ const { credentials } = input;
338
+ const request = options.fetch ?? fetch;
339
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
340
+ const jwt = githubAppJwt(credentials, options);
341
+ let { installationId } = credentials;
342
+ if (installationId === void 0) {
343
+ const installation = await githubAppJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/installation`, jwt, "GET", timeoutMs);
344
+ if (typeof installation.id !== "number") throw new TypeError("GitHub App installation response omitted id");
345
+ installationId = installation.id;
346
+ }
347
+ const minted = await githubAppJson(request, `https://api.github.com/app/installations/${installationId}/access_tokens`, jwt, "POST", timeoutMs);
348
+ if (typeof minted.token !== "string") throw new TypeError("GitHub App token response omitted token");
349
+ return minted.token;
350
+ };
351
+ //#endregion
213
352
  //#region src/execute-alchemy-entry.ts
214
353
  /**
215
354
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
@@ -397,14 +536,18 @@ const isProofReuseCommand = (value) => {
397
536
  *
398
537
  * `undefined` means the selection is unusable — not an array, empty, or
399
538
  * carrying an entry whose `command` is blank or whose `name` is not a plain
400
- * command identity. An empty required set would make *every* passing proof
401
- * trivially covering, so it is never silently treated as "requires nothing";
402
- * callers must refuse instead.
539
+ * command identity, or carrying duplicate names. A name is the executable
540
+ * authorization identity recorded in proof, so two commands may never collapse
541
+ * behind one. An empty required set would make *every* passing proof trivially
542
+ * covering, so it is never silently treated as "requires nothing"; callers
543
+ * must refuse instead.
403
544
  */
404
545
  const proofReuseRequiredCommands = (commands) => {
405
546
  if (!(Array.isArray(commands) && commands.length > 0)) return;
406
547
  if (!commands.every(isProofReuseCommand)) return;
407
- return [...new Set(commands.map(({ name }) => name))].toSorted();
548
+ const names = commands.map(({ name }) => name);
549
+ if (new Set(names).size !== names.length) return;
550
+ return names.toSorted();
408
551
  };
409
552
  /**
410
553
  * jq program: every page of the Checks API result in, three sanitized lines
@@ -736,10 +879,10 @@ const factoryProofGateStep = (options) => Object.freeze({
736
879
  * as CI quietly verifying nothing. The drift is real history: #227 wired a
737
880
  * workspace member into CI while the profile never learned about it.
738
881
  */
739
- const proofReuseCoverage = ({ commands, equivalents = {}, skipped }) => {
882
+ const proofReuseCoverage = ({ commands, skipped }) => {
740
883
  const requiredCommands = proofReuseRequiredCommands(commands) ?? [];
741
884
  const proven = new Set(requiredCommands.length > 0 ? commands.map(({ command }) => command.trim()) : []);
742
- const uncovered = [...new Set((Array.isArray(skipped) ? skipped : []).map((command) => String(command).trim()).filter((command) => command.length > 0 && !(proven.has(command) || Object.hasOwn(equivalents, command))))].toSorted();
885
+ const uncovered = [...new Set((Array.isArray(skipped) ? skipped : []).map((command) => String(command).trim()).filter((command) => command.length > 0 && !proven.has(command)))].toSorted();
743
886
  return Object.freeze({
744
887
  covered: requiredCommands.length > 0 && uncovered.length === 0,
745
888
  requiredCommands,
@@ -755,4 +898,268 @@ const assertProofReuseCoverage = (input) => {
755
898
  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.`);
756
899
  };
757
900
  //#endregion
758
- 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 };
901
+ //#region src/workflow-shell-lint.ts
902
+ const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
903
+ /**
904
+ * GitHub evaluates `${{ }}` before bash ever sees the script, and what it
905
+ * substitutes is not knowable here. Neutralizing each expression to one plain
906
+ * word is what the runner's *shape* looks like: a value in argument position.
907
+ * Leaving them in would make every workflow fail to parse; expanding them to
908
+ * nothing would silently change quoting.
909
+ *
910
+ * Scanned rather than matched with a lazy regex, because `}}` occurs inside
911
+ * Actions string literals: `format('refs/{{0}}', github.ref_name)` escapes a
912
+ * literal brace pair that way, and stopping there would leave half an
913
+ * expression in the script and report a parse error the runner never sees.
914
+ */
915
+ const neutralizeExpressions = (script) => {
916
+ let out = "";
917
+ let cursor = 0;
918
+ while (cursor < script.length) {
919
+ const start = script.indexOf("${{", cursor);
920
+ if (start === -1) {
921
+ out += script.slice(cursor);
922
+ break;
923
+ }
924
+ out += script.slice(cursor, start);
925
+ let scan = start + 3;
926
+ let quote;
927
+ let end = -1;
928
+ while (scan < script.length) {
929
+ const char = script[scan];
930
+ if (quote === void 0) {
931
+ if (char === "'" || char === "\"") quote = char;
932
+ else if (char === "}" && script[scan + 1] === "}") {
933
+ end = scan + 2;
934
+ break;
935
+ }
936
+ } else if (char === quote) if (script[scan + 1] === quote) scan += 1;
937
+ else quote = void 0;
938
+ scan += 1;
939
+ }
940
+ if (end === -1) {
941
+ out += script.slice(start);
942
+ break;
943
+ }
944
+ out += "FACTORY_ACTIONS_EXPRESSION";
945
+ cursor = end;
946
+ }
947
+ return out;
948
+ };
949
+ /** Strip one layer of YAML single quoting from a scalar value. */
950
+ const unquote = (raw) => {
951
+ const value = raw.trim();
952
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) return value.slice(1, -1).replaceAll("''", "'");
953
+ return value;
954
+ };
955
+ const indentOf = (line) => line.length - line.trimStart().length;
956
+ /**
957
+ * Where a mapping key sits, counting the `- ` sequence marker as indentation:
958
+ * `- name:` and the `run:` below it are siblings in the same step even though
959
+ * their raw columns differ by two.
960
+ */
961
+ const keyIndentOf = (line) => indentOf(line) + (line.trimStart().startsWith("- ") ? 2 : 0);
962
+ /** A sibling scalar of the `run:` key under inspection, when the line is one. */
963
+ const keyValueAt = (line, indent, key) => {
964
+ if (keyIndentOf(line) !== indent) return;
965
+ const rest = line.trimStart().replace(/^-\s+/u, "");
966
+ return rest.startsWith(`${key}:`) ? rest.slice(key.length + 1) : void 0;
967
+ };
968
+ /**
969
+ * Every `defaults: { run: { shell } }` in the document, with the span it
970
+ * governs: the mapping that declares it, which is the whole workflow at the
971
+ * top level and one job under `jobs:`.
972
+ *
973
+ * GitHub resolves a step's interpreter as step `shell:`, then the job's
974
+ * default, then the workflow's, then bash. A scanner that only looked at the
975
+ * step would call every step in a `defaults.run.shell: sh` workflow bash and
976
+ * report a pass for scripts `sh` cannot parse — the very false negative this
977
+ * control exists to close.
978
+ */
979
+ const defaultShellScopes = (lines) => {
980
+ const scopes = [];
981
+ for (const [index, line] of lines.entries()) {
982
+ if (line.trim() !== "defaults:") continue;
983
+ const depth = keyIndentOf(line);
984
+ const shell = (() => {
985
+ let inRun = false;
986
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
987
+ const candidate = lines[cursor];
988
+ if (candidate.trim().length === 0) continue;
989
+ if (keyIndentOf(candidate) <= depth) return;
990
+ if (keyValueAt(candidate, depth + 2, "run") !== void 0) {
991
+ inRun = true;
992
+ continue;
993
+ }
994
+ if (keyIndentOf(candidate) <= depth + 2) {
995
+ inRun = false;
996
+ continue;
997
+ }
998
+ const value = inRun ? keyValueAt(candidate, depth + 4, "shell") : void 0;
999
+ if (value !== void 0) return unquote(value);
1000
+ }
1001
+ })();
1002
+ if (shell === void 0) continue;
1003
+ let start = 0;
1004
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
1005
+ start = cursor;
1006
+ break;
1007
+ }
1008
+ let end = lines.length;
1009
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
1010
+ end = cursor;
1011
+ break;
1012
+ }
1013
+ scopes.push({
1014
+ depth,
1015
+ end,
1016
+ shell,
1017
+ start
1018
+ });
1019
+ }
1020
+ return scopes;
1021
+ };
1022
+ /** The innermost `defaults.run.shell` governing a line, if any. */
1023
+ const inheritedShell = (scopes, index) => scopes.filter((scope) => index >= scope.start && index < scope.end).toSorted((left, right) => right.depth - left.depth).at(0)?.shell;
1024
+ /**
1025
+ * Every `run:` block in a generated workflow, paired with the `shell:` its
1026
+ * step declares.
1027
+ *
1028
+ * Deliberately a scanner over the emitted text and not a YAML parse: this
1029
+ * package takes no dependency it does not need, and the emitted shape is one
1030
+ * generator's output, not arbitrary YAML. It reads both block scalars
1031
+ * (`run: |-`) and inline scripts.
1032
+ */
1033
+ const workflowRunBlocks = (yaml) => {
1034
+ const lines = yaml.split("\n");
1035
+ const scopes = defaultShellScopes(lines);
1036
+ const blocks = [];
1037
+ for (const [index, line] of lines.entries()) {
1038
+ const match = RUN_KEY.exec(line);
1039
+ if (match?.groups === void 0) continue;
1040
+ const keyIndent = keyIndentOf(line);
1041
+ const inline = match.groups.inline.trim();
1042
+ let script;
1043
+ let end = index;
1044
+ 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: |\`).`);
1045
+ 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.`);
1046
+ if (inline.startsWith("|")) {
1047
+ const body = [];
1048
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
1049
+ const candidate = lines[cursor];
1050
+ if (candidate.trim().length > 0 && indentOf(candidate) <= keyIndent) break;
1051
+ body.push(candidate);
1052
+ end = cursor;
1053
+ }
1054
+ const strip = Math.min(...body.filter((entry) => entry.trim().length > 0).map((entry) => indentOf(entry)));
1055
+ script = body.map((entry) => entry.slice(strip)).join("\n");
1056
+ } else if (inline.length > 0) script = unquote(inline);
1057
+ else continue;
1058
+ let shell;
1059
+ let step;
1060
+ const readSibling = (line_) => {
1061
+ shell ??= keyValueAt(line_, keyIndent, "shell");
1062
+ step ??= keyValueAt(line_, keyIndent, "name");
1063
+ };
1064
+ for (let cursor = index; cursor >= 0 && keyIndentOf(lines[cursor]) >= keyIndent; cursor -= 1) {
1065
+ readSibling(lines[cursor]);
1066
+ if (lines[cursor].trimStart().startsWith("- ")) break;
1067
+ }
1068
+ for (let cursor = end + 1; cursor < lines.length; cursor += 1) {
1069
+ if (keyIndentOf(lines[cursor]) < keyIndent || lines[cursor].trimStart().startsWith("- ")) break;
1070
+ readSibling(lines[cursor]);
1071
+ }
1072
+ const effectiveShell = shell === void 0 ? inheritedShell(scopes, index) : unquote(shell);
1073
+ blocks.push({
1074
+ script,
1075
+ ...effectiveShell === void 0 ? {} : { shell: effectiveShell },
1076
+ ...step === void 0 ? {} : { step: unquote(step) }
1077
+ });
1078
+ }
1079
+ return blocks;
1080
+ };
1081
+ /** `NAME=value` in a shell command template, as `env` takes them. */
1082
+ const ASSIGNMENT = /^[A-Za-z_]\w*=/u;
1083
+ /**
1084
+ * Interpreters GitHub supports that this control deliberately leaves alone.
1085
+ * Named rather than inferred, so an unfamiliar command fails loudly instead of
1086
+ * being skipped as though it had been considered.
1087
+ */
1088
+ const NON_SHELL_INTERPRETERS = new Set([
1089
+ "cmd",
1090
+ "powershell",
1091
+ "pwsh",
1092
+ "python",
1093
+ "python3"
1094
+ ]);
1095
+ /**
1096
+ * The interpreter that will parse a step's script, or `undefined` for one this
1097
+ * control leaves alone.
1098
+ *
1099
+ * A step declaring no shell gets bash: that is GitHub's default for `run:` on
1100
+ * Linux runners. A step declaring `sh` gets `sh`, because the runner runs it
1101
+ * with `sh` — whose grammar is narrower than bash's, so parsing it with bash
1102
+ * would report a pass for a script the runner cannot run. Anything else
1103
+ * (pwsh, python, cmd) is not this control's business.
1104
+ */
1105
+ const parserFor = (shell) => {
1106
+ if (shell === void 0) return ["bash"];
1107
+ const argv = [];
1108
+ let interpreter;
1109
+ for (const token of shell.trim().split(/\s+/u)) {
1110
+ if (token === "{0}") break;
1111
+ if (interpreter !== void 0) {
1112
+ argv.push(token);
1113
+ continue;
1114
+ }
1115
+ const executable = token.slice(token.lastIndexOf("/") + 1);
1116
+ if (executable === "bash" || executable === "sh") interpreter = token;
1117
+ else if (!(executable === "env" || token.startsWith("-") || ASSIGNMENT.test(token))) {
1118
+ if (NON_SHELL_INTERPRETERS.has(executable)) return;
1119
+ 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.`);
1120
+ }
1121
+ argv.push(token);
1122
+ }
1123
+ if (interpreter === void 0) throw new Error(`unrecognized \`shell:\` command: ${shell}. No interpreter to parse the script with.`);
1124
+ return argv;
1125
+ };
1126
+ /** Every `run:` block its interpreter refuses to parse. Empty means sound. */
1127
+ const workflowShellParseFailures = (yaml) => {
1128
+ const failures = [];
1129
+ for (const block of workflowRunBlocks(yaml)) {
1130
+ const parser = parserFor(block.shell);
1131
+ if (parser === void 0) continue;
1132
+ const [executable, ...parserArgs] = parser;
1133
+ const script = neutralizeExpressions(block.script);
1134
+ try {
1135
+ execFileSync(executable, [...parserArgs, "-n"], {
1136
+ input: script,
1137
+ stdio: [
1138
+ "pipe",
1139
+ "ignore",
1140
+ "pipe"
1141
+ ]
1142
+ });
1143
+ } catch (error) {
1144
+ failures.push({
1145
+ script,
1146
+ ...block.step === void 0 ? {} : { step: block.step },
1147
+ stderr: String(error.stderr ?? error).trim()
1148
+ });
1149
+ }
1150
+ }
1151
+ return failures;
1152
+ };
1153
+ /**
1154
+ * Fail the generated-workflow lint when any embedded `run:` block is not
1155
+ * parseable bash. Call it on the YAML a generator is about to write, so the
1156
+ * defect is caught at generation rather than by the runner.
1157
+ */
1158
+ const assertWorkflowShellParses = (yaml, options) => {
1159
+ const failures = workflowShellParseFailures(yaml);
1160
+ if (failures.length === 0) return;
1161
+ const detail = failures.map((failure) => ` - ${failure.step ?? "unnamed step"}: ${failure.stderr.replaceAll("\n", "\n ")}`).join("\n");
1162
+ throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
1163
+ };
1164
+ //#endregion
1165
+ 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, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, workflowRunBlocks, workflowShellParseFailures };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "0.2.0",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "24.13.3",
41
+ "@vitest/coverage-v8": "4.1.10",
41
42
  "oxfmt": "0.59.0",
42
43
  "oxlint": "1.74.0",
43
44
  "tsdown": "0.21.10",
@@ -56,6 +57,8 @@
56
57
  "prefix": "bash ../scripts/ensure-worktree-bootstrap.sh",
57
58
  "fix": "ultracite fix",
58
59
  "pretest": "bash ../scripts/ensure-worktree-bootstrap.sh",
60
+ "precoverage": "bash ../scripts/ensure-worktree-bootstrap.sh",
61
+ "coverage": "vitest run --coverage",
59
62
  "test": "vitest run",
60
63
  "pretypecheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
61
64
  "typecheck": "tsc --noEmit"
@@ -10,6 +10,79 @@ import { build } from "esbuild";
10
10
  */
11
11
  const ALCHEMY_EXTERNALS = ["alchemy", "alchemy/*", "effect", "effect/*"];
12
12
 
13
+ /**
14
+ * The package names behind `ALCHEMY_EXTERNALS`, derived rather than restated so
15
+ * a future external can never be guarded by only one of two lists.
16
+ */
17
+ const RESERVED_ALIAS_PACKAGES = [
18
+ ...new Set(
19
+ ALCHEMY_EXTERNALS.map((external) =>
20
+ external.replace(/\/\*$/u, "").toLowerCase()
21
+ )
22
+ ),
23
+ ];
24
+
25
+ const reservedAliasKey = (key: string): string | undefined =>
26
+ RESERVED_ALIAS_PACKAGES.find(
27
+ (name) => key === name || key.startsWith(`${name}/`)
28
+ );
29
+
30
+ /**
31
+ * Refuse an alias *key* that is itself a reserved package or one of its
32
+ * subpaths. esbuild substitutes aliases before it decides what is external, so
33
+ * such a key defeats `external` outright. Keys are compared, never resolved,
34
+ * which is what makes string matching sound here.
35
+ */
36
+ const assertAliasKeysAreAdmissible = (
37
+ alias: Readonly<Record<string, string>>
38
+ ): void => {
39
+ for (const key of Object.keys(alias)) {
40
+ const reserved = reservedAliasKey(key);
41
+ if (reserved) {
42
+ throw new Error(
43
+ `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.`
44
+ );
45
+ }
46
+ }
47
+ };
48
+
49
+ /**
50
+ * Whether a bundled input lives inside a reserved package.
51
+ *
52
+ * Metafile inputs are paths esbuild resolved and normalized itself, so
53
+ * comparing whole segments here answers what was *bundled* rather than how the
54
+ * config was spelled. Segments are compared case-insensitively: on a
55
+ * case-insensitive filesystem `node_modules/Effect/…` resolves to the real
56
+ * package and the metafile keeps the caller's spelling.
57
+ */
58
+ const isReservedInput = (input: string): boolean => {
59
+ const segments = input.toLowerCase().split(/[/\\]+/u);
60
+ return segments.some(
61
+ (segment, position) =>
62
+ position > 0 &&
63
+ segments[position - 1] === "node_modules" &&
64
+ RESERVED_ALIAS_PACKAGES.includes(segment)
65
+ );
66
+ };
67
+
68
+ /**
69
+ * Assert the externals contract against the bundle esbuild actually produced.
70
+ *
71
+ * Checking alias *values* instead was unsound by construction: `..` segments,
72
+ * symlinks, and every other spelling of the same file each need another string
73
+ * rule, and the scanner loses. The metafile records the inputs after esbuild's
74
+ * own resolution and normalization, so one check closes the whole class —
75
+ * whatever route reached an identity-sensitive package, it shows up here.
76
+ */
77
+ const assertNoReservedInputs = (inputs: readonly string[]): void => {
78
+ const offenders = inputs.filter(isReservedInput);
79
+ if (offenders.length > 0) {
80
+ throw new Error(
81
+ `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.`
82
+ );
83
+ }
84
+ };
85
+
13
86
  export interface BundleAlchemyEntryOptions {
14
87
  /** The Alchemy entry to bundle, e.g. `alchemy.run.ts`. */
15
88
  readonly entry: string;
@@ -17,6 +90,18 @@ export interface BundleAlchemyEntryOptions {
17
90
  readonly outfile: string;
18
91
  /** esbuild's working directory; also the base for relative paths. */
19
92
  readonly absWorkingDir?: string;
93
+ /**
94
+ * Import specifiers to rewrite before resolution, passed straight to
95
+ * esbuild's `alias`. Substitution happens before the `packages` and
96
+ * `external` decisions, so an aliased bare specifier is inlined even under
97
+ * `packages: "external"`. Values are resolved the way esbuild resolves any
98
+ * import, so give absolute paths or package names — which aliases a repo
99
+ * needs is the consumer's policy and this package bakes in none. A key on
100
+ * `alchemy`, `effect`, or a subpath of either is rejected outright, and the
101
+ * finished bundle is checked for files from those packages however they were
102
+ * reached.
103
+ */
104
+ readonly alias?: Readonly<Record<string, string>>;
20
105
  /**
21
106
  * `"external"` (default) leaves every bare import outside the entry's own
22
107
  * source graph to Node's resolver at run time — the entry's TypeScript is
@@ -49,6 +134,10 @@ export interface BundleAlchemyEntryOptions {
49
134
  export const bundleAlchemyEntry = async (
50
135
  options: BundleAlchemyEntryOptions
51
136
  ): Promise<string> => {
137
+ if (options.alias) {
138
+ assertAliasKeysAreAdmissible(options.alias);
139
+ }
140
+
52
141
  const root = options.absWorkingDir
53
142
  ? path.resolve(options.absWorkingDir)
54
143
  : process.cwd();
@@ -56,21 +145,25 @@ export const bundleAlchemyEntry = async (
56
145
 
57
146
  await mkdir(path.dirname(outfile), { recursive: true });
58
147
 
59
- await build({
148
+ const result = await build({
60
149
  absWorkingDir: root,
61
150
  bundle: true,
62
151
  entryPoints: [path.resolve(root, options.entry)],
63
152
  external: ALCHEMY_EXTERNALS,
64
153
  format: "esm",
154
+ metafile: true,
65
155
  outfile,
66
156
  packages: options.packages ?? "external",
67
157
  platform: "node",
68
158
  sourcemap: options.sourcemap ?? false,
69
159
  target: options.target ?? "node24",
160
+ ...(options.alias ? { alias: { ...options.alias } } : {}),
70
161
  ...(options.tsconfig
71
162
  ? { tsconfig: path.resolve(root, options.tsconfig) }
72
163
  : {}),
73
164
  });
74
165
 
166
+ assertNoReservedInputs(Object.keys(result.metafile.inputs));
167
+
75
168
  return outfile;
76
169
  };