@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.test.ts +51 -5
  4. package/akanConfig/akanConfig.ts +33 -14
  5. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  6. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  7. package/akanContext.ts +4 -4
  8. package/akanMcpContract.ts +1 -1
  9. package/applicationBuildRunner.test.ts +57 -2
  10. package/applicationBuildRunner.ts +15 -6
  11. package/artifact/implicitRootLayout.test.ts +2 -0
  12. package/artifact/implicitRootLayout.ts +1 -0
  13. package/biome.base.json +1 -11
  14. package/biomeBase.ts +1 -1
  15. package/cloud/cloudApi.ts +22 -15
  16. package/cloud/cloudHttpError.test.ts +32 -0
  17. package/cloud/cloudHttpError.ts +54 -0
  18. package/cloud/cloudUpdates.ts +138 -0
  19. package/cloud/configLock.ts +2 -1
  20. package/cloud/globalConfig.test.ts +17 -0
  21. package/cloud/globalConfig.ts +9 -2
  22. package/cloud/index.ts +3 -0
  23. package/cloud/pipelineSecrets.ts +114 -0
  24. package/cloud/updatePackUpload.test.ts +100 -45
  25. package/cloud/updatePackUpload.ts +38 -18
  26. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  27. package/codeAgent/agent/akanCodeModel.ts +1 -1
  28. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  29. package/codeAgent/tools/WebToolPack.ts +4 -4
  30. package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
  31. package/commandDecorators/argMeta.ts +11 -2
  32. package/commandDecorators/command.ts +45 -10
  33. package/commandDecorators/commandBuilder.ts +6 -1
  34. package/commandDecorators/commandDecorators.test.ts +205 -4
  35. package/commandDecorators/exitCodeError.ts +15 -0
  36. package/commandDecorators/index.ts +1 -0
  37. package/createTunnel.test.ts +14 -0
  38. package/createTunnel.ts +3 -1
  39. package/dependencyScanner.ts +1 -1
  40. package/devkitUtils.test.ts +1 -1
  41. package/executors.test.ts +131 -3
  42. package/executors.ts +56 -36
  43. package/frontendBuild/autoImportSync.test.ts +3 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  45. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  46. package/frontendBuild/csrArtifactBuilder.ts +23 -4
  47. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  48. package/frontendBuild/cssBlockParser.test.ts +123 -0
  49. package/frontendBuild/cssBlockParser.ts +132 -0
  50. package/frontendBuild/cssCompiler.test.ts +53 -0
  51. package/frontendBuild/cssCompiler.ts +8 -4
  52. package/frontendBuild/fontOptimizer.ts +2 -1
  53. package/frontendBuild/frontendBuild.test.ts +14 -0
  54. package/frontendBuild/hmrWatcher.ts +1 -1
  55. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  56. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  57. package/frontendBuild/routeClientBuilder.ts +1 -1
  58. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  59. package/frontendBuild/themeValidator.test.ts +83 -2
  60. package/frontendBuild/themeValidator.ts +37 -15
  61. package/getRelatedCnsts.ts +2 -1
  62. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  63. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  65. package/integration/devStabilityHarness.ts +5 -3
  66. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  67. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  68. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  69. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  70. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  71. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  72. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  73. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  74. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  75. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  76. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  77. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  78. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  79. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  80. package/mobile/desktopServerStage.test.ts +13 -0
  81. package/mobile/desktopServerStage.ts +5 -7
  82. package/mobile/index.ts +2 -0
  83. package/mobile/mobileTarget.ts +1 -2
  84. package/mobile/nativeApi.test.ts +26 -0
  85. package/mobile/nativeApi.ts +5 -2
  86. package/mobile/nativeApp.test.ts +135 -33
  87. package/mobile/nativeApp.ts +109 -73
  88. package/mobile/nativeConfig.test.ts +3 -0
  89. package/mobile/nativeConfig.ts +5 -3
  90. package/mobile/nativePublicEnv.test.ts +131 -0
  91. package/mobile/nativePublicEnv.ts +116 -0
  92. package/mobile/nativeWebDir.ts +3 -2
  93. package/mobile/serverBundleSecrets.test.ts +79 -0
  94. package/mobile/serverBundleSecrets.ts +60 -16
  95. package/mobile/updatePackFolder.test.ts +98 -0
  96. package/mobile/updatePackFolder.ts +131 -0
  97. package/mobile/updateUploader.test.ts +115 -22
  98. package/mobile/updateUploader.ts +77 -10
  99. package/package.json +20 -20
  100. package/piDependencyPins.test.ts +18 -19
  101. package/pipeline/PipelineCompiler.test.ts +157 -1
  102. package/pipeline/PipelineCompiler.ts +80 -46
  103. package/pipeline/PipelineDefinition.test.ts +9 -0
  104. package/pipeline/PipelineDefinition.ts +4 -3
  105. package/pipeline/PipelineDryRun.test.ts +27 -0
  106. package/pipeline/PipelineDryRun.ts +2 -2
  107. package/pipeline/PipelineInputReader.ts +157 -253
  108. package/pipeline/PipelineRules.ts +92 -0
  109. package/pipeline/index.ts +1 -0
  110. package/platformTest/LinuxDockerTarget.ts +2 -1
  111. package/platformTest/PlatformTestTarget.ts +7 -5
  112. package/platformTest/WindowsSshTarget.ts +2 -1
  113. package/qualityScanner.test.ts +5 -3
  114. package/qualityScanner.ts +1 -1
  115. package/recipeScanner.test.ts +9 -3
  116. package/scanInfo.ts +1 -1
  117. package/slicePlanner.ts +5 -3
  118. package/subspace.test.ts +90 -19
  119. package/subspace.ts +52 -500
  120. package/subspaceClone.ts +362 -0
  121. package/subspaceConfig.ts +1 -2
  122. package/subspaceReport.ts +162 -0
  123. package/testHelpers.ts +2 -0
  124. package/transforms/barrelImportsPlugin.ts +1 -1
  125. package/transforms/browserDirnamePlugin.test.ts +88 -0
  126. package/transforms/browserDirnamePlugin.ts +25 -0
  127. package/transforms/rscUseClientTransform.ts +2 -2
  128. package/transforms/serverEnvPlugin.test.ts +76 -0
  129. package/transforms/serverEnvPlugin.ts +9 -25
  130. package/transforms/serverEnvProfile.test.ts +189 -0
  131. package/transforms/serverEnvProfile.ts +324 -60
  132. package/transforms/transforms.test.ts +18 -2
  133. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  134. package/typecheck/projectTypecheck.ts +6 -5
  135. package/types.ts +1 -0
  136. package/workflow/artifacts.ts +1 -1
  137. package/workflow/rolloutGate.test.ts +5 -1
  138. package/workflow/source.ts +2 -2
