@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
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for the CLI-layer build-time-parameter helpers factored out by chant
3
+ * #1108 (`parseParamFlags`/`resolveCliBuildParams`) — the exact sequence
4
+ * `chant build` (`./commands/build.ts`'s `buildCommand`) runs, now shared with
5
+ * the component deploy driver (`./handlers/run.ts`, `./handlers/build.ts`'s
6
+ * generate mode) so both resolve `--param`/`--params-file`/a declared `env`
7
+ * mapping/`chant.config.ts`'s `buildParams` defaults identically. Precedence
8
+ * itself is `../build-params.ts`'s `resolveBuildParams`'s responsibility
9
+ * (see `../build-params.test.ts`) — these tests cover the CLI-specific glue:
10
+ * flag parsing, `--params-file` reading, error formatting, and logging.
11
+ */
12
+ import { describe, test, expect, vi } from "vitest";
13
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { parseParamFlags, resolveCliBuildParams } from "./build-params-cli";
17
+ import type { BuildParamsConfig } from "../build-params";
18
+
19
+ describe("parseParamFlags", () => {
20
+ test("undefined/empty input yields undefined (matches resolveBuildParams's 'no cli input' shape)", () => {
21
+ expect(parseParamFlags(undefined)).toBeUndefined();
22
+ expect(parseParamFlags([])).toBeUndefined();
23
+ });
24
+
25
+ test("parses repeated name=value flags into a flat record", () => {
26
+ expect(parseParamFlags(["tier=production", "replicas=3"])).toEqual({
27
+ tier: "production",
28
+ replicas: "3",
29
+ });
30
+ });
31
+
32
+ test("a value containing '=' keeps everything after the first '=' (only the first splits)", () => {
33
+ expect(parseParamFlags(["url=https://example.com?a=b"])).toEqual({
34
+ url: "https://example.com?a=b",
35
+ });
36
+ });
37
+
38
+ test("a flag with no '=' becomes an empty-string value", () => {
39
+ expect(parseParamFlags(["flag-only"])).toEqual({ "flag-only": "" });
40
+ });
41
+ });
42
+
43
+ describe("resolveCliBuildParams", () => {
44
+ test("cli beats params-file beats a declared env mapping beats the default — the exact chant build precedence", () => {
45
+ const defs: BuildParamsConfig = { tier: { type: "string", default: "light", env: "TIER_ENV" } };
46
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
47
+ try {
48
+ const result = resolveCliBuildParams(defs, { cli: { tier: "production" } });
49
+ expect(result).toEqual({
50
+ success: true,
51
+ provenance: [{ name: "tier", value: "production", source: "cli" }],
52
+ errors: [],
53
+ });
54
+ } finally {
55
+ errorSpy.mockRestore();
56
+ }
57
+ });
58
+
59
+ test("logs every resolved parameter as '[param] name = value (source)'", () => {
60
+ const defs: BuildParamsConfig = { tier: { type: "string", default: "light" } };
61
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
62
+ try {
63
+ resolveCliBuildParams(defs, {});
64
+ const logged = errorSpy.mock.calls.map((call) => String(call[0]));
65
+ expect(logged.some((line) => line.includes("[param] tier") && line.includes("light") && line.includes("default"))).toBe(true);
66
+ } finally {
67
+ errorSpy.mockRestore();
68
+ }
69
+ });
70
+
71
+ test("no declared params → success with empty provenance and no logging", () => {
72
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
73
+ try {
74
+ const result = resolveCliBuildParams(undefined, {});
75
+ expect(result).toEqual({ success: true, provenance: [], errors: [] });
76
+ expect(errorSpy).not.toHaveBeenCalled();
77
+ } finally {
78
+ errorSpy.mockRestore();
79
+ }
80
+ });
81
+
82
+ test("an unresolved required parameter is a formatted error, not a thrown exception", () => {
83
+ const defs: BuildParamsConfig = { tier: { type: "string" } };
84
+ const result = resolveCliBuildParams(defs, {});
85
+ expect(result.success).toBe(false);
86
+ expect(result.provenance).toEqual([]);
87
+ expect(result.errors.some((e) => e.includes('"tier"') && e.includes("--param"))).toBe(true);
88
+ });
89
+
90
+ test("an enum violation is a formatted error naming the parameter and the offending value", () => {
91
+ const defs: BuildParamsConfig = { tier: { type: "string", enum: ["light", "production"] } };
92
+ const result = resolveCliBuildParams(defs, { cli: { tier: "bogus" } });
93
+ expect(result.success).toBe(false);
94
+ expect(result.errors.some((e) => e.includes('"tier"') && e.includes("bogus"))).toBe(true);
95
+ });
96
+
97
+ test("an unknown --param name is a formatted error", () => {
98
+ const defs: BuildParamsConfig = { tier: { type: "string", default: "light" } };
99
+ const result = resolveCliBuildParams(defs, { cli: { nonexistent: "x" } });
100
+ expect(result.success).toBe(false);
101
+ expect(result.errors.some((e) => e.includes("nonexistent") && e.includes("--param"))).toBe(true);
102
+ });
103
+
104
+ describe("--params-file", () => {
105
+ let dir: string;
106
+
107
+ test("reads and resolves values from a JSON file (second precedence, after --param)", () => {
108
+ dir = mkdtempSync(join(tmpdir(), "chant-build-params-cli-test-"));
109
+ try {
110
+ const file = join(dir, "params.json");
111
+ writeFileSync(file, JSON.stringify({ tier: "from-file", env: "from-file-env" }));
112
+ const defs: BuildParamsConfig = {
113
+ tier: { type: "string", default: "light" },
114
+ env: { type: "string", default: "dev" },
115
+ };
116
+
117
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
118
+ try {
119
+ const result = resolveCliBuildParams(defs, { cli: { tier: "from-cli" }, paramsFile: file });
120
+ expect(result.success).toBe(true);
121
+ const byName = new Map(result.provenance.map((p) => [p.name, p]));
122
+ expect(byName.get("tier")).toEqual({ name: "tier", value: "from-cli", source: "cli" });
123
+ expect(byName.get("env")).toEqual({ name: "env", value: "from-file-env", source: "params-file" });
124
+ } finally {
125
+ errorSpy.mockRestore();
126
+ }
127
+ } finally {
128
+ rmSync(dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+
132
+ test("an unreadable/unparsable --params-file is a formatted error naming the path, not a thrown exception", () => {
133
+ const defs: BuildParamsConfig = { tier: { type: "string", default: "light" } };
134
+ const result = resolveCliBuildParams(defs, { paramsFile: "/nonexistent/path/params.json" });
135
+ expect(result.success).toBe(false);
136
+ expect(result.errors.some((e) => e.includes("--params-file") && e.includes("/nonexistent/path/params.json"))).toBe(true);
137
+ });
138
+ });
139
+ });
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { resolveBuildParams, type BuildParamsConfig } from "../build-params";
4
+ import type { BuildParamProvenance } from "../provenance";
5
+ import { formatError, formatInfo } from "./format";
6
+
7
+ /**
8
+ * Shared CLI-layer wiring for chant #1064's build-time parameters, factored
9
+ * out (chant #1108) so every command that discovers project source before
10
+ * running it resolves `chant.config.ts`'s declared `buildParams` the exact
11
+ * same way: `chant build` (`../commands/build.ts`'s `buildCommand`), the
12
+ * component deploy driver (`./handlers/run.ts`'s `chant run --components`
13
+ * local + `--temporal` paths), and generate mode (`./handlers/build.ts`'s
14
+ * `chant build --components --generate <lexicon>`).
15
+ *
16
+ * Before #1108, only `buildCommand` ran this sequence — `chant run
17
+ * --components` never resolved `--param`/`--params-file`/a declared `env`
18
+ * mapping at all, so a `*.component.ts` file reading `params.<name>`
19
+ * (`@intentius/chant/params`) always saw `{}`, no matter what a CI job
20
+ * exported into the environment. See ../build-params.ts's module doc for the
21
+ * full precedence rules this wraps.
22
+ */
23
+
24
+ /** Parse repeated `--param name=value` flags into a flat `{ name: value }` record — the raw (unvalidated) strings {@link resolveCliBuildParams} resolves against a project's declared `buildParams`. `undefined` when no `--param` flag was given, matching `resolveBuildParams`'s "no cli input" shape. */
25
+ export function parseParamFlags(entries?: string[]): Record<string, string> | undefined {
26
+ if (!entries?.length) return undefined;
27
+ return Object.fromEntries(
28
+ entries.map((entry) => {
29
+ const eq = entry.indexOf("=");
30
+ return eq === -1 ? [entry, ""] : [entry.slice(0, eq), entry.slice(eq + 1)];
31
+ }),
32
+ );
33
+ }
34
+
35
+ /** Inputs {@link resolveCliBuildParams} needs from a parsed CLI invocation — the `--param`/`--params-file` half of {@link resolveBuildParams}'s `BuildParamsInput` (the `env` half is always `process.env`, resolved internally). */
36
+ export interface CliBuildParamsArgs {
37
+ /** Already-parsed `--param name=value` flags (see {@link parseParamFlags}), or the CLI's already-typed `Record<string,string>` (`chant build`'s own flag parsing does this itself — see `./handlers/build.ts`). */
38
+ cli?: Record<string, string>;
39
+ /** `--params-file <path>` — a JSON file of `{ "name": value }` values, read and parsed here. */
40
+ paramsFile?: string;
41
+ }
42
+
43
+ export interface CliBuildParamsResolution {
44
+ success: boolean;
45
+ /** Every successfully resolved parameter. Empty when the project declares none, or when `success` is `false`. */
46
+ provenance: BuildParamProvenance[];
47
+ /** `formatError`-wrapped messages, ready to print as-is. Empty when `success` is `true`. */
48
+ errors: string[];
49
+ }
50
+
51
+ /**
52
+ * Resolve this invocation's declared build-time parameters (`chant.config.ts`'s
53
+ * `buildParams`) against `args`/the process environment, and log each
54
+ * resolved value — the identical resolution + logging sequence `chant build`
55
+ * runs (`../commands/build.ts`'s `buildCommand`), factored out so every other
56
+ * command that discovers project source runs it too (chant #1108).
57
+ *
58
+ * A resolution failure (an unknown `--param`/`--params-file` name, a missing
59
+ * required value, a type/enum mismatch, or an unreadable `--params-file`) is
60
+ * returned as `{ success: false, errors }` — never thrown — so the caller can
61
+ * print each message and exit non-zero exactly like a build error, matching
62
+ * chant #1064's acceptance criterion that this never surfaces as a thrown
63
+ * error from inside user source.
64
+ *
65
+ * On success, every resolved parameter is logged via `console.error` as
66
+ * `[param] <name> = <value> (<source>)` — unconditional, not gated on
67
+ * `--verbose`, so a build's environment-varying inputs are always visible,
68
+ * the same way #1022's fold decisions are.
69
+ */
70
+ export function resolveCliBuildParams(
71
+ buildParamsConfig: BuildParamsConfig | undefined,
72
+ args: CliBuildParamsArgs,
73
+ ): CliBuildParamsResolution {
74
+ const errors: string[] = [];
75
+
76
+ let fromFile: Record<string, unknown> | undefined;
77
+ if (args.paramsFile) {
78
+ try {
79
+ fromFile = JSON.parse(readFileSync(resolve(args.paramsFile), "utf-8"));
80
+ } catch (err) {
81
+ errors.push(
82
+ formatError({
83
+ message: `Failed to read/parse --params-file "${args.paramsFile}": ${err instanceof Error ? err.message : String(err)}`,
84
+ }),
85
+ );
86
+ }
87
+ }
88
+
89
+ const resolution = resolveBuildParams(buildParamsConfig, {
90
+ cli: args.cli,
91
+ fromFile,
92
+ env: process.env,
93
+ });
94
+ for (const message of resolution.errors) {
95
+ errors.push(formatError({ message }));
96
+ }
97
+
98
+ if (errors.length > 0) {
99
+ return { success: false, provenance: [], errors };
100
+ }
101
+
102
+ for (const p of resolution.provenance) {
103
+ console.error(formatInfo(`[param] ${p.name} = ${JSON.stringify(p.value)} (${p.source})`));
104
+ }
105
+
106
+ return { success: true, provenance: resolution.provenance, errors: [] };
107
+ }
@@ -1,13 +1,13 @@
1
1
  import { build } from "../../build";
2
2
  import { loadChantConfig, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
3
- import { resolveBuildParams } from "../../build-params";
3
+ import { resolveCliBuildParams } from "../build-params-cli";
4
4
  import type { Serializer, SerializerResult } from "../../serializer";
5
5
  import type { LexiconPlugin } from "../../lexicon";
6
6
  import { runPostSynthChecks } from "../../lint/post-synth";
7
7
  import { loadPolicyChecks } from "../../lint/policy";
8
8
  import { sortedJsonReplacer } from "../../utils";
9
9
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
10
- import { writeFileSync, mkdirSync, readFileSync } from "fs";
10
+ import { writeFileSync, mkdirSync } from "fs";
11
11
  import { resolve, dirname, join, relative } from "path";
12
12
  import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
13
13
 
@@ -155,44 +155,24 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
155
155
  // as fold, resolved independently.
156
156
  const sandbox = resolveSandboxEnabled(config, options.sandbox);
157
157
 
158
- // #1064 resolve declared build-time parameters (chant.config.ts's
159
- // buildParams) against this invocation's --param/--params-file/declared
160
- // env mapping, BEFORE calling build() a resolution failure (an unknown
161
- // name, a missing required value, a type/enum mismatch) is reported as a
162
- // chant build error naming the parameter, never a thrown error from inside
163
- // user source (which is what loomster's hand-rolled `tierFromEnv()`-style
164
- // validators did before migrating to this mechanism).
165
- let paramsFileContent: Record<string, unknown> | undefined;
166
- if (options.paramsFile) {
167
- try {
168
- paramsFileContent = JSON.parse(readFileSync(resolve(options.paramsFile), "utf-8"));
169
- } catch (err) {
170
- errors.push(
171
- formatError({
172
- message: `Failed to read/parse --params-file "${options.paramsFile}": ${err instanceof Error ? err.message : String(err)}`,
173
- }),
174
- );
175
- }
176
- }
177
- const paramsResolution = resolveBuildParams(config.buildParams, {
158
+ // #1064 (factored into ../build-params-cli.ts's resolveCliBuildParams by
159
+ // #1108, so the component deploy driver runs the identical sequence) —
160
+ // resolve declared build-time parameters (chant.config.ts's buildParams)
161
+ // against this invocation's --param/--params-file/declared env mapping,
162
+ // BEFORE calling build() a resolution failure (an unknown name, a
163
+ // missing required value, a type/enum mismatch) is reported as a chant
164
+ // build error naming the parameter, never a thrown error from inside user
165
+ // source (which is what loomster's hand-rolled `tierFromEnv()`-style
166
+ // validators did before migrating to this mechanism). Also logs every
167
+ // resolved value (`[param] name = value (source)`) on success.
168
+ const paramsResolution = resolveCliBuildParams(config.buildParams, {
178
169
  cli: options.params,
179
- fromFile: paramsFileContent,
180
- env: process.env,
170
+ paramsFile: options.paramsFile,
181
171
  });
182
- for (const message of paramsResolution.errors) {
183
- errors.push(formatError({ message }));
184
- }
185
- if (errors.length > 0) {
172
+ if (!paramsResolution.success) {
173
+ errors.push(...paramsResolution.errors);
186
174
  return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
187
175
  }
188
- // #1064 — build-provenance visibility: report every resolved build-time
189
- // parameter (name, value, and which source won it) the same
190
- // unconditional-log-not-gated-on---verbose way #1022's fold decisions are
191
- // reported just below, so a build's environment-varying inputs are as
192
- // visible as its fold-vs-run choices.
193
- for (const p of paramsResolution.provenance) {
194
- console.error(formatInfo(`[param] ${p.name} = ${JSON.stringify(p.value)} (${p.source})`));
195
- }
196
176
 
197
177
  // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
198
178
  // `Sub`) through to the fold path, so a file using a registered intrinsic
@@ -332,6 +332,80 @@ describe("plugin integration", () => {
332
332
  });
333
333
  });
334
334
 
335
+ /**
336
+ * chant #1106 — `runLint`'s EVL rules now receive the active lexicons'
337
+ * registered intrinsics (threaded from `loadAllPluginRules`'s
338
+ * `plugin.intrinsics?.()`, mirroring how `../commands/build.ts` gathers the
339
+ * same set for the fold path). Before this, `chant lint` flagged
340
+ * `Ref(...)` — a registered, opted-in call-form intrinsic (aws's
341
+ * `lexicons/aws/src/plugin.ts`) that `chant build --fold` folds cleanly —
342
+ * as EVL001, purely because EVL001's shared `../../fold/subset.ts`
343
+ * predicate never saw the registry. These use the real aws lexicon plugin
344
+ * (already a workspace dependency) rather than a mock, so the registration
345
+ * this asserts against is the one that actually ships.
346
+ */
347
+ describe("lintCommand — EVL/intrinsic registry convergence (#1106)", () => {
348
+ let testDir: string;
349
+
350
+ beforeEach(async () => {
351
+ testDir = join(tmpdir(), `chant-lint-intrinsics-test-${Date.now()}-${Math.random()}`);
352
+ await mkdir(testDir, { recursive: true });
353
+ process.env.NO_COLOR = "1";
354
+ });
355
+
356
+ afterEach(async () => {
357
+ await rm(testDir, { recursive: true, force: true });
358
+ delete process.env.NO_COLOR;
359
+ });
360
+
361
+ test("a registered, opted-in intrinsic call (aws's Ref) is not flagged as EVL001", async () => {
362
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
363
+ await writeFile(
364
+ join(testDir, "index.ts"),
365
+ `
366
+ import { Ref } from "@intentius/chant-lexicon-aws";
367
+
368
+ class Queue {
369
+ constructor(_props: Record<string, unknown>) {}
370
+ }
371
+
372
+ export const environment = "prod";
373
+ export const queue = new Queue({ name: Ref(environment) });
374
+ `,
375
+ );
376
+
377
+ const result = await lintCommand({ path: testDir, format: "stylish" });
378
+
379
+ expect(result.diagnostics.filter((d) => d.ruleId === "EVL001")).toHaveLength(0);
380
+ });
381
+
382
+ test("an unregistered call is still flagged as EVL001 in the same project", async () => {
383
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
384
+ await writeFile(
385
+ join(testDir, "index.ts"),
386
+ `
387
+ import { Ref } from "@intentius/chant-lexicon-aws";
388
+
389
+ class Queue {
390
+ constructor(_props: Record<string, unknown>) {}
391
+ }
392
+
393
+ function makeName(): string {
394
+ return "generated";
395
+ }
396
+
397
+ export const queue = new Queue({ name: makeName() });
398
+ `,
399
+ );
400
+
401
+ const result = await lintCommand({ path: testDir, format: "stylish" });
402
+
403
+ const evl001 = result.diagnostics.filter((d) => d.ruleId === "EVL001");
404
+ expect(evl001).toHaveLength(1);
405
+ expect(evl001[0].message).toContain("statically evaluable");
406
+ });
407
+ });
408
+
335
409
  describe("lintCommand — project-root config resolution (scoped lint)", () => {
336
410
  let testDir: string;
337
411
 
@@ -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,147 @@
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 `loadChantConfig` and exercises the
12
+ * public `runBuild` dispatcher (`runGenerateComponents` itself isn't
13
+ * exported), mirroring `run.test.ts`'s style of driving the handler through
14
+ * its `CommandContext` entrypoint rather than reaching into private helpers.
15
+ */
16
+ import { describe, test, expect, vi, beforeEach } from "vitest";
17
+ import type { ParsedArgs } from "../registry";
18
+
19
+ const generateComponentsPipelineMock = vi.fn();
20
+ const loadChantConfigMock = vi.fn();
21
+
22
+ vi.mock("../../components/cli-support", () => ({
23
+ generateComponentsPipeline: (...args: unknown[]) => generateComponentsPipelineMock(...args),
24
+ }));
25
+ vi.mock("../../config", async () => {
26
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
27
+ return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
28
+ });
29
+
30
+ const { runBuild } = await import("./build");
31
+
32
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
33
+ return {
34
+ command: "build",
35
+ path: ".",
36
+ format: "",
37
+ fix: false,
38
+ watch: false,
39
+ verbose: false,
40
+ help: false,
41
+ live: false,
42
+ components: true,
43
+ generate: "gitlab",
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function makeStderrSpy() {
49
+ const buf: string[] = [];
50
+ vi.spyOn(console, "error").mockImplementation((s: string) => { buf.push(s); });
51
+ return buf;
52
+ }
53
+
54
+ describe("runBuild --components --generate (chant #1108 build-time parameters)", () => {
55
+ beforeEach(() => {
56
+ generateComponentsPipelineMock.mockReset();
57
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
58
+ });
59
+
60
+ test("no declared buildParams → generateComponentsPipeline is called with an empty provenance array", async () => {
61
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
62
+ vi.spyOn(console, "log").mockImplementation(() => {});
63
+
64
+ const exit = await runBuild({ args: makeArgs(), plugins: [], serializers: [] });
65
+
66
+ expect(exit).toBe(0);
67
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(".", "gitlab", { env: undefined }, undefined, []);
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ test("chant.config.ts's declared buildParams resolve, log, and are forwarded to generateComponentsPipeline", async () => {
72
+ loadChantConfigMock.mockResolvedValue({
73
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
74
+ });
75
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
76
+ vi.spyOn(console, "log").mockImplementation(() => {});
77
+ const stderr = makeStderrSpy();
78
+
79
+ const exit = await runBuild({ args: makeArgs(), plugins: [], serializers: [] });
80
+
81
+ expect(exit).toBe(0);
82
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(
83
+ ".",
84
+ "gitlab",
85
+ { env: undefined },
86
+ undefined,
87
+ [{ name: "tier", value: "light", source: "default" }],
88
+ );
89
+ expect(stderr.join("\n")).toContain("[param] tier");
90
+ vi.restoreAllMocks();
91
+ });
92
+
93
+ test("--param overrides a declared default", async () => {
94
+ loadChantConfigMock.mockResolvedValue({
95
+ config: { buildParams: { tier: { type: "string", default: "light" } } },
96
+ });
97
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
98
+ vi.spyOn(console, "log").mockImplementation(() => {});
99
+
100
+ const exit = await runBuild({
101
+ args: makeArgs({ param: ["tier=production"] }),
102
+ plugins: [],
103
+ serializers: [],
104
+ });
105
+
106
+ expect(exit).toBe(0);
107
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(
108
+ ".",
109
+ "gitlab",
110
+ { env: undefined },
111
+ undefined,
112
+ [{ name: "tier", value: "production", source: "cli" }],
113
+ );
114
+ vi.restoreAllMocks();
115
+ });
116
+
117
+ test("an unresolved required build-time parameter → exit 1, never reaches generateComponentsPipeline", async () => {
118
+ loadChantConfigMock.mockResolvedValue({
119
+ config: { buildParams: { tier: { type: "string" } } },
120
+ });
121
+ const stderr = makeStderrSpy();
122
+
123
+ const exit = await runBuild({ args: makeArgs(), plugins: [], serializers: [] });
124
+
125
+ expect(exit).toBe(1);
126
+ expect(stderr.join("\n")).toMatch(/"tier"/);
127
+ expect(generateComponentsPipelineMock).not.toHaveBeenCalled();
128
+ });
129
+
130
+ test("an enum violation on --param → exit 1, never reaches generateComponentsPipeline", async () => {
131
+ loadChantConfigMock.mockResolvedValue({
132
+ config: { buildParams: { tier: { type: "string", enum: ["light", "production"] } } },
133
+ });
134
+ const stderr = makeStderrSpy();
135
+
136
+ const exit = await runBuild({
137
+ args: makeArgs({ param: ["tier=bogus"] }),
138
+ plugins: [],
139
+ serializers: [],
140
+ });
141
+
142
+ expect(exit).toBe(1);
143
+ expect(stderr.join("\n")).toMatch(/"tier"/);
144
+ expect(stderr.join("\n")).toMatch(/bogus/);
145
+ expect(generateComponentsPipelineMock).not.toHaveBeenCalled();
146
+ });
147
+ });