@intentius/chant 0.124.15 → 0.124.17

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 (120) hide show
  1. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  2. package/dist/cli/commands/check-lexicon-plugin.js +2 -1
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +3 -2
  5. package/dist/cli/flush.d.ts +16 -0
  6. package/dist/cli/flush.d.ts.map +1 -0
  7. package/dist/cli/flush.js +42 -0
  8. package/dist/cli/handlers/approve.d.ts +98 -0
  9. package/dist/cli/handlers/approve.d.ts.map +1 -0
  10. package/dist/cli/handlers/approve.js +495 -0
  11. package/dist/cli/handlers/operator.d.ts +1 -95
  12. package/dist/cli/handlers/operator.d.ts.map +1 -1
  13. package/dist/cli/handlers/operator.js +4 -488
  14. package/dist/cli/handlers/override-approve.js +1 -1
  15. package/dist/cli/main.d.ts +4 -15
  16. package/dist/cli/main.d.ts.map +1 -1
  17. package/dist/cli/main.js +4 -1062
  18. package/dist/cli/parse-args.d.ts +6 -0
  19. package/dist/cli/parse-args.d.ts.map +1 -0
  20. package/dist/cli/parse-args.js +1028 -0
  21. package/dist/components/discover.d.ts.map +1 -1
  22. package/dist/components/discover.js +2 -1
  23. package/dist/components/fan-out-record.d.ts +1 -1
  24. package/dist/components/fan-out-record.d.ts.map +1 -1
  25. package/dist/components/fan-out-record.js +3 -7
  26. package/dist/components/fan-out-run.d.ts +5 -3
  27. package/dist/components/fan-out-run.d.ts.map +1 -1
  28. package/dist/components/fan-out-run.js +7 -5
  29. package/dist/config-sandbox.d.ts.map +1 -1
  30. package/dist/config-sandbox.js +5 -1
  31. package/dist/discovery/import.d.ts.map +1 -1
  32. package/dist/discovery/import.js +2 -1
  33. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  34. package/dist/discovery/sandbox/driver.js +8 -3
  35. package/dist/gated-waves.d.ts +25 -6
  36. package/dist/gated-waves.d.ts.map +1 -1
  37. package/dist/gated-waves.js +21 -6
  38. package/dist/lexicon-module.d.ts.map +1 -1
  39. package/dist/lexicon-module.js +4 -3
  40. package/dist/lint/policy-import.d.ts.map +1 -1
  41. package/dist/lint/policy-import.js +2 -1
  42. package/dist/lint/rule-loader.d.ts.map +1 -1
  43. package/dist/lint/rule-loader.js +2 -1
  44. package/dist/module-interop.d.ts +20 -0
  45. package/dist/module-interop.d.ts.map +1 -0
  46. package/dist/module-interop.js +39 -0
  47. package/dist/op/activities/activity-contracts.d.ts +4 -4
  48. package/dist/op/discover.d.ts.map +1 -1
  49. package/dist/op/discover.js +3 -2
  50. package/dist/op/op-waves-run.d.ts +1 -1
  51. package/dist/op/op-waves-run.d.ts.map +1 -1
  52. package/dist/op/op-waves-run.js +5 -6
  53. package/dist/op/op-waves.d.ts.map +1 -1
  54. package/dist/op/op-waves.js +4 -8
  55. package/dist/package-version.d.ts +2 -0
  56. package/dist/package-version.d.ts.map +1 -0
  57. package/dist/package-version.js +10 -0
  58. package/dist/wave-engine.d.ts +62 -0
  59. package/dist/wave-engine.d.ts.map +1 -0
  60. package/dist/wave-engine.js +59 -0
  61. package/dist/wave-layout.d.ts +29 -0
  62. package/dist/wave-layout.d.ts.map +1 -0
  63. package/dist/wave-layout.js +41 -0
  64. package/dist/wave-runtime.d.ts +29 -0
  65. package/dist/wave-runtime.d.ts.map +1 -0
  66. package/dist/wave-runtime.js +103 -0
  67. package/dist/wave-runtime.mjs +331 -0
  68. package/dist/workspace/ajv2020.d.ts +5 -0
  69. package/dist/workspace/ajv2020.d.ts.map +1 -0
  70. package/dist/workspace/ajv2020.js +14 -0
  71. package/dist/workspace/declaration.d.ts.map +1 -1
  72. package/dist/workspace/declaration.js +4 -8
  73. package/dist/workspace/kinds.d.ts.map +1 -1
  74. package/dist/workspace/kinds.js +2 -5
  75. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  76. package/dist/workspace/lineage-migrations.js +2 -1
  77. package/dist/workspace/points.d.ts.map +1 -1
  78. package/dist/workspace/points.js +2 -5
  79. package/dist/workspace/principal-classes.d.ts.map +1 -1
  80. package/dist/workspace/principal-classes.js +2 -5
  81. package/package.json +5 -3
  82. package/src/cli/commands/check-lexicon-plugin.ts +2 -1
  83. package/src/cli/commands/lint.ts +3 -2
  84. package/src/cli/compiled-cli.e2e.test.ts +42 -1
  85. package/src/cli/flush.ts +44 -0
  86. package/src/cli/handlers/approve.ts +618 -0
  87. package/src/cli/handlers/operator.ts +10 -608
  88. package/src/cli/handlers/override-approve.test.ts +1 -1
  89. package/src/cli/handlers/override-approve.ts +1 -1
  90. package/src/cli/main.ts +5 -816
  91. package/src/cli/parse-args.ts +781 -0
  92. package/src/components/discover.ts +2 -1
  93. package/src/components/fan-out-record.ts +3 -7
  94. package/src/components/fan-out-run.ts +7 -5
  95. package/src/config-sandbox.ts +5 -1
  96. package/src/discovery/import.ts +2 -1
  97. package/src/discovery/sandbox/driver.ts +8 -3
  98. package/src/gate-decision-bundle.test.ts +1 -0
  99. package/src/gated-waves.ts +28 -6
  100. package/src/lexicon-module.ts +4 -3
  101. package/src/lint/policy-import.ts +2 -1
  102. package/src/lint/rule-loader.ts +2 -1
  103. package/src/module-interop.test.ts +65 -0
  104. package/src/module-interop.ts +39 -0
  105. package/src/op/discover.ts +3 -2
  106. package/src/op/op-waves-run.ts +5 -6
  107. package/src/op/op-waves.ts +4 -8
  108. package/src/package-version.ts +11 -0
  109. package/src/wave-engine.test.ts +107 -0
  110. package/src/wave-engine.ts +98 -0
  111. package/src/wave-layout.ts +44 -0
  112. package/src/wave-runtime-bundle.test.ts +70 -0
  113. package/src/wave-runtime.e2e.test.ts +177 -0
  114. package/src/wave-runtime.ts +104 -0
  115. package/src/workspace/ajv2020.ts +17 -0
  116. package/src/workspace/declaration.ts +4 -10
  117. package/src/workspace/kinds.ts +2 -5
  118. package/src/workspace/lineage-migrations.ts +2 -1
  119. package/src/workspace/points.ts +2 -5
  120. package/src/workspace/principal-classes.ts +2 -5
