@intentius/chant 0.22.0 → 0.23.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 (47) 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 +14 -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/components/cli-support.d.ts +33 -2
  10. package/dist/components/cli-support.d.ts.map +1 -1
  11. package/dist/components/discover.d.ts +28 -0
  12. package/dist/components/discover.d.ts.map +1 -1
  13. package/dist/discovery/fold-import.d.ts +38 -8
  14. package/dist/discovery/fold-import.d.ts.map +1 -1
  15. package/dist/discovery/index.d.ts +15 -4
  16. package/dist/discovery/index.d.ts.map +1 -1
  17. package/dist/fold/subset.d.ts +16 -2
  18. package/dist/fold/subset.d.ts.map +1 -1
  19. package/dist/lint/engine.d.ts +11 -1
  20. package/dist/lint/engine.d.ts.map +1 -1
  21. package/dist/lint/rule.d.ts +14 -0
  22. package/dist/lint/rule.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/cli/build-params-cli.test.ts +139 -0
  25. package/src/cli/build-params-cli.ts +107 -0
  26. package/src/cli/commands/build.ts +16 -36
  27. package/src/cli/commands/lint.test.ts +74 -0
  28. package/src/cli/commands/lint.ts +33 -9
  29. package/src/cli/handlers/build.test.ts +147 -0
  30. package/src/cli/handlers/build.ts +23 -8
  31. package/src/cli/handlers/run.test.ts +160 -5
  32. package/src/cli/handlers/run.ts +46 -8
  33. package/src/cli/lsp/server.ts +7 -2
  34. package/src/components/cli-support.test.ts +221 -3
  35. package/src/components/cli-support.ts +37 -6
  36. package/src/components/discover.test.ts +63 -1
  37. package/src/components/discover.ts +42 -0
  38. package/src/discovery/fold-import.ts +202 -20
  39. package/src/discovery/index.test.ts +131 -0
  40. package/src/discovery/index.ts +38 -8
  41. package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
  42. package/src/fold/subset.test.ts +28 -14
  43. package/src/fold/subset.ts +16 -2
  44. package/src/lint/engine.ts +12 -0
  45. package/src/lint/rule.ts +14 -0
  46. package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
  47. package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
