@intentius/chant 0.22.0 → 0.24.0

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 (65) hide show
  1. package/dist/cli/build-params-cli.d.ts +55 -0
  2. package/dist/cli/build-params-cli.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +22 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/lsp/server.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/plugins.d.ts +8 -0
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/components/cli-support.d.ts +33 -2
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/discover.d.ts +28 -0
  15. package/dist/components/discover.d.ts.map +1 -1
  16. package/dist/config.d.ts +18 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/discovery/fold-import.d.ts +38 -8
  19. package/dist/discovery/fold-import.d.ts.map +1 -1
  20. package/dist/discovery/index.d.ts +15 -4
  21. package/dist/discovery/index.d.ts.map +1 -1
  22. package/dist/fold/subset.d.ts +16 -2
  23. package/dist/fold/subset.d.ts.map +1 -1
  24. package/dist/lint/config.d.ts +1 -12
  25. package/dist/lint/config.d.ts.map +1 -1
  26. package/dist/lint/engine.d.ts +11 -1
  27. package/dist/lint/engine.d.ts.map +1 -1
  28. package/dist/lint/rule.d.ts +14 -0
  29. package/dist/lint/rule.d.ts.map +1 -1
  30. package/dist/project-root.d.ts +51 -0
  31. package/dist/project-root.d.ts.map +1 -0
  32. package/package.json +1 -1
  33. package/src/cli/build-params-cli.test.ts +139 -0
  34. package/src/cli/build-params-cli.ts +107 -0
  35. package/src/cli/commands/build.ts +44 -41
  36. package/src/cli/commands/lint.test.ts +74 -0
  37. package/src/cli/commands/lint.ts +33 -9
  38. package/src/cli/handlers/build.test.ts +149 -0
  39. package/src/cli/handlers/build.ts +28 -8
  40. package/src/cli/handlers/run.test.ts +191 -5
  41. package/src/cli/handlers/run.ts +61 -8
  42. package/src/cli/lsp/server.ts +7 -2
  43. package/src/cli/main.test.ts +23 -0
  44. package/src/cli/main.ts +17 -3
  45. package/src/cli/plugins.ts +10 -2
  46. package/src/components/cli-support.test.ts +221 -3
  47. package/src/components/cli-support.ts +37 -6
  48. package/src/components/discover.test.ts +63 -1
  49. package/src/components/discover.ts +42 -0
  50. package/src/config.ts +23 -0
  51. package/src/discovery/fold-import.ts +202 -20
  52. package/src/discovery/index.test.ts +131 -0
  53. package/src/discovery/index.ts +38 -8
  54. package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
  55. package/src/fold/subset.test.ts +28 -14
  56. package/src/fold/subset.ts +16 -2
  57. package/src/lint/config.test.ts +9 -4
  58. package/src/lint/config.ts +7 -23
  59. package/src/lint/engine.ts +12 -0
  60. package/src/lint/policy.ts +5 -5
  61. package/src/lint/rule.ts +14 -0
  62. package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
  63. package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
  64. package/src/project-root.test.ts +105 -0
  65. package/src/project-root.ts +78 -0
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
3
3
  import { execFileSync } from "child_process";
4
4
  import { runLint, parseDisableComments } from "../../lint/engine";
5
5
  import type { LintRule, LintDiagnostic, LintFix } from "../../lint/rule";
6
+ import type { IntrinsicDef } from "../../lexicon";
6
7
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
7
8
  import { formatStylish, formatJson, formatSarif } from "../reporters/stylish";
8
9
  import { loadLocalRules } from "../../lint/rule-loader";