@@ -51,6 +51,7 @@ import { hasDiscoveryMarkerSync } from "../discovery/files";
51
51
  import { resolveDiscoveryGlobs } from "../config";
52
52
  import { walkDiscovery, workspaceMemberDirs } from "../discovery/walk";
53
53
  import { ensureTypeScriptLoader } from "../ts-loader";
54
+ import { esModuleNamespace } from "../module-interop";
54
55
 
55
56
  /** One discovered component, paired with the file it was exported from. */
56
57
  export interface DiscoveredComponent {
@@ -214,7 +215,7 @@ async function importAndCollectComponents(
214
215
  // a bare "." path) would otherwise be treated by `import()` as a bare
215
216
  // package specifier and fail with "Cannot find package 'x.component.ts'".
216
217
  ensureTypeScriptLoader();
217
- const exports = await import(pathToFileURL(resolve(filePath)).href);
218
+ const exports = esModuleNamespace(await import(pathToFileURL(resolve(filePath)).href));
218
219
  modules.push({ file: filePath, exports });
219
220
  } catch (err) {
220
221
  importErrors.push(new DiscoveryError(filePath, err instanceof Error ? err.message : String(err), "import"));
@@ -27,9 +27,8 @@
27
27
  * per-wave CI job hands to the next.
28
28
  */
29
29
 
30
- import { dirname } from "node:path";
31
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
32
- import { readWaveRecords, type WaveRecord } from "../gated-waves";
30
+ import { existsSync, readFileSync } from "node:fs";
31
+ import { readWaveRecords, writeWaveRecordFile, type WaveRecord } from "../wave-engine";
33
32
  import type { ChangeSetDocument } from "../change-set";
34
33
  import type { PrMember } from "../pr-loop";
35
34
 
@@ -126,8 +125,5 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
126
125
  * write leaves the previous record whole rather than half a JSON document.
127
126
  */
128
127
  export function writeFanOutAttempt(path: string, attempt: FanOutAttempt): void {
129
- mkdirSync(dirname(path), { recursive: true });
130
- const temporary = `${path}.${process.pid}.tmp`;
131
- writeFileSync(temporary, JSON.stringify(attempt, null, 2) + "\n");
132
- renameSync(temporary, path);
128
+ writeWaveRecordFile(path, attempt);
133
129
  }
@@ -31,8 +31,10 @@
31
31
  * ## Or one approval per wave
32
32
  *
33
33
  * `waveGate` (#3049) is the other mode. Each wave is planned once the waves
34
- * before it applied, and its gate binds that wave's set digest
35
- * (`../gated-waves.ts`). See {@link runGatedWaves}.
34
+ * before it applied, and its gate binds that wave's set digest. Gate names,
35
+ * the digest and the gate decision come from the wave engine
36
+ * (`../wave-engine.ts`, #3766), which `chant run wave` uses too. See
37
+ * {@link runGatedWaves}.
36
38
  */
37
39
 
38
40
  import {
@@ -50,7 +52,7 @@ import type { CapabilityRegistry } from "./capability";
50
52
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
51
53
  import type { DeployContext } from "./capability";
52
54
  import { planWaveComponent, type WaveComponentPlan } from "./wave-plan";
53
- import { waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../gated-waves";
55
+ import { decideWaveGate, waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../wave-engine";
54
56
 
55
57
  /**
56
58
  * One gate per wave (#3049), each bound to its wave's set digest. The other
@@ -452,10 +454,10 @@ async function runGatedWaves(
452
454
  continue;
453
455
  }
454
456
 
455
- const check = await evaluateGate(gates.port, {
457
+ const check = await decideWaveGate(gates.port, {
456
458
  op: waveGate.op,
457
459
  gate: gateName,
458
- planDigest: digest,
460
+ digest,
459
461
  description: waveGate.description ?? `wave ${waveNum} of ${plan.waves.length}: ${toRun.join(", ")}`,
460
462
  ...(waveGate.timeout ? { timeout: waveGate.timeout } : {}),
461
463
  ...(options.now ? { now: options.now } : {}),
@@ -1,5 +1,6 @@
1
1
  import { dirname } from "node:path";
2
2
  import { importConfigModule, requireConfigModule, type ConfigModuleNamespace } from "./config-import";
3
+ import { esModuleNamespace } from "./module-interop";
3
4
 
4
5
  /**
5
6
  * chant #1113 — decides WHERE a project's `chant.config.ts` is evaluated.
@@ -84,7 +85,10 @@ export function resetSandboxConfigEvaluationForTests(): void {
84
85
  * evaluated, never how its result is read.
85
86
  */
86
87
  export function selectConfigExport(namespace: ConfigModuleNamespace): unknown {
87
- return namespace.default ?? namespace.config ?? namespace;
88
+ // A config tsx compiled as CommonJS arrives as `{ default: module.exports }`
89
+ // under a native import (#3897); read the module's own namespace instead.
90
+ const ns = esModuleNamespace(namespace);
91
+ return ns.default ?? ns.config ?? ns;
88
92
  }
89
93
 
90
94
  /**
@@ -1,5 +1,6 @@
1
1
  import { DiscoveryError } from "../errors";
2
2
  import { ensureTypeScriptLoader } from "../ts-loader";
3
+ import { esModuleNamespace } from "../module-interop";
3
4
 
4
5
  /**
5
6
  * Modules whose evaluation threw, and the error each threw with (#2368).
@@ -59,7 +60,7 @@ export async function importModule(
59
60
 
60
61
  try {
61
62
  ensureTypeScriptLoader();
62
- return await import(path);
63
+ return esModuleNamespace(await import(path));
63
64
  } catch (error) {
64
65
  const message =
65
66
  error instanceof Error ? error.message : "Unknown import error";
@@ -59,6 +59,8 @@ const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
59
59
  // file, so the driver's `setBuildParams` call below mutates the one object
60
60
  // every bundled import observes.
61
61
  const PARAMS_MODULE = join(dirname(DISCOVERY_DIR), "params.ts");
62
+ // #3897: a project file tsx compiled as CommonJS reads as its own namespace.
63
+ const INTEROP_MODULE = join(dirname(DISCOVERY_DIR), "module-interop.ts");
62
64
 
63
65
  export interface GenerateDriverOptions {
64
66
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -130,6 +132,7 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
130
132
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
131
133
  `import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
132
134
  `import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
135
+ `import { esModuleNamespace } from ${lit(INTEROP_MODULE)};`,
133
136
  `import { writeSync } from "node:fs";`,
134
137
  ``,
135
138
  `const BUILD_ROOT = ${lit(buildRoot)};`,
@@ -181,7 +184,7 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
181
184
  lines.push(
182
185
  ` importing = ${lit(file)};`,
183
186
  ` try {`,
184
- ` const mod = await import(${lit(file)});`,
187
+ ` const mod = esModuleNamespace(await import(${lit(file)}));`,
185
188
  ` modules.push({ file: ${lit(file)}, exports: mod });`,
186
189
  ` } catch (err) {`,
187
190
  ` errors.push(classifyChildError(${lit(file)}, err).toJSON());`,
@@ -261,6 +264,7 @@ export function generateConfigDriverSource(configPath: string): string {
261
264
  return [
262
265
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
263
266
  `import { scanConfigWireSafety } from ${lit(CONFIG_WIRE_MODULE)};`,
267
+ `import { esModuleNamespace } from ${lit(INTEROP_MODULE)};`,
264
268
  `import { writeSync } from "node:fs";`,
265
269
  ``,
266
270
  ...sendFunctionSource(),
@@ -268,7 +272,7 @@ export function generateConfigDriverSource(configPath: string): string {
268
272
  `async function main() {`,
269
273
  ` let namespace;`,
270
274
  ` try {`,
271
- ` namespace = await import(${lit(configPath)});`,
275
+ ` namespace = esModuleNamespace(await import(${lit(configPath)}));`,
272
276
  ` } catch (err) {`,
273
277
  ` await send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
274
278
  ` return;`,
@@ -337,6 +341,7 @@ export function generatePolicyDriverSource(policyPaths: readonly string[]): stri
337
341
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
338
342
  `import { decodePolicyBuildResult, scanPolicyDiagnostics, PolicyWireError } from ${lit(POLICY_WIRE_MODULE)};`,
339
343
  `import { runPostSynthChecks, isPostSynthCheck } from ${lit(POST_SYNTH_MODULE)};`,
344
+ `import { esModuleNamespace } from ${lit(INTEROP_MODULE)};`,
340
345
  `import { writeSync } from "node:fs";`,
341
346
  ``,
342
347
  ...sendFunctionSource(),
@@ -382,7 +387,7 @@ export function generatePolicyDriverSource(policyPaths: readonly string[]): stri
382
387
  for (const policyPath of policyPaths) {
383
388
  lines.push(
384
389
  ` try {`,
385
- ` const mod = await import(${lit(policyPath)});`,
390
+ ` const mod = esModuleNamespace(await import(${lit(policyPath)}));`,
386
391
  ` for (const value of Object.values(mod)) {`,
387
392
  ` if (isPostSynthCheck(value)) checks.push(guard(value, ${lit(policyPath)}));`,
388
393
  ` }`,
@@ -46,6 +46,7 @@ describe("the gate-decision bundle", () => {
46
46
  "packages/core/src/notify.ts",
47
47
  "packages/core/src/op/gate-point.ts",
48
48
  "packages/core/src/op/op-waves.ts",
49
+ "packages/core/src/wave-layout.ts",
49
50
  "packages/core/src/workspace/class-data.ts",
50
51
  "packages/core/src/workspace/identity-gate-rule.ts",
51
52
  "packages/core/src/workspace/jsonc.ts",
@@ -2,12 +2,17 @@
2
2
  * Gated waves (#3049): one change rolled out across many roots, a wave at a
3
3
  * time, each wave behind its own gate bound to that wave's set digest.
4
4
  *
5
- * `chant components fan-out --wave-gate` is the runner. This module holds the
6
- * parts that need no runner: how a set of roots is cut into waves, what a
7
- * wave's digest is, what its gate is called, and what the attempt record says
8
- * about each wave. They are kept apart from the runner because terragucci
9
- * calls them from its CI stages as one bundled file with no TypeScript
10
- * toolchain (#3421). Everything here imports `./change-set` and nothing else,
5
+ * Two runners use it (#3766): `chant components fan-out --wave-gate` and the
6
+ * Op waves spec's `chant run wave` (`./op/op-waves-run.ts`). This module
7
+ * holds the parts that need no runner: how a set of roots is cut into waves,
8
+ * what a wave's digest is and which members it covers, what its gate is
9
+ * called, and what the attempt record says about each wave. A gate name
10
+ * pattern and the share split of a wide wave are in `./wave-layout.ts`;
11
+ * `./wave-engine.ts` puts them together with the gate decision call and the
12
+ * record file writer, which need the gate ledger and the filesystem.
13
+ *
14
+ * They are kept apart from the runners because terragucci calls them from
15
+ * its CI stages as one bundled file with no TypeScript toolchain (#3421). Everything here imports `./change-set` and nothing else,
11
16
  * and `gated-waves-bundle.test.ts` holds that.
12
17
  *
13
18
  * ## The set digest
@@ -144,6 +149,23 @@ export function waveGateName(gate: string, wave: number): string {
144
149
  return `${gate}-wave-${wave}`;
145
150
  }
146
151
 
152
+ /** Which members a wave's set digest covers (#3821). */
153
+ export type WaveDigestOver = "all" | "changing";
154
+
155
+ /**
156
+ * The members a wave's set digest is taken over (#3821, #3766). `all` (the
157
+ * default): every member. `changing`: the members whose plan is not
158
+ * `empty`, or every member when all of them are empty. `extras`, members
159
+ * that are no run (such as a wave's cost), are always in it.
160
+ */
161
+ export function waveDigestMembers<M extends WaveMember & { empty?: boolean }>(
162
+ members: readonly M[],
163
+ options: { over?: WaveDigestOver; extras?: readonly WaveMember[] } = {},
164
+ ): WaveMember[] {
165
+ const changing = options.over === "changing" ? members.filter((m) => m.empty !== true) : [];
166
+ return [...(changing.length > 0 ? changing : members), ...(options.extras ?? [])];
167
+ }
168
+
147
169
  /** Where a wave got to. */
148
170
  export type WaveStatus =
149
171
  /** Planned, and its gate is not approved for this digest. Nothing in it ran. */
@@ -28,6 +28,7 @@ import { createRequire } from "node:module";
28
28
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
29
29
  import { pathToFileURL } from "node:url";
30
30
  import { ensureTypeScriptLoader } from "./ts-loader";
31
+ import { esModuleNamespace } from "./module-interop";
31
32
 
32
33
  /**
33
34
  * One `lexicons` entry: a package-backed name, or a name plus the module that
@@ -181,7 +182,7 @@ export async function importLexiconModule(name: string): Promise<Record<string,
181
182
  if (path === undefined) return undefined;
182
183
  try {
183
184
  ensureTypeScriptLoader();
184
- return (await import(path)) as Record<string, unknown>;
185
+ return esModuleNamespace((await import(path)) as Record<string, unknown>);
185
186
  } catch (err) {
186
187
  throw new Error(
187
188
  `lexicon "${name}" is declared with module ${path}, which could not be loaded: ` +
@@ -262,12 +263,12 @@ export function resolveFromProject(spec: string, fromDir: string = process.cwd()
262
263
  export async function importLexiconPackage(spec: string, fromDir: string = process.cwd()): Promise<Record<string, unknown>> {
263
264
  try {
264
265
  ensureTypeScriptLoader();
265
- return (await import(spec)) as Record<string, unknown>;
266
+ return esModuleNamespace((await import(spec)) as Record<string, unknown>);
266
267
  } catch (err) {
267
268
  const pkg = packageNameOf(spec);
268
269
  if (pkg === undefined || !isPackageNotFound(err, pkg)) throw err;
269
270
  const resolved = resolveFromProject(spec, fromDir);
270
271
  if (resolved === undefined) throw err;
271
- return (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
272
+ return esModuleNamespace((await import(pathToFileURL(resolved).href)) as Record<string, unknown>);
272
273
  }
273
274
  }
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { ensureTypeScriptLoader } from "../ts-loader";
27
+ import { esModuleNamespace } from "../module-interop";
27
28
 
28
29
  /** Whether this process must run project policy modules inside the sandbox boundary instead of here. */
29
30
  let armed = false;
@@ -69,5 +70,5 @@ export async function importPolicyModule(policyPath: string): Promise<PolicyModu
69
70
  );
70
71
  }
71
72
  ensureTypeScriptLoader();
72
- return (await import(policyPath)) as PolicyModuleNamespace;
73
+ return esModuleNamespace((await import(policyPath)) as PolicyModuleNamespace);
73
74
  }
@@ -2,6 +2,7 @@ import { readdirSync, existsSync } from "fs";
2
2
  import { dirname, join, resolve } from "path";
3
3
  import type { LintRule } from "./rule";
4
4
  import { ensureTypeScriptLoader } from "../ts-loader";
5
+ import { esModuleNamespace } from "../module-interop";
5
6
 
6
7
  /**
7
8
  * Type guard to check if a value conforms to the LintRule interface.
@@ -84,7 +85,7 @@ export async function loadLocalRules(projectDir: string): Promise<LintRule[]> {
84
85
  let mod: Record<string, unknown>;
85
86
  try {
86
87
  ensureTypeScriptLoader();
87
- mod = await import(filePath);
88
+ mod = esModuleNamespace(await import(filePath));
88
89
  } catch (err) {
89
90
  throw new Error(
90
91
  `Failed to load rule file "${file}": ${err instanceof Error ? err.message : String(err)}`,
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { selectConfigExport } from "./config-sandbox";
3
+ import { esModuleNamespace } from "./module-interop";
4
+
5
+ /** What node's native import() hands back for a CommonJS module tsx compiled from `export default`. */
6
+ function commonJsNamespace(exports: Record<string, unknown>): Record<string, unknown> {
7
+ const moduleExports: Record<string, unknown> = { ...exports };
8
+ Object.defineProperty(moduleExports, "__esModule", { value: true });
9
+ return { ...exports, default: moduleExports };
10
+ }
11
+
12
+ describe("esModuleNamespace (#3897)", () => {
13
+ test("a CommonJS __esModule namespace reads as the module's own exports", () => {
14
+ const config = { lexicons: ["terraform"] };
15
+ const helper = () => 1;
16
+ const ns = esModuleNamespace(commonJsNamespace({ default: config, helper }));
17
+ expect(ns.default).toBe(config);
18
+ expect(ns.helper).toBe(helper);
19
+ expect(Object.keys(ns)).not.toContain("__esModule");
20
+ });
21
+
22
+ test("a CommonJS module with no default export has none after unwrapping", () => {
23
+ const ns = esModuleNamespace(commonJsNamespace({ config: { lexicons: ["aws"] } }));
24
+ expect(ns.default).toBeUndefined();
25
+ expect(ns.config).toEqual({ lexicons: ["aws"] });
26
+ });
27
+
28
+ test("an enumerable __esModule marker is left out too", () => {
29
+ const ns = esModuleNamespace({ default: { __esModule: true, default: 1 } });
30
+ expect(ns).toEqual({ default: 1 });
31
+ });
32
+
33
+ test("an ES module whose default export has a default key is unchanged", () => {
34
+ const namespace = { default: { default: { lexicons: ["aws"] } } };
35
+ expect(esModuleNamespace(namespace)).toBe(namespace);
36
+ });
37
+
38
+ test("a default without the marker set to true is unchanged", () => {
39
+ const namespace = { default: { __esModule: "yes", default: 1 } };
40
+ expect(esModuleNamespace(namespace)).toBe(namespace);
41
+ const primitive = { default: 3 };
42
+ expect(esModuleNamespace(primitive)).toBe(primitive);
43
+ const empty = {};
44
+ expect(esModuleNamespace(empty)).toBe(empty);
45
+ });
46
+ });
47
+
48
+ describe("selectConfigExport (#3897)", () => {
49
+ test("a config tsx compiled as CommonJS selects the config object, not {default: config}", () => {
50
+ const config = { lexicons: ["terraform"], terraform: { roots: { estate: { dir: "estate" } } } };
51
+ expect(selectConfigExport(commonJsNamespace({ default: config }))).toBe(config);
52
+ });
53
+
54
+ test("an ES module config that legitimately has a default key keeps it", () => {
55
+ const config = { default: { lexicons: ["aws"] }, lexicons: ["aws"] };
56
+ expect(selectConfigExport({ default: config })).toBe(config);
57
+ });
58
+
59
+ test("a require()d CommonJS config still selects its default", () => {
60
+ const config = { lexicons: ["aws"] };
61
+ const moduleExports: Record<string, unknown> = { default: config };
62
+ Object.defineProperty(moduleExports, "__esModule", { value: true });
63
+ expect(selectConfigExport(moduleExports)).toBe(config);
64
+ });
65
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The namespace a project module was authored with, whichever module format
3
+ * tsx compiled it to (#3897).
4
+ *
5
+ * A `.ts` file with no `"type": "module"` `package.json` above it is compiled
6
+ * by tsx as CommonJS: `exports.default = ...` plus a non-enumerable
7
+ * `__esModule: true` marker. The compiled CLI loads project files with a
8
+ * native `import()`, and Node hands a CommonJS module back as a namespace
9
+ * whose `default` is the whole `module.exports`. So `export default x` arrives
10
+ * as `namespace.default.default`. Under the source CLI tsx's ESM loader sits
11
+ * in front of the `import()` and does this interop itself, which is why the
12
+ * nesting only showed up once the CLI ran compiled (#3879).
13
+ *
14
+ * When `namespace.default` carries the `__esModule` marker, the module's real
15
+ * namespace is that exports object: this returns a copy of it with the marker
16
+ * left out. Anything else, including an ES module whose default export is an
17
+ * object that happens to have a `default` key, is returned unchanged.
18
+ */
19
+ export function esModuleNamespace<T extends Record<string, unknown>>(namespace: T): T {
20
+ // `in` before the read: a vitest factory mock throws on reading an export
21
+ // it does not define, and most modules have no `default` at all.
22
+ if (namespace === null || typeof namespace !== "object" || !("default" in namespace)) return namespace;
23
+ const exported = namespace.default;
24
+ if (!isCommonJsEsModule(exported)) return namespace;
25
+ const unwrapped: Record<string, unknown> = {};
26
+ for (const key of Object.keys(exported)) {
27
+ if (key !== "__esModule") unwrapped[key] = exported[key];
28
+ }
29
+ return unwrapped as T;
30
+ }
31
+
32
+ /** A CommonJS `module.exports` that a compiler marked as an ES module. */
33
+ function isCommonJsEsModule(value: unknown): value is Record<string, unknown> {
34
+ return (
35
+ value !== null &&
36
+ (typeof value === "object" || typeof value === "function") &&
37
+ (value as { __esModule?: unknown }).__esModule === true
38
+ );
39
+ }
@@ -7,6 +7,7 @@ import { hasDiscoveryMarkerSync } from "../discovery/files";
7
7
  import { resolveDiscoveryGlobs } from "../config";
8
8
  import { isStewardDeclaration, type StewardDeclaration } from "./steward";
9
9
  import { ensureTypeScriptLoader } from "../ts-loader";
10
+ import { esModuleNamespace } from "../module-interop";
10
11
 
11
12
  export interface DiscoveredOp {
12
13
  config: OpConfig;
@@ -152,7 +153,7 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
152
153
  for (const filePath of files) {
153
154
  try {
154
155
  ensureTypeScriptLoader();
155
- const mod = (await import(filePath)) as Record<string, unknown>;
156
+ const mod = esModuleNamespace((await import(filePath)) as Record<string, unknown>);
156
157
  const exported = opsExportedBy(mod);
157
158
 
158
159
  // A file may hold a steward and no Op (#2731): the steward sits beside
@@ -223,7 +224,7 @@ export async function discoverStewards(opts?: { cwd?: string }): Promise<Steward
223
224
  let mod: Record<string, unknown>;
224
225
  try {
225
226
  ensureTypeScriptLoader();
226
- mod = (await import(filePath)) as Record<string, unknown>;
227
+ mod = esModuleNamespace((await import(filePath)) as Record<string, unknown>);
227
228
  } catch (err) {
228
229
  errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
229
230
  continue;
@@ -51,7 +51,7 @@
51
51
  import { execFileSync, spawnSync } from "node:child_process";
52
52
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
53
53
  import { dirname, isAbsolute, join, posix } from "node:path";
54
- import { waveSetDigest, type WaveMember } from "../gated-waves";
54
+ import { decideWaveGate, waveDigestMembers, waveSetDigest, writeWaveRecordFile, type WaveMember } from "../wave-engine";
55
55
  import type { ReviewApproval } from "../gate-decision";
56
56
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
57
57
  import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
@@ -817,8 +817,7 @@ function pendingFields(value: unknown, where: string): Record<string, unknown> |
817
817
  * file's members that are no run (a cost), are always in it.
818
818
  */
819
819
  export function opWaveSetDigest(spec: Pick<OpWavesSpec, "digest">, members: ReadonlyArray<WaveMember & { empty?: boolean }>, extras: readonly WaveMember[] = []): string {
820
- const changing = spec.digest?.over === "changing" ? members.filter((m) => m.empty !== true) : [];
821
- return waveSetDigest([...(changing.length > 0 ? changing : members), ...extras]);
820
+ return waveSetDigest(waveDigestMembers(members, { ...(spec.digest?.over ? { over: spec.digest.over } : {}), extras }));
822
821
  }
823
822
 
824
823
  /** What one moment's hooks came to. */
@@ -1627,10 +1626,10 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1627
1626
  // #3784: under the spend rule at base, an approval a wave already applied under counts for nothing.
1628
1627
  const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
1629
1628
  const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
1630
- const check = await evaluateGate(port, {
1629
+ const check = await decideWaveGate(port, {
1631
1630
  op: spec.name,
1632
1631
  gate,
1633
- planDigest: digest,
1632
+ digest,
1634
1633
  description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
1635
1634
  now,
1636
1635
  ...(spent ? { spent } : {}),
@@ -1728,7 +1727,7 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
1728
1727
  /** Write the decision record, with every hook's outcome so far (#3804). */
1729
1728
  function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
1730
1729
  if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
1731
- writeFileSync(recordPath, JSON.stringify(decision, null, 2) + "\n");
1730
+ writeWaveRecordFile(recordPath, decision);
1732
1731
  }
1733
1732
 
1734
1733
  /**
@@ -69,6 +69,7 @@
69
69
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
70
70
  import { gatePointProblems, type GatePoint } from "./gate-point";
71
71
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
72
+ import { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "../wave-layout";
72
73
 
73
74
  /**
74
75
  * The variables a wave job reads its notify secrets from, for the lexicons
@@ -361,11 +362,11 @@ export interface OpWavesDigest {
361
362
  export const OP_WAVES_DIGEST_OVER = ["all", "changing"] as const;
362
363
 
363
364
  /** The default gate name pattern. */
364
- export const OP_WAVE_DEFAULT_GATE_NAME = "{gate}-wave-{k}";
365
+ export const OP_WAVE_DEFAULT_GATE_NAME = WAVE_GATE_NAME_PATTERN;
365
366
 
366
367
  /** The gate wave `k` waits on (#3821): the spec's `gateName` pattern, by default `<gate>-wave-<k>`. */
367
368
  export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateName">, k: number): string {
368
- return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
369
+ return waveGateNameFrom(spec.gateName, spec.gate ?? spec.name, k);
369
370
  }
370
371
 
371
372
  /** Where a wave reads who may override a policy denial (#3821). */
@@ -638,12 +639,7 @@ export function opWaveApplyHooksRunBy(spec: Pick<OpWavesSpec, "hooks" | "waves">
638
639
  * by target, cut into contiguous slices whose sizes differ by at most one.
639
640
  */
640
641
  export function opWaveShare<T extends { target: string }>(runs: readonly T[], share: number, shares: number): T[] {
641
- const sorted = [...runs].sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
642
- const base = Math.floor(sorted.length / shares);
643
- const extra = sorted.length % shares;
644
- const start = (share - 1) * base + Math.min(share - 1, extra);
645
- const size = base + (share <= extra ? 1 : 0);
646
- return sorted.slice(start, start + size);
642
+ return waveShare(runs, share, shares, (run) => run.target);
647
643
  }
648
644
 
649
645
  /**
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@intentius/chant`'s own version, read from its package.json as a file. The
3
+ * path is the same from `src/` and `dist/`. The slim wave-job runtime (#3896)
4
+ * has no package.json beside it, and its build writes the version in here.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ export function readChantPackageVersion(): string {
10
+ return (JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf-8")) as { version: string }).version;
11
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * One wave engine under both runners (#3766): the same runs, planned to the
3
+ * same digests, are the same set digest and the same gate name whether
4
+ * `chant run wave` (an Op waves spec) or `chant components fan-out
5
+ * --wave-gate` decides the wave. An approval recorded through one matches a
6
+ * wave the other plans.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { describe, expect, test } from "vitest";
13
+ import { memoryGateLedgerPort } from "./op/gate";
14
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op/op-waves-run";
15
+ import { opWaveGateName, opWaveShare, type OpWavesSpec } from "./op/op-waves";
16
+ import { planFanOut } from "./components/fan-out";
17
+ import { runFanOut } from "./components/fan-out-run";
18
+ import { CapabilityRegistry } from "./components/capability";
19
+ import type { DriverComponent } from "./components/driver";
20
+ import { waveGateName, waveGateNameFrom, waveSetDigest, waveShare, WAVE_GATE_NAME_PATTERN } from "./wave-engine";
21
+
22
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
23
+ /** The runs both runners plan: a member name and its plan digest. */
24
+ const RUNS: Record<string, string> = { "tenant-b": digest("b"), "tenant-a": digest("a"), "tenant-c": digest("c") };
25
+ const NOW = "2026-10-11T00:00:00Z";
26
+
27
+ async function throughOpWaves(): Promise<{ digest: string; gate: string; pending: string | undefined }> {
28
+ const spec: OpWavesSpec = {
29
+ name: "rollout",
30
+ op: "migrate",
31
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
32
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
33
+ waves: [{ name: "tenants", runs: Object.keys(RUNS).map((target) => ({ target })) }],
34
+ };
35
+ const cwd = mkdtempSync(join(tmpdir(), "wave-engine-"));
36
+ try {
37
+ const exec: OpWaveExec = (argv, dir) => {
38
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: RUNS[argv[2]!] }));
39
+ return 0;
40
+ };
41
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
42
+ const gates = memoryGateLedgerPort();
43
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd, exec, show, gates, now: NOW });
44
+ expect(result.exitCode).toBe(3);
45
+ return { digest: result.decision!.digest, gate: result.decision!.gate, pending: gates.appended[0]?.planDigest };
46
+ } finally {
47
+ rmSync(cwd, { recursive: true, force: true });
48
+ }
49
+ }
50
+
51
+ async function throughFanOut(): Promise<{ digest: string; gate: string; pending: string | undefined }> {
52
+ const components: DriverComponent[] = Object.keys(RUNS).map((name) => ({ name, deploy: [{ phase: "Apply", steps: [] }] }));
53
+ const plan = planFanOut({ components, changed: Object.keys(RUNS) });
54
+ const gates = memoryGateLedgerPort();
55
+ const result = await runFanOut(plan, components, new CapabilityRegistry(), {
56
+ env: "prod",
57
+ gates,
58
+ now: NOW,
59
+ waveGate: {
60
+ op: "rollout",
61
+ gate: "rollout",
62
+ planComponent: async (component) => ({
63
+ component: component.name,
64
+ members: [{ member: component.name, planDigest: RUNS[component.name]! }],
65
+ plans: {},
66
+ parts: [],
67
+ }),
68
+ },
69
+ });
70
+ expect(result.status).toBe("gated");
71
+ const wave = result.waves![0]!;
72
+ return { digest: wave.digest, gate: wave.gate, pending: gates.appended[0]?.planDigest };
73
+ }
74
+
75
+ describe("one wave engine (#3766)", () => {
76
+ test("the same runs bind the same set digest and gate through both runners", async () => {
77
+ const expected = waveSetDigest(Object.entries(RUNS).map(([member, planDigest]) => ({ member, planDigest })));
78
+ const op = await throughOpWaves();
79
+ const fan = await throughFanOut();
80
+ expect(op.digest).toBe(expected);
81
+ expect(fan.digest).toBe(expected);
82
+ expect(op.pending).toBe(expected);
83
+ expect(fan.pending).toBe(expected);
84
+ expect(op.gate).toBe("rollout-wave-1");
85
+ expect(fan.gate).toBe(op.gate);
86
+ });
87
+
88
+ test("gate names: the default pattern is waveGateName's name, and a spec's pattern still wins", () => {
89
+ for (const k of [1, 2, 12]) {
90
+ expect(waveGateNameFrom(undefined, "deploy", k)).toBe(waveGateName("deploy", k));
91
+ expect(waveGateNameFrom(WAVE_GATE_NAME_PATTERN, "deploy", k)).toBe(`deploy-wave-${k}`);
92
+ expect(opWaveGateName({ name: "deploy" }, k)).toBe(waveGateName("deploy", k));
93
+ }
94
+ expect(opWaveGateName({ name: "x", gate: "tg", gateName: "wave-{k}" }, 3)).toBe("wave-3");
95
+ expect(opWaveGateName({ name: "x", gateName: "{gate}/{k}" }, 3)).toBe("x/3");
96
+ });
97
+
98
+ test("the share split is the Op waves split by target", () => {
99
+ const runs = ["t5", "t1", "t4", "t2", "t3"].map((target) => ({ target }));
100
+ for (const shares of [1, 2, 3, 5]) {
101
+ for (let share = 1; share <= shares; share++) {
102
+ expect(waveShare(runs, share, shares, (r) => r.target)).toEqual(opWaveShare(runs, share, shares));
103
+ }
104
+ }
105
+ expect(opWaveShare(runs, 1, 2).map((r) => r.target)).toEqual(["t1", "t2", "t3"]);
106
+ });
107
+ });