@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
@@ -2,12 +2,13 @@ import { resolve, join, dirname } from "node:path";
2
2
  import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { createConnection } from "node:net";
4
4
  import { spawn as spawnChild, type ChildProcess } from "node:child_process";
5
- import { loadChantConfig, resolveAutoReleaseDisabled } from "../../config";
5
+ import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
6
6
  import { discoverOps } from "../../op/discover";
7
7
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
8
8
  import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
9
9
  import { renderHuman, renderJson } from "../../op/local-output";
10
10
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
11
+ import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
11
12
  import type { CommandContext } from "../registry";
12
13
  import {
13
14
  loadTemporalClient,
@@ -27,6 +28,7 @@ import { applyConfigDefaults } from "../../components/config-defaults";
27
28
  import { maybeRecordAutoRelease, extractRunDigestFromPhaseOutputs } from "../../components/auto-release";
28
29
  import { maybePersistBuildManifest, extractRunManifestFromPhaseOutputs } from "../../components/manifest-persistence";
29
30
  import type { DriverComponentResult } from "../../components/driver";
31
+ import type { BuildParamProvenance } from "../../provenance";
30
32
 
31
33
  function kebabToCamel(s: string): string {
32
34
  return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
@@ -546,8 +548,23 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
546
548
  * (`../../components/driver.ts`) rather than a `*.op.ts` Op. Checked first,
547
549
  * mirroring `runGraph`'s `if (ctx.args.components) return
548
550
  * runComponentGraph(ctx)` branch (../handlers/graph.ts).
551
+ *
552
+ * chant #1116 — `--report` with `--components` is checked and hard-errored
553
+ * before that dispatch. There is no preview/dry-run mode for the component
554
+ * driver: unlike the Op path (where `--report` reads a past Temporal run),
555
+ * `runOpComponents` has never read `ctx.args.report` at all, so the flag was
556
+ * silently ignored and the command fell through to a real dispatch — observed
557
+ * live reaching an actual cloud shell-out. Erroring here is the safe minimum
558
+ * called out on the issue; a real preview is future work.
549
559
  */
550
560
  export async function runOp(ctx: CommandContext): Promise<number> {
561
+ if (ctx.args.components && ctx.args.report) {
562
+ console.error(formatError({
563
+ message: "--report is not supported with --components",
564
+ hint: "No preview/dry-run mode exists yet for the component driver (see chant#1116). Omit --report.",
565
+ }));
566
+ return 1;
567
+ }
551
568
  if (ctx.args.components) return runOpComponents(ctx);
552
569
  if (ctx.args.local && ctx.args.temporal) {
553
570
  console.error(formatError({
@@ -669,6 +686,18 @@ async function recordAutoReleasesForRun(
669
686
  * Opt out with `--no-release-record` or `chant.config.ts`'s
670
687
  * `release.autoRecord: false`; the default is ON. A failed run never reaches
671
688
  * this step, so it writes nothing.
689
+ *
690
+ * chant #1108 — resolves this invocation's declared build-time parameters
691
+ * (`chant.config.ts`'s `buildParams`, against `--param`/`--params-file`/a
692
+ * declared `env` mapping) the exact same way `chant build` does
693
+ * (`resolveCliBuildParams`, shared with `buildCommand`), BEFORE either the
694
+ * local or `--temporal` path discovers/imports any `*.component.ts` file.
695
+ * Before this, `params.*` (`@intentius/chant/params`) was always `{}` under
696
+ * this command, no matter what a component's `chant.config.ts` declared or a
697
+ * CI job's environment supplied — see chant #1108. `chant.config.ts` is
698
+ * loaded once here (with the same defensive fallback the post-run
699
+ * auto-release check below used to apply itself) and reused for that check,
700
+ * rather than loaded a second time.
672
701
  */
673
702
  export async function runOpComponents(ctx: CommandContext): Promise<number> {
674
703
  const selector = ctx.args.path;
@@ -680,7 +709,18 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
680
709
  return 1;
681
710
  }
682
711
 
683
- if (ctx.args.temporal) return runComponentTemporal(ctx, selector);
712
+ const projectPath = resolve(".");
713
+ const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
714
+ const paramsResolution = resolveCliBuildParams(config.buildParams, {
715
+ cli: parseParamFlags(ctx.args.param),
716
+ paramsFile: ctx.args.paramsFile,
717
+ });
718
+ if (!paramsResolution.success) {
719
+ for (const message of paramsResolution.errors) console.error(message);
720
+ return 1;
721
+ }
722
+
723
+ if (ctx.args.temporal) return runComponentTemporal(ctx, selector, config, paramsResolution.provenance);
684
724
 
685
725
  const env = ctx.args.env ?? "local";
686
726
  // Seed cross-component/cross-stack outputs from upstream jobs' dumped files
@@ -704,11 +744,12 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
704
744
  // `undefined` and every `onProgress?.(...)` call in the driver is a no-op —
705
745
  // behavior is byte-for-byte unchanged from before this flag existed.
706
746
  const onProgress = ctx.args.progressJson ? ndjsonProgressSink() : undefined;
707
- const result = await runComponents(resolve("."), selector, {
747
+ const result = await runComponents(projectPath, selector, {
708
748
  env: ctx.args.env,
709
749
  componentOutputs: seededOutputs,
710
750
  onProgress,
711
751
  sandbox: ctx.args.sandbox,
752
+ buildParams: paramsResolution.provenance,
712
753
  });
713
754
 
714
755
  // Dump the accumulated outputs for a downstream job to seed from. Written
@@ -739,7 +780,6 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
739
780
  }
740
781
 
741
782
  if (result.success && result.run) {
742
- const { config } = await loadChantConfig(resolve(".")).catch(() => ({ config: {} }));
743
783
  const disabled = resolveAutoReleaseDisabled(config, ctx.args.noReleaseRecord);
744
784
  await recordAutoReleasesForRun(result.run.results, env, `local-${Date.now()}`, disabled);
745
785
  }
@@ -771,8 +811,19 @@ function componentWorkflowId(componentName: string): string {
771
811
  * crash and clearable via `chant run signal <name> <signal> --components
772
812
  * --temporal` (`runOpSignal` above, extended for components alongside this
773
813
  * issue).
814
+ *
815
+ * `config`/`buildParams` are resolved once by the caller (`runOpComponents`,
816
+ * chant #1108) BEFORE this function runs, so `resolveComponentTargets`
817
+ * below — which discovers/imports the target `*.component.ts` file — sees
818
+ * `params.*` (`@intentius/chant/params`) already populated, the same
819
+ * guarantee the local executor path gets.
774
820
  */
775
- async function runComponentTemporal(ctx: CommandContext, selector: string): Promise<number> {
821
+ async function runComponentTemporal(
822
+ ctx: CommandContext,
823
+ selector: string,
824
+ config: ChantConfig,
825
+ buildParams: BuildParamProvenance[],
826
+ ): Promise<number> {
776
827
  if (selector === "all") {
777
828
  console.error(formatError({
778
829
  message: "`chant run --components all --temporal` is not supported",
@@ -782,15 +833,17 @@ async function runComponentTemporal(ctx: CommandContext, selector: string): Prom
782
833
  }
783
834
 
784
835
  const projectPath = resolve(".");
785
- const resolved = await resolveComponentTargets(projectPath, selector, ctx.args.sandbox);
836
+ const resolved = await resolveComponentTargets(projectPath, selector, ctx.args.sandbox, buildParams);
786
837
  if (!resolved.success || resolved.targets.length === 0) {
787
838
  console.error(formatError({ message: resolved.error ?? `Component "${selector}" not found` }));
788
839
  return 1;
789
840
  }
790
841
  const component = resolved.targets[0];
791
842
 
792
- // Load config + profile (mirrors runOpTemporal).
793
- const { config: chantConfig } = await loadChantConfig(projectPath);
843
+ // Config + profile (mirrors runOpTemporal). `config` was already loaded by
844
+ // the caller to resolve build-time parameters (chant #1108); reused here
845
+ // rather than loading chant.config.ts a second time.
846
+ const chantConfig = config;
794
847
  let profile;
795
848
  try {
796
849
  profile = resolveProfile(chantConfig as Record<string, unknown>, ctx.args.profile);
@@ -1,4 +1,4 @@
1
- import type { LexiconPlugin } from "../../lexicon";
1
+ import type { LexiconPlugin, IntrinsicDef } from "../../lexicon";
2
2
  import type { CompletionContext, HoverContext, CodeActionContext } from "../../lsp/types";
3
3
  import { computeCapabilities } from "./capabilities";
4
4
  import { toLspDiagnostics } from "./diagnostics";
@@ -356,13 +356,18 @@ export class LspServer {
356
356
  try {
357
357
  const { runLint } = await import("../../lint/engine");
358
358
  const rules = [];
359
+ // chant #1106 — the same active plugins' registered intrinsics, so
360
+ // EVL001 answers exactly like `fold()` does for a registered,
361
+ // opted-in call instead of flagging every call as a violation.
362
+ const intrinsics: IntrinsicDef[] = [];
359
363
  for (const plugin of this.plugins) {
360
364
  rules.push(...(plugin.lintRules?.() ?? []));
365
+ intrinsics.push(...(plugin.intrinsics?.() ?? []));
361
366
  }
362
367
 
363
368
  if (rules.length === 0) return [];
364
369
 
365
- const { diagnostics } = await runLint([filePath], rules);
370
+ const { diagnostics } = await runLint([filePath], rules, undefined, intrinsics);
366
371
  return toLspDiagnostics(diagnostics);
367
372
  } catch {
368
373
  return [];
@@ -270,6 +270,29 @@ describe("parseArgs", () => {
270
270
  const result = parseArgs(["build", "src", "--params-file", "./params.json"]);
271
271
  expect(result.paramsFile).toBe("./params.json");
272
272
  });
273
+
274
+ // ── --param=name=value hard error (chant #1118) ──────────────────────────
275
+ // The joined `--flag=value` form is not supported anywhere in this parser
276
+ // (see "ignores unknown flags" above) — a dropped --param can silently
277
+ // change what a build measures/deploys, so this form is rejected loudly
278
+ // instead of silently accepted as a no-op.
279
+
280
+ test("--param=name=value throws instead of silently dropping", () => {
281
+ expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param=tier=production/);
282
+ });
283
+
284
+ test("--param=name=value error names the working form", () => {
285
+ expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param name=value/);
286
+ });
287
+
288
+ test("--param= (empty value) also throws", () => {
289
+ expect(() => parseArgs(["build", "src", "--param="])).toThrow(/--param=/);
290
+ });
291
+
292
+ test("plain --param name=value is unaffected", () => {
293
+ const result = parseArgs(["build", "src", "--param", "tier=production"]);
294
+ expect(result.param).toEqual(["tier=production"]);
295
+ });
273
296
  });
274
297
 
275
298
  // ── resolveCommand tests ──────────────────────────────────────────
package/src/cli/main.ts CHANGED
@@ -5,7 +5,7 @@ import { isEntryPoint } from "./is-entry-point";
5
5
  import { formatSuccess, formatError } from "./format";
6
6
  import { loadPlugins, resolveProjectLexicons } from "./plugins";
7
7
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
8
- import { loadChantConfig } from "../config";
8
+ import { loadChantConfigUpward } from "../config";
9
9
  import { ENV_VAR, unknownEnvError } from "../env";
10
10
  import { initRuntime } from "../runtime-adapter";
11
11
  import { runBuild } from "./handlers/build";
@@ -224,6 +224,16 @@ export function parseArgs(args: string[]): ParsedArgs {
224
224
  result.sandbox = true;
225
225
  } else if (arg === "--param") {
226
226
  (result.param ??= []).push(args[++i]);
227
+ } else if (arg.startsWith("--param=")) {
228
+ // chant #1118 — this parser never supports an `--flag=value` joined
229
+ // form for any value-taking flag (every branch above is an exact `===`
230
+ // match, so a joined token falls through unrecognized and is silently
231
+ // dropped — see the "ignores unknown flags" case below). `--param
232
+ // name=value` (space-separated) is the only accepted form. Rather than
233
+ // teach the parser joined forms generally, `--param=name=value` is
234
+ // called out as a hard error instead of a silent no-op: a dropped
235
+ // `--param` can silently change what a build measures/deploys.
236
+ throw new Error(`${arg} is not supported. Use --param name=value (space-separated) instead.`);
227
237
  } else if (arg === "--params-file") {
228
238
  result.paramsFile = args[++i];
229
239
  } else if (!arg.startsWith("-")) {
@@ -588,11 +598,15 @@ async function main(): Promise<void> {
588
598
  // chant.config itself may branch on the env. (#505)
589
599
  if (args.env) process.env[ENV_VAR] = args.env;
590
600
 
591
- // Initialize runtime adapter early — before plugins or commands run
601
+ // Initialize runtime adapter early — before plugins or commands run.
602
+ // chant #1117 — walks up from `args.path` to the project root: for a
603
+ // subdirectory build/command (`chant build src/<stack> --env prod`) the
604
+ // declared `environments` almost always live in the root `chant.config.ts`,
605
+ // not `args.path` itself.
592
606
  const projectPath0 = resolve(args.path === "." ? "." : args.path);
593
607
  let loadedConfig;
594
608
  try {
595
- loadedConfig = await loadChantConfig(projectPath0);
609
+ loadedConfig = await loadChantConfigUpward(projectPath0);
596
610
  initRuntime();
597
611
  } catch {
598
612
  // Config may not exist yet (e.g. `chant init`)
@@ -1,5 +1,5 @@
1
1
  import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
2
- import { loadChantConfig } from "../config";
2
+ import { loadChantConfigUpward } from "../config";
3
3
  import { findInfraFiles, detectLexicons } from "../index";
4
4
  import { checkConflicts } from "./conflict-check";
5
5
 
@@ -96,9 +96,17 @@ export async function loadPlugins(lexiconNames: string[]): Promise<LexiconPlugin
96
96
  * that never needed live module exports to begin with. This is strictly
97
97
  * better than routing the detection through the sandbox: it removes the
98
98
  * execution entirely rather than containing it, at no bundling/spawn cost.
99
+ *
100
+ * chant #1117 — walks up from `projectPath` to the project root
101
+ * (`loadChantConfigUpward`) rather than reading `projectPath` alone: `chant
102
+ * build src/<stack>` calls this with the stack's own subdirectory, and a
103
+ * project that declares `lexicons` only in its root `chant.config.ts` (never
104
+ * detectable by `detectLexicons()`'s import scan alone, e.g. a lexicon that's
105
+ * loaded but never imported by name in that particular stack's files) would
106
+ * otherwise silently miss it.
99
107
  */
100
108
  export async function resolveProjectLexicons(projectPath: string): Promise<string[]> {
101
- const { config } = await loadChantConfig(projectPath);
109
+ const { config } = await loadChantConfigUpward(projectPath);
102
110
 
103
111
  if (config.lexicons && config.lexicons.length > 0) {
104
112
  return config.lexicons;
@@ -8,16 +8,45 @@
8
8
  * rather than going through the CLI arg-parsing/handler dispatch layer.
9
9
  */
10
10
 
11
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
11
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
12
12
  import { mkdir, writeFile, rm } from "node:fs/promises";
13
- import { join } from "node:path";
13
+ import { join, dirname, resolve as resolvePath } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { tmpdir } from "node:os";
15
16
  import Ajv2020 from "ajv/dist/2020";
16
17
  import componentSchema from "./component.schema.json";
17
- import { listComponents, describeComponent, computeComponentGraph, runComponents, findComponentGate } from "./cli-support";
18
+ import {
19
+ listComponents,
20
+ describeComponent,
21
+ computeComponentGraph,
22
+ runComponents,
23
+ findComponentGate,
24
+ resolveComponentTargets,
25
+ generateComponentsPipeline,
26
+ } from "./cli-support";
18
27
  import { CapabilityRegistry, type DeployContext } from "./capability";
19
28
  import type { DriverComponent, RunProgressEvent } from "./driver";
20
29
 
30
+ // A minimal stand-in for the real gitlab lexicon plugin, satisfying
31
+ // `isLexiconPlugin` (../lexicon.ts) — used only by the `generateComponentsPipeline`
32
+ // buildParams test below, so that test doesn't depend on how `@intentius/
33
+ // chant-lexicon-gitlab`'s own real module resolves under the test runner.
34
+ vi.mock("@intentius/chant-lexicon-gitlab", () => ({
35
+ gitlab: {
36
+ name: "gitlab",
37
+ serializer: { name: "gitlab", rulePrefix: "GL", serialize: () => "" },
38
+ generate: () => {},
39
+ validate: () => [],
40
+ coverage: () => ({ total: 0, covered: 0 }),
41
+ package: () => "gitlab",
42
+ generateComponentPipeline: (components: DriverComponent[]) => ({
43
+ yaml: components.map((c) => c.name).join(","),
44
+ stages: ["wave-1"],
45
+ jobs: components.map((c) => ({ jobName: c.name, component: c.name, stage: "wave-1", needs: [] })),
46
+ }),
47
+ },
48
+ }));
49
+
21
50
  describe("listComponents", () => {
22
51
  let testDir: string;
23
52
 
@@ -578,6 +607,195 @@ describe("runComponents", () => {
578
607
  });
579
608
  });
580
609
 
610
+ // ── build-time parameters (chant #1108) ──────────────────────────────────────
611
+ //
612
+ // Before #1108, `discoverComponents`'s callers here never resolved
613
+ // `chant.config.ts`'s declared `buildParams` at all, so a `*.component.ts`
614
+ // file reading `params.<name>` (`@intentius/chant/params`) always saw `{}` —
615
+ // the exact probe from chant#1108's reproduction. These tests lock in that
616
+ // `runComponents`/`resolveComponentTargets`/`generateComponentsPipeline` now
617
+ // forward an already-resolved `buildParams` into discovery, so a component's
618
+ // `params.<name>` read reflects it before any step dispatches.
619
+
620
+ const thisDir = dirname(fileURLToPath(import.meta.url));
621
+ const paramsModulePath = resolvePath(thisDir, "../params");
622
+
623
+ describe("runComponents — buildParams (chant #1108)", () => {
624
+ let testDir: string;
625
+
626
+ beforeEach(async () => {
627
+ testDir = join(tmpdir(), `chant-run-components-buildparams-test-${Date.now()}-${Math.random()}`);
628
+ await mkdir(testDir, { recursive: true });
629
+ });
630
+
631
+ afterEach(async () => {
632
+ await rm(testDir, { recursive: true, force: true });
633
+ });
634
+
635
+ test("the previously-{} case: a component's params.<name> read resolves when buildParams is passed", async () => {
636
+ await writeFile(
637
+ join(testDir, "svc.component.ts"),
638
+ `
639
+ import { params } from ${JSON.stringify(paramsModulePath)};
640
+ export const svc = {
641
+ name: "svc",
642
+ dependsOn: [],
643
+ deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing", tier: params.tier }] }],
644
+ };
645
+ `,
646
+ );
647
+
648
+ const capability = fakeCapability("deploy-thing");
649
+ const registry = new CapabilityRegistry();
650
+ registry.register(capability);
651
+
652
+ const result = await runComponents(testDir, "svc", {
653
+ registry,
654
+ buildParams: [{ name: "tier", value: "production", source: "cli" }],
655
+ });
656
+
657
+ expect(result.success).toBe(true);
658
+ expect(capability.calls[0]?.input).toMatchObject({ tier: "production" });
659
+ });
660
+
661
+ test("without buildParams, params.<name> is undefined (the bug this issue fixes, still true for a caller that resolves none)", async () => {
662
+ await writeFile(
663
+ join(testDir, "svc.component.ts"),
664
+ `
665
+ import { params } from ${JSON.stringify(paramsModulePath)};
666
+ export const svc = {
667
+ name: "svc",
668
+ dependsOn: [],
669
+ deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing", tier: params.tier ?? "unset" }] }],
670
+ };
671
+ `,
672
+ );
673
+
674
+ const capability = fakeCapability("deploy-thing");
675
+ const registry = new CapabilityRegistry();
676
+ registry.register(capability);
677
+
678
+ const result = await runComponents(testDir, "svc", { registry });
679
+
680
+ expect(result.success).toBe(true);
681
+ expect(capability.calls[0]?.input).toMatchObject({ tier: "unset" });
682
+ });
683
+
684
+ test("RunComponentsResult.buildParams echoes back the resolved provenance that was passed in", async () => {
685
+ await writeFile(
686
+ join(testDir, "svc.component.ts"),
687
+ `export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }] };`,
688
+ );
689
+
690
+ const registry = fakeRegistry();
691
+ const result = await runComponents(testDir, "svc", {
692
+ registry,
693
+ buildParams: [{ name: "tier", value: "production", source: "cli" }],
694
+ });
695
+
696
+ expect(result.buildParams).toEqual([{ name: "tier", value: "production", source: "cli" }]);
697
+ });
698
+
699
+ test("selector 'all' also forwards buildParams into discovery", async () => {
700
+ await writeFile(
701
+ join(testDir, "svc.component.ts"),
702
+ `
703
+ import { params } from ${JSON.stringify(paramsModulePath)};
704
+ export const svc = { name: "svc", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing", tier: params.tier }] }] };
705
+ `,
706
+ );
707
+
708
+ const capability = fakeCapability("deploy-thing");
709
+ const registry = new CapabilityRegistry();
710
+ registry.register(capability);
711
+
712
+ const result = await runComponents(testDir, "all", {
713
+ registry,
714
+ buildParams: [{ name: "tier", value: "staging", source: "env" }],
715
+ });
716
+
717
+ expect(result.success).toBe(true);
718
+ expect(capability.calls[0]?.input).toMatchObject({ tier: "staging" });
719
+ });
720
+ });
721
+
722
+ describe("resolveComponentTargets — buildParams (chant #1108)", () => {
723
+ let testDir: string;
724
+
725
+ beforeEach(async () => {
726
+ testDir = join(tmpdir(), `chant-resolve-targets-buildparams-test-${Date.now()}-${Math.random()}`);
727
+ await mkdir(testDir, { recursive: true });
728
+ });
729
+
730
+ afterEach(async () => {
731
+ await rm(testDir, { recursive: true, force: true });
732
+ });
733
+
734
+ test("forwards buildParams into discovery — the durable (--temporal) path's entrypoint", async () => {
735
+ await writeFile(
736
+ join(testDir, "svc.component.ts"),
737
+ `
738
+ import { params } from ${JSON.stringify(paramsModulePath)};
739
+ export const svc = {
740
+ name: "svc",
741
+ dependsOn: [],
742
+ deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: params.env }] }],
743
+ };
744
+ `,
745
+ );
746
+
747
+ const result = await resolveComponentTargets(testDir, "svc", undefined, [
748
+ { name: "env", value: "prod-a", source: "env" },
749
+ ]);
750
+
751
+ expect(result.success).toBe(true);
752
+ expect((result.targets[0].deploy[0].steps[0] as { stack?: unknown }).stack).toBe("prod-a");
753
+ });
754
+ });
755
+
756
+ describe("generateComponentsPipeline — buildParams (chant #1108)", () => {
757
+ let testDir: string;
758
+
759
+ beforeEach(async () => {
760
+ testDir = join(tmpdir(), `chant-generate-components-buildparams-test-${Date.now()}-${Math.random()}`);
761
+ await mkdir(testDir, { recursive: true });
762
+ });
763
+
764
+ afterEach(async () => {
765
+ await rm(testDir, { recursive: true, force: true });
766
+ });
767
+
768
+ test("forwards buildParams into discovery before synthesizing the pipeline", async () => {
769
+ await writeFile(
770
+ join(testDir, "svc.component.ts"),
771
+ `
772
+ import { params } from ${JSON.stringify(paramsModulePath)};
773
+ export const svc = {
774
+ name: String(params.name),
775
+ dependsOn: [],
776
+ deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }],
777
+ };
778
+ `,
779
+ );
780
+
781
+ // Uses the mocked "gitlab" plugin above (see the `vi.mock` at the top of
782
+ // this file) — proves the component name `discoverComponents` resolved
783
+ // (via `params.name`) made it all the way through to the synthesized
784
+ // pipeline, not just to `discover()`'s own return value.
785
+ const result = await generateComponentsPipeline(
786
+ testDir,
787
+ "gitlab",
788
+ undefined,
789
+ undefined,
790
+ [{ name: "name", value: "named-from-params", source: "cli" }],
791
+ );
792
+
793
+ expect(result.success).toBe(true);
794
+ expect(result.jobs?.map((j) => j.component)).toEqual(["named-from-params"]);
795
+ expect(result.buildParams).toEqual([{ name: "name", value: "named-from-params", source: "cli" }]);
796
+ });
797
+ });
798
+
581
799
  describe("runComponents — onProgress (--progress-json wiring, M3)", () => {
582
800
  let testDir: string;
583
801
 
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { discoverComponents } from "./discover";
26
+ import type { BuildParamProvenance } from "../provenance";
26
27
  import { projectToJson, type Archetype } from "./component";
27
28
  import {
28
29
  resolveComponentGraph,
@@ -185,6 +186,8 @@ export interface GenerateComponentsResult {
185
186
  /** Every generated job, for a machine-readable view (`--format json`). */
186
187
  jobs?: Array<{ jobName: string; component: string; stage: string; needs: string[] }>;
187
188
  error?: string;
189
+ /** This invocation's resolved build-time parameters (chant #1108) — the generate-mode counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Empty when the project declares/supplies none. */
190
+ buildParams?: BuildParamProvenance[];
188
191
  }
189
192
 
190
193
  /**
@@ -221,6 +224,15 @@ export async function generateComponentsPipeline(
221
224
  lexicon: GenerateLexicon,
222
225
  options?: ComponentPipelineOptions,
223
226
  sandbox?: boolean,
227
+ /**
228
+ * chant #1108 — this invocation's resolved build-time parameter values
229
+ * (../cli/handlers/build.ts resolves them, the same sequence `chant build`
230
+ * runs, BEFORE calling this function), forwarded into discovery so a
231
+ * `params.<name>` reference inside a discovered `*.component.ts` file
232
+ * resolves instead of reading `{}`. See `discoverComponents`'s
233
+ * `buildParams` option (./discover.ts) for the full doc.
234
+ */
235
+ buildParams?: BuildParamProvenance[],
224
236
  ): Promise<GenerateComponentsResult> {
225
237
  const plugin = await loadLexiconPlugin(lexicon);
226
238
  if (!plugin?.generateComponentPipeline) {
@@ -230,7 +242,7 @@ export async function generateComponentsPipeline(
230
242
  };
231
243
  }
232
244
 
233
- const result = await discoverComponents(path, { sandbox });
245
+ const result = await discoverComponents(path, { sandbox, buildParams });
234
246
  if (result.errors.length > 0) {
235
247
  return { success: false, error: result.errors.map((e) => e.message).join("\n") };
236
248
  }
@@ -243,7 +255,7 @@ export async function generateComponentsPipeline(
243
255
 
244
256
  try {
245
257
  const { yaml, stages, jobs } = plugin.generateComponentPipeline(driverComponents, options);
246
- return { success: true, yaml, stages, jobs };
258
+ return { success: true, yaml, stages, jobs, buildParams };
247
259
  } catch (err) {
248
260
  if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
249
261
  return { success: false, error: err.message };
@@ -344,6 +356,21 @@ export interface RunComponentsOptions {
344
356
  * `--progress-json`, in which case nothing changes.
345
357
  */
346
358
  onProgress?: (event: RunProgressEvent) => void;
359
+ /**
360
+ * chant #1108 — this run's resolved build-time parameter values, resolved
361
+ * the exact same way `chant build` resolves them
362
+ * (../cli/build-params-cli.ts's `resolveCliBuildParams`, driven by
363
+ * `--param`/`--params-file`/a declared `env` mapping/`chant.config.ts`'s
364
+ * `buildParams` defaults). The CLI handler (../cli/handlers/run.ts)
365
+ * resolves + logs these BEFORE calling `runComponents`, the same
366
+ * sequencing `chant build` uses — this function only forwards the
367
+ * already-resolved values into discovery (`resolveComponentTargets` below,
368
+ * then `discoverComponents`); it does not resolve them itself, so
369
+ * `runComponents` stays free of CLI-flag-parsing/formatting concerns.
370
+ * Default: none — `params.*` stays `{}`, matching every caller (including
371
+ * every test in `cli-support.test.ts`) that doesn't supply this.
372
+ */
373
+ buildParams?: BuildParamProvenance[];
347
374
  }
348
375
 
349
376
  /** Result of `chant run --components <name|all>`. */
@@ -356,6 +383,8 @@ export interface RunComponentsResult {
356
383
  error?: string;
357
384
  /** Set when a selected component (or one of its `deploy`/`rollback` phases) contains a `gate` the local executor cannot run. */
358
385
  gateUnsupported?: { component: string; signalName: string };
386
+ /** This run's resolved build-time parameters (chant #1108) — the component-driver counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Present only once the run actually reached dispatch (mirrors `BuildResult.buildParams`, which is likewise absent on an early-error return). */
387
+ buildParams?: BuildParamProvenance[];
359
388
  }
360
389
 
361
390
  /**
@@ -404,8 +433,10 @@ export async function resolveComponentTargets(
404
433
  path: string,
405
434
  selector: string,
406
435
  sandbox?: boolean,
436
+ /** chant #1108 — this invocation's resolved build-time parameter values, forwarded into `discoverComponents` so a `params.<name>` reference inside a discovered `*.component.ts` file resolves instead of reading `{}`. See `discoverComponents`'s `buildParams` option (./discover.ts). */
437
+ buildParams?: BuildParamProvenance[],
407
438
  ): Promise<ResolvedComponentTargets> {
408
- const result = await discoverComponents(path, { sandbox });
439
+ const result = await discoverComponents(path, { sandbox, buildParams });
409
440
  if (result.errors.length > 0) {
410
441
  return { success: false, targets: [], error: result.errors.map((e) => e.message).join("\n") };
411
442
  }
@@ -433,7 +464,7 @@ export async function runComponents(
433
464
  selector: string,
434
465
  options: RunComponentsOptions = {},
435
466
  ): Promise<RunComponentsResult> {
436
- const resolved = await resolveComponentTargets(path, selector, options.sandbox);
467
+ const resolved = await resolveComponentTargets(path, selector, options.sandbox, options.buildParams);
437
468
  if (!resolved.success) {
438
469
  return { success: false, selected: [], error: resolved.error };
439
470
  }
@@ -479,7 +510,7 @@ export async function runComponents(
479
510
  try {
480
511
  if (selector === "all") {
481
512
  const run = await runInterpretDriver(resolvedTargets, registry, { env, componentOutputs: seedOutputs, onProgress });
482
- return { success: true, run, selected };
513
+ return { success: true, run, selected, buildParams: options.buildParams };
483
514
  }
484
515
 
485
516
  // Single-component invocation: run just this component, bypassing
@@ -518,7 +549,7 @@ export async function runComponents(
518
549
  failedComponent: componentResult.ok ? undefined : componentResult.component,
519
550
  componentOutputs,
520
551
  };
521
- return { success: componentResult.ok, run, selected };
552
+ return { success: componentResult.ok, run, selected, buildParams: options.buildParams };
522
553
  } catch (err) {
523
554
  if (err instanceof DriverRunFailure) {
524
555
  return { success: false, run: err.result, selected, error: err.message };