@intentius/chant 0.124.22 → 0.124.24

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 (187) hide show
  1. package/dist/audit/rules-doc.js +1 -1
  2. package/dist/ci-jobs.d.ts +41 -1
  3. package/dist/ci-jobs.d.ts.map +1 -1
  4. package/dist/ci-jobs.js +48 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/build.js +14 -0
  7. package/dist/cli/handlers/approve.d.ts.map +1 -1
  8. package/dist/cli/handlers/approve.js +9 -0
  9. package/dist/cli/handlers/emulator.d.ts +13 -2
  10. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  11. package/dist/cli/handlers/emulator.js +63 -3
  12. package/dist/cli/handlers/graph.js +24 -1
  13. package/dist/cli/handlers/promote.d.ts.map +1 -1
  14. package/dist/cli/handlers/promote.js +7 -1
  15. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  16. package/dist/cli/handlers/run-wave.js +5 -4
  17. package/dist/cli/handlers/run.d.ts +4 -3
  18. package/dist/cli/handlers/run.d.ts.map +1 -1
  19. package/dist/cli/handlers/run.js +73 -9
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +17 -4
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +31 -0
  24. package/dist/cli/registry.d.ts +12 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/cli-support.d.ts +45 -6
  27. package/dist/components/cli-support.d.ts.map +1 -1
  28. package/dist/components/cli-support.js +97 -22
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +25 -10
  31. package/dist/graph-detail.d.ts.map +1 -1
  32. package/dist/graph-detail.js +24 -2
  33. package/dist/graph-ir.d.ts +43 -0
  34. package/dist/graph-ir.d.ts.map +1 -1
  35. package/dist/graph-ir.js +27 -0
  36. package/dist/lexicon.d.ts +25 -1
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/git.d.ts +11 -0
  39. package/dist/lifecycle/git.d.ts.map +1 -1
  40. package/dist/lifecycle/git.js +62 -6
  41. package/dist/lifecycle/no-record.d.ts +59 -0
  42. package/dist/lifecycle/no-record.d.ts.map +1 -0
  43. package/dist/lifecycle/no-record.js +88 -0
  44. package/dist/op/activities/shell.d.ts.map +1 -1
  45. package/dist/op/activities/shell.js +30 -9
  46. package/dist/op/emulator-lifecycle.d.ts +23 -0
  47. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  48. package/dist/op/emulator-lifecycle.js +39 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +3 -0
  51. package/dist/op/index.d.ts +1 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +1 -1
  54. package/dist/op/local-executor.d.ts +10 -0
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +7 -2
  57. package/dist/op/op-waves-notify.d.ts +2 -0
  58. package/dist/op/op-waves-notify.d.ts.map +1 -1
  59. package/dist/op/op-waves-notify.js +9 -3
  60. package/dist/op/op-waves-run.d.ts +8 -2
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves-run.js +36 -11
  63. package/dist/op/op-waves.d.ts +85 -1
  64. package/dist/op/op-waves.d.ts.map +1 -1
  65. package/dist/op/op-waves.js +153 -1
  66. package/dist/op/runtimes/local.d.ts.map +1 -1
  67. package/dist/op/runtimes/local.js +6 -0
  68. package/dist/op/step-console.d.ts +65 -0
  69. package/dist/op/step-console.d.ts.map +1 -0
  70. package/dist/op/step-console.js +140 -0
  71. package/dist/point-decision.d.ts.map +1 -1
  72. package/dist/point-decision.js +3 -0
  73. package/dist/wave-runtime.mjs +240 -224
  74. package/dist/workspace/checks/links.d.ts +5 -0
  75. package/dist/workspace/checks/links.d.ts.map +1 -1
  76. package/dist/workspace/checks/links.js +54 -8
  77. package/dist/workspace/checks.d.ts +4 -1
  78. package/dist/workspace/checks.d.ts.map +1 -1
  79. package/dist/workspace/checks.js +6 -2
  80. package/dist/workspace/compose-graph.d.ts +26 -4
  81. package/dist/workspace/compose-graph.d.ts.map +1 -1
  82. package/dist/workspace/compose-graph.js +21 -7
  83. package/dist/workspace/dbt-project-reader.d.ts +36 -0
  84. package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
  85. package/dist/workspace/dbt-project-reader.js +290 -0
  86. package/dist/workspace/graph-cli.d.ts.map +1 -1
  87. package/dist/workspace/graph-cli.js +3 -1
  88. package/dist/workspace/graph.schema.json +109 -4
  89. package/dist/workspace/intent.js +7 -2
  90. package/dist/workspace/kind-link-readers.d.ts +84 -0
  91. package/dist/workspace/kind-link-readers.d.ts.map +1 -0
  92. package/dist/workspace/kind-link-readers.js +53 -0
  93. package/dist/workspace/kinds.d.ts +15 -0
  94. package/dist/workspace/kinds.d.ts.map +1 -1
  95. package/dist/workspace/kinds.js +11 -0
  96. package/dist/workspace/lexicon-outputs.d.ts +63 -0
  97. package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
  98. package/dist/workspace/lexicon-outputs.js +241 -0
  99. package/dist/workspace/links.d.ts +29 -7
  100. package/dist/workspace/links.d.ts.map +1 -1
  101. package/dist/workspace/links.js +109 -34
  102. package/dist/workspace/member-handles.d.ts +18 -1
  103. package/dist/workspace/member-handles.d.ts.map +1 -1
  104. package/dist/workspace/member-handles.js +31 -14
  105. package/dist/workspace/nested-graph.d.ts +1 -1
  106. package/dist/workspace/nested-graph.d.ts.map +1 -1
  107. package/dist/workspace/nested-graph.js +5 -1
  108. package/dist/workspace/records-checkout.d.ts +11 -1
  109. package/dist/workspace/records-checkout.d.ts.map +1 -1
  110. package/dist/workspace/records-checkout.js +38 -2
  111. package/dist/workspace/runs.d.ts.map +1 -1
  112. package/dist/workspace/runs.js +4 -2
  113. package/dist/workspace/status.d.ts.map +1 -1
  114. package/dist/workspace/status.js +3 -2
  115. package/dist/workspace/workspace-kinds.schema.json +5 -0
  116. package/package.json +1 -1
  117. package/src/audit/rules-doc.ts +1 -1
  118. package/src/ci-jobs.ts +58 -1
  119. package/src/cli/commands/build.ts +18 -0
  120. package/src/cli/handlers/approve.ts +9 -0
  121. package/src/cli/handlers/emulator.test.ts +61 -1
  122. package/src/cli/handlers/emulator.ts +65 -4
  123. package/src/cli/handlers/graph.ts +22 -1
  124. package/src/cli/handlers/promote.test.ts +7 -0
  125. package/src/cli/handlers/promote.ts +7 -1
  126. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  127. package/src/cli/handlers/run-wave.ts +5 -4
  128. package/src/cli/handlers/run.test.ts +126 -0
  129. package/src/cli/handlers/run.ts +69 -9
  130. package/src/cli/main.test.ts +24 -0
  131. package/src/cli/main.ts +17 -4
  132. package/src/cli/parse-args.ts +21 -0
  133. package/src/cli/registry.ts +12 -0
  134. package/src/components/cli-support.test.ts +75 -0
  135. package/src/components/cli-support.ts +130 -25
  136. package/src/components/driver.test.ts +28 -0
  137. package/src/components/driver.ts +23 -10
  138. package/src/components/generate-disabled.test.ts +56 -0
  139. package/src/graph-ci-edges.test.ts +50 -0
  140. package/src/graph-detail.ts +21 -2
  141. package/src/graph-ir.test.ts +23 -1
  142. package/src/graph-ir.ts +65 -0
  143. package/src/lexicon.ts +26 -1
  144. package/src/lifecycle/git.ts +61 -6
  145. package/src/lifecycle/no-record.test.ts +175 -0
  146. package/src/lifecycle/no-record.ts +108 -0
  147. package/src/op/activities/shell.ts +28 -6
  148. package/src/op/emulator-lifecycle.test.ts +24 -1
  149. package/src/op/emulator-lifecycle.ts +58 -2
  150. package/src/op/gate.ts +3 -0
  151. package/src/op/index.ts +1 -1
  152. package/src/op/local-executor.ts +20 -3
  153. package/src/op/op-waves-jobs.test.ts +213 -0
  154. package/src/op/op-waves-notify.ts +11 -5
  155. package/src/op/op-waves-run.ts +42 -11
  156. package/src/op/op-waves.ts +202 -2
  157. package/src/op/runtimes/local.ts +6 -0
  158. package/src/op/step-console.test.ts +150 -0
  159. package/src/op/step-console.ts +155 -0
  160. package/src/point-decision-bundle.test.ts +1 -0
  161. package/src/point-decision.ts +3 -0
  162. package/src/wave-loop-golden.test.ts +3 -0
  163. package/src/workspace/checks/links.ts +57 -8
  164. package/src/workspace/checks.e2e.test.ts +1 -1
  165. package/src/workspace/checks.ts +9 -2
  166. package/src/workspace/compose-graph.test.ts +55 -0
  167. package/src/workspace/compose-graph.ts +33 -9
  168. package/src/workspace/dbt-kind.e2e.test.ts +95 -0
  169. package/src/workspace/dbt-kind.test.ts +283 -0
  170. package/src/workspace/dbt-project-reader.ts +291 -0
  171. package/src/workspace/graph-cli.ts +3 -1
  172. package/src/workspace/graph-contract.test.ts +26 -1
  173. package/src/workspace/graph.schema.json +109 -4
  174. package/src/workspace/intent.ts +7 -2
  175. package/src/workspace/kind-link-readers.ts +109 -0
  176. package/src/workspace/kinds.ts +21 -1
  177. package/src/workspace/lexicon-outputs.ts +272 -0
  178. package/src/workspace/links.test.ts +22 -0
  179. package/src/workspace/links.ts +129 -34
  180. package/src/workspace/member-handles.ts +28 -8
  181. package/src/workspace/nested-graph.test.ts +14 -0
  182. package/src/workspace/nested-graph.ts +7 -2
  183. package/src/workspace/records-checkout.ts +36 -2
  184. package/src/workspace/records-uncommitted.test.ts +5 -1
  185. package/src/workspace/runs.ts +4 -2
  186. package/src/workspace/status.ts +3 -2
  187. package/src/workspace/workspace-kinds.schema.json +5 -0
