@intentius/chant 0.124.48 → 0.124.49

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 (167) hide show
  1. package/dist/cli/handlers/approve.d.ts.map +1 -1
  2. package/dist/cli/handlers/approve.js +20 -12
  3. package/dist/cli/handlers/graph-reader.d.ts +26 -0
  4. package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
  5. package/dist/cli/handlers/graph-reader.js +41 -0
  6. package/dist/cli/handlers/graph.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.js +14 -0
  8. package/dist/cli/handlers/pr.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.js +40 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +22 -0
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +11 -0
  14. package/dist/cli/registry.d.ts +8 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/pr-run.d.ts +3 -0
  17. package/dist/components/pr-run.d.ts.map +1 -1
  18. package/dist/components/pr-run.js +1 -0
  19. package/dist/config.d.ts +16 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +4 -0
  22. package/dist/gate-decision.d.ts +9 -0
  23. package/dist/gate-decision.d.ts.map +1 -1
  24. package/dist/gate-decision.js +22 -7
  25. package/dist/lexicon.d.ts +23 -0
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/op/activities/activity-contracts.d.ts +4 -4
  28. package/dist/op/gate-approval-base.d.ts +25 -0
  29. package/dist/op/gate-approval-base.d.ts.map +1 -0
  30. package/dist/op/gate-approval-base.js +71 -0
  31. package/dist/op/gate-approval.d.ts +7 -4
  32. package/dist/op/gate-approval.d.ts.map +1 -1
  33. package/dist/op/gate-approval.js +2 -25
  34. package/dist/op/gate-name.d.ts +13 -0
  35. package/dist/op/gate-name.d.ts.map +1 -1
  36. package/dist/op/gate-name.js +14 -1
  37. package/dist/op/gate.d.ts +4 -3
  38. package/dist/op/gate.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +9 -2
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-executor.js +24 -5
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +4 -0
  45. package/dist/pr-migrations.d.ts +62 -0
  46. package/dist/pr-migrations.d.ts.map +1 -0
  47. package/dist/pr-migrations.js +50 -0
  48. package/dist/pr-post.js +1 -1
  49. package/dist/wave-runtime.mjs +219 -218
  50. package/dist/workspace/check.schema.json +4 -2
  51. package/dist/workspace/checks/gate-approval.d.ts +58 -0
  52. package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
  53. package/dist/workspace/checks/gate-approval.js +103 -0
  54. package/dist/workspace/checks.d.ts +1 -0
  55. package/dist/workspace/checks.d.ts.map +1 -1
  56. package/dist/workspace/checks.js +4 -0
  57. package/dist/workspace/declaration.d.ts +7 -0
  58. package/dist/workspace/declaration.d.ts.map +1 -1
  59. package/dist/workspace/declaration.js +13 -1
  60. package/dist/workspace/declaration.schema.json +33 -0
  61. package/dist/workspace/exit-cli.d.ts +44 -0
  62. package/dist/workspace/exit-cli.d.ts.map +1 -0
  63. package/dist/workspace/exit-cli.js +198 -0
  64. package/dist/workspace/exit.d.ts +159 -0
  65. package/dist/workspace/exit.d.ts.map +1 -0
  66. package/dist/workspace/exit.js +480 -0
  67. package/dist/workspace/exit.schema.json +257 -0
  68. package/dist/workspace/graph-cache.d.ts +3 -1
  69. package/dist/workspace/graph-cache.d.ts.map +1 -1
  70. package/dist/workspace/graph-cache.js +12 -1
  71. package/dist/workspace/import-fetch.d.ts +38 -0
  72. package/dist/workspace/import-fetch.d.ts.map +1 -0
  73. package/dist/workspace/import-fetch.js +99 -0
  74. package/dist/workspace/import-init.d.ts +26 -0
  75. package/dist/workspace/import-init.d.ts.map +1 -0
  76. package/dist/workspace/import-init.js +130 -0
  77. package/dist/workspace/import-source.d.ts +147 -0
  78. package/dist/workspace/import-source.d.ts.map +1 -0
  79. package/dist/workspace/import-source.js +370 -0
  80. package/dist/workspace/init.d.ts +4 -0
  81. package/dist/workspace/init.d.ts.map +1 -1
  82. package/dist/workspace/init.js +70 -3
  83. package/dist/workspace/kind-readers.d.ts +6 -0
  84. package/dist/workspace/kind-readers.d.ts.map +1 -1
  85. package/dist/workspace/kind-readers.js +18 -0
  86. package/dist/workspace/kinds.d.ts +24 -1
  87. package/dist/workspace/kinds.d.ts.map +1 -1
  88. package/dist/workspace/kinds.js +64 -4
  89. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  90. package/dist/workspace/lineage-upgrade.js +13 -4
  91. package/dist/workspace/new-migrations.d.ts +47 -0
  92. package/dist/workspace/new-migrations.d.ts.map +1 -0
  93. package/dist/workspace/new-migrations.js +91 -0
  94. package/dist/workspace/pr-report.schema.json +46 -0
  95. package/dist/workspace/promote.d.ts +96 -0
  96. package/dist/workspace/promote.d.ts.map +1 -0
  97. package/dist/workspace/promote.js +263 -0
  98. package/dist/workspace/reason-codes.d.ts +13 -0
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +14 -0
  101. package/dist/workspace/search-contract.d.ts +1 -1
  102. package/dist/workspace/search-contract.d.ts.map +1 -1
  103. package/dist/workspace/workspace-kinds.schema.json +50 -7
  104. package/package.json +1 -1
  105. package/src/cli/handlers/approve.ts +10 -2
  106. package/src/cli/handlers/graph-reader.test.ts +31 -0
  107. package/src/cli/handlers/graph-reader.ts +51 -0
  108. package/src/cli/handlers/graph.ts +14 -0
  109. package/src/cli/handlers/pr.ts +38 -1
  110. package/src/cli/main.test.ts +2 -0
  111. package/src/cli/main.ts +22 -0
  112. package/src/cli/parse-args.ts +8 -0
  113. package/src/cli/registry.ts +8 -0
  114. package/src/components/pr-env-run-bundle.test.ts +1 -0
  115. package/src/components/pr-run.ts +4 -0
  116. package/src/config.ts +16 -0
  117. package/src/gate-decision-bundle.test.ts +4 -2
  118. package/src/gate-decision-identity.test.ts +11 -0
  119. package/src/gate-decision.ts +26 -6
  120. package/src/lexicon.ts +24 -0
  121. package/src/op/gate-approval-at-base.test.ts +97 -0
  122. package/src/op/gate-approval-base.ts +72 -0
  123. package/src/op/gate-approval.ts +10 -21
  124. package/src/op/gate-name.ts +16 -1
  125. package/src/op/gate.ts +4 -3
  126. package/src/op/local-executor.ts +25 -5
  127. package/src/pr-loop-bundle.test.ts +3 -2
  128. package/src/pr-loop.test.ts +52 -0
  129. package/src/pr-loop.ts +12 -0
  130. package/src/pr-migrations.ts +97 -0
  131. package/src/pr-post.ts +1 -1
  132. package/src/workspace/__fixtures__/import/spacelift.json +9 -0
  133. package/src/workspace/__fixtures__/import/tfc.json +13 -0
  134. package/src/workspace/behold-kinds.test.ts +1 -0
  135. package/src/workspace/check-contract.test.ts +3 -2
  136. package/src/workspace/check.schema.json +4 -2
  137. package/src/workspace/checks/gate-approval.test.ts +77 -0
  138. package/src/workspace/checks/gate-approval.ts +121 -0
  139. package/src/workspace/checks.ts +4 -0
  140. package/src/workspace/declaration.schema.json +33 -0
  141. package/src/workspace/declaration.ts +19 -2
  142. package/src/workspace/exit-cli.ts +219 -0
  143. package/src/workspace/exit.schema.json +257 -0
  144. package/src/workspace/exit.test.ts +185 -0
  145. package/src/workspace/exit.ts +560 -0
  146. package/src/workspace/graph-cache.ts +12 -2
  147. package/src/workspace/identity.test.ts +18 -3
  148. package/src/workspace/import-fetch.ts +151 -0
  149. package/src/workspace/import-init.ts +147 -0
  150. package/src/workspace/import-source.test.ts +183 -0
  151. package/src/workspace/import-source.ts +462 -0
  152. package/src/workspace/init.test.ts +55 -1
  153. package/src/workspace/init.ts +66 -4
  154. package/src/workspace/kind-readers.test.ts +36 -0
  155. package/src/workspace/kind-readers.ts +18 -1
  156. package/src/workspace/kinds.test.ts +23 -0
  157. package/src/workspace/kinds.ts +81 -5
  158. package/src/workspace/lineage-upgrade.test.ts +20 -0
  159. package/src/workspace/lineage-upgrade.ts +16 -7
  160. package/src/workspace/new-migrations.test.ts +98 -0
  161. package/src/workspace/new-migrations.ts +109 -0
  162. package/src/workspace/pr-report.schema.json +46 -0
  163. package/src/workspace/promote.test.ts +114 -0
  164. package/src/workspace/promote.ts +302 -0
  165. package/src/workspace/reason-codes.test.ts +4 -0
  166. package/src/workspace/reason-codes.ts +14 -0
  167. package/src/workspace/workspace-kinds.schema.json +50 -7
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `chant graph` over a graph reader module (#4413).
3
+ *
4
+ * A kind's `graph.reader` names a module in the package that supplies the
5
+ * kind; `chant workspace graph` writes a reader project whose
6
+ * `graph.reader.module` is that module, and the member's own `chant graph`
7
+ * runs here. Because the member's chant loads it, the reader runs under the
8
+ * member's toolchain, as a lexicon reader does. The module's default export
9
+ * is `(input: { dir, config }) => GraphIR`, sync or async. It reads files and
10
+ * returns data; chant gives it no write handle, and nothing it returns is
11
+ * run.
12
+ */
13
+
14
+ import { pathToFileURL } from "node:url";
15
+ import { isAbsolute } from "node:path";
16
+ import type { GraphIR } from "../../graph-ir";
17
+
18
+ export interface GraphReaderInput {
19
+ /** The member's directory, absolute, as the kind's config placed it in `config.dir` (or the reader project when it didn't). */
20
+ dir: string;
21
+ /** The kind's `graph.config`, placeholders substituted. */
22
+ config: Record<string, unknown>;
23
+ }
24
+
25
+ /** Load the module and call it, or say why not. Never throws. */
26
+ export async function readGraphWithModule(
27
+ module: string,
28
+ config: Record<string, unknown>,
29
+ projectPath: string,
30
+ ): Promise<{ ir: GraphIR } | { error: string }> {
31
+ if (!isAbsolute(module)) return { error: `graph.reader.module must be an absolute path, got ${module}` };
32
+ let read: unknown;
33
+ try {
34
+ read = ((await import(pathToFileURL(module).href)) as { default?: unknown }).default;
35
+ } catch (err) {
36
+ return { error: `graph reader ${module} could not be loaded: ${err instanceof Error ? err.message : String(err)}` };
37
+ }
38
+ if (typeof read !== "function") return { error: `graph reader ${module} has no default export function` };
39
+ let out: unknown;
40
+ try {
41
+ const dir = typeof config.dir === "string" ? config.dir : projectPath;
42
+ out = await (read as (input: GraphReaderInput) => unknown)({ dir, config });
43
+ } catch (err) {
44
+ return { error: `graph reader ${module} failed: ${err instanceof Error ? err.message : String(err)}` };
45
+ }
46
+ const ir = out as Partial<GraphIR> | null;
47
+ if (ir === null || typeof ir !== "object" || !Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) {
48
+ return { error: `graph reader ${module} returned something with no nodes and edges` };
49
+ }
50
+ return { ir: { ...ir, nodes: ir.nodes, edges: ir.edges, groups: ir.groups ?? {} } as GraphIR };
51
+ }
@@ -118,6 +118,20 @@ export function runGraph(ctx: CommandContext): Promise<number> {
118
118
  async function runGraphCommand(ctx: CommandContext): Promise<number> {
119
119
  const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
120
120
  const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
121
+ // A project whose config names a graph reader module is read by it (#4413): `chant workspace graph` writes such a project for a kind that supplies one.
122
+ if (isViewFormat && !ctx.args.live && !ctx.args.at && !ctx.args.components) {
123
+ const { config } = await loadChantConfigUpward(resolve(ctx.args.path === "." ? "." : ctx.args.path)).catch(() => ({ config: {} as ChantConfig }));
124
+ const reader = config.graph?.reader;
125
+ if (reader) {
126
+ const { readGraphWithModule } = await import("./graph-reader");
127
+ const read = await readGraphWithModule(reader.module, reader.config ?? {}, resolve(ctx.args.path === "." ? "." : ctx.args.path));
128
+ if ("error" in read) {
129
+ console.error(formatError({ message: read.error }));
130
+ return 1;
131
+ }
132
+ return emitIr(read.ir, ctx, ctx.args.format as (typeof viewFormats)[number]);
133
+ }
134
+ }
121
135
  // `--projection <lexicon>` (#989) only means anything for the component
122
136
  // graph's IR — it adds the CI/pipeline shape to `GraphIR.pipeline`, a field
123
137
  // the other view formats' emitters (mermaid/dot/layout) don't read, and the
@@ -135,6 +135,8 @@ import { prLoopHooksAt, runPrHooks, type PrHoldSettled, type PrHookPlace, type P
135
135
  import { decidePrPolicyOverride, policyReviewOf, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrLoopPolicy } from "../../components/pr-policy";
136
136
  import type { OpWaveHookMoment } from "../../op/op-waves";
137
137
  import type { PrHookOutcome } from "../../pr-loop";
138
+ import type { PrMigrationFile } from "../../pr-migrations";
139
+ import { lexiconNames } from "../../lexicon-module";
138
140
 
139
141
 
140
142
  /** Where the reports land unless `--output` says. */
@@ -525,7 +527,7 @@ async function publish(forge: PrForge | undefined, report: PrReport, note: strin
525
527
  if (report.pr === null) return;
526
528
  // A member the change does not reach has nothing to say: one note per
527
529
  // member would put a line on every pull request for each member it skips.
528
- if (report.member && report.status === "nothing" && !report.overdue?.length) return;
530
+ if (report.member && report.status === "nothing" && !report.overdue?.length && !report.migrations?.length) return;
529
531
  try {
530
532
  await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
531
533
  } catch (err) {
@@ -726,8 +728,10 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
726
728
  }
727
729
  const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
728
730
  const overdue = await overdueOps(p.forge, p.member);
731
+ const migrations = await newMigrations(p);
729
732
  const planned = prReport({
730
733
  stage: "plan",
734
+ ...(migrations.length > 0 ? { migrations } : {}),
731
735
  pr: ctx.args.pr,
732
736
  env: p.env,
733
737
  ...(p.member ? { member: p.member } : {}),
@@ -757,6 +761,39 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
757
761
  return status === "plan-failed" || judged.stop ? 1 : 0;
758
762
  }
759
763
 
764
+ /**
765
+ * The migration files the pull request adds in a migration-directory member
766
+ * (#4407), each statement classified offline by the sql lexicon. Nothing is
767
+ * applied and no server is contacted. A workspace without such a member, or
768
+ * a classifier that cannot load, leaves the note as it was.
769
+ */
770
+ async function newMigrations(p: Prepared): Promise<PrMigrationFile[]> {
771
+ const { newMigrationFilesBetween } = await import("../../workspace/new-migrations");
772
+ try {
773
+ return await newMigrationFilesBetween(resolve("."), p.base, p.head, {
774
+ ...(p.member ? { member: p.member } : {}),
775
+ plugins: async () => {
776
+ const { loadPlugin } = await import("../plugins");
777
+ const names = ["sql", ...lexiconNames(p.config.lexicons ?? []).filter((n) => n !== "sql")];
778
+ const loaded = [];
779
+ for (const name of names) {
780
+ try {
781
+ const plugin = await loadPlugin(name);
782
+ if (plugin.classifyMigrations) loaded.push(plugin);
783
+ if (loaded.length > 0) break;
784
+ } catch {
785
+ // not installed: the next name, or the note says no lexicon classifies the dialect
786
+ }
787
+ }
788
+ return loaded;
789
+ },
790
+ });
791
+ } catch (err) {
792
+ console.error(formatWarning({ message: `could not classify the new migration files: ${err instanceof Error ? err.message : String(err)}` }));
793
+ return [];
794
+ }
795
+ }
796
+
760
797
  /**
761
798
  * The scheduled watch and reconcile Ops that stopped running (#4289,
762
799
  * `op/schedule-overdue.ts`), asked of the forge. Without a forge, or when
@@ -852,6 +852,7 @@ describe("workspace init and ls (#2534)", () => {
852
852
  "workspace build",
853
853
  "workspace check",
854
854
  "workspace evidence",
855
+ "workspace exit",
855
856
  "workspace export",
856
857
  "workspace graph",
857
858
  "workspace hash-index",
@@ -868,6 +869,7 @@ describe("workspace init and ls (#2534)", () => {
868
869
  "workspace patch",
869
870
  "workspace pin",
870
871
  "workspace points",
872
+ "workspace promote",
871
873
  "workspace records",
872
874
  "workspace run",
873
875
  "workspace runs",
package/src/cli/main.ts CHANGED
@@ -614,6 +614,16 @@ Workspace (level 1, #2524):
614
614
  write it only on confirmation (--yes writes without
615
615
  asking; --name <name> names the workspace; --verbose
616
616
  lists every file leaving the root project)
617
+ --from tfc|spacelift|backstage proposes from a
618
+ Terraform Cloud organization (--org, or --input <export>),
619
+ a Spacelift account (--endpoint, or --input <export>) or
620
+ the catalog-info.yaml files under dir, and shows what
621
+ it left out (#3178)
622
+ workspace promote <member> [--state <tfstate>] [--lexicon <name>] [--dry-run] [--output <patch>] [--json]
623
+ Move a member one rung up the kind ladder (terraform to
624
+ choudoufu runs live-check and the adoption report;
625
+ choudoufu or other to chant runs chant import), staged
626
+ in a worktree and gated on the patch digest like upgrade
617
627
  workspace ls [dir] List the declaration's members and example groups.
618
628
  A member that can't be read is listed with a reason
619
629
  code and still exits 0. --at <rev> reads a commit's
@@ -861,6 +871,15 @@ Workspace (level 1, #2524):
861
871
  manual steps. Needs no workspace file
862
872
  workspace lineage resolve <path>
863
873
  Close a manual step once the file is merged by hand
874
+ workspace exit [<member>...] [--dry-run] [--drill] [--output <file>] [--json]
875
+ Leave chant: build each chant member once, commit the
876
+ native output (CloudFormation, ARM, Kubernetes YAML,
877
+ Compose, CI files), point its CI at the platform's own
878
+ CLI, drop the chant dependencies and set the member to
879
+ other. Staged in a worktree and gated on the patch
880
+ digest (chant approve workspace-exit <scope>). op.json
881
+ files stay. --drill runs it on a scratch copy and
882
+ checks each member, changing nothing.
864
883
  workspace upgrade [<scope>] [--to <ref|dir>] [--source <repo>[#<member>]] [--allow-code] [--dry-run] [--output <file>]
865
884
  Bring a lineage scope to a newer template version: fetch
866
885
  it, migrate and merge per file in a worktree, run build,
@@ -1655,6 +1674,9 @@ export const commandRegistry: CommandDef[] = [
1655
1674
  { name: "workspace metrics", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/delivery-metrics")).runWorkspaceMetrics(ctx) },
1656
1675
  { name: "workspace status", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
1657
1676
  { name: "workspace lineage", handler: async (ctx) => (await import("../workspace/lineage-cli")).runWorkspaceLineage(ctx) },
1677
+ { name: "workspace exit", handler: async (ctx) => (await import("../workspace/exit-cli")).runWorkspaceExit(ctx) },
1678
+ // #3178 — move a member one rung up the kind ladder, staged in a worktree and gated on the patch digest.
1679
+ { name: "workspace promote", handler: async (ctx) => (await import("../workspace/promote")).runWorkspacePromote(ctx) },
1658
1680
  { name: "workspace upgrade", handler: async (ctx) => (await import("../workspace/lineage-upgrade-cli")).runWorkspaceUpgrade(ctx) },
1659
1681
  // #2551 — adopt a lineage, the hash index it matches against, and the versions of a family of workspaces.
1660
1682
  { name: "workspace adopt-lineage", handler: async (ctx) => (await import("../workspace/lineage-adopt-cli")).runWorkspaceAdoptLineage(ctx) },
@@ -284,6 +284,9 @@ export function parseArgs(args: string[]): ParsedArgs {
284
284
  result.emit = args[++i];
285
285
  } else if (arg === "--dry-run") {
286
286
  result.dryRun = true;
287
+ } else if (arg === "--drill") {
288
+ // #3177 — `chant workspace exit --drill` runs the exit on a scratch copy.
289
+ result.drill = true;
287
290
  } else if (arg === "--yes") {
288
291
  result.yes = true;
289
292
  } else if (arg === "--push") {
@@ -922,6 +925,11 @@ export function parseArgs(args: string[]): ParsedArgs {
922
925
  const value = args[++i];
923
926
  if (!value || value.startsWith("-")) throw new Error(`${arg} needs a value: ${arg} <${arg.slice(2)}>`);
924
927
  result[arg.slice(2) as "holder" | "ttl" | "token" | "outcome"] = value;
928
+ } else if (arg === "--input" || arg === "--org" || arg === "--endpoint") {
929
+ // `chant workspace init --from tfc|spacelift` (#3178): an exported file instead of the API, the Terraform Cloud organization, the Spacelift GraphQL endpoint.
930
+ const value = args[++i];
931
+ if (!value || value.startsWith("-")) throw new Error(`${arg} needs a value: ${arg} <${arg === "--input" ? "file" : arg.slice(2)}>`);
932
+ result[arg.slice(2) as "input" | "org" | "endpoint"] = value;
925
933
  } else if (arg === "--signer") {
926
934
  // `chant workspace runs statement <id> --signer <principal>` (#3192): the runner principal that will sign.
927
935
  const value = args[++i];
@@ -165,6 +165,8 @@ export interface ParsedArgs {
165
165
  remove?: boolean;
166
166
  /** `chant lifecycle rollback --dry-run` — compute the rollback delta and print it; open no PR, push nothing, leave no branch. */
167
167
  dryRun?: boolean;
168
+ /** `chant workspace exit --drill` (#3177): run the exit on a scratch copy and check it, changing nothing. */
169
+ drill?: boolean;
168
170
  /** `chant lifecycle teardown <env> --yes` — execute the planned deletion
169
171
  * (#1222). Without it the command plans and stops. */
170
172
  yes?: boolean;
@@ -628,6 +630,12 @@ export interface ParsedArgs {
628
630
  claim?: string;
629
631
  /** `chant workspace evidence sign --environment <file>` (#2553): the runner environment, hashed into the evidence. */
630
632
  environment?: string;
633
+ /** `chant workspace init --from tfc|spacelift --input <file>` (#3178): an exported file read instead of the API. */
634
+ input?: string;
635
+ /** `chant workspace init --from tfc --org <name>` (#3178): the Terraform Cloud organization. */
636
+ org?: string;
637
+ /** `chant workspace init --from spacelift --endpoint <url>` (#3178): the account's GraphQL endpoint. */
638
+ endpoint?: string;
631
639
  /** `chant workspace runs statement <id> --signer <principal>` (#3192): the runner principal that will sign. */
632
640
  signer?: string;
633
641
  /** `chant workspace evidence verify --envelope <file>` (#2553). */
@@ -47,6 +47,7 @@ describe("the pr-env-run bundle", () => {
47
47
  "packages/core/src/op/emulator-lifecycle.ts",
48
48
  "packages/core/src/plan-summary.ts",
49
49
  "packages/core/src/pr-loop.ts",
50
+ "packages/core/src/pr-migrations.ts",
50
51
  ]);
51
52
  expect(inputs.some((i) => /node_modules\/(ajv|js-yaml)\//.test(i) || i.endsWith("/op/gate.ts") || i.endsWith("/src/config.ts") || i.endsWith("/ts-loader.ts"))).toBe(false);
52
53
  expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(40 * 1024);
@@ -99,6 +99,7 @@ import type { CapabilityRegistry, PlannedOutputs } from "./capability";
99
99
  import type { RunProgressEvent } from "./run-progress";
100
100
  import type { RunEventSink } from "../run-event-stream";
101
101
  import { capabilitySteps, planWaveComponent, readDeployedOutputs } from "./wave-plan";
102
+ import type { PrMigrationFile } from "../pr-migrations";
102
103
 
103
104
  export interface PrPlanSetOptions {
104
105
  /** Every component in the project, not the selection: a planned member's dependency outside it is read too (#4104). */
@@ -773,6 +774,8 @@ export interface PrReportInput {
773
774
  waves?: PrWave[];
774
775
  /** The rounds the apply planned after an upstream applied (#3999). */
775
776
  rounds?: PrRound[];
777
+ /** Plan stage (#4407): the new migration files, classified. */
778
+ migrations?: PrMigrationFile[];
776
779
  }
777
780
 
778
781
  /** The report a stage writes. */
@@ -797,6 +800,7 @@ export function prReport(input: PrReportInput): PrReport {
797
800
  ...(input.set.deployedOutputs ? { deployedOutputs: input.set.deployedOutputs } : {}),
798
801
  ...(input.set.plannedOutputs ? { plannedOutputs: input.set.plannedOutputs } : {}),
799
802
  ...(input.rounds && input.rounds.length > 0 ? { rounds: input.rounds } : {}),
803
+ ...(input.migrations && input.migrations.length > 0 ? { migrations: input.migrations } : {}),
800
804
  approval: input.approval,
801
805
  ...(input.waveGate ? { waveGate: input.waveGate } : {}),
802
806
  ...(input.waveGate && input.waves?.length ? { waves: [...input.waves].sort((a, b) => a.wave - b.wave) } : {}),
package/src/config.ts CHANGED
@@ -76,6 +76,10 @@ export const ChantConfigSchema = z.object({
76
76
  // Strict like `run`: a misspelt `lint` would leave the gate on with nothing said.
77
77
  graph: z.object({
78
78
  lint: z.enum(["gate", "report"]).optional(),
79
+ reader: z.object({
80
+ module: z.string().min(1),
81
+ config: z.record(z.string(), z.unknown()).optional(),
82
+ }).strict().optional(),
79
83
  }).strict().optional(),
80
84
  buildParams: z.record(
81
85
  z.string(),
@@ -234,6 +238,18 @@ export interface ChantConfig {
234
238
  */
235
239
  graph?: {
236
240
  lint?: "gate" | "report";
241
+ /**
242
+ * A graph reader module (#4413): when set, `chant graph --format ir` loads
243
+ * `module` (an absolute path to an ES module whose default export is
244
+ * `(input: { dir, config }) => GraphIR`) and prints what it returns,
245
+ * instead of discovering lexicon entities. The reader project
246
+ * `chant workspace graph` writes for a kind whose `graph.reader` names a
247
+ * module sets it. A chant older than the key ignores it.
248
+ */
249
+ reader?: {
250
+ module: string;
251
+ config?: Record<string, unknown>;
252
+ };
237
253
  };
238
254
 
239
255
  /**
@@ -45,6 +45,8 @@ describe("the gate-decision bundle", () => {
45
45
  "packages/core/src/lifecycle/plan-digest.ts",
46
46
  "packages/core/src/notify-verify.ts",
47
47
  "packages/core/src/notify.ts",
48
+ "packages/core/src/op/gate-approval-base.ts",
49
+ "packages/core/src/op/gate-plan-context.ts",
48
50
  "packages/core/src/op/gate-point.ts",
49
51
  "packages/core/src/op/gate-quorum.ts",
50
52
  "packages/core/src/op/op-waves.ts",
@@ -55,8 +57,8 @@ describe("the gate-decision bundle", () => {
55
57
  "packages/core/src/workspace/jsonc.ts",
56
58
  "packages/core/src/workspace/trust/trust-files.ts",
57
59
  ]);
58
- // About 49 KB unminified today: 7 KB of it the pinned classes read as data (#3888), 6 KB the approval rules (#4290).
60
+ // About 49 KB unminified today: 7 KB of it the pinned classes read as data (#3888), 6 KB the approval rules (#4290), and the base approval checks (#4404) bring it to about 55 KB.
59
61
  const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
60
- expect(bytes).toBeLessThan(54 * 1024);
62
+ expect(bytes).toBeLessThan(58 * 1024);
61
63
  });
62
64
  });
@@ -19,6 +19,7 @@ import {
19
19
  import { gateAdmission } from "./workspace/identity";
20
20
  import { hasSshKeygen, TestRepo, type Key } from "./workspace/trust/test-repo";
21
21
  import { evaluateGate, memoryGateLedgerPort } from "./op/gate";
22
+ import { gatePolicyVersion } from "./op/gate-approval";
22
23
 
23
24
  const D1 = "sha256:" + "a".repeat(64);
24
25
  const GATES = { ship: {}, deploy: { class: "human" }, release: { class: "runner" }, ops: { class: "operator" } };
@@ -227,6 +228,16 @@ describe.skipIf(!hasSshKeygen)("identity.gates read at base, as chant reads it",
227
228
  expect(here.requirement.quorum).toEqual(gateAdmission(r.dir, "ship", base)!.requirement.quorum);
228
229
  expect(identityGateAdmissionAt(r.dir, base, "deploy")!.requirement).toEqual({ gate: "deploy", class: "human" });
229
230
  });
231
+
232
+ test("a gate's policy, mode and context at base, as chant reads them (#4404)", () => {
233
+ const text = "permit (principal, action, resource);";
234
+ const policy = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(text), text };
235
+ const { r, base } = setup(() => ({}), { identity: { gates: { ship: { policy, mode: "enforce", context: { risk: "high" } } } } });
236
+ const here = identityGateAdmissionAt(r.dir, base, "ship")!.requirement;
237
+ const theirs = gateAdmission(r.dir, "ship", base)!.requirement;
238
+ expect(here).toMatchObject({ policy, mode: "enforce", context: { risk: "high" } });
239
+ expect({ policy: here.policy, mode: here.mode, context: here.context }).toEqual({ policy: theirs.policy, mode: theirs.mode, context: theirs.context });
240
+ });
230
241
  });
231
242
 
232
243
  describe("admitApprover", () => {
@@ -67,6 +67,8 @@ import { diskFiles, findInstalledPackage, mergePinClasses, parseClassDataByHand,
67
67
  import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
68
68
  import type { MergedReview } from "./op/gate-review";
69
69
  import { quorumProblems as quorumProblemsOf, type GateQuorumSpec as QuorumSpec } from "./op/gate-quorum";
70
+ import { gateBaseApprovalProblems } from "./op/gate-approval-base";
71
+ import type { GateApprovalMode, GatePolicyRef } from "./op/gate-approval";
70
72
  import type { GateApprovalSource } from "./op/op-waves";
71
73
 
72
74
  export { GATE_APPROVAL_SOURCES, type GateApprovalSource } from "./op/op-waves";
@@ -230,6 +232,14 @@ export interface IdentityGate {
230
232
  * this name takes in place of its own. Absent when the entry sets none.
231
233
  */
232
234
  quorum?: QuorumSpec;
235
+ /**
236
+ * `identity.gates.<gate>.policy`, `.mode` and `.context` (#4404): what an Op
237
+ * gate of this name takes in place of its own `approval` fields. Each is
238
+ * absent when the entry sets none.
239
+ */
240
+ policy?: GatePolicyRef;
241
+ mode?: GateApprovalMode;
242
+ context?: Record<string, string | number | boolean | string[]>;
233
243
  }
234
244
 
235
245
  /** The declaration at `base`, parsed, or null when there is none. Throws when it cannot be parsed. */
@@ -245,13 +255,23 @@ function declarationAt(repo: string, base: string): { file: string; value: unkno
245
255
  }
246
256
 
247
257
  function identityGatesOf(declaration: unknown): IdentityGate[] {
248
- const gates = (declaration as { identity?: { gates?: Record<string, { class?: unknown; quorum?: unknown } | null> } } | null)?.identity?.gates ?? {};
258
+ const gates = (declaration as { identity?: { gates?: Record<string, { class?: unknown; quorum?: unknown; policy?: unknown; mode?: unknown; context?: unknown } | null> } } | null)?.identity?.gates ?? {};
249
259
  return Object.entries(gates).map(([gate, entry]) => {
250
- if (entry?.quorum === undefined) return { gate, class: typeof entry?.class === "string" ? entry.class : null };
251
- // #4324: a quorum chant would refuse leaves the gate undecidable, as chant's own read does.
252
- const [problem] = quorumProblemsOf(entry.quorum, `identity.gates.${gate}.quorum`, { notAuthor: false });
253
- if (problem) throw new Error(`chant.workspace.json at base: ${problem.replace(/`/g, "")}, so the gate cannot be decided`);
254
- return { gate, class: typeof entry.class === "string" ? entry.class : null, quorum: structuredClone(entry.quorum) as QuorumSpec };
260
+ const out: IdentityGate = { gate, class: typeof entry?.class === "string" ? entry.class : null };
261
+ if (!entry) return out;
262
+ if (entry.quorum !== undefined) {
263
+ // #4324: a quorum chant would refuse leaves the gate undecidable, as chant's own read does.
264
+ const [problem] = quorumProblemsOf(entry.quorum, `identity.gates.${gate}.quorum`, { notAuthor: false });
265
+ if (problem) throw new Error(`chant.workspace.json at base: ${problem.replace(/`/g, "")}, so the gate cannot be decided`);
266
+ out.quorum = structuredClone(entry.quorum) as QuorumSpec;
267
+ }
268
+ // #4404: the same for the policy, mode and context.
269
+ const [rest] = gateBaseApprovalProblems(entry, `identity.gates.${gate}`);
270
+ if (rest) throw new Error(`chant.workspace.json at base: ${rest.replace(/`/g, "")}, so the gate cannot be decided`);
271
+ if (entry.policy !== undefined) out.policy = structuredClone(entry.policy) as GatePolicyRef;
272
+ if (entry.mode !== undefined) out.mode = entry.mode as GateApprovalMode;
273
+ if (entry.context !== undefined) out.context = structuredClone(entry.context) as NonNullable<IdentityGate["context"]>;
274
+ return out;
255
275
  });
256
276
  }
257
277
 
package/src/lexicon.ts CHANGED
@@ -1742,6 +1742,30 @@ export interface LexiconPlugin {
1742
1742
  changes: DisruptionQuery[];
1743
1743
  }): Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
1744
1744
 
1745
+ /**
1746
+ * Classify the migration files a pull request adds (#4407), offline. Core
1747
+ * lists the new files of a migration-directory workspace member (goose,
1748
+ * golang-migrate, clickhouse-migrations, Flyway) and asks the lexicon whose
1749
+ * dialect they are written in; the lexicon reads each file's up statements
1750
+ * and gives each the class its schema-diff classifier gives the same change.
1751
+ * A statement that alone cannot decide a class comes back `undecided`,
1752
+ * naming the candidate rules. The answer is shown in the plan note; nothing
1753
+ * is applied and no server is contacted. Omit for a lexicon with no
1754
+ * migration dialect.
1755
+ */
1756
+ classifyMigrations?(options: {
1757
+ /** The member's kind reader: `goose-dir`, `golang-migrate-dir`, `clickhouse-migrations-dir` or `flyway-dir`. */
1758
+ reader: string;
1759
+ /** The SQL dialect the member's migrations are written in. A lexicon answers only for its own dialects (an empty list otherwise). */
1760
+ dialect: string;
1761
+ /** The tree the files are read from (the pull request's head), rooted at the workspace root. */
1762
+ tree: import("./workspace/tree").WorkspaceTree;
1763
+ /** The member's directory in that tree; "" for the root. */
1764
+ dir: string;
1765
+ /** The new files to classify, relative to the workspace root. */
1766
+ files: string[];
1767
+ }): import("./pr-migrations").MigrationFileClassification[] | Promise<import("./pr-migrations").MigrationFileClassification[]>;
1768
+
1745
1769
  /**
1746
1770
  * Report the undeclared resources this estate *depends on* (#1273), as
1747
1771
  * opposed to the ones it manages.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * #4404: an Op gate's `policy`, `mode` and `context` are fields of
3
+ * `identity.gates.<gate>` in the workspace declaration at base, as the quorum
4
+ * is (#4324), so a change can't switch its own gate to `log-only` or swap in a
5
+ * permissive policy and be judged by that.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "vitest";
9
+ import type { GateStep, OpConfig } from "./types";
10
+ import type { ActivityFn } from "./activity-registry";
11
+ import { resolveGateApproval, runOpLocally } from "./local-executor";
12
+ import { gitGateLedgerPort, memoryGateLedgerPort, type GateLedgerPort } from "./gate";
13
+ import { gatePolicyVersion, type GatePolicyRef } from "./gate-approval";
14
+ import { stepOutput } from "./step-output-ref";
15
+ import { resetGateOrigin } from "../lifecycle/gate-origin";
16
+ import { hasSshKeygen, TestRepo } from "../workspace/trust/test-repo";
17
+
18
+ const policy = (name: string, text: string): GatePolicyRef => ({ kind: "gate-policy", lexicon: "cedar", name, version: gatePolicyVersion(text), text });
19
+ const STRICT = policy("strict", "forbid unless maintainer");
20
+ const PERMISSIVE = policy("permissive", "permit all");
21
+
22
+ const step = (approval?: GateStep["approval"]): GateStep => ({ kind: "gate", gate: "ship", ...(approval ? { approval } : {}) }) as GateStep;
23
+
24
+ describe("resolveGateApproval reads policy, mode and context at base (#4404)", () => {
25
+ test("the base policy and mode replace the Op's own", () => {
26
+ const opSide = step({ policy: PERMISSIVE, mode: "log-only" });
27
+ const got = resolveGateApproval(opSide, new Map(), undefined, null, { policy: STRICT, mode: "enforce" });
28
+ expect(got).toEqual({ policy: STRICT, mode: "enforce" });
29
+ });
30
+
31
+ test("a gate whose Op dropped its approval block still takes the base policy and mode", () => {
32
+ expect(resolveGateApproval(step(), new Map(), undefined, null, { policy: STRICT, mode: "enforce" })).toEqual({ policy: STRICT, mode: "enforce" });
33
+ });
34
+
35
+ test("a field the entry leaves out comes from the Op", () => {
36
+ const opSide = step({ policy: PERMISSIVE, mode: "enforce" });
37
+ expect(resolveGateApproval(opSide, new Map(), undefined, null, { mode: "log-only" })).toEqual({ policy: PERMISSIVE, mode: "log-only" });
38
+ expect(resolveGateApproval(opSide, new Map(), undefined, null, { policy: STRICT })).toEqual({ policy: STRICT, mode: "enforce" });
39
+ });
40
+
41
+ test("the base context wins key by key, and a key only the Op sets still reaches the policy", () => {
42
+ const opSide = step({ policy: STRICT, context: { risk: "low", team: "payments" } });
43
+ const got = resolveGateApproval(opSide, new Map(), undefined, null, { context: { risk: "high" } });
44
+ expect(got?.context).toEqual({ risk: "high", team: "payments" });
45
+ });
46
+
47
+ test("a step output reference in the Op's context still resolves beside a base key", () => {
48
+ const opSide = step({ policy: STRICT, context: { rows: stepOutput("verify", "rows") } });
49
+ const got = resolveGateApproval(opSide, new Map([["verify", { rows: 12 }]]), undefined, null, { context: { risk: "high" } });
50
+ expect(got?.context).toEqual({ risk: "high", rows: 12 });
51
+ });
52
+
53
+ test("with nothing at base, the Op's own applies", () => {
54
+ expect(resolveGateApproval(step({ policy: PERMISSIVE, mode: "enforce" }), new Map(), undefined, null, { policy: null, mode: null, context: null })).toEqual({ policy: PERMISSIVE, mode: "enforce" });
55
+ expect(resolveGateApproval(step(), new Map(), undefined, null, { policy: null, mode: null, context: null })).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe.skipIf(!hasSshKeygen)("an Op gate's policy and mode at base (#4404)", () => {
60
+ const repos: TestRepo[] = [];
61
+ afterEach(() => {
62
+ resetGateOrigin();
63
+ while (repos.length) repos.pop()!.cleanup();
64
+ });
65
+
66
+ const declaration = (gate: Record<string, unknown>): string => JSON.stringify({ name: "acme", schema: 1, members: [], identity: { gates: { ship: gate } } });
67
+ const config = (approval: NonNullable<GateStep["approval"]>): OpConfig => ({
68
+ name: "release",
69
+ overview: "",
70
+ phases: [
71
+ { name: "Gate", steps: [{ kind: "gate", gate: "ship", approval }] },
72
+ { name: "Apply", steps: [{ kind: "activity", fn: "ship", args: {} }] },
73
+ ],
74
+ });
75
+ const activities = new Map<string, ActivityFn>([["ship", async () => ({})]]);
76
+
77
+ test("a change that switches its gate to a permissive log-only policy is judged by the base's", async () => {
78
+ const r = new TestRepo("4404-policy");
79
+ repos.push(r);
80
+ r.git(["config", "user.email", "t@example.test"]);
81
+ r.git(["config", "user.name", "t"]);
82
+ r.git(["config", "commit.gpgsign", "false"]);
83
+ r.write("chant.workspace.json", declaration({ policy: STRICT, mode: "enforce", context: { risk: "high" } }));
84
+ r.commit("base");
85
+ // The pull request rewrites the declaration's entry too; only base counts.
86
+ r.git(["checkout", "-q", "-b", "loosen"]);
87
+ r.write("chant.workspace.json", declaration({ policy: PERMISSIVE, mode: "log-only", context: { risk: "low" } }));
88
+ r.commit("loosen the gate");
89
+
90
+ const git = gitGateLedgerPort({ cwd: r.dir });
91
+ const gates: GateLedgerPort = { ...memoryGateLedgerPort(), approvalRule: git.approvalRule };
92
+ // The Op the change builds says permissive, log-only and low risk.
93
+ const run = await runOpLocally(config({ policy: PERMISSIVE, mode: "log-only", context: { risk: "low" } }), activities, {}, undefined, { gates, now: "2026-10-23T12:00:00.000Z" });
94
+ expect(run.status).toBe("gated");
95
+ expect(run.gate?.approval).toMatchObject({ policy: STRICT, mode: "enforce", context: { risk: "high" } });
96
+ });
97
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The parts of an Op gate's approval that an `identity.gates.<gate>` entry
3
+ * also carries (#4404): the modes, the policy check and the entry check. Its
4
+ * only runtime import is `node:crypto`, so `gate-decision.ts` (which checks an
5
+ * entry) bundles without the Op runtime or zod (gate-decision-bundle.test.ts).
6
+ * `gate-approval.ts` re-exports everything here.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { GATE_PLAN_CONTEXT_KEY } from "./gate-plan-context";
11
+ import type { GateApprovalMode, GatePolicyRef } from "./gate-approval";
12
+
13
+ export const GATE_APPROVAL_MODES: readonly GateApprovalMode[] = ["log-only", "enforce"];
14
+
15
+ // The brand `stepOutput()` stamps (`step-output-ref.ts`), as a global symbol so
16
+ // this module need not import that file and its zod schemas; the same copy
17
+ // `activity-contract.ts` keeps for the same reason.
18
+ const STEP_OUTPUT_REF_BRAND = Symbol.for("chant.op.stepOutputRef");
19
+ function isStepOutputRef(value: unknown): boolean {
20
+ return typeof value === "object" && value !== null && (value as Record<symbol, unknown>)[STEP_OUTPUT_REF_BRAND] === true;
21
+ }
22
+
23
+ /** The version of a gate policy: `sha256:` and the hex digest of its text. A lexicon's helper stamps it, and OPS015 checks it still matches. */
24
+ export function gatePolicyVersion(text: string): string {
25
+ return `sha256:${createHash("sha256").update(text).digest("hex")}`;
26
+ }
27
+
28
+ export function isGatePolicyRef(value: unknown): value is GatePolicyRef {
29
+ if (typeof value !== "object" || value === null) return false;
30
+ const v = value as Record<string, unknown>;
31
+ return (
32
+ v.kind === "gate-policy" &&
33
+ typeof v.lexicon === "string" && v.lexicon !== "" &&
34
+ typeof v.name === "string" && v.name !== "" &&
35
+ typeof v.version === "string" && v.version !== "" &&
36
+ typeof v.text === "string" && v.text.trim() !== ""
37
+ );
38
+ }
39
+
40
+ /**
41
+ * What is wrong with the `policy`, `mode` and `context` of an
42
+ * `identity.gates.<gate>` entry (#4404), one line each, named by `at`. Each
43
+ * field stands alone: base `mode: "enforce"` with no base `policy` is fine, as
44
+ * the Op's own policy then applies. `chant workspace check` (WSP153) says so.
45
+ */
46
+ export function gateBaseApprovalProblems(entry: { policy?: unknown; mode?: unknown; context?: unknown }, at: string): string[] {
47
+ const problems: string[] = [];
48
+ if (entry.policy !== undefined) {
49
+ if (!isGatePolicyRef(entry.policy)) problems.push(`\`${at}.policy\` does not resolve to a gate policy set: it needs kind "gate-policy", lexicon, name, version and text`);
50
+ else if (entry.policy.version !== gatePolicyVersion(entry.policy.text)) problems.push(`\`${at}.policy\` "${entry.policy.name}" has a version that is not the digest of its text`);
51
+ }
52
+ if (entry.mode !== undefined && !GATE_APPROVAL_MODES.includes(entry.mode as GateApprovalMode)) {
53
+ problems.push(`\`${at}.mode\` must be one of ${GATE_APPROVAL_MODES.map((m) => `"${m}"`).join(", ")}`);
54
+ }
55
+ if (entry.context !== undefined) {
56
+ if (typeof entry.context !== "object" || entry.context === null || Array.isArray(entry.context)) problems.push(`\`${at}.context\` must be an object of plan attributes`);
57
+ else {
58
+ for (const [key, value] of Object.entries(entry.context)) {
59
+ if (key === GATE_PLAN_CONTEXT_KEY) problems.push(`\`${at}.context.${key}\` is reserved: the run fills it with the plan's change-set summary (#3182)`);
60
+ else if (isStepOutputRef(value) || !isContextValue(value)) problems.push(`\`${at}.context.${key}\` must be a string, number, boolean or list of strings`);
61
+ }
62
+ }
63
+ }
64
+ return problems;
65
+ }
66
+
67
+ export function isContextValue(value: unknown): boolean {
68
+ if (typeof value === "string" || typeof value === "boolean") return true;
69
+ if (typeof value === "number") return Number.isFinite(value);
70
+ if (Array.isArray(value)) return value.every((v) => typeof v === "string");
71
+ return isStepOutputRef(value);
72
+ }