@@ -1,4 +1,8 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import path from "node:path";
3
+ import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
4
+ import { isRecord, trimTrailingSlashes } from "akanjs/common";
5
+ import { UpdatePackFolder } from "./updatePackFolder";
2
6
 
3
7
  /** What the uploader needs of an S3 client; Bun.S3Client is one. */
4
8
  export interface UpdateBucket {
@@ -11,6 +15,15 @@ export interface UpdateUploadResult {
11
15
  skipped: number;
12
16
  }
13
17
 
18
+ /** The release one `publish-update` wrote, as it reports it. */
19
+ export interface PublishedUpdate {
20
+ /** `<updates>/<os>-<arch>` for a desktop app, `<updates>/<platform>` for a phone's web bundle. */
21
+ dir: string;
22
+ channel: string;
23
+ /** Its manifest's `bundle`, which tells it from the other manifests of the channel there. */
24
+ bundle: string;
25
+ }
26
+
14
27
  //* A manifest names files that must already be there, so they go first and the manifests last, each signature before
15
28
  //* its manifest as `publish-update` writes them. A release file never changes once published (its name carries its
16
29
  //* hash), so one the bucket holds is skipped; a manifest is always written.
@@ -19,9 +32,9 @@ export class UpdateUploader {
19
32
 
20
33
  /** `s3://bucket/prefix`: the credentials and endpoint come from S3_* (or AWS_*) in the environment. */
21
34
  static parseTarget(target: string): { bucket: string; prefix: string } {
22
- const match = /^s3:\/\/([^/]+)\/?(.*)$/.exec(target.trim());
35
+ const match = /^s3:\/\/([^/]+)(?:\/(.*))?$/.exec(target.trim());
23
36
  if (!match?.[1]) throw new Error(`--upload takes s3://<bucket>/<prefix>, not ${JSON.stringify(target)}.`);
24
- return { bucket: match[1], prefix: (match[2] ?? "").replace(/\/+$/, "") };
37
+ return { bucket: match[1], prefix: trimTrailingSlashes(match[2] ?? "") };
25
38
  }
26
39
 
27
40
  static order(files: string[]): { releases: string[]; manifests: string[] } {
@@ -34,6 +47,64 @@ export class UpdateUploader {
34
47
  };
35
48
  }
36
49
 
50
+ //* Never the whole folder: pack-update's records sit there too, and so do manifests of other channels and native
51
+ //* APIs, which a later publish from another machine may have replaced.
52
+ static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
53
+ const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
54
+ const desktop = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
+ if (desktop?.bundle === bundle) {
56
+ const archive = desktop.archive?.url;
57
+ const patches = UpdateUploader.#field(desktop.patches, "url");
58
+ return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
59
+ }
60
+ const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
61
+ for (const name of names.filter((name) => name.endsWith(".json"))) {
62
+ const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
63
+ const manifest =
64
+ typeof envelope?.manifest === "string"
65
+ ? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
66
+ : null;
67
+ if (manifest?.bundle !== bundle) continue;
68
+ const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
69
+ if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
70
+ throw new Error(
71
+ `${path.join(dir, channel, name)} lists a file whose sha256 is not 64 hex digits; nothing was uploaded.`,
72
+ );
73
+ return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
74
+ }
75
+ throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
76
+ }
77
+
78
+ static #keyOf(updatesDir: string, dir: string, file: unknown) {
79
+ const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
80
+ const key = path.relative(updatesDir, resolved);
81
+ if (!resolved.startsWith(`${path.resolve(dir)}${path.sep}`) || key.startsWith("..") || path.isAbsolute(key))
82
+ throw new Error(
83
+ `The release in ${dir} names ${typeof file === "string" ? JSON.stringify(file) : "a file with no path"}, which is not inside that folder; nothing was uploaded.`,
84
+ );
85
+ return key.split(path.sep).join("/");
86
+ }
87
+
88
+ static #field(list: unknown, key: string): unknown[] {
89
+ return Array.isArray(list) ? list.map((item) => (isRecord(item) ? item[key] : undefined)) : [];
90
+ }
91
+
92
+ static async #read<T>(file: string) {
93
+ const text = await Bun.file(file)
94
+ .text()
95
+ .catch(() => "");
96
+ return UpdateUploader.#parse<T>(text);
97
+ }
98
+
99
+ static #parse<T>(text: string): Partial<T> | null {
100
+ try {
101
+ const value: unknown = JSON.parse(text);
102
+ return isRecord(value) ? (value as Partial<T>) : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
37
108
  constructor(
38
109
  readonly bucket: UpdateBucket,
39
110
  readonly prefix: string,
@@ -44,12 +115,8 @@ export class UpdateUploader {
44
115
  return new UpdateUploader(new Bun.S3Client({ bucket }), prefix);
45
116
  }
46
117
 
47
- async upload(dir: string): Promise<UpdateUploadResult> {
48
- const files: string[] = [];
49
- for await (const file of new Bun.Glob("**/*").scan({ cwd: dir, onlyFiles: true })) {
50
- if (!file.endsWith(".tmp")) files.push(file.split(path.sep).join("/"));
51
- }
52
- const { releases, manifests } = UpdateUploader.order(files);
118
+ async upload(updatesDir: string, release: PublishedUpdate): Promise<UpdateUploadResult> {
119
+ const { releases, manifests } = UpdateUploader.order(await UpdateUploader.releaseFiles(updatesDir, release));
53
120
  const keyOf = (file: string) => (this.prefix ? `${this.prefix}/${file}` : file);
54
121
  let uploaded = 0;
55
122
  let skipped = 0;
@@ -58,12 +125,12 @@ export class UpdateUploader {
58
125
  skipped++;
59
126
  continue;
60
127
  }
61
- await this.bucket.write(keyOf(file), Bun.file(path.join(dir, file)));
128
+ await this.bucket.write(keyOf(file), Bun.file(path.join(updatesDir, file)));
62
129
  uploaded++;
63
130
  }
64
131
  for (const file of manifests) {
65
132
  const type = file.endsWith(".json") ? "application/json" : "text/plain";
66
- await this.bucket.write(keyOf(file), Bun.file(path.join(dir, file)), { type });
133
+ await this.bucket.write(keyOf(file), Bun.file(path.join(updatesDir, file)), { type });
67
134
  uploaded++;
68
135
  }
69
136
  return { uploaded, skipped };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.1",
3
+ "version": "3.1.0-rc.10",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -40,31 +40,31 @@
40
40
  "./*": "./*.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@earendil-works/chord": "0.86.1",
44
- "@earendil-works/pi-agent-core": "0.86.1",
45
- "@earendil-works/pi-ai": "0.86.1",
46
- "@earendil-works/pi-coding-agent": "0.86.1",
47
- "@earendil-works/pi-telemetry": "0.86.1",
48
- "@earendil-works/pi-tui": "0.86.1",
49
- "@inquirer/prompts": "^8.4.3",
50
- "@tailwindcss/node": "^4.3.0",
51
- "akanjs": "3.1.0-rc.1",
52
- "chalk": "^5.6.2",
53
- "commander": "^14.0.3",
54
- "dayjs": "^1.11.20",
55
- "fontaine": "^0.8.0",
43
+ "@earendil-works/pi-agent-core": "1.3.0",
44
+ "@earendil-works/pi-ai": "1.3.0",
45
+ "@earendil-works/pi-codemode": "1.3.0",
46
+ "@earendil-works/pi-coding-agent": "1.3.0",
47
+ "@earendil-works/pi-mcp": "1.3.0",
48
+ "@earendil-works/pi-tui": "1.3.0",
49
+ "@inquirer/prompts": "^8.7.3",
50
+ "@tailwindcss/node": "^4.3.3",
51
+ "akanjs": "3.1.0-rc.10",
52
+ "chalk": "^6.0.1",
53
+ "commander": "^15.0.0",
54
+ "dayjs": "^1.11.23",
55
+ "fontaine": "^1.0.1",
56
56
  "fonteditor-core": "^2.6.3",
57
- "ignore": "^7.0.5",
58
- "ora": "^9.4.0",
57
+ "ignore": "^7.0.12",
58
+ "ora": "^9.4.1",
59
59
  "ssh2": "^1.17.0",
60
- "subset-font": "^2.5.0",
60
+ "subset-font": "^2.9.0",
61
61
  "tailwind-scrollbar": "^4.0.2",
62
- "tailwindcss": "^4.3.0",
63
- "typebox": "1.1.38",
62
+ "tailwindcss": "^4.3.3",
63
+ "typebox": "1.3.37",
64
64
  "typescript": "^6.0.3"
65
65
  },
66
66
  "peerDependencies": {
67
- "react": "19.3.0"
67
+ "react": "19.3.1"
68
68
  },
69
69
  "peerDependenciesMeta": {
70
70
  "react": {
@@ -2,31 +2,24 @@ import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import type { PackageJson } from "@akanjs/devkit/types";
4
4
 
5
- //? A published install reads neither pi's npm-shrinkwrap.json nor our overrides, so PackageRunner re-declares pi's
6
- //? sibling pins (a floating 0.80.x once broke every global install); this keeps them equal to the shrinkwrap.
5
+ //? PackageRunner re-declares pi's sibling pins because neither our `overrides` nor pi's own ranges reach a
6
+ //? consumer's install. The source of truth is pi's `dependencies`: since 1.3.0 it pins every sibling exactly, where
7
+ //? 0.86.1 floated `^0.86.1` and only the (now removed) `npm-shrinkwrap.json` held the resolved version.
7
8
 
8
9
  const repoRoot = path.resolve(import.meta.dir, "../../..");
9
10
  const piName = "@earendil-works/pi-coding-agent";
10
11
  const piDir = `${repoRoot}/node_modules/${piName}`;
11
12
  const publishedPackages = ["pkgs/@akanjs/devkit/package.json", "pkgs/@akanjs/cli/package.json"];
12
13
 
13
- interface Shrinkwrap {
14
- packages: Record<string, { version?: string }>;
15
- }
16
-
17
14
  const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
18
15
 
19
- const shrinkwrappedPins = async () => {
20
- const { packages } = await readJson<Shrinkwrap>(`${piDir}/npm-shrinkwrap.json`);
21
- // Top-level entries only: a nested `node_modules/` key is another package's placement, not ours to pin.
22
- const entries = Object.entries(packages)
23
- .filter(([key]) => /^node_modules\/@earendil-works\/[^/]+$/.test(key))
24
- .map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const);
25
- return Object.fromEntries(entries);
16
+ const siblingEntries = async () => {
17
+ const { dependencies = {} } = await readJson<PackageJson>(`${piDir}/package.json`);
18
+ return Object.entries(dependencies).filter(([name]) => /^@earendil-works\/[^/]+$/.test(name));
26
19
  };
27
20
 
28
21
  describe("pi dependency pins", () => {
29
- test("the shrinkwrap read here belongs to the coding agent we publish", async () => {
22
+ test("the package read here belongs to the coding agent we publish", async () => {
30
23
  const installed = await readJson<PackageJson>(`${piDir}/package.json`);
31
24
  for (const file of publishedPackages) {
32
25
  const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
@@ -34,13 +27,19 @@ describe("pi dependency pins", () => {
34
27
  }
35
28
  });
36
29
 
37
- test("every sibling pi package is published at the version the coding agent shrinkwrapped", async () => {
38
- const pins = await shrinkwrappedPins();
39
- expect(Object.keys(pins).length).toBeGreaterThan(0);
30
+ test("the coding agent pins each sibling exactly", async () => {
31
+ const siblings = await siblingEntries();
32
+ expect(siblings.length).toBeGreaterThan(0);
33
+ const floating = siblings.filter(([, version]) => !/^\d+\.\d+\.\d+/.test(version));
34
+ expect(floating).toEqual([]);
35
+ });
36
+
37
+ test("every sibling pi package is published at the version the coding agent pins", async () => {
38
+ const siblings = await siblingEntries();
40
39
  for (const file of publishedPackages) {
41
40
  const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
42
- const declared = Object.fromEntries(Object.keys(pins).map((name) => [name, dependencies[name]]));
43
- expect({ file, ...declared }).toEqual({ file, ...pins });
41
+ const declared = Object.fromEntries(siblings.map(([name]) => [name, dependencies[name]]));
42
+ expect({ file, ...declared }).toEqual({ file, ...Object.fromEntries(siblings) });
44
43
  }
45
44
  });
46
45
  });
@@ -337,7 +337,28 @@ describe("PipelineCompiler", () => {
337
337
  ]);
338
338
  const e2e = plan.phases.at(-1);
339
339
  expect(e2e).toMatchObject({ id: "e2e", timeoutSeconds: 3600, milestone: "finish" });
340
- expect(e2e?.stages.map((stage) => [stage.id, stage.name, stage.app])).toEqual([["e2e:api", "api", "api"]]);
340
+ expect(e2e?.stages.map((stage) => [stage.id, stage.name, stage.app])).toEqual([["e2e.apps.api", "api", "api"]]);
341
+ });
342
+
343
+ test("a matrix copy's id never collides with an app's own stage or a post step", () => {
344
+ const stageIds = (plan: PipelinePlan) => plan.phases.flatMap((phase) => phase.stages.map((stage) => stage.id));
345
+ const plan = planOf(
346
+ compile({
347
+ workspace: { after: [{ name: "Web", matrix: "apps", sh: "bun run e2e $AKAN_CI_APP" }] },
348
+ apps: { web: { stages: [{ use: "build" }, { name: "API", sh: "bun run test:api" }] } },
349
+ }),
350
+ );
351
+ expect(stageIds(plan)).toEqual(expect.arrayContaining(["web:api", "web.apps.api", "web.apps.web"]));
352
+ expect(new Set(stageIds(plan)).size).toBe(stageIds(plan).length);
353
+ const post = planOf(
354
+ compile({
355
+ ctx: context({ apps: ["success"] }),
356
+ workspaceApps: ["success"],
357
+ workspace: { after: [{ name: "Post", matrix: "apps", sh: "x" }], post: { success: ["y"] } },
358
+ }),
359
+ );
360
+ expect(stageIds(post)).toContain("post.apps.success");
361
+ expect(post.post.success.map((step) => step.id)).toEqual(["post.success/1"]);
341
362
  });
342
363
 
343
364
  test("env is workspace, then app, then stage, then step; secrets are each step's union", () => {
@@ -571,6 +592,10 @@ describe("PipelineCompiler", () => {
571
592
  expect(codes(appResult({ stages: [{ name: "x", sh: "x", secrets: ["lower"] }] }))).toContain(
572
593
  "pipeline.secret.name",
573
594
  );
595
+ const pasted = appResult({ stages: [{ name: "x", sh: "x", secrets: ["NPM_TOKEN", "npm_live_value"] }] });
596
+ const secretName = pasted.diagnostics.find((diagnostic) => diagnostic.code === "pipeline.secret.name");
597
+ expect(secretName?.path).toEndWith(".secrets[1]");
598
+ expect(secretName?.message).not.toContain("npm_live_value");
574
599
  expect(codes(appResult({ stages: [{ name: "x", steps: [{ sh: "x", cwd: "../up" }] }] }))).toContain(
575
600
  "pipeline.cwd.outside",
576
601
  );
@@ -604,6 +629,30 @@ describe("PipelineCompiler", () => {
604
629
  );
605
630
  });
606
631
 
632
+ test("a parallel branch may not take the id of a post list's steps", () => {
633
+ const refused = compile({
634
+ workspace: { before: [{ name: "Post", parallel: [{ name: "Success", sh: "x" }] }], post: { success: ["y"] } },
635
+ });
636
+ expect(refused.ok).toBe(false);
637
+ expect(refused.diagnostics).toEqual([
638
+ expect.objectContaining({
639
+ code: "pipeline.id.reserved",
640
+ file: "akan.config.ts",
641
+ path: "pipeline.before[0].parallel[0].name",
642
+ }),
643
+ ]);
644
+ expect(
645
+ compile({
646
+ workspace: { after: [{ id: "post", name: "Checks", parallel: [{ id: "always", name: "A", sh: "x" }] }] },
647
+ }).diagnostics,
648
+ ).toEqual([expect.objectContaining({ code: "pipeline.id.reserved", path: "pipeline.after[0].parallel[0].id" })]);
649
+ const otherBranch = compile({
650
+ workspace: { before: [{ name: "Post", parallel: [{ name: "Lint", sh: "x" }] }], post: { success: ["y"] } },
651
+ });
652
+ expect(otherBranch.ok).toBe(true);
653
+ expect(compile({ workspace: { after: [{ name: "Post", sh: "x" }], post: { success: ["y"] } } }).ok).toBe(true);
654
+ });
655
+
607
656
  test("loading and sampling", () => {
608
657
  const thrown = appResult(() => {
609
658
  throw new Error("boom");
@@ -620,6 +669,113 @@ describe("PipelineCompiler", () => {
620
669
  expect(codes(sampled)).toContain("pipeline.function.sampled");
621
670
  });
622
671
 
672
+ test("a function returning nothing, an async one, and a falsy pipeline are errors, never a default", async () => {
673
+ const refused = (result: PipelinePlanResult, file: string) => {
674
+ expect(result.ok).toBe(false);
675
+ expect(result.diagnostics).toContainEqual(
676
+ expect.objectContaining({ severity: "error", code: "pipeline.schema.invalid", file, path: "pipeline" }),
677
+ );
678
+ return result.diagnostics.map((diagnostic) => diagnostic.message).join("\n");
679
+ };
680
+ const onMainOnly = ((ctx: { branch: string }) =>
681
+ ctx.branch === "main" ? { stages: [{ use: "build" }] } : undefined) as unknown as AppPipelineConfig;
682
+ expect(appResult(onMainOnly).ok).toBe(true);
683
+ const develop = compile({ ctx: context({ branch: "develop", apps: ["web"] }), apps: { web: onMainOnly } });
684
+ expect(refused(develop, "apps/web/akan.config.ts")).toContain("returned nothing");
685
+ expect(refused(compile({ workspace: (() => undefined) as never }), "akan.config.ts")).toContain(
686
+ "returned nothing",
687
+ );
688
+ expect(refused(compile({ workspace: (async () => ({ before: [] })) as never }), "akan.config.ts")).toContain(
689
+ "Promise",
690
+ );
691
+ const rejecting = (async () => {
692
+ throw new Error("boom");
693
+ }) as unknown as AppPipelineConfig;
694
+ expect(refused(appResult(rejecting), "apps/web/akan.config.ts")).toContain("Promise");
695
+ await Bun.sleep(1);
696
+ expect(refused(appResult(false as never), "apps/web/akan.config.ts")).toContain("must be an object");
697
+ expect(refused(compile({ workspace: 0 as never }), "akan.config.ts")).toContain("must be an object");
698
+ });
699
+
700
+ test("a plan larger than an executor reads, as written, is refused", () => {
701
+ const steps = Array.from({ length: 20 }, () => "x".repeat(60_000));
702
+ const result = appResult({ stages: [{ use: "build" }, { name: "Big", steps }] });
703
+ expect(result.ok).toBe(false);
704
+ expect(result.diagnostics).toContainEqual(
705
+ expect.objectContaining({ code: "pipeline.limit.exceeded", file: null, path: null }),
706
+ );
707
+ });
708
+
709
+ test("conditions", () => {
710
+ expect(appResult({ stages: [{ use: "build", when: { trigger: [] } }] }).diagnostics).toContainEqual(
711
+ expect.objectContaining({ code: "pipeline.schema.invalid", path: "pipeline.stages[0].when.trigger" }),
712
+ );
713
+ const branch = Array.from({ length: 6 }, (_, idx) => `release/${String(idx).repeat(240)}`);
714
+ const plan = planOf(
715
+ compile({
716
+ ctx: context({ branch: "develop", env: "develop", apps: ["web"] }),
717
+ apps: { web: { stages: [{ use: "build", when: { branch } }] } },
718
+ }),
719
+ );
720
+ const reason = plan.phases[0]?.stages[0]?.skip?.reason ?? "";
721
+ expect(reason.startsWith('branch "develop" does not match "release/')).toBe(true);
722
+ expect(reason.endsWith("…")).toBe(true);
723
+ expect(reason.length).toBeLessThanOrEqual(1024);
724
+ });
725
+
726
+ test("a value the plan would refuse is reported where it was written", () => {
727
+ expect(
728
+ appResult({ stages: [{ use: "build" }, { name: "x", sh: "x", env: { BIG: "x".repeat(64 * 1024 + 1) } }] })
729
+ .diagnostics,
730
+ ).toContainEqual(
731
+ expect.objectContaining({ code: "pipeline.limit.exceeded", path: "pipeline.stages[1].env.BIG" }),
732
+ );
733
+ const unset = compile({ workspace: { before: [{ name: "Checks", parallel: undefined as never }] } });
734
+ expect(unset.ok).toBe(false);
735
+ expect(unset.diagnostics).toEqual([
736
+ expect.objectContaining({
737
+ code: "pipeline.schema.invalid",
738
+ file: "akan.config.ts",
739
+ path: "pipeline.before[0].parallel",
740
+ }),
741
+ ]);
742
+ });
743
+
744
+ test("a key set to undefined is the key left out", () => {
745
+ const stage = { name: "Test", sh: "bun test", use: undefined, parallel: undefined, matrix: undefined };
746
+ const plan = planOf(
747
+ compile({
748
+ workspace: {
749
+ before: [stage as never, { name: "Checks", parallel: [{ name: "Lint", sh: "x", use: undefined }] }],
750
+ },
751
+ apps: { web: { stages: [{ use: "build", targets: undefined } as never, stage as never] } },
752
+ }),
753
+ );
754
+ expect(phaseIds(plan)).toEqual(["test", "checks", "build", "after-build", "docker", "deploy"]);
755
+ expect(appResult({ stages: [{ use: undefined } as never] }).diagnostics).toContainEqual(
756
+ expect.objectContaining({ code: "pipeline.schema.invalid", path: "pipeline.stages[0].use" }),
757
+ );
758
+ });
759
+
760
+ test("a value JSON cannot hold is reported, never thrown", () => {
761
+ const cyclic: Record<string, unknown> = {};
762
+ cyclic.self = cyclic;
763
+ const result = appResult({ stages: [{ use: "build", timeout: 10n as never }, { use: cyclic as never }] });
764
+ expect(result.ok).toBe(false);
765
+ expect(result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message.split(" ")[0]])).toEqual([
766
+ ["pipeline.duration.invalid", "10n"],
767
+ ["pipeline.schema.invalid", "[object"],
768
+ ]);
769
+ });
770
+
771
+ test("a long first line names its step without splitting a surrogate pair", () => {
772
+ const plan = planOf(
773
+ appResult({ stages: [{ use: "build" }, { name: "Emoji", sh: `${"a".repeat(78)}${"😀".repeat(5)}` }] }),
774
+ );
775
+ const name = plan.phases.find((phase) => phase.id === "after-build")?.stages[0]?.steps[0]?.name;
776
+ expect(name).toBe(`${"a".repeat(78)}…`);
777
+ });
778
+
623
779
  test("milestones that move backwards are a warning", () => {
624
780
  const result = appResult({
625
781
  stages: [{ use: "build" }, { use: "docker" }, { name: "Late", sh: "x", milestone: "verify" }],
@@ -25,19 +25,21 @@ import {
25
25
  pipelineActions,
26
26
  pipelineMilestones,
27
27
  pipelineOtaPlatforms,
28
+ pipelinePlanLimits,
28
29
  pipelinePlanVersion,
30
+ toError,
29
31
  } from "akanjs/common";
30
- import {
31
- type PipelineActionStageRule,
32
- type PipelineAppRule,
33
- PipelineInputReader,
34
- type PipelinePostRule,
35
- type PipelineShellStageRule,
36
- type PipelineStepRule,
37
- type PipelineWhenRule,
38
- type PipelineWorkspaceRule,
39
- type PipelineWorkspaceStageRule,
40
- } from "./PipelineInputReader";
32
+ import { PipelineInputReader } from "./PipelineInputReader";
33
+ import type {
34
+ PipelineActionStageRule,
35
+ PipelineAppRule,
36
+ PipelinePostRule,
37
+ PipelineShellStageRule,
38
+ PipelineStepRule,
39
+ PipelineWhenRule,
40
+ PipelineWorkspaceRule,
41
+ PipelineWorkspaceStageRule,
42
+ } from "./PipelineRules";
41
43
 
42
44
  export interface PipelineSourceFile<T> {
43
45
  /** Relative to the workspace root, with `/`. */
@@ -59,7 +61,6 @@ export interface PipelineCompileInput {
59
61
  akanVersion: string;
60
62
  /** Function forms run against a sample context (`validate`), which is said once per file. */
61
63
  sampled?: boolean;
62
- /** What loading the files already found. */
63
64
  diagnostics?: readonly PipelineDiagnostic[];
64
65
  }
65
66
 
@@ -87,7 +88,6 @@ interface StageOptions {
87
88
  timeoutSeconds?: number | null;
88
89
  }
89
90
 
90
- /** Composes the workspace's and each app's rules into one plan for one run. */
91
91
  export class PipelineCompiler {
92
92
  static readonly #slotAfter = {
93
93
  build: "after-build",
@@ -112,6 +112,8 @@ export class PipelineCompiler {
112
112
  static readonly #reservedPhaseId =
113
113
  /^(?:build|docker|deploy|ota|(?:before-build|after-build|after-docker|after-deploy|after-ota)(?:-\d+)?)$/;
114
114
  static readonly #actionStepNames = { build: "akan build", docker: "image", deploy: "deploy", ota: "ota" } as const;
115
+ //? PipelinePlanSchema reads at most 1024 characters of a skip reason.
116
+ static readonly #reasonChars = 1024;
115
117
 
116
118
  static compile(input: PipelineCompileInput): PipelinePlanResult {
117
119
  return new PipelineCompiler(input).#compile();
@@ -130,13 +132,9 @@ export class PipelineCompiler {
130
132
  const { context, workspaceApps } = this.#input;
131
133
  this.#checkContext();
132
134
  const pipelineContext: PipelineContext = {
133
- branch: context.branch,
134
- env: context.env,
135
+ ...context,
135
136
  apps: [...context.apps],
136
137
  workspaceApps: [...workspaceApps],
137
- trigger: context.trigger,
138
- buildNum: context.buildNum,
139
- commit: context.commit,
140
138
  };
141
139
  const workspace = this.#readWorkspace(pipelineContext);
142
140
  const lanes = new Map<string, AppLane>();
@@ -164,11 +162,23 @@ export class PipelineCompiler {
164
162
  try {
165
163
  PipelinePlanSchema.parse(plan);
166
164
  } catch (error) {
167
- const message = error instanceof Error ? error.message : String(error);
165
+ const message = toError(error).message;
168
166
  this.#error(message.includes("at most") ? "pipeline.limit.exceeded" : "pipeline.internal", message, null, null);
169
167
  return this.#failed();
170
168
  }
171
- return { ok: true, plan, diagnostics: this.#diagnostics };
169
+ const result: PipelinePlanResult = { ok: true, plan, diagnostics: this.#diagnostics };
170
+ //? Sized as `akan pipeline plan --out` writes it, since that text is what an executor's parseText bounds.
171
+ const bytes = new TextEncoder().encode(`${JSON.stringify(result, null, 2)}\n`).length;
172
+ if (bytes > pipelinePlanLimits.planBytes) {
173
+ this.#error(
174
+ "pipeline.limit.exceeded",
175
+ `the plan is ${bytes} bytes as written, and an executor reads at most ${pipelinePlanLimits.planBytes}`,
176
+ null,
177
+ null,
178
+ );
179
+ return this.#failed();
180
+ }
181
+ return result;
172
182
  }
173
183
 
174
184
  #checkContext() {
@@ -184,11 +194,11 @@ export class PipelineCompiler {
184
194
 
185
195
  #readWorkspace(context: PipelineContext): PipelineWorkspaceRule | null {
186
196
  const source = this.#input.workspace;
187
- if (!source?.pipeline) return null;
188
- const value = this.#evaluate(source.pipeline, context, source.file);
189
- if (value === undefined) return null;
197
+ if (source?.pipeline == null) return null;
198
+ const evaluated = this.#evaluate(source.pipeline, context, source.file);
199
+ if (!evaluated) return null;
190
200
  const reader = new PipelineInputReader(source.file);
191
- const rule = reader.workspace(value);
201
+ const rule = reader.workspace(evaluated.value);
192
202
  this.#diagnostics.push(...reader.diagnostics);
193
203
  return rule;
194
204
  }
@@ -197,34 +207,55 @@ export class PipelineCompiler {
197
207
  const source = this.#input.apps.get(app);
198
208
  if (!source) return null;
199
209
  const appContext: AppPipelineContext = { ...context, app };
200
- const value = source.pipeline ? this.#evaluate(source.pipeline, appContext, source.file) : defaultAppPipeline;
201
- if (value === undefined) return null;
210
+ const evaluated =
211
+ source.pipeline == null
212
+ ? { value: defaultAppPipeline }
213
+ : this.#evaluate(source.pipeline, appContext, source.file);
214
+ if (!evaluated) return null;
202
215
  const reader = new PipelineInputReader(source.file);
203
- const rule = reader.app(value);
216
+ const rule = reader.app(evaluated.value);
204
217
  this.#diagnostics.push(...reader.diagnostics);
205
218
  return rule ? this.#lane(app, rule, source.file) : null;
206
219
  }
207
220
 
208
- #evaluate<C>(config: unknown, context: C, file: string): unknown {
209
- if (typeof config !== "function") return config;
210
- if (this.#input.sampled)
211
- this.#warning(
212
- "pipeline.function.sampled",
213
- "a function form was checked against one sample context; other branches, triggers or apps may differ",
214
- file,
215
- "pipeline",
216
- );
217
- try {
218
- return (config as (context: C) => unknown)(context);
219
- } catch (error) {
221
+ #evaluate<C>(config: unknown, context: C, file: string): { value: unknown } | null {
222
+ let value = config;
223
+ if (typeof config === "function") {
224
+ if (this.#input.sampled)
225
+ this.#warning(
226
+ "pipeline.function.sampled",
227
+ "a function form was checked against one sample context; other branches, triggers or apps may differ",
228
+ file,
229
+ "pipeline",
230
+ );
231
+ try {
232
+ value = (config as (context: C) => unknown)(context);
233
+ } catch (error) {
234
+ this.#error("pipeline.load.failed", `the pipeline function threw: ${toError(error).message}`, file, "pipeline");
235
+ return null;
236
+ }
237
+ if (value === undefined) {
238
+ this.#error(
239
+ "pipeline.schema.invalid",
240
+ "the pipeline function returned nothing for this run: return a pipeline on every path",
241
+ file,
242
+ "pipeline",
243
+ );
244
+ return null;
245
+ }
246
+ }
247
+ if (typeof (value as PromiseLike<unknown> | null)?.then === "function") {
248
+ //? Handled so a rejection cannot end the CLI before it writes the envelope.
249
+ void Promise.resolve(value).catch(() => undefined);
220
250
  this.#error(
221
- "pipeline.load.failed",
222
- `the pipeline function threw: ${error instanceof Error ? error.message : String(error)}`,
251
+ "pipeline.schema.invalid",
252
+ "is a Promise, which the planner cannot wait for: return the pipeline itself, not from an async function",
223
253
  file,
224
254
  "pipeline",
225
255
  );
226
- return undefined;
256
+ return null;
227
257
  }
258
+ return { value };
228
259
  }
229
260
 
230
261
  #lane(app: string, rule: PipelineAppRule, file: string): AppLane {
@@ -552,7 +583,9 @@ export class PipelineCompiler {
552
583
  maxParallel: stage.maxParallel,
553
584
  failFast: false,
554
585
  timeoutSeconds: stage.timeoutSeconds,
555
- stages: apps.map((app) => this.#shellStage(copy, { id: `${stage.id}:${app}`, name: app, app, milestone, env })),
586
+ stages: apps.map((app) =>
587
+ this.#shellStage(copy, { id: `${stage.id}.apps.${app}`, name: app, app, milestone, env }),
588
+ ),
556
589
  },
557
590
  ];
558
591
  }
@@ -603,10 +636,11 @@ export class PipelineCompiler {
603
636
 
604
637
  #skipOf(when: PipelineWhenRule): PipelineSkip | null {
605
638
  const { branch, trigger } = this.#input.context;
639
+ const skip = (reason: string) => ({ reason: PipelineInputReader.clip(reason, PipelineCompiler.#reasonChars) });
606
640
  if (when.branch && !PipelinePattern.matches(when.branch, branch))
607
- return { reason: `branch "${branch}" does not match ${when.branch.map((glob) => `"${glob}"`).join(", ")}` };
641
+ return skip(`branch "${branch}" does not match ${when.branch.map((glob) => `"${glob}"`).join(", ")}`);
608
642
  if (when.trigger && !when.trigger.includes(trigger))
609
- return { reason: `trigger "${trigger}" is not ${when.trigger.map((name) => `"${name}"`).join(" or ")}` };
643
+ return skip(`trigger "${trigger}" is not ${when.trigger.map((name) => `"${name}"`).join(" or ")}`);
610
644
  return null;
611
645
  }
612
646
 
@@ -615,7 +649,7 @@ export class PipelineCompiler {
615
649
  const sourceOf = (source: PipelineSourceFile<unknown>): PipelinePlanSource => ({
616
650
  file: source.file,
617
651
  sha256: source.sha256,
618
- declared: source.pipeline !== null,
652
+ declared: source.pipeline != null,
619
653
  });
620
654
  return {
621
655
  workspace: workspace ? sourceOf(workspace) : null,