@@ -228,6 +228,28 @@ export async function computeComponentGraph(
228
228
  /** A generate-mode target lexicon name — any lexicon whose plugin implements `generateComponentPipeline` (gitlab, github, and forgejo today). */
229
229
  export type GenerateLexicon = string;
230
230
 
231
+ /**
232
+ * The components an "all" run takes, in `discovered` order (chant #1522). A
233
+ * seam-gated component (`enabled: false`, computed from this run's own
234
+ * params) sits out: the run deploys the estate the parameters describe, not
235
+ * the units the seams turned off. A disabled dependency is satisfied
236
+ * vacuously, since ordering is dependsOn's only meaning, so it is also
237
+ * stripped from the survivors' dependsOn rather than failing wave resolution
238
+ * as an unknown name. `chant run --components all`, a component list's
239
+ * dependency closure (#3950) and the generated pipelines (#3951) all select
240
+ * through this.
241
+ */
242
+ export function withoutDisabled<T extends { name: string; dependsOn?: string[] }>(
243
+ discovered: ReadonlyArray<{ name: string; enabled?: boolean }>,
244
+ components: T[],
245
+ ): T[] {
246
+ const disabled = new Set(discovered.filter((c) => c.enabled === false).map((c) => c.name));
247
+ if (disabled.size === 0) return components;
248
+ return components
249
+ .filter((c) => !disabled.has(c.name))
250
+ .map((c) => (c.dependsOn?.some((d) => disabled.has(d)) ? { ...c, dependsOn: c.dependsOn.filter((d) => !disabled.has(d)) } : c));
251
+ }
252
+
231
253
  /** Result of `chant build --components --generate <lexicon>`. */
232
254
  export interface GenerateComponentsResult {
233
255
  success: boolean;
@@ -301,11 +323,13 @@ export async function generateComponentsPipeline(
301
323
  return { success: false, error: result.errors.map((e) => e.message).join("\n") };
302
324
  }
303
325
 
304
- const driverComponents: DriverComponent[] = [...result.components.values()].map(({ component }) => ({
305
- name: component.name,
306
- dependsOn: component.dependsOn,
307
- deploy: component.deploy,
308
- }));
326
+ // #3951: the pipeline holds the jobs `chant run --components all` would
327
+ // run, so a disabled component has no job and no other job needs it.
328
+ const discovered = [...result.components.values()].map(({ component }) => component);
329
+ const driverComponents = withoutDisabled(
330
+ discovered,
331
+ discovered.map((component) => ({ name: component.name, dependsOn: component.dependsOn, deploy: component.deploy })),
332
+ );
309
333
 
310
334
  try {
311
335
  const { yaml, stages, jobs, env } = plugin.generateComponentPipeline(driverComponents, options);
@@ -404,7 +428,7 @@ export interface RunComponentsOptions {
404
428
  /**
405
429
  * Opt-in structured progress observer (`chant run --components <name|all>
406
430
  * --progress-json`, see ./run-progress.ts). Threaded straight through to
407
- * `runInterpretDriver` for `selector === "all"`; for a single-component
431
+ * `runInterpretDriver` for `"all"` and a list; for a single-component
408
432
  * selector (which bypasses `runInterpretDriver` — see the docstring above),
409
433
  * this function emits the same `run-start`/`wave-start`/`component-start`/
410
434
  * …/`run-done` envelope itself, treating the one component as a run with a
@@ -430,6 +454,12 @@ export interface RunComponentsOptions {
430
454
  buildParams?: BuildParamProvenance[];
431
455
  /** Where a `gate` step's facts are read and written (#2119). Defaults to the `chant/lifecycle` orphan branch; a test passes `memoryGateLedgerPort()` from `../op/gate.ts`. */
432
456
  gates?: GateLedgerPort;
457
+ /**
458
+ * Called once for a list selector (`a,b,c`, #3950) with the resolved run
459
+ * order, waves and the dependencies the list pulled in, after selection and
460
+ * before the first component runs. The CLI prints it.
461
+ */
462
+ onPlan?: (plan: ComponentRunPlan) => void;
433
463
  /**
434
464
  * The commit the run deploys, for the workloads' `vcs.ref.head.revision`
435
465
  * (#3061). Default: `git rev-parse HEAD` in the project, or nothing outside
@@ -438,7 +468,15 @@ export interface RunComponentsOptions {
438
468
  revision?: string | null;
439
469
  }
440
470
 
441
- /** Result of `chant run --components <name|all>`. */
471
+ /** The resolved order of a list run (#3950), reported through `RunComponentsOptions.onPlan`. */
472
+ export interface ComponentRunPlan {
473
+ order: string[];
474
+ waves: string[][];
475
+ /** Dependencies the list did not name but the run includes. */
476
+ added: string[];
477
+ }
478
+
479
+ /** Result of `chant run --components <name|all|a,b,c>`. */
442
480
  export interface RunComponentsResult {
443
481
  success: boolean;
444
482
  /** The driver's run result, when the run reached the driver (even on a failed component). */
@@ -468,6 +506,10 @@ export interface RunComponentsResult {
468
506
  * - `selector === "all"`: resolves the whole set's dependency order/waves
469
507
  * and dispatches every component through `runInterpretDriver`, in the same
470
508
  * order `chant graph --components` reports.
509
+ * - a list (`a,b,c`, #3950): the named components plus everything they
510
+ * depend on, through the same `runInterpretDriver` path as `"all"`, so the
511
+ * subset runs in the order `"all"` would give it, in one process. The
512
+ * resolved order goes to `onPlan` before the first component starts.
471
513
  * - a single name: dispatches just that component via `runComponentDeploy`,
472
514
  * without requiring the rest of its `dependsOn` graph to be present in
473
515
  * this invocation. This mirrors generate mode's per-job invocation model
@@ -483,17 +525,24 @@ export interface RunComponentsResult {
483
525
  * stops the run there with `gated` set, which the CLI turns into exit code 3
484
526
  * and the `chant approve` line.
485
527
  */
486
- /** Result of resolving a `<name|all>` selector against discovered components. */
528
+ /** Result of resolving a `<name|all|a,b,c>` selector against discovered components. */
487
529
  export interface ResolvedComponentTargets {
488
530
  success: boolean;
489
531
  targets: DriverComponent[];
532
+ /** For a list selector (#3950): the dependencies the closure added that the list did not name, in discovery order. */
533
+ added?: string[];
490
534
  error?: string;
491
535
  }
492
536
 
537
+ /** Whether `selector` is a comma-separated component list (#3950), as opposed to `all` or one name. */
538
+ export function isComponentList(selector: string): boolean {
539
+ return selector.includes(",");
540
+ }
541
+
493
542
  /**
494
- * Discover every component under `path` and resolve `selector` (`"all"` or a
495
- * single component name) to the `DriverComponent`(s) to run, with no gate
496
- * check and no dispatch — just discovery + selection, factored out of
543
+ * Discover every component under `path` and resolve `selector` (`"all"`, a
544
+ * single component name, or a comma-separated list, #3950) to the
545
+ * `DriverComponent`(s) to run, with no gate check and no dispatch — just discovery + selection, factored out of
497
546
  * `runComponents` so another entrypoint can reuse the exact same selection
498
547
  * semantics without inheriting the run itself.
499
548
  */
@@ -512,19 +561,9 @@ export async function resolveComponentTargets(
512
561
  const discovered = [...result.components.values()].map(({ component }) => component);
513
562
  const all = discovered.map((component) => toDriverComponent(component));
514
563
 
515
- if (selector === "all") {
516
- // chant #1522 — a seam-gated component (`enabled: false`, computed from
517
- // this run's own params) sits out of "all": the run deploys the estate
518
- // the parameters describe, not the units the seams turned off. A disabled
519
- // dependency is satisfied vacuously — ordering is dependsOn's only
520
- // semantics — so it is also stripped from the survivors' dependsOn,
521
- // rather than failing wave resolution as an unknown name.
522
- const disabled = new Set(discovered.filter((c) => c.enabled === false).map((c) => c.name));
523
- const targets = all
524
- .filter((c) => !disabled.has(c.name))
525
- .map((c) => (c.dependsOn?.some((d) => disabled.has(d)) ? { ...c, dependsOn: c.dependsOn.filter((d) => !disabled.has(d)) } : c));
526
- return { success: true, targets };
527
- }
564
+ if (isComponentList(selector)) return resolveComponentList(selector, discovered, all);
565
+
566
+ if (selector === "all") return { success: true, targets: withoutDisabled(discovered, all) };
528
567
 
529
568
  const found = all.find((c) => c.name === selector);
530
569
  const disabledByName = discovered.find((c) => c.name === selector && c.enabled === false);
@@ -548,6 +587,65 @@ export async function resolveComponentTargets(
548
587
  return { success: true, targets: [found] };
549
588
  }
550
589
 
590
+ /**
591
+ * A list selector (`a,b,c`, #3950): the named components plus every component
592
+ * they depend on, transitively, so the list runs as one dependency-ordered
593
+ * set. Refused before anything runs when a name is unknown, disabled, or `all`
594
+ * (which already means every component). Dependencies are followed through
595
+ * `withoutDisabled`, the selection `"all"` uses, so a disabled *dependency*
596
+ * is dropped and stripped from dependsOn exactly as `"all"` drops it. A
597
+ * dependsOn name that matches no discovered component is left in place, so
598
+ * wave resolution reports it as `"all"` would.
599
+ */
600
+ function resolveComponentList(
601
+ selector: string,
602
+ discovered: ReadonlyArray<{ name: string; enabled?: boolean }>,
603
+ all: DriverComponent[],
604
+ ): ResolvedComponentTargets {
605
+ const names = [...new Set(selector.split(",").map((n) => n.trim()).filter((n) => n.length > 0))];
606
+ if (names.length === 0) {
607
+ return { success: false, targets: [], error: `Component list "${selector}" names no components.` };
608
+ }
609
+ if (names.includes("all")) {
610
+ return { success: false, targets: [], error: `"all" cannot be part of a component list; pass --components all on its own, or name the components.` };
611
+ }
612
+ // An explicit ask for a unit the parameters excluded is a mistake to
613
+ // surface, not to skip silently, as it is for a single name.
614
+ const off = names.filter((n) => discovered.some((c) => c.name === n && c.enabled === false));
615
+ if (off.length > 0) {
616
+ const one = off.length === 1;
617
+ return {
618
+ success: false,
619
+ targets: [],
620
+ error: `${one ? "Component" : "Components"} ${off.map((n) => `"${n}"`).join(", ")} ${one ? "is" : "are"} disabled under this run's parameters (enabled: false). Enable the seam ${one ? "it is" : "they are"} gated on, or leave ${one ? "it" : "them"} out of the list.`,
621
+ };
622
+ }
623
+ const unknown = names.filter((n) => !all.some((c) => c.name === n));
624
+ if (unknown.length > 0) {
625
+ const known = all.map((c) => c.name).sort().join(", ");
626
+ return {
627
+ success: false,
628
+ targets: [],
629
+ error: `${unknown.length === 1 ? "Component" : "Components"} ${unknown.map((n) => `"${n}"`).join(", ")} not found.${known ? ` Known components: ${known}` : " No components discovered."}`,
630
+ };
631
+ }
632
+
633
+ const enabled = withoutDisabled(discovered, all);
634
+ const byName = new Map(enabled.map((c) => [c.name, c]));
635
+ const included = new Set<string>();
636
+ const visit = (name: string): void => {
637
+ const component = byName.get(name);
638
+ if (!component || included.has(name)) return;
639
+ included.add(name);
640
+ for (const dep of component.dependsOn ?? []) visit(dep);
641
+ };
642
+ for (const name of names) visit(name);
643
+
644
+ const targets = enabled.filter((c) => included.has(c.name));
645
+ const named = new Set(names);
646
+ return { success: true, targets, added: targets.map((c) => c.name).filter((n) => !named.has(n)) };
647
+ }
648
+
551
649
  export async function runComponents(
552
650
  path: string,
553
651
  selector: string,
@@ -595,7 +693,14 @@ export async function runComponents(
595
693
  const releaseIdentity = () => (revision ? { revision } : {});
596
694
 
597
695
  try {
598
- if (selector === "all") {
696
+ if (selector === "all" || isComponentList(selector)) {
697
+ if (isComponentList(selector) && options.onPlan) {
698
+ // Resolved here, before dispatch, so the order is printed before any
699
+ // component runs. A cycle or unknown dependency throws the same error
700
+ // `runInterpretDriver` would, caught below.
701
+ const graph = resolveComponentGraph(resolvedTargets);
702
+ options.onPlan({ order: graph.order, waves: graph.waves, added: resolved.added ?? [] });
703
+ }
599
704
  const run = await runInterpretDriver(resolvedTargets, registry, {
600
705
  env,
601
706
  releaseIdentity,
@@ -239,6 +239,34 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
239
239
  return { registry, calls, rolledBack };
240
240
  }
241
241
 
242
+ // #3955: a run that records nothing cannot spend an approval or leave a
243
+ // pending fact, so the gate fails even with an approval standing for it.
244
+ it("fails at the gate under CHANT_NO_RECORD, writing nothing and running nothing after it", async () => {
245
+ const { registry, calls } = registryWithCalls();
246
+ const port = memoryGateLedgerPort({
247
+ resolutions: [{ op: "neo4j-cluster", gate: "approve-node-1", resolvedBy: "alice", timestamp: NOW, environment: "dev" } as never],
248
+ });
249
+ process.env.CHANT_NO_RECORD = "1";
250
+ let result;
251
+ try {
252
+ result = await runComponentDeploy(
253
+ gatedComponent(), { env: "dev", component: "neo4j-cluster" }, registry, {}, undefined,
254
+ { port, now: NOW },
255
+ );
256
+ } finally {
257
+ delete process.env.CHANT_NO_RECORD;
258
+ }
259
+
260
+ expect(result.status).toBe("fail");
261
+ expect(port.appended).toHaveLength(0);
262
+ // A failure, so the component unwinds as any failed step would; nothing after the gate ran.
263
+ expect(calls).not.toContain("code-deploy");
264
+ const gate = result.records.find((r) => r.kind === "gate:approve-node-1");
265
+ expect(gate).toMatchObject({ status: "fail" });
266
+ expect(gate!.error).toContain("cannot be passed under --no-record");
267
+ expect(result.records.find((r) => r.kind === "code-deploy")).toMatchObject({ status: "skipped" });
268
+ });
269
+
242
270
  it("ends gated, records the pending fact, and runs nothing after the gate", async () => {
243
271
  const { registry, calls, rolledBack } = registryWithCalls();
244
272
  const port = memoryGateLedgerPort();
@@ -50,6 +50,7 @@ import { topoSort } from "../codegen/topo-sort";
50
50
  import { kahnLayers } from "./layers";
51
51
  import { evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "../op/gate";
52
52
  import { gateName } from "../op/gate-name";
53
+ import { GateNotRecordableError } from "../lifecycle/no-record";
53
54
  import { componentPlanDigest } from "./gate-plan";
54
55
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
55
56
  import type { CapabilityRegistry, DeployContext } from "./capability";
@@ -473,17 +474,29 @@ async function runPhase(
473
474
  const gateRecords: DriverStepRecord[] = [];
474
475
  for (const gate of phaseDef.steps.filter(isGateStep)) {
475
476
  const start = Date.now();
476
- const check = await evaluateGate(gates.port, {
477
- op: ctx.component,
478
- gate: gateName(gate),
479
- // #2574: an approval counts only for this environment and this plan.
480
- environment: ctx.env,
481
- ...(gates.planDigest !== undefined ? { planDigest: gates.planDigest } : {}),
482
- ...(gate.description ? { description: gate.description } : {}),
483
- ...(gate.timeout ? { timeout: gate.timeout } : {}),
484
- ...(gates.now ? { now: gates.now } : {}),
485
- });
486
477
  const base = { component: ctx.component, phase: phaseDef.phase, kind: `gate:${gateName(gate)}` };
478
+ let check: Awaited<ReturnType<typeof evaluateGate>>;
479
+ try {
480
+ check = await evaluateGate(gates.port, {
481
+ op: ctx.component,
482
+ gate: gateName(gate),
483
+ // #2574: an approval counts only for this environment and this plan.
484
+ environment: ctx.env,
485
+ ...(gates.planDigest !== undefined ? { planDigest: gates.planDigest } : {}),
486
+ ...(gate.description ? { description: gate.description } : {}),
487
+ ...(gate.timeout ? { timeout: gate.timeout } : {}),
488
+ ...(gates.now ? { now: gates.now } : {}),
489
+ });
490
+ } catch (err) {
491
+ // #3955: a run that records nothing fails at the gate, as the Op
492
+ // executor's gate step does, and the component unwinds like any failure.
493
+ if (!(err instanceof GateNotRecordableError)) throw err;
494
+ gateRecords.push({ ...base, status: "fail" as const, durationMs: Date.now() - start, error: err.message });
495
+ for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
496
+ gateRecords.push(skippedRecord(skipped));
497
+ }
498
+ throw new StepFailure(gateRecords, []);
499
+ }
487
500
  if (!check.satisfied) {
488
501
  gateRecords.push({ ...base, status: "skipped" as const, durationMs: Date.now() - start });
489
502
  for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `chant build --components --generate <forge>` leaves out a component with
3
+ * `enabled: false` (#3951), the same way `chant run --components all` skips it
4
+ * (#1522): no job for it, and no other job needs it. Runs against the real
5
+ * GitHub, GitLab and Forgejo plugins, not a stand-in, so each forge's own
6
+ * `needs` rendering is what the assertions read.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
10
+ import { mkdir, writeFile, rm } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { generateComponentsPipeline, resolveComponentTargets } from "./cli-support";
14
+
15
+ describe.each(["github", "gitlab", "forgejo"])("generateComponentsPipeline %s: enabled: false (#3951)", (forge) => {
16
+ let testDir: string;
17
+
18
+ beforeEach(async () => {
19
+ testDir = join(tmpdir(), `chant-generate-disabled-${forge}-${Date.now()}-${Math.random()}`);
20
+ await mkdir(testDir, { recursive: true });
21
+ await writeFile(
22
+ join(testDir, "tunnel.component.ts"),
23
+ `export const tunnel = { name: "tunnel", enabled: false, dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`,
24
+ );
25
+ await writeFile(
26
+ join(testDir, "db.component.ts"),
27
+ `export const db = { name: "db", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`,
28
+ );
29
+ await writeFile(
30
+ join(testDir, "api.component.ts"),
31
+ `export const api = { name: "api", dependsOn: ["db", "tunnel"], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`,
32
+ );
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await rm(testDir, { recursive: true, force: true });
37
+ });
38
+
39
+ test("the disabled component has no job, and its dependent no longer needs it", async () => {
40
+ const result = await generateComponentsPipeline(testDir, forge, { env: "prod" });
41
+
42
+ expect(result.success).toBe(true);
43
+ expect(result.jobs?.map((j) => j.component).sort()).toEqual(["api", "db"]);
44
+ const api = result.jobs?.find((j) => j.component === "api");
45
+ const db = result.jobs?.find((j) => j.component === "db");
46
+ expect(api?.needs).toEqual([db?.jobName]);
47
+ expect(result.yaml).not.toMatch(/tunnel/);
48
+ });
49
+
50
+ test("the pipeline holds the components an \"all\" run takes", async () => {
51
+ const result = await generateComponentsPipeline(testDir, forge, { env: "prod" });
52
+ const run = await resolveComponentTargets(testDir, "all");
53
+
54
+ expect(result.jobs?.map((j) => j.component).sort()).toEqual(run.targets.map((t) => t.name).sort());
55
+ });
56
+ });
@@ -95,3 +95,53 @@ describe("detail and lens keep CI edges (#3910)", () => {
95
95
  expect(focused.groups.byPipeline).toEqual({ ci: ["test"], shared: ["shared"] });
96
96
  });
97
97
  });
98
+
99
+ describe("detail tiers carry groups.byPipeline (#3921)", () => {
100
+ // `build` and `test` sit inside one composite instance; `lint` in another
101
+ // that also holds a job of the `shared` pipeline.
102
+ const composed: GraphIR = {
103
+ nodes: [
104
+ { id: "ci", kind: "GitHub::Actions::Workflow", lexicon: "github", attrs: {} },
105
+ { id: "build", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {}, compositeInstance: "pair", compositeParent: "Pair" },
106
+ { id: "test", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {}, compositeInstance: "pair", compositeParent: "Pair" },
107
+ { id: "lint", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {}, compositeInstance: "mixed", compositeParent: "Mixed" },
108
+ { id: "publish", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {}, compositeInstance: "mixed", compositeParent: "Mixed" },
109
+ { id: "shared", kind: "GitHub::Actions::Workflow", lexicon: "github", attrs: {} },
110
+ ],
111
+ edges: [],
112
+ groups: {
113
+ byLexicon: { github: ["build", "ci", "lint", "publish", "shared", "test"] },
114
+ byPipeline: { ci: ["build", "ci", "lint", "test"], shared: ["publish", "shared"], gone: ["ghost"] },
115
+ },
116
+ };
117
+
118
+ test("the composite tier maps members to the composites that hold them, deduped", () => {
119
+ expect(applyDetail(composed, 1).groups.byPipeline).toEqual({
120
+ ci: ["ci", "mixed", "pair"],
121
+ shared: ["mixed", "shared"],
122
+ });
123
+ });
124
+
125
+ test("the composite tier keeps plain members as they are", () => {
126
+ expect(applyDetail(merged, 1).groups.byPipeline).toEqual({ ci: ["build", "ci", "test"], shared: ["shared"] });
127
+ });
128
+
129
+ test("a pipeline with no kept member is dropped, and no pipelines means no byPipeline", () => {
130
+ const none = applyDetail({ ...composed, groups: { byPipeline: { gone: ["ghost"] } } }, 1);
131
+ expect(none.groups.byPipeline).toBeUndefined();
132
+ });
133
+
134
+ test("the stacks tier has no pipelines: each would hold only its lexicon's node", () => {
135
+ expect(applyDetail(composed, 0).groups.byPipeline).toBeUndefined();
136
+ });
137
+
138
+ test("a lens then the composite tier keeps the pipelines of what survives", () => {
139
+ const t1 = applyDetail(applyLens(composed, parseLens("blast:shared")), 1);
140
+ expect(t1.groups.byPipeline).toEqual({ shared: ["shared"] });
141
+ });
142
+
143
+ test("declarable and attribute tiers pass byPipeline through", () => {
144
+ expect(applyDetail(composed, 2).groups.byPipeline).toEqual(composed.groups.byPipeline);
145
+ expect(applyDetail(composed, 3).groups.byPipeline).toEqual(composed.groups.byPipeline);
146
+ });
147
+ });
@@ -99,7 +99,9 @@ function byLexiconOf(nodes: IRNode[]): Record<string, string[]> {
99
99
  return sorted;
100
100
  }
101
101
 
102
- /** T0 — collapse every resource to its lexicon; edges become cross-lexicon deps. */
102
+ /** T0 — collapse every resource to its lexicon; edges become cross-lexicon deps.
103
+ * No groups: a pipeline never spans lexicons, so `byPipeline` would put every
104
+ * pipeline of a lexicon around that lexicon's one stack node (#3921). */
103
105
  function toStacks(ir: GraphIR): GraphIR {
104
106
  const lexOf = new Map(ir.nodes.map((n) => [n.id, n.lexicon]));
105
107
  const lexicons = [...new Set(ir.nodes.map((n) => n.lexicon))].sort();
@@ -178,10 +180,27 @@ function toComposites(ir: GraphIR): GraphIR {
178
180
  const imports = ir.imports?.filter((i) => surviving.has(i.node));
179
181
  // Unresolved references (#3910) stay with whichever node now holds them.
180
182
  const dangling = ir.dangling?.map((d) => ({ ...d, from: idMap.get(d.from) ?? d.from }));
183
+ // Pipeline membership (#3921) follows each member to the node that now holds
184
+ // it: a job inside a collapsed composite is drawn as that composite. A
185
+ // composite that spans two pipelines sits in both; a pipeline whose members
186
+ // all fell away is dropped.
187
+ const groups: GraphIR["groups"] = { byLexicon: byLexiconOf(nodes) };
188
+ if (ir.groups.byPipeline) {
189
+ const byPipeline: Record<string, string[]> = {};
190
+ for (const name of Object.keys(ir.groups.byPipeline).sort()) {
191
+ const kept = new Set<string>();
192
+ for (const m of ir.groups.byPipeline[name]) {
193
+ const id = idMap.get(m) ?? m;
194
+ if (surviving.has(id)) kept.add(id);
195
+ }
196
+ if (kept.size) byPipeline[name] = [...kept].sort();
197
+ }
198
+ if (Object.keys(byPipeline).length) groups.byPipeline = byPipeline;
199
+ }
181
200
  return {
182
201
  nodes,
183
202
  edges: sortEdges([...seen.values()]),
184
- groups: { byLexicon: byLexiconOf(nodes) },
203
+ groups,
185
204
  ...(imports && imports.length ? { imports } : {}),
186
205
  ...(dangling && dangling.length ? { dangling } : {}),
187
206
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { buildGraphIr } from "./graph-ir";
2
+ import { applyLexiconOutputs, buildGraphIr } from "./graph-ir";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "./declarable";
4
4
  import { AttrRef } from "./attrref";
5
5
  import { LexiconOutput } from "./lexicon-output";
@@ -370,3 +370,25 @@ describe("whole declarables held in an entity's fields (#3257)", () => {
370
370
  expect(ir.edges).toEqual([{ from: "lb", to: "vpc", kind: "ref", viaAttr: "actions" }]);
371
371
  });
372
372
  });
373
+
374
+ describe("applyLexiconOutputs (#3931)", () => {
375
+ test("adds each reported output to exports on the node that declares it, keeping a hand-written export", () => {
376
+ const orders = decl({ lexicon: "sql", entityType: "Postgres::Table", props: {} });
377
+ const ir = buildGraphIr(new Map<string, Declarable>([["orders", orders]]));
378
+ const withOutputs = applyLexiconOutputs({ ...ir, exports: [{ name: "public.orders", node: "orders", attr: "name" }] }, [
379
+ {
380
+ outputs: [
381
+ { name: "public.orders", entity: "orders" },
382
+ { name: "public.orders.buyer_id", entity: "orders", previously: ["public.orders.customer_id"] },
383
+ { name: "other.thing", entity: "notANode" },
384
+ ],
385
+ },
386
+ ]);
387
+ expect(withOutputs.exports).toEqual([
388
+ { name: "other.thing" },
389
+ { name: "public.orders", node: "orders", attr: "name" },
390
+ { name: "public.orders.buyer_id", node: "orders", previously: ["public.orders.customer_id"] },
391
+ ]);
392
+ expect(applyLexiconOutputs(ir, [{ outputs: [] }])).toBe(ir);
393
+ });
394
+ });
package/src/graph-ir.ts CHANGED
@@ -155,6 +155,43 @@ export interface LexiconGraphReference {
155
155
  dangling?: boolean;
156
156
  }
157
157
 
158
+ /**
159
+ * One output a lexicon reports for its own declarations (#3931): a name a
160
+ * workspace link may target, such as a sql table's `public.orders` or a
161
+ * column's `public.orders.customer_id`.
162
+ */
163
+ export interface LexiconWorkspaceOutput {
164
+ /** The output's name, as a link's `output` names it. */
165
+ name: string;
166
+ /** The entity key that declares it. */
167
+ entity: string;
168
+ /**
169
+ * Names the output had before, from a rename hint in source (a sql
170
+ * `-- previously:` comment). A link to one of them is told where it went.
171
+ */
172
+ previously?: string[];
173
+ }
174
+
175
+ /** One file the reader was handed but could not read without running it (#3931). */
176
+ export interface LexiconUnreadSource {
177
+ /** The file's path, as the caller names it in messages. */
178
+ file: string;
179
+ text: string;
180
+ /** Why it was not read: the fold's located reason. */
181
+ reason: string;
182
+ }
183
+
184
+ /** What a lexicon's `workspaceOutputs` hook returns (#3931). */
185
+ export interface LexiconWorkspaceOutputs {
186
+ outputs: LexiconWorkspaceOutput[];
187
+ /**
188
+ * Why the list may be incomplete, one line each: a declaration whose name
189
+ * can't be known statically, a file of this lexicon's that did not fold, a
190
+ * view whose columns can't be derived. Empty or absent when it is complete.
191
+ */
192
+ gaps?: string[];
193
+ }
194
+
158
195
  /** What a lexicon's `graphEdges` hook returns (#3910). */
159
196
  export interface LexiconGraphEdges {
160
197
  references: LexiconGraphReference[];
@@ -304,6 +341,8 @@ export interface IRExport {
304
341
  value?: unknown;
305
342
  /** The deployed stack that publishes it, on an observed graph (#1279). */
306
343
  stack?: string;
344
+ /** Names the output had before, from a rename hint a lexicon read (#3931). */
345
+ previously?: string[];
307
346
  }
308
347
 
309
348
  /** A cross-stack import this stack consumes — a `Parameter` fed from another
@@ -834,6 +873,32 @@ export function applyLexiconGraphEdges(ir: GraphIR, reports: readonly LexiconGra
834
873
  return out;
835
874
  }
836
875
 
876
+ /**
877
+ * Add the outputs the lexicons' `workspaceOutputs` hooks report (#3931) to a
878
+ * built IR's `exports`, each on the node that declares it. An output whose
879
+ * name is already exported (a hand-written `output()`) keeps the existing
880
+ * export. Pure. Returns `ir` itself when no report added anything.
881
+ */
882
+ export function applyLexiconOutputs(ir: GraphIR, reports: readonly LexiconWorkspaceOutputs[]): GraphIR {
883
+ const nodeIds = new Set(ir.nodes.map((n) => n.id));
884
+ const names = new Set((ir.exports ?? []).map((e) => e.name));
885
+ const added: IRExport[] = [];
886
+ for (const report of reports) {
887
+ for (const o of report.outputs) {
888
+ if (names.has(o.name)) continue;
889
+ names.add(o.name);
890
+ added.push({
891
+ name: o.name,
892
+ ...(nodeIds.has(o.entity) ? { node: o.entity } : {}),
893
+ ...(o.previously && o.previously.length > 0 ? { previously: [...o.previously] } : {}),
894
+ });
895
+ }
896
+ }
897
+ if (added.length === 0) return ir;
898
+ const exports = [...(ir.exports ?? []), ...added].sort((a, b) => a.name.localeCompare(b.name));
899
+ return { ...ir, exports };
900
+ }
901
+
837
902
  function danglingKey(d: IRDanglingRef): string {
838
903
  return `${d.from}\0${d.path}\0${d.value}`;
839
904
  }
package/src/lexicon.ts CHANGED
@@ -23,7 +23,7 @@ import type { OwnershipChannel, OwnershipMarker } from "./ownership";
23
23
  import type { LexiconConfigSchema } from "./lexicon-config";
24
24
  import type { RuleMeta } from "./audit/catalog";
25
25
  import type { ReferenceCatalog } from "./graph-refs";
26
- import type { IREdge, LexiconGraphEdges } from "./graph-ir";
26
+ import type { IREdge, LexiconGraphEdges, LexiconUnreadSource, LexiconWorkspaceOutputs } from "./graph-ir";
27
27
  import type { DescribeResourcesResult, UnobservedReason } from "./observation";
28
28
  import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
29
29
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
@@ -1450,6 +1450,31 @@ export interface LexiconPlugin {
1450
1450
  */
1451
1451
  graphEdges?(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined;
1452
1452
 
1453
+ /**
1454
+ * The workspace outputs this lexicon's declarations expose (#3931): names
1455
+ * another member's link may target, without anyone writing `output(...)`.
1456
+ * The sql lexicon answers each table and view as `<schema>.<table>` and
1457
+ * each column as `<schema>.<table>.<column>`.
1458
+ *
1459
+ * Called two ways, with the same answer expected from both:
1460
+ *
1461
+ * - by `chant workspace check`, offline, with the entities the member's
1462
+ * source folds to (`foldProject` under `sandbox`: no member code runs),
1463
+ * and `unread` listing the files that did not fold, with their text. A
1464
+ * file of this lexicon's among them is a gap: the list is then incomplete
1465
+ * and a link to a name not in it is unresolved rather than missing;
1466
+ * - by `chant graph --format ir`, with the built entities and no `unread`.
1467
+ * The outputs land on the IR's `exports`, which `chant workspace graph`
1468
+ * resolves links against.
1469
+ *
1470
+ * Names come from the declarations as written in source, never from an
1471
+ * environment's or profile's rendering, so they stay the same everywhere
1472
+ * the member deploys. Pure: no I/O. Return `undefined` when the project
1473
+ * declares nothing this lexicon reports. Omit for lexicons with no such
1474
+ * declarations.
1475
+ */
1476
+ workspaceOutputs?(entities: ReadonlyMap<string, Declarable>, unread?: readonly LexiconUnreadSource[]): LexiconWorkspaceOutputs | undefined;
1477
+
1453
1478
  // LSP
1454
1479
  /** Provide completions for LSP */
1455
1480
  completionProvider?(ctx: CompletionContext): CompletionItem[];