@intentius/chant 0.124.31 → 0.124.32

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 (321) hide show
  1. package/bin/chant +7 -2
  2. package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-plugin.js +10 -7
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.js +18 -12
  7. package/dist/cli/commands/import.d.ts +10 -0
  8. package/dist/cli/commands/import.d.ts.map +1 -1
  9. package/dist/cli/commands/import.js +37 -0
  10. package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
  11. package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
  12. package/dist/cli/commands/onboard.d.ts.map +1 -1
  13. package/dist/cli/commands/onboard.js +8 -4
  14. package/dist/cli/commands/update.d.ts.map +1 -1
  15. package/dist/cli/commands/update.js +2 -1
  16. package/dist/cli/dependencies.d.ts +18 -0
  17. package/dist/cli/dependencies.d.ts.map +1 -0
  18. package/dist/cli/dependencies.js +120 -0
  19. package/dist/cli/handlers/build.d.ts.map +1 -1
  20. package/dist/cli/handlers/build.js +23 -4
  21. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  22. package/dist/cli/handlers/fan-out.js +12 -4
  23. package/dist/cli/handlers/graph.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.js +16 -6
  25. package/dist/cli/handlers/lint.d.ts.map +1 -1
  26. package/dist/cli/handlers/lint.js +6 -0
  27. package/dist/cli/handlers/operator.d.ts +2 -2
  28. package/dist/cli/handlers/operator.js +3 -3
  29. package/dist/cli/handlers/pr.d.ts +14 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +488 -32
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +3 -1
  34. package/dist/cli/handlers/run.d.ts +13 -1
  35. package/dist/cli/handlers/run.d.ts.map +1 -1
  36. package/dist/cli/handlers/run.js +38 -15
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +44 -16
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +15 -0
  41. package/dist/cli/registry.d.ts +13 -4
  42. package/dist/cli/registry.d.ts.map +1 -1
  43. package/dist/cli/registry.js +6 -2
  44. package/dist/codegen/generate-post-synth-barrel.js +2 -2
  45. package/dist/codegen/lexicon-regen.d.ts +4 -0
  46. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  47. package/dist/codegen/lexicon-regen.js +1 -1
  48. package/dist/codegen/pinned-upgrade.js +2 -2
  49. package/dist/components/capability.d.ts +44 -3
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/capability.js +21 -0
  52. package/dist/components/changed-files.d.ts +7 -1
  53. package/dist/components/changed-files.d.ts.map +1 -1
  54. package/dist/components/changed-files.js +9 -1
  55. package/dist/components/driver.d.ts +38 -0
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/driver.js +47 -6
  58. package/dist/components/fan-out-record.d.ts +6 -1
  59. package/dist/components/fan-out-record.d.ts.map +1 -1
  60. package/dist/components/fan-out-record.js +24 -0
  61. package/dist/components/fan-out-run.d.ts +20 -0
  62. package/dist/components/fan-out-run.d.ts.map +1 -1
  63. package/dist/components/fan-out-run.js +21 -1
  64. package/dist/components/pr-pipeline.d.ts +65 -2
  65. package/dist/components/pr-pipeline.d.ts.map +1 -1
  66. package/dist/components/pr-pipeline.js +107 -15
  67. package/dist/components/pr-run.d.ts +127 -29
  68. package/dist/components/pr-run.d.ts.map +1 -1
  69. package/dist/components/pr-run.js +290 -70
  70. package/dist/components/wave-plan.d.ts +5 -2
  71. package/dist/components/wave-plan.d.ts.map +1 -1
  72. package/dist/components/wave-plan.js +12 -1
  73. package/dist/discovery/fold-import.d.ts.map +1 -1
  74. package/dist/discovery/fold-import.js +16 -6
  75. package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
  76. package/dist/discovery/sandbox/bundle.js +6 -0
  77. package/dist/discovery/specifier-resolve.d.ts.map +1 -1
  78. package/dist/discovery/specifier-resolve.js +8 -2
  79. package/dist/fold/fold.d.ts.map +1 -1
  80. package/dist/fold/fold.js +68 -1
  81. package/dist/fold/subset.d.ts +37 -0
  82. package/dist/fold/subset.d.ts.map +1 -1
  83. package/dist/fold/subset.js +88 -0
  84. package/dist/lexicon-module.d.ts +10 -0
  85. package/dist/lexicon-module.d.ts.map +1 -1
  86. package/dist/lexicon-module.js +53 -8
  87. package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
  88. package/dist/lexicon-plugin-helpers.js +6 -4
  89. package/dist/lexicon.d.ts +2 -0
  90. package/dist/lexicon.d.ts.map +1 -1
  91. package/dist/lifecycle/affected.d.ts.map +1 -1
  92. package/dist/lifecycle/affected.js +7 -1
  93. package/dist/lifecycle/release-ledger.d.ts +21 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +24 -0
  96. package/dist/lifecycle/work-dir.d.ts +45 -0
  97. package/dist/lifecycle/work-dir.d.ts.map +1 -0
  98. package/dist/lifecycle/work-dir.js +78 -0
  99. package/dist/lint/discover.d.ts +17 -9
  100. package/dist/lint/discover.d.ts.map +1 -1
  101. package/dist/lint/discover.js +62 -54
  102. package/dist/module-source.d.ts +57 -0
  103. package/dist/module-source.d.ts.map +1 -0
  104. package/dist/module-source.js +104 -0
  105. package/dist/op/activities/factory.js +1 -1
  106. package/dist/op/discover.d.ts.map +1 -1
  107. package/dist/op/discover.js +13 -3
  108. package/dist/op/gate.d.ts.map +1 -1
  109. package/dist/op/gate.js +3 -0
  110. package/dist/op/op-waves-run.d.ts.map +1 -1
  111. package/dist/op/op-waves-run.js +20 -5
  112. package/dist/op/operator.d.ts +2 -2
  113. package/dist/op/operator.js +2 -2
  114. package/dist/op/steward.d.ts +6 -4
  115. package/dist/op/steward.d.ts.map +1 -1
  116. package/dist/op/steward.js +5 -3
  117. package/dist/op/work-lease-run.d.ts +20 -7
  118. package/dist/op/work-lease-run.d.ts.map +1 -1
  119. package/dist/op/work-lease-run.js +56 -12
  120. package/dist/pr-forge.d.ts +10 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +39 -0
  123. package/dist/pr-loop.d.ts +141 -4
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +122 -6
  126. package/dist/pr-report-url.d.ts +2 -2
  127. package/dist/pr-report-url.d.ts.map +1 -1
  128. package/dist/pr-report-url.js +5 -5
  129. package/dist/ts-loader.d.ts +38 -4
  130. package/dist/ts-loader.d.ts.map +1 -1
  131. package/dist/ts-loader.js +190 -16
  132. package/dist/wave-runtime.mjs +208 -208
  133. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
  134. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
  135. package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
  136. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  137. package/dist/workspace/chant-migrations.js +2 -0
  138. package/dist/workspace/checks/boxes.d.ts +6 -6
  139. package/dist/workspace/checks/boxes.js +9 -9
  140. package/dist/workspace/ci-green-forge.d.ts +6 -4
  141. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  142. package/dist/workspace/ci-green.d.ts +28 -12
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +49 -21
  145. package/dist/workspace/ci-list.schema.json +12 -6
  146. package/dist/workspace/ci-report-url.d.ts +18 -0
  147. package/dist/workspace/ci-report-url.d.ts.map +1 -0
  148. package/dist/workspace/ci-report-url.js +33 -0
  149. package/dist/workspace/ci-run-index.d.ts +12 -0
  150. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  151. package/dist/workspace/ci-run-index.js +6 -4
  152. package/dist/workspace/ci-run-index.schema.json +5 -3
  153. package/dist/workspace/ci-run.d.ts +75 -2
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +195 -53
  156. package/dist/workspace/ci-runner-key.d.ts +16 -3
  157. package/dist/workspace/ci-runner-key.d.ts.map +1 -1
  158. package/dist/workspace/ci-runner-key.js +30 -10
  159. package/dist/workspace/ci-serve.d.ts +54 -4
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +175 -26
  162. package/dist/workspace/declaration.d.ts +8 -0
  163. package/dist/workspace/declaration.d.ts.map +1 -1
  164. package/dist/workspace/declaration.js +11 -2
  165. package/dist/workspace/declaration.schema.json +7 -2
  166. package/dist/workspace/export.d.ts +1 -1
  167. package/dist/workspace/export.d.ts.map +1 -1
  168. package/dist/workspace/export.js +7 -3
  169. package/dist/workspace/ls.d.ts +6 -1
  170. package/dist/workspace/ls.d.ts.map +1 -1
  171. package/dist/workspace/ls.js +1 -0
  172. package/dist/workspace/ls.schema.json +7 -1
  173. package/dist/workspace/pin-integrity.d.ts.map +1 -1
  174. package/dist/workspace/pin-integrity.js +3 -2
  175. package/dist/workspace/pr-report.schema.json +74 -7
  176. package/dist/workspace/reason-codes.d.ts +1 -1
  177. package/dist/workspace/reason-codes.d.ts.map +1 -1
  178. package/dist/workspace/reason-codes.js +2 -2
  179. package/dist/workspace/run-event.schema.json +3 -1
  180. package/dist/workspace/status.schema.json +3 -1
  181. package/dist/workspace/write-scope.d.ts +9 -0
  182. package/dist/workspace/write-scope.d.ts.map +1 -1
  183. package/dist/workspace/write-scope.js +11 -1
  184. package/package.json +1 -1
  185. package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
  186. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
  187. package/src/cli/commands/audit.test.ts +2 -2
  188. package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
  189. package/src/cli/commands/check-lexicon-plugin.ts +11 -8
  190. package/src/cli/commands/check-lexicon.test.ts +1 -1
  191. package/src/cli/commands/check-lexicon.ts +16 -11
  192. package/src/cli/commands/import.ts +40 -0
  193. package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
  194. package/src/cli/commands/init-lexicon.test.ts +8 -5
  195. package/src/cli/commands/onboard.test.ts +45 -0
  196. package/src/cli/commands/onboard.ts +8 -4
  197. package/src/cli/commands/update.test.ts +6 -0
  198. package/src/cli/commands/update.ts +4 -1
  199. package/src/cli/compiled-cli.e2e.test.ts +76 -11
  200. package/src/cli/dependencies.e2e.test.ts +101 -0
  201. package/src/cli/dependencies.test.ts +93 -0
  202. package/src/cli/dependencies.ts +122 -0
  203. package/src/cli/handlers/build.test.ts +69 -0
  204. package/src/cli/handlers/build.ts +20 -4
  205. package/src/cli/handlers/fan-out.test.ts +73 -0
  206. package/src/cli/handlers/fan-out.ts +11 -4
  207. package/src/cli/handlers/graph.test.ts +76 -0
  208. package/src/cli/handlers/graph.ts +19 -12
  209. package/src/cli/handlers/lint.test.ts +42 -0
  210. package/src/cli/handlers/lint.ts +6 -0
  211. package/src/cli/handlers/operator.ts +3 -3
  212. package/src/cli/handlers/pr-waves.test.ts +449 -0
  213. package/src/cli/handlers/pr.test.ts +134 -0
  214. package/src/cli/handlers/pr.ts +523 -31
  215. package/src/cli/handlers/promote.test.ts +26 -1
  216. package/src/cli/handlers/promote.ts +3 -2
  217. package/src/cli/handlers/run-components-exit.test.ts +99 -0
  218. package/src/cli/handlers/run-signer.test.ts +79 -0
  219. package/src/cli/handlers/run.ts +34 -16
  220. package/src/cli/main.test.ts +18 -0
  221. package/src/cli/main.ts +43 -16
  222. package/src/cli/parse-args.ts +12 -0
  223. package/src/cli/registry.ts +19 -6
  224. package/src/codegen/generate-post-synth-barrel.ts +2 -2
  225. package/src/codegen/lexicon-regen.ts +1 -1
  226. package/src/codegen/pinned-upgrade.ts +2 -2
  227. package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
  228. package/src/components/capability.ts +51 -3
  229. package/src/components/changed-files.test.ts +16 -1
  230. package/src/components/changed-files.ts +9 -1
  231. package/src/components/driver.test.ts +36 -1
  232. package/src/components/driver.ts +76 -4
  233. package/src/components/fan-out-record.ts +29 -1
  234. package/src/components/fan-out-run.test.ts +35 -0
  235. package/src/components/fan-out-run.ts +40 -1
  236. package/src/components/pr-pipeline.test.ts +100 -0
  237. package/src/components/pr-pipeline.ts +132 -17
  238. package/src/components/pr-run.test.ts +323 -18
  239. package/src/components/pr-run.ts +354 -75
  240. package/src/components/wave-plan.ts +16 -4
  241. package/src/discovery/fold-host-method.test.ts +87 -0
  242. package/src/discovery/fold-import.ts +15 -5
  243. package/src/discovery/fold-package-composite.test.ts +27 -0
  244. package/src/discovery/fold-specifiers.test.ts +8 -0
  245. package/src/discovery/sandbox/bundle.ts +6 -0
  246. package/src/discovery/specifier-resolve.ts +8 -2
  247. package/src/fold/fold.test.ts +72 -0
  248. package/src/fold/fold.ts +71 -0
  249. package/src/fold/subset.test.ts +31 -0
  250. package/src/fold/subset.ts +99 -0
  251. package/src/lexicon-dist.e2e.test.ts +254 -0
  252. package/src/lexicon-module.test.ts +47 -0
  253. package/src/lexicon-module.ts +49 -7
  254. package/src/lexicon-plugin-helpers.ts +6 -4
  255. package/src/lexicon.ts +2 -0
  256. package/src/lifecycle/affected.test.ts +7 -0
  257. package/src/lifecycle/affected.ts +6 -1
  258. package/src/lifecycle/release-ledger.ts +34 -0
  259. package/src/lifecycle/work-dir.test.ts +77 -0
  260. package/src/lifecycle/work-dir.ts +82 -0
  261. package/src/lint/discover.test.ts +46 -2
  262. package/src/lint/discover.ts +55 -49
  263. package/src/module-source.test.ts +85 -0
  264. package/src/module-source.ts +112 -0
  265. package/src/op/activities/factory.ts +1 -1
  266. package/src/op/discover-root.test.ts +31 -2
  267. package/src/op/discover.ts +14 -4
  268. package/src/op/gate.test.ts +30 -0
  269. package/src/op/gate.ts +3 -0
  270. package/src/op/op-waves-run.ts +20 -5
  271. package/src/op/op-waves.test.ts +49 -0
  272. package/src/op/operator.ts +2 -2
  273. package/src/op/steward.ts +6 -4
  274. package/src/op/work-lease-run.test.ts +88 -2
  275. package/src/op/work-lease-run.ts +52 -12
  276. package/src/pr-forge-bundle.test.ts +2 -2
  277. package/src/pr-forge.test.ts +42 -0
  278. package/src/pr-forge.ts +40 -0
  279. package/src/pr-loop-bundle.test.ts +2 -1
  280. package/src/pr-loop.test.ts +117 -1
  281. package/src/pr-loop.ts +252 -10
  282. package/src/pr-report-url.ts +5 -5
  283. package/src/terraform/__fixtures__/advise.test.ts +21 -2
  284. package/src/ts-loader.test.ts +89 -1
  285. package/src/ts-loader.ts +180 -13
  286. package/src/workspace/changes.test.ts +1 -1
  287. package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
  288. package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
  289. package/src/workspace/chant-migrations.e2e.test.ts +5 -0
  290. package/src/workspace/chant-migrations.ts +2 -0
  291. package/src/workspace/checks/boxes.ts +9 -9
  292. package/src/workspace/ci-green-forge.ts +6 -4
  293. package/src/workspace/ci-green-runs.test.ts +53 -11
  294. package/src/workspace/ci-green.test.ts +1 -1
  295. package/src/workspace/ci-green.ts +50 -22
  296. package/src/workspace/ci-list.schema.json +12 -6
  297. package/src/workspace/ci-report-url.test.ts +220 -0
  298. package/src/workspace/ci-report-url.ts +33 -0
  299. package/src/workspace/ci-run-index.schema.json +5 -3
  300. package/src/workspace/ci-run-index.test.ts +42 -3
  301. package/src/workspace/ci-run-index.ts +18 -4
  302. package/src/workspace/ci-run.test.ts +185 -7
  303. package/src/workspace/ci-run.ts +224 -54
  304. package/src/workspace/ci-runner-key.test.ts +55 -17
  305. package/src/workspace/ci-runner-key.ts +36 -11
  306. package/src/workspace/ci-serve.test.ts +150 -6
  307. package/src/workspace/ci-serve.ts +169 -25
  308. package/src/workspace/declaration.schema.json +7 -2
  309. package/src/workspace/declaration.test.ts +23 -0
  310. package/src/workspace/declaration.ts +19 -3
  311. package/src/workspace/export.ts +6 -3
  312. package/src/workspace/ls.schema.json +7 -1
  313. package/src/workspace/ls.ts +7 -2
  314. package/src/workspace/pin-integrity.test.ts +5 -0
  315. package/src/workspace/pin-integrity.ts +3 -2
  316. package/src/workspace/pr-report.schema.json +74 -7
  317. package/src/workspace/reason-codes.ts +2 -2
  318. package/src/workspace/run-event.schema.json +3 -1
  319. package/src/workspace/status.schema.json +3 -1
  320. package/src/workspace/write-scope.test.ts +29 -1
  321. package/src/workspace/write-scope.ts +12 -1
