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