@@ -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 { loadChantConfig, 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,30 @@ 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.
15
26
  */
16
27
  async function runGenerateComponents(ctx: CommandContext): Promise<number> {
17
28
  const { args } = ctx;
18
29
  const lexicon = args.generate as string;
19
30
 
31
+ const { config } = await loadChantConfig(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
32
+ const paramsResolution = resolveCliBuildParams(config.buildParams, {
33
+ cli: parseParamFlags(args.param),
34
+ paramsFile: args.paramsFile,
35
+ });
36
+ if (!paramsResolution.success) {
37
+ for (const message of paramsResolution.errors) console.error(message);
38
+ return 1;
39
+ }
40
+
20
41
  // Which lexicons support generate mode is a property of the loaded lexicon
21
42
  // plugins (those implementing `generateComponentPipeline`, #688), not a
22
43
  // hard-coded core list — `generateComponentsPipeline` returns a descriptive
@@ -26,6 +47,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
26
47
  lexicon,
27
48
  { env: args.env },
28
49
  args.sandbox,
50
+ paramsResolution.provenance,
29
51
  );
30
52
 
31
53
  if (!result.success) {
@@ -87,14 +109,7 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
87
109
  }
88
110
 
89
111
  // #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;
112
+ const params = parseParamFlags(args.param);
98
113
 
99
114
  if (args.watch) {
100
115
  const cleanup = buildCommandWatch({
@@ -878,7 +878,7 @@ 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
  });
@@ -900,6 +900,87 @@ describe("runOpComponents", () => {
900
900
  expect(runComponentsMock).not.toHaveBeenCalled();
901
901
  });
902
902
 
903
+ // ── build-time parameters (chant #1108) — resolved BEFORE dispatch ────────
904
+
905
+ describe("build-time parameters", () => {
906
+ test("chant.config.ts's declared buildParams resolve and log before dispatching to runComponents", async () => {
907
+ loadChantConfigMock.mockResolvedValue({
908
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
909
+ });
910
+ runComponentsMock.mockResolvedValue({
911
+ success: true,
912
+ selected: ["svc"],
913
+ run: { order: ["svc"], waves: [["svc"]], results: [{ component: "svc", ok: true, records: [] }], ok: true },
914
+ });
915
+ const stderr = makeStderrSpy();
916
+
917
+ const exit = await runOpComponents({ args: makeArgs({ path: "svc", temporal: false }), plugins: [], serializers: [] });
918
+
919
+ expect(exit).toBe(0);
920
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
921
+ buildParams: [{ name: "tier", value: "light", source: "default" }],
922
+ }));
923
+ expect(stderr.join("\n")).toContain("[param] tier");
924
+ });
925
+
926
+ test("--param overrides a declared default and is threaded through to runComponents", async () => {
927
+ loadChantConfigMock.mockResolvedValue({
928
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
929
+ });
930
+ runComponentsMock.mockResolvedValue({
931
+ success: true,
932
+ selected: ["svc"],
933
+ run: { order: ["svc"], waves: [["svc"]], results: [{ component: "svc", ok: true, records: [] }], ok: true },
934
+ });
935
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
936
+ const stderr = makeStderrSpy();
937
+
938
+ const exit = await runOpComponents({
939
+ args: makeArgs({ path: "svc", temporal: false, param: ["tier=production"] }),
940
+ plugins: [],
941
+ serializers: [],
942
+ });
943
+
944
+ expect(exit).toBe(0);
945
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
946
+ buildParams: [{ name: "tier", value: "production", source: "cli" }],
947
+ }));
948
+ expect(stderr.join("\n")).toContain("[param] tier");
949
+ vi.restoreAllMocks();
950
+ });
951
+
952
+ 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 () => {
953
+ loadChantConfigMock.mockResolvedValue({
954
+ config: { buildParams: { tier: { type: "string" } } },
955
+ });
956
+ const stderr = makeStderrSpy();
957
+
958
+ const exit = await runOpComponents({ args: makeArgs({ path: "svc", temporal: false }), plugins: [], serializers: [] });
959
+
960
+ expect(exit).toBe(1);
961
+ expect(stderr.join("\n")).toMatch(/"tier"/);
962
+ expect(runComponentsMock).not.toHaveBeenCalled();
963
+ });
964
+
965
+ test("an enum violation on --param → exit 1 with a formatted error, never reaches runComponents", async () => {
966
+ loadChantConfigMock.mockResolvedValue({
967
+ config: { buildParams: { tier: { type: "string", enum: ["light", "production"] } } },
968
+ });
969
+ const stderr = makeStderrSpy();
970
+
971
+ const exit = await runOpComponents({
972
+ args: makeArgs({ path: "svc", temporal: false, param: ["tier=bogus"] }),
973
+ plugins: [],
974
+ serializers: [],
975
+ });
976
+
977
+ expect(exit).toBe(1);
978
+ expect(stderr.join("\n")).toMatch(/"tier"/);
979
+ expect(stderr.join("\n")).toMatch(/bogus/);
980
+ expect(runComponentsMock).not.toHaveBeenCalled();
981
+ });
982
+ });
983
+
903
984
  test("happy path: single component, human output, exit 0", async () => {
904
985
  runComponentsMock.mockResolvedValue({
905
986
  success: true,
@@ -916,7 +997,7 @@ describe("runOpComponents", () => {
916
997
  const exit = await runOpComponents({ args: makeArgs({ path: "svc", temporal: false }), plugins: [], serializers: [] });
917
998
 
918
999
  expect(exit).toBe(0);
919
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: undefined, componentOutputs: {} });
1000
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: undefined, componentOutputs: {}, buildParams: [] });
920
1001
  const printed = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
921
1002
  expect(printed).toContain("interpret run completed");
922
1003
  vi.restoreAllMocks();
@@ -928,7 +1009,7 @@ describe("runOpComponents", () => {
928
1009
 
929
1010
  await runOpComponents({ args: makeArgs({ path: "svc", env: "staging", temporal: false }), plugins: [], serializers: [] });
930
1011
 
931
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: "staging", componentOutputs: {} });
1012
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", { env: "staging", componentOutputs: {}, buildParams: [] });
932
1013
  vi.restoreAllMocks();
933
1014
  });