@@ -86,8 +87,17 @@ export async function loadPluginRules(
86
87
 
87
88
  /**
88
89
  * Load all lint rules: core COR/EVL rules, then lexicon plugin rules.
90
+ *
91
+ * Also returns the active lexicons' registered intrinsics (chant #1106) —
92
+ * gathered here because this is where the project's lexicon plugins are
93
+ * already resolved and loaded (`loadPlugins`), the same set `../commands/
94
+ * build.ts` reads `plugin.intrinsics?.()` off of for the fold path. Handed
95
+ * back alongside `rules` so `lintCommand` can thread it into every
96
+ * `runLint` call without loading plugins a second time.
89
97
  */
90
- async function loadAllPluginRules(projectPath: string): Promise<Map<string, LintRule>> {
98
+ async function loadAllPluginRules(
99
+ projectPath: string,
100
+ ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[] }> {
91
101
  const rules = new Map<string, LintRule>();
92
102
 
93
103
  // Load core COR/EVL rules directly
@@ -106,6 +116,13 @@ async function loadAllPluginRules(projectPath: string): Promise<Map<string, Lint
106
116
  // Load only project lexicon plugins (no "chant" injection)
107
117
  const plugins = await loadPlugins(lexiconNames);
108
118
 
119
+ // chant #1106 — the same plugins' registered intrinsics (`Ref`, `GetAtt`,
120
+ // ...), so EVL001 can answer "does this call fold?" exactly like fold()
121
+ // does instead of flagging every call as a violation. A plugin's
122
+ // `intrinsics` is an optional extension (not every lexicon defines any),
123
+ // hence the guard.
124
+ const intrinsics = plugins.flatMap((plugin) => plugin.intrinsics?.() ?? []);
125
+
109
126
  for (const plugin of plugins) {
110
127
  if (plugin.lintRules) {
111
128
  for (const r of plugin.lintRules()) {
@@ -127,7 +144,7 @@ async function loadAllPluginRules(projectPath: string): Promise<Map<string, Lint
127
144
  rules.set(r.id, r);
128
145
  }
129
146
 
130
- return rules;
147
+ return { rules, intrinsics };
131
148
  }
132
149
 
133
150
  /**
@@ -428,7 +445,14 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
428
445
  const hasOverrides = config.overrides && config.overrides.length > 0;
429
446
 
430
447
  // Load all rules from lexicon plugins (core "chant" + lexicon-specific)
431
- let allRules = await loadAllPluginRules(projectRoot);
448
+ const loaded = await loadAllPluginRules(projectRoot);
449
+ let allRules = loaded.rules;
450
+ // chant #1106 — the active lexicons' registered intrinsics, threaded into
451
+ // every runLint() call below so EVL001 answers exactly like fold() does
452
+ // for a registered, opted-in call (`Ref(...)`, `GetAtt(...)`) instead of
453
+ // flagging it. Computed once here regardless of which branch below runs,
454
+ // same as `allRules`.
455
+ const intrinsics = loaded.intrinsics;
432
456
 
433
457
  // Merge in any config-level plugin rules (custom .ts rule files)
434
458
  if (config.plugins && config.plugins.length > 0) {
@@ -443,7 +467,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
443
467
  let diagnostics: LintDiagnostic[];
444
468
  let suppressed: Array<LintDiagnostic & { reason?: string }> = [];
445
469
  if (options.rules) {
446
- const result = await runLint(files, options.rules, undefined);
470
+ const result = await runLint(files, options.rules, undefined, intrinsics);
447
471
  diagnostics = result.diagnostics;
448
472
  suppressed = result.suppressed;
449
473
  } else if (hasOverrides) {
@@ -451,13 +475,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
451
475
  for (const file of files) {
452
476
  const relativePath = relative(projectRoot, file);
453
477
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
454
- const result = await runLint([file], fileRules, ruleOptions);
478
+ const result = await runLint([file], fileRules, ruleOptions, intrinsics);
455
479
  diagnostics.push(...result.diagnostics);
456
480
  suppressed.push(...result.suppressed);
457
481
  }
458
482
  } else {
459
483
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
460
- const result = await runLint(files, rules, ruleOptions);
484
+ const result = await runLint(files, rules, ruleOptions, intrinsics);
461
485
  diagnostics = result.diagnostics;
462
486
  suppressed = result.suppressed;
463
487
  }
@@ -491,7 +515,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
491
515
 
492
516
  // Re-lint after fixes to get updated diagnostics
493
517
  if (options.rules) {
494
- const postResult = await runLint(files, options.rules, undefined);
518
+ const postResult = await runLint(files, options.rules, undefined, intrinsics);
495
519
  diagnostics = postResult.diagnostics;
496
520
  suppressed = postResult.suppressed;
497
521
  } else if (hasOverrides) {
@@ -500,13 +524,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
500
524
  for (const file of files) {
501
525
  const relativePath = relative(projectRoot, file);
502
526
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
503
- const postResult = await runLint([file], fileRules, ruleOptions);
527
+ const postResult = await runLint([file], fileRules, ruleOptions, intrinsics);
504
528
  diagnostics.push(...postResult.diagnostics);
505
529
  suppressed.push(...postResult.suppressed);
506
530
  }
507
531
  } else {
508
532
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
509
- const postResult = await runLint(files, rules, ruleOptions);
533
+ const postResult = await runLint(files, rules, ruleOptions, intrinsics);
510
534
  diagnostics = postResult.diagnostics;
511
535
  suppressed = postResult.suppressed;
512
536
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tests for `chant build --components --generate <lexicon>` (generate mode,
3
+ * #563) — specifically its chant #1108 build-time-parameter resolution,
4
+ * which was entirely missing before this fix: `generateComponentsPipeline`'s
5
+ * `discoverComponents` call never resolved `chant.config.ts`'s declared
6
+ * `buildParams`, so a `*.component.ts` file reading `params.<name>` always
7
+ * saw `{}` under `chant build --components --generate`, exactly like `chant
8
+ * run --components` (see ../handlers/run.test.ts's "build-time parameters"
9
+ * describe block for the equivalent local/`--temporal` coverage).
10
+ *
11
+ * Mocks `generateComponentsPipeline` and `loadChantConfigUpward` (chant
12
+ * #1117 — `runGenerateComponents` walks up to the project root now, same as
13
+ * `chant build` proper, instead of reading `args.path` alone) and exercises
14
+ * the public `runBuild` dispatcher (`runGenerateComponents` itself isn't
15
+ * exported), mirroring `run.test.ts`'s style of driving the handler through
16
+ * its `CommandContext` entrypoint rather than reaching into private helpers.
17
+ */
18
+ import { describe, test, expect, vi, beforeEach } from "vitest";
19
+ import type { ParsedArgs } from "../registry";
20
+
21
+ const generateComponentsPipelineMock = vi.fn();
22
+ const loadChantConfigUpwardMock = vi.fn();
23
+
24
+ vi.mock("../../components/cli-support", () => ({
25
+ generateComponentsPipeline: (...args: unknown[]) => generateComponentsPipelineMock(...args),
26
+ }));
27
+ vi.mock("../../config", async () => {
28
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
29
+ return { ...actual, loadChantConfigUpward: (...args: unknown[]) => loadChantConfigUpwardMock(...args) };
30
+ });
31
+
32
+ const { runBuild } = await import("./build");
33
+
34
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
35
+ return {
36
+ command: "build",
37
+ path: ".",
38
+ format: "",
39
+ fix: false,
40
+ watch: false,
41
+ verbose: false,
42
+ help: false,
43
+ live: false,
44
+ components: true,
45
+ generate: "gitlab",
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function makeStderrSpy() {
51
+ const buf: string[] = [];
52
+ vi.spyOn(console, "error").mockImplementation((s: string) => { buf.push(s); });
53
+ return buf;
54
+ }
55
+
56
+ describe("runBuild --components --generate (chant #1108 build-time parameters)", () => {
57
+ beforeEach(() => {
58
+ generateComponentsPipelineMock.mockReset();
59
+ loadChantConfigUpwardMock.mockReset().mockResolvedValue({ config: {} });
60
+ });
61
+
62
+ test("no declared buildParams → generateComponentsPipeline is called with an empty provenance array", async () => {
63
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
64
+ vi.spyOn(console, "log").mockImplementation(() => {});
65
+
66
+ const exit = await runBuild({ args: makeArgs(), plugins: [], serializers: [] });
67
+
68
+ expect(exit).toBe(0);
69
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(".", "gitlab", { env: undefined }, undefined, []);
70
+ vi.restoreAllMocks();
71
+ });
72
+
73
+ test("chant.config.ts's declared buildParams resolve, log, and are forwarded to generateComponentsPipeline", async () => {
74
+ loadChantConfigUpwardMock.mockResolvedValue({
75
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
76
+ });
77
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
78
+ vi.spyOn(console, "log").mockImplementation(() => {});
79
+ const stderr = makeStderrSpy();
80
+
81
+ const exit = await runBuild({ args: makeArgs(), plugins: [], serializers: [] });
82
+
83
+ expect(exit).toBe(0);
84
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(
85
+ ".",
86
+ "gitlab",
87
+ { env: undefined },
88
+ undefined,
89
+ [{ name: "tier", value: "light", source: "default" }],
90
+ );
91
+ expect(stderr.join("\n")).toContain("[param] tier");
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ test("--param overrides a declared default", async () => {
96
+ loadChantConfigUpwardMock.mockResolvedValue({
97
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
98
+ });
99
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
100
+ vi.spyOn(console, "log").mockImplementation(() => {});
101
+
102
+ const exit = await runBuild({
103
+ args: makeArgs({ param: ["tier=production"] }),
104
+ plugins: [],
105
+ serializers: [],
106
+ });
107
+
108
+ expect(exit).toBe(0);
109
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(
110
+ ".",
111
+ "gitlab",
112
+ { env: undefined },
113
+ undefined,
114
+ [{ name: "tier", value: "production", source: "cli" }],
115
+ );
116
+ vi.restoreAllMocks();
117
+ });
118
+
119
+ test("an unresolved required build-time parameter → exit 1, never reaches generateComponentsPipeline", async () => {
120
+ loadChantConfigUpwardMock.mockResolvedValue({
121
+ config: { buildParams: { tier: { type: "string" } } },
122
+ });
123
+ const stderr = makeStderrSpy();
124
+
125
+ const exit = await runBuild({ args: makeArgs(), plugins: [], serializers: [] });
126
+
127
+ expect(exit).toBe(1);
128
+ expect(stderr.join("\n")).toMatch(/"tier"/);
129
+ expect(generateComponentsPipelineMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ test("an enum violation on --param → exit 1, never reaches generateComponentsPipeline", async () => {
133
+ loadChantConfigUpwardMock.mockResolvedValue({
134
+ config: { buildParams: { tier: { type: "string", enum: ["light", "production"] } } },
135
+ });
136
+ const stderr = makeStderrSpy();
137
+
138
+ const exit = await runBuild({
139
+ args: makeArgs({ param: ["tier=bogus"] }),
140
+ plugins: [],
141
+ serializers: [],
142
+ });
143
+
144
+ expect(exit).toBe(1);
145
+ expect(stderr.join("\n")).toMatch(/"tier"/);
146
+ expect(stderr.join("\n")).toMatch(/bogus/);
147
+ expect(generateComponentsPipelineMock).not.toHaveBeenCalled();
148
+ });
149
+ });
@@ -4,6 +4,8 @@ import { buildCommand, buildCommandWatch, printErrors, printWarnings, resolveBui
4
4
  import { formatError, formatInfo, formatSuccess, formatBold } from "../format";
5
5
  import type { CommandContext } from "../registry";
6
6
  import { generateComponentsPipeline } from "../../components/cli-support";
7
+ import { loadChantConfigUpward, type ChantConfig } from "../../config";
8
+ import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
7
9
 
8
10
  /**
9
11
  * `chant build --components --generate <lexicon>` — generate mode (#563,
@@ -12,11 +14,35 @@ import { generateComponentsPipeline } from "../../components/cli-support";
12
14
  * normal lexicon build: no entity discovery, no serializers, no post-synth
13
15
  * checks — those apply to lexicon resources, which is a different input to
14
16
  * a different command path (`chant build` without `--components`).
17
+ *
18
+ * chant #1108 — resolves this invocation's declared build-time parameters
19
+ * (`chant.config.ts`'s `buildParams`, against `--param`/`--params-file`/a
20
+ * declared `env` mapping) the exact same way `chant build` does
21
+ * (`resolveCliBuildParams`, shared with `buildCommand`), BEFORE
22
+ * `generateComponentsPipeline` discovers/imports any `*.component.ts` file.
23
+ * Before this, `params.*` (`@intentius/chant/params`) was always `{}` under
24
+ * this command too — generate mode shares `discoverComponents` with `chant
25
+ * run --components`, so it had the identical gap.
26
+ *
27
+ * chant #1117 — loads config by walking up from `args.path` to the project
28
+ * root (`loadChantConfigUpward`), same as `chant build` proper, instead of
29
+ * `args.path` alone: a components-only project built from a subdirectory
30
+ * otherwise never sees the root `chant.config.ts`'s `buildParams` either.
15
31
  */
16
32
  async function runGenerateComponents(ctx: CommandContext): Promise<number> {
17
33
  const { args } = ctx;
18
34
  const lexicon = args.generate as string;
19
35
 
36
+ const { config } = await loadChantConfigUpward(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
37
+ const paramsResolution = resolveCliBuildParams(config.buildParams, {
38
+ cli: parseParamFlags(args.param),
39
+ paramsFile: args.paramsFile,
40
+ });
41
+ if (!paramsResolution.success) {
42
+ for (const message of paramsResolution.errors) console.error(message);
43
+ return 1;
44
+ }
45
+
20
46
  // Which lexicons support generate mode is a property of the loaded lexicon
21
47
  // plugins (those implementing `generateComponentPipeline`, #688), not a
22
48
  // hard-coded core list — `generateComponentsPipeline` returns a descriptive
@@ -26,6 +52,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
26
52
  lexicon,
27
53
  { env: args.env },
28
54
  args.sandbox,
55
+ paramsResolution.provenance,
29
56
  );
30
57
 
31
58
  if (!result.success) {
@@ -87,14 +114,7 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
87
114
  }
88
115
 
89
116
  // #1064 — `--param name=value`, repeated, into a flat { name: value } record.
90
- const params = args.param?.length
91
- ? Object.fromEntries(
92
- args.param.map((entry) => {
93
- const eq = entry.indexOf("=");
94
- return eq === -1 ? [entry, ""] : [entry.slice(0, eq), entry.slice(eq + 1)];
95
- }),
96
- )
97
- : undefined;
117
+ const params = parseParamFlags(args.param);
98
118
 
99
119
  if (args.watch) {
100
120
  const cleanup = buildCommandWatch({
@@ -878,10 +878,41 @@ describe("runOp dispatcher: --components routes to runOpComponents", () => {
878
878
  const exit = await runOp({ args: makeArgs({ path: "svc", components: true, temporal: false }), plugins: [], serializers: [] });
879
879
 
880
880
  expect(exit).toBe(0);
881
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: undefined, componentOutputs: {} });
881
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: undefined, componentOutputs: {}, buildParams: [] });
882
882
  expect(discoverOpsMock).not.toHaveBeenCalled();
883
883
  vi.restoreAllMocks();
884
884
  });
885
+
886
+ // chant #1116 — --report is Op/Temporal-only (reads a past workflow run);
887
+ // the component driver never checked it, so it was silently ignored and the
888
+ // command fell through to a real dispatch. Hard-error instead, before
889
+ // runComponents is ever reached.
890
+ test("--report combined with --components → exit 1 before any dispatch, no fall-through (#1116)", async () => {
891
+ discoverOpsMock.mockReset();
892
+ const stderr = makeStderrSpy();
893
+
894
+ const exit = await runOp({ args: makeArgs({ path: "svc", components: true, report: true, temporal: false }), plugins: [], serializers: [] });
895
+
896
+ expect(exit).toBe(1);
897
+ expect(stderr.join("\n")).toContain("not supported with --components");
898
+ expect(stderr.join("\n")).toContain("#1116");
899
+ expect(discoverOpsMock).not.toHaveBeenCalled();
900
+ expect(runComponentsMock).not.toHaveBeenCalled();
901
+ });
902
+
903
+ // Plain --components (no --report) must be unaffected: it still reaches a
904
+ // real dispatch through runComponents — mocked here, never a real cloud call.
905
+ test("plain --components (no --report) still dispatches to runComponents (#1116 regression guard)", async () => {
906
+ discoverOpsMock.mockReset();
907
+ runComponentsMock.mockResolvedValue({ success: true, selected: ["svc"], run: { order: ["svc"], waves: [["svc"]], results: [{ component: "svc", ok: true, records: [] }], ok: true } });
908
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
909
+
910
+ const exit = await runOp({ args: makeArgs({ path: "svc", components: true, report: false, temporal: false }), plugins: [], serializers: [] });
911
+
912
+ expect(exit).toBe(0);
913
+ expect(runComponentsMock).toHaveBeenCalled();
914
+ vi.restoreAllMocks();
915
+ });
885
916
  });
886
917
 
887
918
  describe("runOpComponents", () => {
@@ -900,6 +931,87 @@ describe("runOpComponents", () => {
900
931
  expect(runComponentsMock).not.toHaveBeenCalled();
901
932
  });
902
933
 
934
+ // ── build-time parameters (chant #1108) — resolved BEFORE dispatch ────────
935
+
936
+ describe("build-time parameters", () => {
937
+ test("chant.config.ts's declared buildParams resolve and log before dispatching to runComponents", async () => {
938
+ loadChantConfigMock.mockResolvedValue({
939
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
940
+ });
941
+ runComponentsMock.mockResolvedValue({
942
+ success: true,
943
+ selected: ["svc"],
944
+ run: { order: ["svc"], waves: [["svc"]], results: [{ component: "svc", ok: true, records: [] }], ok: true },
945
+ });
946
+ const stderr = makeStderrSpy();
947
+
948
+ const exit = await runOpComponents({ args: makeArgs({ path: "svc", temporal: false }), plugins: [], serializers: [] });
949
+
950
+ expect(exit).toBe(0);
951
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
952
+ buildParams: [{ name: "tier", value: "light", source: "default" }],
953
+ }));
954
+ expect(stderr.join("\n")).toContain("[param] tier");
955
+ });
956
+
957
+ test("--param overrides a declared default and is threaded through to runComponents", async () => {
958
+ loadChantConfigMock.mockResolvedValue({
959
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
960
+ });
961
+ runComponentsMock.mockResolvedValue({
962
+ success: true,
963
+ selected: ["svc"],
964
+ run: { order: ["svc"], waves: [["svc"]], results: [{ component: "svc", ok: true, records: [] }], ok: true },
965
+ });
966
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
967
+ const stderr = makeStderrSpy();
968
+
969
+ const exit = await runOpComponents({
970
+ args: makeArgs({ path: "svc", temporal: false, param: ["tier=production"] }),
971
+ plugins: [],
972
+ serializers: [],
973
+ });
974
+
975
+ expect(exit).toBe(0);
976
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
977
+ buildParams: [{ name: "tier", value: "production", source: "cli" }],
978
+ }));
979
+ expect(stderr.join("\n")).toContain("[param] tier");
980
+ vi.restoreAllMocks();
981
+ });
982
+
983
+ test("an unresolved required build-time parameter → exit 1 with a formatted error naming it, never reaches runComponents (the previously-{} probe, now a hard stop instead)", async () => {
984
+ loadChantConfigMock.mockResolvedValue({
985
+ config: { buildParams: { tier: { type: "string" } } },
986
+ });
987
+ const stderr = makeStderrSpy();
988
+
989
+ const exit = await runOpComponents({ args: makeArgs({ path: "svc", temporal: false }), plugins: [], serializers: [] });
990
+
991
+ expect(exit).toBe(1);
992
+ expect(stderr.join("\n")).toMatch(/"tier"/);
993
+ expect(runComponentsMock).not.toHaveBeenCalled();
994
+ });
995
+
996
+ test("an enum violation on --param → exit 1 with a formatted error, never reaches runComponents", async () => {
997
+ loadChantConfigMock.mockResolvedValue({
998
+ config: { buildParams: { tier: { type: "string", enum: ["light", "production"] } } },
999
+ });
1000
+ const stderr = makeStderrSpy();
1001
+
1002
+ const exit = await runOpComponents({
1003
+ args: makeArgs({ path: "svc", temporal: false, param: ["tier=bogus"] }),
1004
+ plugins: [],
1005
+ serializers: [],
1006
+ });
1007
+
1008
+ expect(exit).toBe(1);
1009
+ expect(stderr.join("\n")).toMatch(/"tier"/);
1010
+ expect(stderr.join("\n")).toMatch(/bogus/);
1011
+ expect(runComponentsMock).not.toHaveBeenCalled();
1012
+ });
1013
+ });
1014
+
903
1015
  test("happy path: single component, human output, exit 0", async () => {
904
1016
  runComponentsMock.mockResolvedValue({
905
1017
  success: true,
@@ -916,7 +1028,7 @@ describe("runOpComponents", () => {
916
1028
  const exit = await runOpComponents({ args: makeArgs({ path: "svc", temporal: false }), plugins: [], serializers: [] });
917
1029
 
918
1030
  expect(exit).toBe(0);
919
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: undefined, componentOutputs: {} });
1031
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: undefined, componentOutputs: {}, buildParams: [] });
920
1032
  const printed = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
921
1033
  expect(printed).toContain("interpret run completed");
922
1034
  vi.restoreAllMocks();
@@ -928,7 +1040,7 @@ describe("runOpComponents", () => {
928
1040
 
929
1041
  await runOpComponents({ args: makeArgs({ path: "svc", env: "staging", temporal: false }), plugins: [], serializers: [] });
930
1042
 
931
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: "staging", componentOutputs: {} });
1043
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: "staging", componentOutputs: {}, buildParams: [] });
932
1044
  vi.restoreAllMocks();
933
1045
  });
934
1046
 
@@ -994,6 +1106,7 @@ describe("runOpComponents", () => {
994
1106
  env: undefined,
995
1107
  componentOutputs: {},
996
1108
  onProgress: undefined,
1109
+ buildParams: [],
997
1110
  });
998
1111
  expect(stdoutWrite).not.toHaveBeenCalled();
999
1112
  vi.restoreAllMocks();
@@ -1018,7 +1131,7 @@ describe("runOpComponents", () => {
1018
1131
  const exit = await runOpComponents({ args: makeArgs({ path: "all", temporal: false }), plugins: [], serializers: [] });
1019
1132
 
1020
1133
  expect(exit).toBe(0);
1021
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "all", { env: undefined, componentOutputs: {} });
1134
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "all", { env: undefined, componentOutputs: {}, buildParams: [] });
1022
1135
  const printed = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
1023
1136
  expect(printed).toContain("shared-alb");
1024
1137
  expect(printed).toContain("search-service");
@@ -1269,7 +1382,13 @@ describe("runOpComponents: --temporal routes to the durable path", () => {
1269
1382
  resolveComponentTargetsMock.mockReset();
1270
1383
  findComponentGateMock.mockReset();
1271
1384
  loadComponentTemporalCodegenMock.mockReset();
1272
- loadChantConfigMock.mockReset();
1385
+ // chant #1108 — runOpComponents now resolves build-time parameters (which
1386
+ // needs chant.config.ts's declared `buildParams`) BEFORE dispatching to
1387
+ // either the local or --temporal path, so every test in this block hits
1388
+ // loadChantConfig at least once now, even ones that never reach the rest
1389
+ // of the durable path (e.g. "unknown component"). Individual tests below
1390
+ // still override this where they care about a specific config shape.
1391
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
1273
1392
  resolveProfileMock.mockReset();
1274
1393
  loadTemporalClientMock.mockReset();
1275
1394
  spawnChildMock.mockReset();
@@ -1297,6 +1416,73 @@ describe("runOpComponents: --temporal routes to the durable path", () => {
1297
1416
  expect(stderr.join("\n")).toContain('Component "missing" not found');
1298
1417
  });
1299
1418
 
1419
+ // ── build-time parameters (chant #1108) — resolved before discovery here too ─
1420
+
1421
+ test("resolved build-time parameters are forwarded into resolveComponentTargets on the --temporal path", async () => {
1422
+ process.env.LOOM_ENV = "staging";
1423
+ resolveComponentTargetsMock.mockResolvedValue({
1424
+ success: true,
1425
+ targets: [{ name: "gated-svc", dependsOn: [], deploy: [] }],
1426
+ });
1427
+ resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
1428
+ loadComponentTemporalCodegenMock.mockResolvedValue({
1429
+ serializeComponent: () => ({ "components/gated-svc/worker.ts": "// worker" }),
1430
+ componentWorkflowFnName: (name: string) => `${name}ComponentWorkflow`,
1431
+ });
1432
+ const mockClient = createMockTemporalClient({
1433
+ describeByWorkflowId: {
1434
+ "chant-component-gated-svc": {
1435
+ workflowId: "chant-component-gated-svc", runId: "r1",
1436
+ status: { name: "COMPLETED" }, startTime: new Date(),
1437
+ taskQueue: "gated-svc", type: { name: "gatedSvcComponentWorkflow" },
1438
+ },
1439
+ },
1440
+ historyByWorkflowId: { "chant-component-gated-svc": [] },
1441
+ });
1442
+ // setupTemporalClient sets its own default loadChantConfigMock resolved
1443
+ // value, so the test's own (buildParams-declaring) config must be set
1444
+ // AFTER calling it, not before.
1445
+ setupTemporalClient(mockClient);
1446
+ loadChantConfigMock.mockResolvedValue({
1447
+ config: { buildParams: { env: { type: "string", env: "LOOM_ENV", default: "dev" } } },
1448
+ });
1449
+ const { proc } = makeFakeChildProcess();
1450
+ spawnChildMock.mockReturnValue(proc);
1451
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1452
+
1453
+ try {
1454
+ vi.useFakeTimers();
1455
+ const promise = runOpComponents({ args: makeArgs({ path: "gated-svc", temporal: true }), plugins: [], serializers: [] });
1456
+ await vi.advanceTimersByTimeAsync(5000);
1457
+ const exit = await promise;
1458
+
1459
+ expect(exit).toBe(0);
1460
+ expect(resolveComponentTargetsMock).toHaveBeenCalledWith(
1461
+ expect.any(String),
1462
+ "gated-svc",
1463
+ undefined,
1464
+ [{ name: "env", value: "staging", source: "env" }],
1465
+ );
1466
+ } finally {
1467
+ vi.useRealTimers();
1468
+ vi.restoreAllMocks();
1469
+ delete process.env.LOOM_ENV;
1470
+ }
1471
+ });
1472
+
1473
+ test("an unresolved required build-time parameter → exit 1, never reaches resolveComponentTargets", async () => {
1474
+ loadChantConfigMock.mockResolvedValue({
1475
+ config: { buildParams: { tier: { type: "string" } } },
1476
+ });
1477
+ const stderr = makeStderrSpy();
1478
+
1479
+ const exit = await runOpComponents({ args: makeArgs({ path: "gated-svc", temporal: true }), plugins: [], serializers: [] });
1480
+
1481
+ expect(exit).toBe(1);
1482
+ expect(stderr.join("\n")).toMatch(/"tier"/);
1483
+ expect(resolveComponentTargetsMock).not.toHaveBeenCalled();
1484
+ });
1485
+
1300
1486
  test("compiles the component, spawns the worker, submits the workflow, polls to COMPLETED", async () => {
1301
1487
  vi.useFakeTimers();
1302
1488
  try {