@@ -29,7 +29,7 @@ import { regenLexicon, type RegenResult } from "./lexicon-regen";
29
29
  import { fetchWithRetry } from "./fetch";
30
30
  import { isLexiconPlugin, type UpstreamPin } from "../lexicon";
31
31
  import { importLexiconPackage } from "../lexicon-module";
32
- import { ensureTypeScriptLoader } from "../ts-loader";
32
+ import { ensureTypeScriptLoaderFor } from "../ts-loader";
33
33
 
34
34
  // ── Public types ──────────────────────────────────────────────────────
35
35
 
@@ -252,7 +252,7 @@ export async function loadUpstreamPins(lexicon: LexiconId, lexiconDir?: string):
252
252
  const entry = lexiconDir ? lexiconEntry(lexiconDir) : null;
253
253
  if (entry) {
254
254
  try {
255
- ensureTypeScriptLoader();
255
+ ensureTypeScriptLoaderFor(entry);
256
256
  const pins = pinsOf((await import(pathToFileURL(entry).href)) as Record<string, unknown>);
257
257
  if (pins.length > 0) return pins;
258
258
  } catch {
@@ -26,17 +26,12 @@ describe("post-synth barrel is exported", () => {
26
26
  test.each(lexiconDirs)("%s exports ./lint/post-synth", (lexicon) => {
27
27
  const pkg = JSON.parse(readFileSync(join(lexiconsDir, lexicon, "package.json"), "utf-8"));
28
28
  const entry = pkg.exports?.["./lint/post-synth"];
29
- // Lexicons shipping .d.ts use a conditional export — `types` resolves to the
30
- // emitted declaration for external consumers; `development`/`default` keep
31
- // runtime resolution on source (in-repo and published alike). Lexicons not
32
- // yet building dist keep the plain source string.
33
- if (typeof entry === "string") {
34
- expect(entry).toBe("./src/lint/post-synth/index.ts");
35
- } else {
36
- expect(entry?.development).toBe("./src/lint/post-synth/index.ts");
37
- expect(entry?.types).toBe("./dist/lint/post-synth/index.d.ts");
38
- expect(entry?.default).toBe("./src/lint/post-synth/index.ts");
39
- }
29
+ // A conditional export: `types` resolves to the emitted declaration,
30
+ // `development` keeps the repository's own resolution on source, and
31
+ // `default` is the compiled JS the package publishes (#3981).
32
+ expect(entry?.development).toBe("./src/lint/post-synth/index.ts");
33
+ expect(entry?.types).toBe("./dist/lint/post-synth/index.d.ts");
34
+ expect(entry?.default).toBe("./dist/lint/post-synth/index.js");
40
35
  });
41
36
  });
42
37
 
@@ -43,7 +43,9 @@ export interface DeployContext {
43
43
  * (#3049), keyed by {@link CapabilityPlan.member}. Set by a gated-wave
44
44
  * fan-out and by a pull request's apply (#3183). A capability whose `run`
45
45
  * finds its own member here applies that plan, the one the approver
46
- * approved, instead of planning again.
46
+ * approved, instead of planning again. A `run` that finds the target moved
47
+ * since that plan refuses with an error {@link isPlanMovedError} accepts
48
+ * (#3997), and the command that applied it exits 3, as for a moved digest.
47
49
  */
48
50
  plans?: Record<string, unknown>;
49
51
  /**
@@ -90,6 +92,27 @@ export interface CapabilityPlan {
90
92
  * still counts toward the digest, with no entries.
91
93
  */
92
94
  changeSet?: ChangeSetPart;
95
+ /**
96
+ * The outputs `run` will expose once it applies this plan (#3999), as far
97
+ * as the plan can tell. A pull request's plan reads a dependent's
98
+ * `stackOutput()` references into this member from here when both are in
99
+ * the change, instead of from the outputs standing now. Absent when the
100
+ * plan cannot say: every output of the step is then known only after it
101
+ * applies.
102
+ */
103
+ outputs?: PlannedOutputs;
104
+ }
105
+
106
+ /**
107
+ * What a plan says about the outputs its apply will leave (#3999). An
108
+ * output named in neither field is known only after apply, the same as one
109
+ * in `unknown`.
110
+ */
111
+ export interface PlannedOutputs {
112
+ /** Each output whose value the plan knows, by name: what `run` will expose. */
113
+ known: Record<string, unknown>;
114
+ /** Outputs the plan names whose value is known only after apply, sorted. */
115
+ unknown: string[];
93
116
  }
94
117
 
95
118
  /**
@@ -136,8 +159,11 @@ export interface Capability<In = unknown, Out = unknown> {
136
159
  /**
137
160
  * Optional: the outputs `run` exposed when it last ran, read without
138
161
  * changing anything (#3183). A pull request's plan reads them for each
139
- * component a planned one depends on, so a `stackOutput()` wiring resolves
140
- * to the value standing now. The shape is `run`'s `outputs` field.
162
+ * component a planned one depends on that the change does not plan
163
+ * (#4104), so a `stackOutput()` wiring resolves to the value standing now.
164
+ * A dependency the change plans is read from its plan's
165
+ * {@link CapabilityPlan.outputs} instead (#3999). The shape is `run`'s
166
+ * `outputs` field.
141
167
  */
142
168
  outputs?(ctx: DeployContext, input: In): Promise<Record<string, unknown>>;
143
169
  /**
@@ -160,6 +186,28 @@ export type CapabilityInput<C> = C extends Capability<infer In, unknown> ? In :
160
186
  /** Extract a capability's `Out` type. */
161
187
  export type CapabilityOutput<C> = C extends Capability<unknown, infer Out> ? Out : never;
162
188
 
189
+ /**
190
+ * The exit code of a command whose capability step refused a plan that moved
191
+ * since it was approved: 3, the code `pr-apply` gives a moved digest and a
192
+ * run gives a gate (#3997).
193
+ */
194
+ export const PLAN_MOVED_EXIT_CODE = 3;
195
+
196
+ /**
197
+ * Whether `err` is a `run`'s refusal to apply a plan from
198
+ * `DeployContext.plans` because the target moved since it was planned
199
+ * (#3997). The mark is a `planMoved: true` property, not a class, so a
200
+ * lexicon built against an older core can set it. An `exitCode` of
201
+ * {@link PLAN_MOVED_EXIT_CODE} on the error is the same mark. Any other
202
+ * `exitCode` is ignored: a failed step exits 1, so a capability can't pick
203
+ * the process's exit code.
204
+ */
205
+ export function isPlanMovedError(err: unknown): boolean {
206
+ if (typeof err !== "object" || err === null) return false;
207
+ const e = err as { planMoved?: unknown; exitCode?: unknown };
208
+ return e.planMoved === true || e.exitCode === PLAN_MOVED_EXIT_CODE;
209
+ }
210
+
163
211
  /**
164
212
  * Thrown by a stub `run`/`rollback` — the verb is specified and typed but has
165
213
  * no cloud implementation yet. Distinguishes "not implemented" from a runtime
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
  import { afterAll, describe, expect, test } from "vitest";
12
- import { changedFilesBetween } from "./changed-files";
12
+ import { changedFilesBetween, EMPTY_TREE_SHA, resolveMergeBase } from "./changed-files";
13
13
 
14
14
  const repo = mkdtempSync(join(tmpdir(), "chant-changed-files-"));
15
15
  afterAll(() => rmSync(repo, { recursive: true, force: true }));
@@ -56,3 +56,18 @@ describe("changedFilesBetween", () => {
56
56
  ]);
57
57
  });
58
58
  });
59
+
60
+ describe("the empty tree as the base (a root commit, #4004)", () => {
61
+ test("is its own merge base, and every file the head holds counts as changed", async () => {
62
+ expect(await resolveMergeBase(repo, EMPTY_TREE_SHA, "HEAD")).toBe(EMPTY_TREE_SHA);
63
+ expect(await changedFilesBetween(repo, EMPTY_TREE_SHA)).toEqual([
64
+ "apps/web/main.tf",
65
+ "infra/network/roots/vpc/main.tf",
66
+ "shared/modules/base/main.tf",
67
+ ]);
68
+ });
69
+
70
+ test("is git's own empty tree", () => {
71
+ expect(git("hash-object", "-t", "tree", "/dev/null")).toBe(EMPTY_TREE_SHA);
72
+ });
73
+ });
@@ -20,13 +20,21 @@ async function git(cwd: string, args: string[]): Promise<string> {
20
20
  return stdout;
21
21
  }
22
22
 
23
+ /**
24
+ * Git's empty tree. A root commit has no parent, so a push of one is measured
25
+ * from this (#4004): every file it holds counts as changed.
26
+ */
27
+ export const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
28
+
23
29
  /**
24
30
  * The commit a change is measured from: the merge base of `base` and `head`.
25
31
  * A pull request's head that branched before `base` moved is measured from
26
32
  * where it branched, so the other changes on `base` are not counted as its
27
- * own; a merge commit's first parent is its own merge base.
33
+ * own; a merge commit's first parent is its own merge base. The empty tree
34
+ * has no merge base with anything and is returned as it is.
28
35
  */
29
36
  export async function resolveMergeBase(cwd: string, base: string, head = "HEAD"): Promise<string> {
37
+ if (base === EMPTY_TREE_SHA) return base;
30
38
  try {
31
39
  return (await git(cwd, ["merge-base", base, head])).trim();
32
40
  } catch (err) {
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { describe, expect, it } from "vitest";
14
- import { CapabilityRegistry, type DeployContext } from "./capability";
14
+ import { CapabilityRegistry, isPlanMovedError, type DeployContext } from "./capability";
15
15
  import { stubCapability } from "./verbs/stub";
16
16
  import { describeGateMismatch, memoryGateLedgerPort } from "../op/gate";
17
17
  import { componentPlanDigest } from "./gate-plan";
@@ -22,6 +22,7 @@ import {
22
22
  UnknownDependencyError,
23
23
  accumulateComponentOutputs,
24
24
  collectComponentOutputs,
25
+ failedRunExitCode,
25
26
  resolveComponentGraph,
26
27
  resolveWiring,
27
28
  runComponentDeploy,
@@ -1173,3 +1174,37 @@ describe("unresolvedWiringSources (#4084)", () => {
1173
1174
  ).toEqual([]);
1174
1175
  });
1175
1176
  });
1177
+
1178
+ describe("a step that refuses a moved plan (#3997)", () => {
1179
+ it.each([
1180
+ ["planMoved: true", { planMoved: true }, true],
1181
+ ["exitCode 3", { exitCode: 3 }, true],
1182
+ ["exitCode 7", { exitCode: 7 }, false],
1183
+ ['exitCode "3"', { exitCode: "3" }, false],
1184
+ ["planMoved: \"yes\"", { planMoved: "yes" }, false],
1185
+ ["no mark", {}, false],
1186
+ ])("isPlanMovedError reads an error with %s", (_label, mark, moved) => {
1187
+ expect(isPlanMovedError(Object.assign(new Error("refused"), mark))).toBe(moved);
1188
+ });
1189
+
1190
+ it("is not fooled by a non-object", () => {
1191
+ expect(isPlanMovedError(null)).toBe(false);
1192
+ expect(isPlanMovedError("planMoved")).toBe(false);
1193
+ });
1194
+
1195
+ it("marks the failed step's record, and failedRunExitCode answers 3 for it and 1 for any other failure", async () => {
1196
+ const run = async (mark: object) => {
1197
+ const registry = new CapabilityRegistry();
1198
+ registry.register(fakeCapability("apply", { run: () => { throw Object.assign(new Error("refused"), mark); } }).capability);
1199
+ const component: DriverComponent = { name: "c", deploy: [{ phase: "Apply", steps: [{ kind: "apply" }] }] };
1200
+ return runComponentDeploy(component, { env: "dev", component: "c" }, registry, {});
1201
+ };
1202
+ const moved = await run({ exitCode: 3 });
1203
+ expect(moved.records[0]).toMatchObject({ status: "fail", planMoved: true });
1204
+ expect(failedRunExitCode([moved])).toBe(3);
1205
+ const other = await run({ exitCode: 7 });
1206
+ expect(other.records[0]).not.toHaveProperty("planMoved");
1207
+ expect(failedRunExitCode([other])).toBe(1);
1208
+ expect(failedRunExitCode([other, moved])).toBe(3);
1209
+ });
1210
+ });
@@ -54,6 +54,7 @@ import { GateNotRecordableError } from "../lifecycle/no-record";
54
54
  import { componentPlanDigest } from "./gate-plan";
55
55
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
56
56
  import type { CapabilityRegistry, DeployContext } from "./capability";
57
+ import { isPlanMovedError, PLAN_MOVED_EXIT_CODE } from "./capability";
57
58
  import { isPromotedArtifact } from "./auto-release";
58
59
  import type { ReleaseIdentity } from "../telemetry-attribution";
59
60
  import type { RunProgressEvent } from "./run-progress";
@@ -154,6 +155,8 @@ export interface DriverStepRecord {
154
155
  durationMs: number;
155
156
  output?: unknown;
156
157
  error?: string;
158
+ /** Set on a failed step whose capability refused a plan that moved since it was approved (`isPlanMovedError`, #3997). */
159
+ planMoved?: true;
157
160
  /** Set on a `gate` step that passed (#2119): who resolved it, when, and at what address. */
158
161
  approval?: {
159
162
  gate: string;
@@ -356,12 +359,51 @@ export function unresolvedWiringSources(
356
359
  input: Record<string, unknown>,
357
360
  componentOutputs: Record<string, Record<string, unknown>>,
358
361
  ): string[] {
359
- const found = new Set<string>();
362
+ return [...new Set(unresolvedWiringReferences(input, componentOutputs).map((r) => r.source))].sort();
363
+ }
364
+
365
+ /** One cross-component reference a step input makes: whose output, and which. */
366
+ export interface WiringReference {
367
+ /** The component or stack whose output it reads. */
368
+ source: string;
369
+ /** `<stack>.<name>` for `stackOutput()`, `<component>.publish.<field>` for an artifact. */
370
+ ref: string;
371
+ /**
372
+ * Whether it reads a published artifact's identity (#3999): an
373
+ * `@<component>.publish.*` reference, or a `stackOutput()` whose name is
374
+ * under `publish`. The driver namespaces every publish-family output there
375
+ * (`collectComponentOutputs`), whatever capability wrote it.
376
+ */
377
+ publish: boolean;
378
+ }
379
+
380
+ /**
381
+ * Every cross-component reference in `input` that has no value in
382
+ * `componentOutputs`, sorted by `ref` and each once (#3999). A pull
383
+ * request's plan names these when the output is known only after its
384
+ * upstream applies.
385
+ */
386
+ export function unresolvedWiringReferences(
387
+ input: Record<string, unknown>,
388
+ componentOutputs: Record<string, Record<string, unknown>>,
389
+ ): WiringReference[] {
390
+ return wiringReferences(input).filter((r) => r.value(componentOutputs) === undefined).map(({ source, ref, publish }) => ({ source, ref, publish }));
391
+ }
392
+
393
+ /**
394
+ * Every cross-component reference in `input`, each once, sorted by `ref`,
395
+ * with the value it resolves to against a given outputs map.
396
+ */
397
+ function wiringReferences(input: Record<string, unknown>): Array<WiringReference & { value: (outputs: Record<string, Record<string, unknown>>) => unknown }> {
398
+ const found = new Map<string, WiringReference & { value: (outputs: Record<string, Record<string, unknown>>) => unknown }>();
360
399
  const walk = (value: unknown): void => {
361
400
  if (typeof value === "string") {
362
401
  if (PRIOR_STEP_REF.test(value)) return;
363
402
  const ref = value.match(COMPONENT_ARTIFACT_REF);
364
- if (ref && resolvePath(componentOutputs[ref[1]]?.publish, ref[2]) === undefined) found.add(ref[1]);
403
+ if (ref) {
404
+ const [, source, field] = ref;
405
+ found.set(`${source}.publish.${field}`, { source, ref: `${source}.publish.${field}`, publish: true, value: (o) => resolvePath(o[source]?.publish, field) });
406
+ }
365
407
  return;
366
408
  }
367
409
  if (Array.isArray(value)) {
@@ -371,14 +413,29 @@ export function unresolvedWiringSources(
371
413
  if (value && typeof value === "object") {
372
414
  if ("stackOutput" in value) {
373
415
  const { stack, name } = (value as { stackOutput: { stack: string; name: string } }).stackOutput;
374
- if (resolvePath(componentOutputs[stack], name) === undefined) found.add(stack);
416
+ found.set(`${stack}.${name}`, { source: stack, ref: `${stack}.${name}`, publish: name === "publish" || name.startsWith("publish."), value: (o) => resolvePath(o[stack], name) });
375
417
  return;
376
418
  }
377
419
  Object.values(value as Record<string, unknown>).forEach(walk);
378
420
  }
379
421
  };
380
422
  walk(input);
381
- return [...found].sort();
423
+ return [...found.values()].sort((a, b) => a.ref.localeCompare(b.ref));
424
+ }
425
+
426
+ /**
427
+ * The cross-component references in `input` that resolve to another value
428
+ * against `now` than against `read`, sorted (#3999). A pull request's apply
429
+ * holds back a member whose plan read `read` when its upstream applied and
430
+ * left `now`.
431
+ */
432
+ export function movedWiringReferences(
433
+ input: Record<string, unknown>,
434
+ read: Record<string, Record<string, unknown>>,
435
+ now: Record<string, Record<string, unknown>>,
436
+ ): WiringReference[] {
437
+ const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
438
+ return wiringReferences(input).filter((r) => !same(r.value(read), r.value(now))).map(({ source, ref, publish }) => ({ source, ref, publish }));
382
439
  }
383
440
 
384
441
  // ── Step + phase execution ───────────────────────────────────────────────
@@ -501,6 +558,7 @@ async function runCapabilityStep(
501
558
  status: "fail",
502
559
  durationMs: Date.now() - start,
503
560
  error: errMessage(err),
561
+ ...(isPlanMovedError(err) ? { planMoved: true as const } : {}),
504
562
  },
505
563
  resolvedInput,
506
564
  };
@@ -995,6 +1053,20 @@ function findStackOutputs(
995
1053
  return found;
996
1054
  }
997
1055
 
1056
+ /** Whether a step in `results` refused a plan that moved since it was approved (#3997). */
1057
+ export function planMovedIn(results: readonly DriverComponentResult[]): boolean {
1058
+ return results.some((r) => r.records.some((s) => s.planMoved === true));
1059
+ }
1060
+
1061
+ /**
1062
+ * The exit code of a run that failed: {@link PLAN_MOVED_EXIT_CODE} when a
1063
+ * step refused a plan that moved since it was approved, 1 otherwise. Every
1064
+ * command that runs capability steps ends a failed run with it.
1065
+ */
1066
+ export function failedRunExitCode(results: readonly DriverComponentResult[]): number {
1067
+ return planMovedIn(results) ? PLAN_MOVED_EXIT_CODE : 1;
1068
+ }
1069
+
998
1070
  function errMessage(err: unknown): string {
999
1071
  return err instanceof Error ? err.message : String(err);
1000
1072
  }
@@ -38,7 +38,7 @@
38
38
  import { existsSync, readFileSync } from "node:fs";
39
39
  import { readWaveRecords, writeWaveRecordFile, type WaveRecord } from "../wave-engine";
40
40
  import type { ChangeSetDocument } from "../change-set";
41
- import type { PrMember } from "../pr-loop";
41
+ import type { PrMember, PrWave } from "../pr-loop";
42
42
 
43
43
  /** What a pull request's apply approved, kept so a re-run can finish it (#3464). */
44
44
  export interface PrApplyRecord {
@@ -59,6 +59,11 @@ export interface PrApplyRecord {
59
59
  * against them again, so an unchanged member plans to the same digest.
60
60
  */
61
61
  planOutputs: Record<string, Record<string, unknown>>;
62
+ /**
63
+ * With `--wave-gate` (#4003): each wave reached so far and the approval it
64
+ * applied or waits under, so the next wave's job reports every wave.
65
+ */
66
+ waves?: PrWave[];
62
67
  }
63
68
 
64
69
  export interface FanOutAttempt {
@@ -100,6 +105,7 @@ function readPrApplyRecord(value: unknown): PrApplyRecord | undefined {
100
105
  if (isRecord(planOutputs)) {
101
106
  for (const [name, v] of Object.entries(planOutputs)) if (isRecord(v)) outputs[name] = v;
102
107
  }
108
+ const waves = Array.isArray(value.waves) ? value.waves.map(readPrWave).filter((w): w is PrWave => w !== undefined) : [];
103
109
  return {
104
110
  op,
105
111
  gate,
@@ -109,6 +115,28 @@ function readPrApplyRecord(value: unknown): PrApplyRecord | undefined {
109
115
  members: members as unknown as PrMember[],
110
116
  changeSet: changeSet as unknown as ChangeSetDocument,
111
117
  planOutputs: outputs,
118
+ ...(waves.length > 0 ? { waves } : {}),
119
+ };
120
+ }
121
+
122
+ const WAVE_STATUSES: ReadonlySet<string> = new Set(["applied", "failed", "plan-failed", "waiting"]);
123
+
124
+ /** One `prApply.waves` entry, or `undefined` when it is malformed. */
125
+ function readPrWave(value: unknown): PrWave | undefined {
126
+ if (!isRecord(value)) return undefined;
127
+ const { wave, components, status, approval, gate, digest } = value;
128
+ if (typeof wave !== "number" || !isStringArray(components) || typeof status !== "string" || !WAVE_STATUSES.has(status)) return undefined;
129
+ if ((approval !== "pull-request" && approval !== "wave") || typeof gate !== "string" || typeof digest !== "string") return undefined;
130
+ return {
131
+ wave,
132
+ components,
133
+ status: status as PrWave["status"],
134
+ approval,
135
+ gate,
136
+ digest,
137
+ ...(isStringArray(value.approvedBy) ? { approvedBy: value.approvedBy } : {}),
138
+ ...(typeof value.approved === "string" ? { approved: value.approved } : {}),
139
+ ...(isStringArray(value.moved) ? { moved: value.moved } : {}),
112
140
  };
113
141
  }
114
142
 
@@ -209,3 +209,38 @@ describe("one approval over the whole set", () => {
209
209
  expect(ran.sort()).toEqual(["app-one", "app-three", "app-two", "cluster-b"]);
210
210
  });
211
211
  });
212
+
213
+ describe("holding a component back (#3999)", () => {
214
+ test("a held component and what is downstream of it do not run and settle waiting; independent branches run", async () => {
215
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
216
+ const { registry, ran } = registryWith();
217
+ const events: Array<{ type: string; component?: string; status?: string }> = [];
218
+ const asked: Array<[string, string[]]> = [];
219
+ const result = await runFanOut(plan, ESTATE, registry, {
220
+ ...opts(),
221
+ onRunEvent: (e) => events.push(e as never),
222
+ hold: (name, outputs) => {
223
+ asked.push([name, Object.keys(outputs).sort()]);
224
+ return name === "cluster-a" ? "its plan read net.id before that output applied as another value" : undefined;
225
+ },
226
+ });
227
+ expect(result.status).toBe("ok");
228
+ expect(ran).not.toContain("cluster-a");
229
+ expect(ran).not.toContain("app-one");
230
+ expect(ran).toContain("app-three");
231
+ expect(result.held).toEqual([
232
+ { component: "cluster-a", reason: "its plan read net.id before that output applied as another value" },
233
+ { component: "app-one", reason: "its plan read net.id before that output applied as another value", heldBy: "cluster-a" },
234
+ { component: "app-two", reason: "its plan read net.id before that output applied as another value", heldBy: "cluster-a" },
235
+ ].sort((a, b) => plan.order.indexOf(a.component) - plan.order.indexOf(b.component)));
236
+ // Asked once per component as its wave starts, never for one already held.
237
+ expect(asked.map(([name]) => name).sort()).toEqual(["app-three", "cluster-a", "cluster-b", "net"]);
238
+ expect(events.filter((e) => e.type === "component-settle" && e.status === "waiting").map((e) => e.component).sort()).toEqual(["app-one", "app-two", "cluster-a"]);
239
+ });
240
+
241
+ test("with no hook, or a hook that holds nothing, the result has no held list", async () => {
242
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
243
+ expect((await runFanOut(plan, ESTATE, registryWith().registry, opts())).held).toBeUndefined();
244
+ expect((await runFanOut(plan, ESTATE, registryWith().registry, { ...opts(), hold: () => undefined })).held).toBeUndefined();
245
+ });
246
+ });
@@ -205,6 +205,25 @@ export interface FanOutRunOptions {
205
205
  * the returned result cannot do.
206
206
  */
207
207
  onComponentSettled?: (result: DriverComponentResult, outputs: Record<string, unknown> | undefined) => void;
208
+ /**
209
+ * Asked as each component's wave starts, with the outputs standing then
210
+ * (#3999). A reason holds the component back: it does not run, and nothing
211
+ * downstream of it runs either; each is listed in `held` and settles
212
+ * `waiting`. A pull request's apply holds a member whose plan read an
213
+ * upstream output that applied as another value, or that was known only
214
+ * after apply. Not with `waveGate`, which plans each wave after the one
215
+ * before it applied.
216
+ */
217
+ hold?: (component: string, componentOutputs: Record<string, Record<string, unknown>>) => string | undefined;
218
+ }
219
+
220
+ /** A component {@link FanOutRunOptions.hold} held back, or one downstream of it. */
221
+ export interface FanOutHeld {
222
+ component: string;
223
+ /** Why the hook held it, or, downstream, why it held the component named in `heldBy`. */
224
+ reason: string;
225
+ /** Downstream of a held component: that component. */
226
+ heldBy?: string;
208
227
  }
209
228
 
210
229
  export interface FanOutRunResult {
@@ -223,6 +242,8 @@ export interface FanOutRunResult {
223
242
  gate?: PendingGateRecord;
224
243
  /** With `waveGate`: each wave this attempt planned, in order, the one it stopped at included. */
225
244
  waves?: WaveRecord[];
245
+ /** What {@link FanOutRunOptions.hold} held back, in plan order. Absent when nothing was. */
246
+ held?: FanOutHeld[];
226
247
  }
227
248
 
228
249
  /**
@@ -296,9 +317,21 @@ export async function runFanOut(
296
317
  const completed: string[] = [];
297
318
  const failed: string[] = [];
298
319
  const blockedBy = new Map<string, string>();
320
+ const held = new Map<string, FanOutHeld>();
299
321
  let gatedFact: PendingGateRecord | undefined;
300
322
 
301
323
  const inPlan = new Set(active.order);
324
+ const waveOf = waveNumbers(active.waves);
325
+ /** Hold `root` and everything downstream of it that has not settled otherwise. */
326
+ const markHeld = (root: string, reason: string): void => {
327
+ held.set(root, { component: root, reason });
328
+ emit?.({ type: "component-settle", wave: waveOf.get(root)!, component: root, status: "waiting" });
329
+ for (const [component] of downstreamWithin(components, inPlan, [root])) {
330
+ if (held.has(component) || blockedBy.has(component) || failed.includes(component)) continue;
331
+ held.set(component, { component, reason, heldBy: root });
332
+ emit?.({ type: "component-settle", wave: waveOf.get(component)!, component, status: "waiting" });
333
+ }
334
+ };
302
335
  /** Skip everything downstream of `root`, blaming `root` rather than the nearest edge. */
303
336
  const markBlocked = (root: string): void => {
304
337
  for (const [component, by] of downstreamWithin(components, inPlan, [root])) {
@@ -310,7 +343,12 @@ export async function runFanOut(
310
343
  for (const [waveIndex, wave] of active.waves.entries()) {
311
344
  const waveNum = waveIndex + 1;
312
345
  // A component whose upstream failed in an earlier wave is not dispatched.
313
- const runnable = wave.filter((name) => inPlan.has(name) && !blockedBy.has(name));
346
+ const candidates = wave.filter((name) => inPlan.has(name) && !blockedBy.has(name) && !held.has(name));
347
+ for (const name of candidates) {
348
+ const reason = options.hold?.(name, componentOutputs);
349
+ if (reason !== undefined) markHeld(name, reason);
350
+ }
351
+ const runnable = candidates.filter((name) => !held.has(name));
314
352
  if (runnable.length === 0) continue;
315
353
 
316
354
  emit?.({ type: "wave-start", wave: waveNum, components: runnable });
@@ -384,6 +422,7 @@ export async function runFanOut(
384
422
  blocked,
385
423
  componentOutputs,
386
424
  ...(gatedFact ? { gate: gatedFact } : {}),
425
+ ...(held.size > 0 ? { held: active.order.filter((name) => held.has(name)).map((name) => held.get(name)!) } : {}),
387
426
  };
388
427
  }
389
428
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The apply job's base fallback (#4004), run in sh against a real
3
+ * repository. GitHub and Forgejo set `github.event.before`, and GitLab
4
+ * `CI_COMMIT_BEFORE_SHA`, to forty zeros on a branch's first push.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { EMPTY_TREE_SHA } from "./changed-files";
13
+ import { githubBaseFallback, gitlabBaseFallback, prLoopJobs } from "./pr-pipeline";
14
+
15
+ const ZEROS = "0".repeat(40);
16
+ const dir = mkdtempSync(join(tmpdir(), "chant-pr-base-"));
17
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
18
+ const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" };
19
+
20
+ const git = (cwd: string, ...args: string[]): string => execFileSync("git", args, { cwd, encoding: "utf8", env: ENV }).trim();
21
+
22
+ /** A bare origin and a clone of it, `main` holding `commits` commits. */
23
+ function repo(name: string, commits: number): string {
24
+ const origin = join(dir, `${name}.git`);
25
+ const work = join(dir, name);
26
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", origin]);
27
+ execFileSync("git", ["init", "-q", "-b", "main", work]);
28
+ git(work, "remote", "add", "origin", origin);
29
+ for (let i = 0; i < commits; i++) {
30
+ writeFileSync(join(work, "f.txt"), `${i}\n`);
31
+ git(work, "add", "-A");
32
+ git(work, "commit", "-q", "-m", `c${i}`);
33
+ }
34
+ git(work, "push", "-q", "origin", "main");
35
+ return work;
36
+ }
37
+
38
+ /** BASE_SHA after `line` runs in `cwd` with `env`. */
39
+ function settle(cwd: string, line: string, env: Record<string, string>): string {
40
+ return execFileSync("sh", ["-ec", `${line}\nprintf %s "$BASE_SHA"`], { cwd, encoding: "utf8", env: { ...ENV, ...env } });
41
+ }
42
+
43
+ describe("githubBaseFallback (#4004)", () => {
44
+ test("a set base is kept", () => {
45
+ const work = repo("kept", 2);
46
+ const parent = git(work, "rev-parse", "HEAD^");
47
+ expect(settle(work, githubBaseFallback(), { BASE_SHA: parent, GITHUB_SHA: git(work, "rev-parse", "HEAD") })).toBe(parent);
48
+ });
49
+
50
+ test("forty zeros or empty on the target branch's first push: the pushed commit's parent", () => {
51
+ const work = repo("first", 2);
52
+ const head = git(work, "rev-parse", "HEAD");
53
+ const parent = git(work, "rev-parse", "HEAD^");
54
+ expect(settle(work, githubBaseFallback(), { BASE_SHA: ZEROS, GITHUB_SHA: head })).toBe(parent);
55
+ expect(settle(work, githubBaseFallback(), { BASE_SHA: "", GITHUB_SHA: head })).toBe(parent);
56
+ });
57
+
58
+ test("a branch's first push: the merge base with the target branch", () => {
59
+ const work = repo("branch", 1);
60
+ const fork = git(work, "rev-parse", "HEAD");
61
+ git(work, "checkout", "-q", "-b", "feature");
62
+ writeFileSync(join(work, "g.txt"), "g\n");
63
+ git(work, "add", "-A");
64
+ git(work, "commit", "-q", "-m", "feature");
65
+ expect(settle(work, githubBaseFallback(), { BASE_SHA: ZEROS, GITHUB_SHA: git(work, "rev-parse", "HEAD") })).toBe(fork);
66
+ });
67
+
68
+ test("a root commit: the empty tree", () => {
69
+ const work = repo("root", 1);
70
+ expect(settle(work, githubBaseFallback(), { BASE_SHA: ZEROS, GITHUB_SHA: git(work, "rev-parse", "HEAD") })).toBe(EMPTY_TREE_SHA);
71
+ });
72
+
73
+ test("a named branch is the one fetched", () => {
74
+ expect(githubBaseFallback()).toContain('git fetch origin "main"');
75
+ expect(githubBaseFallback("trunk")).toContain('git fetch origin "trunk"');
76
+ });
77
+ });
78
+
79
+ describe("gitlabBaseFallback", () => {
80
+ test("a root commit's first push: the empty tree, as on GitHub (#4004)", () => {
81
+ const work = repo("gitlab-root", 1);
82
+ expect(settle(work, gitlabBaseFallback("main"), { BASE_SHA: ZEROS, CI_COMMIT_SHA: git(work, "rev-parse", "HEAD") })).toBe(EMPTY_TREE_SHA);
83
+ });
84
+
85
+ test("the merge request's diff base comes first", () => {
86
+ const work = repo("gitlab-mr", 2);
87
+ const parent = git(work, "rev-parse", "HEAD^");
88
+ expect(settle(work, gitlabBaseFallback("main"), { BASE_SHA: ZEROS, CI_MERGE_REQUEST_DIFF_BASE_SHA: parent, CI_COMMIT_SHA: git(work, "rev-parse", "HEAD") })).toBe(parent);
89
+ });
90
+ });
91
+
92
+ describe("prLoopJobs", () => {
93
+ test("every forge's apply settles its base first; no plan does", () => {
94
+ for (const forge of ["github", "forgejo", "gitlab"] as const) {
95
+ const [plan, apply] = prLoopJobs(forge, "prod", { branch: "trunk" });
96
+ expect(plan!.setup).toBeUndefined();
97
+ expect(apply!.setup).toEqual([forge === "gitlab" ? gitlabBaseFallback("trunk") : githubBaseFallback("trunk")]);
98
+ }
99
+ });
100
+ });