934
1015
 
@@ -994,6 +1075,7 @@ describe("runOpComponents", () => {
994
1075
  env: undefined,
995
1076
  componentOutputs: {},
996
1077
  onProgress: undefined,
1078
+ buildParams: [],
997
1079
  });
998
1080
  expect(stdoutWrite).not.toHaveBeenCalled();
999
1081
  vi.restoreAllMocks();
@@ -1018,7 +1100,7 @@ describe("runOpComponents", () => {
1018
1100
  const exit = await runOpComponents({ args: makeArgs({ path: "all", temporal: false }), plugins: [], serializers: [] });
1019
1101
 
1020
1102
  expect(exit).toBe(0);
1021
- expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "all", { env: undefined, componentOutputs: {} });
1103
+ expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "all", { env: undefined, componentOutputs: {}, buildParams: [] });
1022
1104
  const printed = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
1023
1105
  expect(printed).toContain("shared-alb");
1024
1106
  expect(printed).toContain("search-service");
@@ -1269,7 +1351,13 @@ describe("runOpComponents: --temporal routes to the durable path", () => {
1269
1351
  resolveComponentTargetsMock.mockReset();
1270
1352
  findComponentGateMock.mockReset();
1271
1353
  loadComponentTemporalCodegenMock.mockReset();
1272
- loadChantConfigMock.mockReset();
1354
+ // chant #1108 — runOpComponents now resolves build-time parameters (which
1355
+ // needs chant.config.ts's declared `buildParams`) BEFORE dispatching to
1356
+ // either the local or --temporal path, so every test in this block hits
1357
+ // loadChantConfig at least once now, even ones that never reach the rest
1358
+ // of the durable path (e.g. "unknown component"). Individual tests below
1359
+ // still override this where they care about a specific config shape.
1360
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
1273
1361
  resolveProfileMock.mockReset();
1274
1362
  loadTemporalClientMock.mockReset();
1275
1363
  spawnChildMock.mockReset();
@@ -1297,6 +1385,73 @@ describe("runOpComponents: --temporal routes to the durable path", () => {
1297
1385
  expect(stderr.join("\n")).toContain('Component "missing" not found');
1298
1386
  });
1299
1387
 
1388
+ // ── build-time parameters (chant #1108) — resolved before discovery here too ─
1389
+
1390
+ test("resolved build-time parameters are forwarded into resolveComponentTargets on the --temporal path", async () => {
1391
+ process.env.LOOM_ENV = "staging";
1392
+ resolveComponentTargetsMock.mockResolvedValue({
1393
+ success: true,
1394
+ targets: [{ name: "gated-svc", dependsOn: [], deploy: [] }],
1395
+ });
1396
+ resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
1397
+ loadComponentTemporalCodegenMock.mockResolvedValue({
1398
+ serializeComponent: () => ({ "components/gated-svc/worker.ts": "// worker" }),
1399
+ componentWorkflowFnName: (name: string) => `${name}ComponentWorkflow`,
1400
+ });
1401
+ const mockClient = createMockTemporalClient({
1402
+ describeByWorkflowId: {
1403
+ "chant-component-gated-svc": {
1404
+ workflowId: "chant-component-gated-svc", runId: "r1",
1405
+ status: { name: "COMPLETED" }, startTime: new Date(),
1406
+ taskQueue: "gated-svc", type: { name: "gatedSvcComponentWorkflow" },
1407
+ },
1408
+ },
1409
+ historyByWorkflowId: { "chant-component-gated-svc": [] },
1410
+ });
1411
+ // setupTemporalClient sets its own default loadChantConfigMock resolved
1412
+ // value, so the test's own (buildParams-declaring) config must be set
1413
+ // AFTER calling it, not before.
1414
+ setupTemporalClient(mockClient);
1415
+ loadChantConfigMock.mockResolvedValue({
1416
+ config: { buildParams: { env: { type: "string", env: "LOOM_ENV", default: "dev" } } },
1417
+ });
1418
+ const { proc } = makeFakeChildProcess();
1419
+ spawnChildMock.mockReturnValue(proc);
1420
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1421
+
1422
+ try {
1423
+ vi.useFakeTimers();
1424
+ const promise = runOpComponents({ args: makeArgs({ path: "gated-svc", temporal: true }), plugins: [], serializers: [] });
1425
+ await vi.advanceTimersByTimeAsync(5000);
1426
+ const exit = await promise;
1427
+
1428
+ expect(exit).toBe(0);
1429
+ expect(resolveComponentTargetsMock).toHaveBeenCalledWith(
1430
+ expect.any(String),
1431
+ "gated-svc",
1432
+ undefined,
1433
+ [{ name: "env", value: "staging", source: "env" }],
1434
+ );
1435
+ } finally {
1436
+ vi.useRealTimers();
1437
+ vi.restoreAllMocks();
1438
+ delete process.env.LOOM_ENV;
1439
+ }
1440
+ });
1441
+
1442
+ test("an unresolved required build-time parameter → exit 1, never reaches resolveComponentTargets", async () => {
1443
+ loadChantConfigMock.mockResolvedValue({
1444
+ config: { buildParams: { tier: { type: "string" } } },
1445
+ });
1446
+ const stderr = makeStderrSpy();
1447
+
1448
+ const exit = await runOpComponents({ args: makeArgs({ path: "gated-svc", temporal: true }), plugins: [], serializers: [] });
1449
+
1450
+ expect(exit).toBe(1);
1451
+ expect(stderr.join("\n")).toMatch(/"tier"/);
1452
+ expect(resolveComponentTargetsMock).not.toHaveBeenCalled();
1453
+ });
1454
+
1300
1455
  test("compiles the component, spawns the worker, submits the workflow, polls to COMPLETED", async () => {
1301
1456
  vi.useFakeTimers();
1302
1457
  try {
@@ -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());
@@ -669,6 +671,18 @@ async function recordAutoReleasesForRun(
669
671
  * Opt out with `--no-release-record` or `chant.config.ts`'s
670
672
  * `release.autoRecord: false`; the default is ON. A failed run never reaches
671
673
  * this step, so it writes nothing.
674
+ *
675
+ * chant #1108 — resolves this invocation's declared build-time parameters
676
+ * (`chant.config.ts`'s `buildParams`, against `--param`/`--params-file`/a
677
+ * declared `env` mapping) the exact same way `chant build` does
678
+ * (`resolveCliBuildParams`, shared with `buildCommand`), BEFORE either the
679
+ * local or `--temporal` path discovers/imports any `*.component.ts` file.
680
+ * Before this, `params.*` (`@intentius/chant/params`) was always `{}` under
681
+ * this command, no matter what a component's `chant.config.ts` declared or a
682
+ * CI job's environment supplied — see chant #1108. `chant.config.ts` is
683
+ * loaded once here (with the same defensive fallback the post-run
684
+ * auto-release check below used to apply itself) and reused for that check,
685
+ * rather than loaded a second time.
672
686
  */
673
687
  export async function runOpComponents(ctx: CommandContext): Promise<number> {
674
688
  const selector = ctx.args.path;
@@ -680,7 +694,18 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
680
694
  return 1;
681
695
  }
682
696
 
683
- if (ctx.args.temporal) return runComponentTemporal(ctx, selector);
697
+ const projectPath = resolve(".");
698
+ const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
699
+ const paramsResolution = resolveCliBuildParams(config.buildParams, {
700
+ cli: parseParamFlags(ctx.args.param),
701
+ paramsFile: ctx.args.paramsFile,
702
+ });
703
+ if (!paramsResolution.success) {
704
+ for (const message of paramsResolution.errors) console.error(message);
705
+ return 1;
706
+ }
707
+
708
+ if (ctx.args.temporal) return runComponentTemporal(ctx, selector, config, paramsResolution.provenance);
684
709
 
685
710
  const env = ctx.args.env ?? "local";
686
711
  // Seed cross-component/cross-stack outputs from upstream jobs' dumped files
@@ -704,11 +729,12 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
704
729
  // `undefined` and every `onProgress?.(...)` call in the driver is a no-op —
705
730
  // behavior is byte-for-byte unchanged from before this flag existed.
706
731
  const onProgress = ctx.args.progressJson ? ndjsonProgressSink() : undefined;
707
- const result = await runComponents(resolve("."), selector, {
732
+ const result = await runComponents(projectPath, selector, {
708
733
  env: ctx.args.env,
709
734
  componentOutputs: seededOutputs,
710
735
  onProgress,
711
736
  sandbox: ctx.args.sandbox,
737
+ buildParams: paramsResolution.provenance,
712
738
  });
713
739
 
714
740
  // Dump the accumulated outputs for a downstream job to seed from. Written
@@ -739,7 +765,6 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
739
765
  }
740
766
 
741
767
  if (result.success && result.run) {
742
- const { config } = await loadChantConfig(resolve(".")).catch(() => ({ config: {} }));
743
768
  const disabled = resolveAutoReleaseDisabled(config, ctx.args.noReleaseRecord);
744
769
  await recordAutoReleasesForRun(result.run.results, env, `local-${Date.now()}`, disabled);
745
770
  }
@@ -771,8 +796,19 @@ function componentWorkflowId(componentName: string): string {
771
796
  * crash and clearable via `chant run signal <name> <signal> --components
772
797
  * --temporal` (`runOpSignal` above, extended for components alongside this
773
798
  * issue).
799
+ *
800
+ * `config`/`buildParams` are resolved once by the caller (`runOpComponents`,
801
+ * chant #1108) BEFORE this function runs, so `resolveComponentTargets`
802
+ * below — which discovers/imports the target `*.component.ts` file — sees
803
+ * `params.*` (`@intentius/chant/params`) already populated, the same
804
+ * guarantee the local executor path gets.
774
805
  */
775
- async function runComponentTemporal(ctx: CommandContext, selector: string): Promise<number> {
806
+ async function runComponentTemporal(
807
+ ctx: CommandContext,
808
+ selector: string,
809
+ config: ChantConfig,
810
+ buildParams: BuildParamProvenance[],
811
+ ): Promise<number> {
776
812
  if (selector === "all") {
777
813
  console.error(formatError({
778
814
  message: "`chant run --components all --temporal` is not supported",
@@ -782,15 +818,17 @@ async function runComponentTemporal(ctx: CommandContext, selector: string): Prom
782
818
  }
783
819
 
784
820
  const projectPath = resolve(".");
785
- const resolved = await resolveComponentTargets(projectPath, selector, ctx.args.sandbox);
821
+ const resolved = await resolveComponentTargets(projectPath, selector, ctx.args.sandbox, buildParams);
786
822
  if (!resolved.success || resolved.targets.length === 0) {
787
823
  console.error(formatError({ message: resolved.error ?? `Component "${selector}" not found` }));
788
824
  return 1;
789
825
  }
790
826
  const component = resolved.targets[0];
791
827
 
792
- // Load config + profile (mirrors runOpTemporal).
793
- const { config: chantConfig } = await loadChantConfig(projectPath);
828
+ // Config + profile (mirrors runOpTemporal). `config` was already loaded by
829
+ // the caller to resolve build-time parameters (chant #1108); reused here
830
+ // rather than loading chant.config.ts a second time.
831
+ const chantConfig = config;
794
832
  let profile;
795
833
  try {
796
834
  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 [];
@@ -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