@intentius/chant 0.59.0 → 0.60.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/build-params.d.ts +2 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
- package/dist/config.d.ts +4 -4
- package/dist/lexicon.d.ts +48 -5
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -4
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +79 -7
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -4
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/params.d.ts +1 -1
- package/dist/project-root.d.ts +2 -2
- package/package.json +1 -1
- package/src/build-params.ts +2 -2
- package/src/cli/commands/build.ts +8 -8
- package/src/cli/commands/lint.test.ts +151 -0
- package/src/cli/commands/lint.ts +37 -4
- package/src/cli/handlers/graph.test.ts +4 -4
- package/src/cli/handlers/graph.ts +11 -11
- package/src/cli/handlers/lint.test.ts +107 -0
- package/src/cli/handlers/lint.ts +30 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +2 -2
- package/src/config.ts +4 -4
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/discovery/fold-import.ts +3 -3
- package/src/lexicon.ts +49 -5
- package/src/lifecycle/observe.test.ts +2 -2
- package/src/lifecycle/observe.ts +8 -8
- package/src/lifecycle/release-ledger.test.ts +2 -2
- package/src/op/activities/index.ts +8 -1
- package/src/op/activities/reconcile.test.ts +238 -0
- package/src/op/activities/reconcile.ts +267 -13
- package/src/op/gate-summary.test.ts +62 -0
- package/src/op/gate-summary.ts +17 -5
- package/src/params.ts +1 -1
- package/src/project-root.ts +2 -2
|
@@ -5,6 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { params, setBuildParams } from "../../params";
|
|
8
9
|
|
|
9
10
|
describe("lintCommand", () => {
|
|
10
11
|
let testDir: string;
|
|
@@ -118,6 +119,69 @@ export default {
|
|
|
118
119
|
).toBe(true);
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
// #2251 — `chant lint` imports every `*.op.ts` file to read the Op it
|
|
123
|
+
// declares, and an Op that takes a step argument from `params.<name>`
|
|
124
|
+
// (`@intentius/chant/params`) evaluates that read at module load. Before
|
|
125
|
+
// this, nothing populated the shared parameters object first, so every such
|
|
126
|
+
// argument was `undefined` and OPS012 reported the activity contract
|
|
127
|
+
// violated on source that builds and runs. The fixture imports the real
|
|
128
|
+
// params module by absolute path — `@intentius/chant/params` maps to the
|
|
129
|
+
// same file (packages/core/package.json's `exports`), so it is the one
|
|
130
|
+
// module record `lintCommand` mutates.
|
|
131
|
+
describe("build parameters reach an Op's step arguments (#2251)", () => {
|
|
132
|
+
const paramsModule = resolve(import.meta.dirname, "../../params.ts");
|
|
133
|
+
|
|
134
|
+
async function writeParamReadingOp(): Promise<void> {
|
|
135
|
+
await writeFile(
|
|
136
|
+
join(testDir, "mini.op.ts"),
|
|
137
|
+
`
|
|
138
|
+
import { params } from ${JSON.stringify(paramsModule)};
|
|
139
|
+
|
|
140
|
+
export default {
|
|
141
|
+
[Symbol.for("chant.declarable")]: true,
|
|
142
|
+
entityType: "Chant::Op",
|
|
143
|
+
lexicon: "chant",
|
|
144
|
+
kind: "resource",
|
|
145
|
+
props: {
|
|
146
|
+
name: "mini",
|
|
147
|
+
overview: "test",
|
|
148
|
+
phases: [
|
|
149
|
+
{ name: "Greet", steps: [
|
|
150
|
+
{ kind: "activity", fn: "shellCmd", args: { cmd: params.greeting } },
|
|
151
|
+
] },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test("OPS012 does not fire when the invocation's parameters are supplied", async () => {
|
|
160
|
+
await writeParamReadingOp();
|
|
161
|
+
|
|
162
|
+
const result = await lintCommand({
|
|
163
|
+
path: testDir,
|
|
164
|
+
format: "stylish",
|
|
165
|
+
buildParams: [{ name: "greeting", value: "echo hi", source: "default" }],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.diagnostics.filter((d) => d.ruleId === "OPS012")).toEqual([]);
|
|
169
|
+
expect(result.success).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("OPS012 fires on the same source when no parameters are supplied", async () => {
|
|
173
|
+
await writeParamReadingOp();
|
|
174
|
+
|
|
175
|
+
const result = await lintCommand({ path: testDir, format: "stylish" });
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
result.diagnostics.some(
|
|
179
|
+
(d) => d.ruleId === "OPS012" && d.message.includes("args.cmd"),
|
|
180
|
+
),
|
|
181
|
+
).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
121
185
|
test("formats output as JSON", async () => {
|
|
122
186
|
await writeFile(
|
|
123
187
|
join(testDir, "nested.ts"),
|
|
@@ -686,3 +750,90 @@ describe("lintCommand — a declared lexicon that cannot be resolved (#2222)", (
|
|
|
686
750
|
expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
|
|
687
751
|
});
|
|
688
752
|
});
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* chant #2249 — `lintCommand` resolves the project's own declared
|
|
756
|
+
* `buildParams` when its caller supplied none, so an in-process lint agrees
|
|
757
|
+
* with `chant lint` on the command line.
|
|
758
|
+
*
|
|
759
|
+
* #2251 taught the CLI handler (../handlers/lint.ts) to resolve
|
|
760
|
+
* `--param`/`--params-file`/declared defaults and pass them down, because
|
|
761
|
+
* the OPS* checks import every `*.op.ts` file and an Op reading
|
|
762
|
+
* `params.<name>` evaluates that read at module load. Every other caller of
|
|
763
|
+
* `lintCommand` has no flags to read and passed nothing, so `params` stayed
|
|
764
|
+
* empty and OPS012 reported the activity contract violated for source that
|
|
765
|
+
* lints clean from a shell. That is how the root-examples gate
|
|
766
|
+
* (examples/root-examples-gate.test.ts) went red on github-pr-preview while
|
|
767
|
+
* `chant lint .` in the same directory exited 0, and it applied equally to
|
|
768
|
+
* `handleLint` over MCP and to test-utils' example harness.
|
|
769
|
+
*
|
|
770
|
+
* These drive the real `lintCommand` against a temp project holding one
|
|
771
|
+
* `*.op.ts` file (enough for the OPS* pass to run its parameter binding) and
|
|
772
|
+
* read the shared `params` object the binding populates.
|
|
773
|
+
*/
|
|
774
|
+
describe("lintCommand build-time parameters (#2249)", () => {
|
|
775
|
+
let testDir: string;
|
|
776
|
+
|
|
777
|
+
beforeEach(async () => {
|
|
778
|
+
testDir = join(tmpdir(), `chant-lint-params-${Date.now()}-${Math.random()}`);
|
|
779
|
+
await mkdir(testDir, { recursive: true });
|
|
780
|
+
// The OPS* pass binds parameters only when there is an Op file to import.
|
|
781
|
+
// A plain default export is enough: it is skipped as not-an-Op after the
|
|
782
|
+
// binding has already happened.
|
|
783
|
+
await writeFile(join(testDir, "noop.op.ts"), `export default { props: {} };\n`);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
afterEach(async () => {
|
|
787
|
+
await rm(testDir, { recursive: true, force: true });
|
|
788
|
+
delete process.env.CHANT_TEST_ENV_2249;
|
|
789
|
+
setBuildParams({});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("a declared default reaches params with no caller-supplied provenance", async () => {
|
|
793
|
+
await writeFile(
|
|
794
|
+
join(testDir, "chant.config.json"),
|
|
795
|
+
JSON.stringify({ buildParams: { env: { type: "string", default: "local" } } }),
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
await lintCommand({ path: testDir, format: "stylish" });
|
|
799
|
+
|
|
800
|
+
expect(params.env).toBe("local");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("a declared env mapping reaches params the same way chant lint resolves it", async () => {
|
|
804
|
+
await writeFile(
|
|
805
|
+
join(testDir, "chant.config.json"),
|
|
806
|
+
JSON.stringify({
|
|
807
|
+
buildParams: { env: { type: "string", default: "local", env: "CHANT_TEST_ENV_2249" } },
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
process.env.CHANT_TEST_ENV_2249 = "pr-42";
|
|
811
|
+
|
|
812
|
+
await lintCommand({ path: testDir, format: "stylish" });
|
|
813
|
+
|
|
814
|
+
expect(params.env).toBe("pr-42");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("caller-supplied parameters win over the declared defaults", async () => {
|
|
818
|
+
await writeFile(
|
|
819
|
+
join(testDir, "chant.config.json"),
|
|
820
|
+
JSON.stringify({ buildParams: { env: { type: "string", default: "local" } } }),
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
await lintCommand({
|
|
824
|
+
path: testDir,
|
|
825
|
+
format: "stylish",
|
|
826
|
+
buildParams: [{ name: "env", value: "pr-42", source: "cli" }],
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
expect(params.env).toBe("pr-42");
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("a project declaring none binds an empty parameter set", async () => {
|
|
833
|
+
await writeFile(join(testDir, "chant.config.json"), JSON.stringify({}));
|
|
834
|
+
|
|
835
|
+
await lintCommand({ path: testDir, format: "stylish" });
|
|
836
|
+
|
|
837
|
+
expect(params).toEqual({});
|
|
838
|
+
});
|
|
839
|
+
});
|
package/src/cli/commands/lint.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { rule } from "../../lint/declarative";
|
|
|
20
20
|
import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
|
|
21
21
|
import { formatError, formatInfo } from "../format";
|
|
22
22
|
import { GENERATED_MARKER } from "../../discovery/files";
|
|
23
|
+
import { buildParamValues, resolveBuildParams } from "../../build-params";
|
|
24
|
+
import { setBuildParams } from "../../params";
|
|
23
25
|
import { isNoLexiconDetected } from "../../detectLexicon";
|
|
24
26
|
|
|
25
27
|
// Import config loader
|
|
@@ -535,11 +537,21 @@ async function runComponentCheckDiagnostics(
|
|
|
535
537
|
async function runOpCheckDiagnostics(
|
|
536
538
|
infraPath: string,
|
|
537
539
|
files: string[],
|
|
540
|
+
buildParams?: BuildParamProvenance[],
|
|
538
541
|
): Promise<{ diagnostics: LintDiagnostic[]; suppressed: Array<LintDiagnostic & { reason?: string }> }> {
|
|
539
542
|
const config = loadConfig(findProjectRoot(infraPath));
|
|
540
543
|
const opFiles = files.filter((f) => f.endsWith(".op.ts"));
|
|
541
544
|
if (opFiles.length === 0) return { diagnostics: [], suppressed: [] };
|
|
542
545
|
|
|
546
|
+
// chant #2251 — the same step `discover()` runs before it imports or folds a
|
|
547
|
+
// project file (../../discovery/index.ts): populate the shared build-time
|
|
548
|
+
// parameters object BEFORE the imports below, so an Op that takes a step
|
|
549
|
+
// argument from `params.<name>` reads the value this invocation resolved
|
|
550
|
+
// rather than `undefined`. Unconditional, so a stale value from a prior
|
|
551
|
+
// lint in the same process (a test, `--watch`) never leaks into one that
|
|
552
|
+
// resolved none.
|
|
553
|
+
setBuildParams(buildParamValues(buildParams ?? []));
|
|
554
|
+
|
|
543
555
|
const entities = new Map<string, unknown>();
|
|
544
556
|
const fileByOpName = new Map<string, string>();
|
|
545
557
|
for (const filePath of opFiles) {
|
|
@@ -640,10 +652,31 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
640
652
|
// than a failure — `loadOkfBundle` already treats a missing directory as
|
|
641
653
|
// an empty bundle.
|
|
642
654
|
let knowledgeBundle: OkfBundle | undefined;
|
|
655
|
+
/**
|
|
656
|
+
* chant #2249 — this invocation's build-time parameters, falling back to
|
|
657
|
+
* the project's own declared `buildParams` when the caller passed none.
|
|
658
|
+
*
|
|
659
|
+
* `chant lint` resolves them in ./handlers/lint.ts (#2251) because only
|
|
660
|
+
* the CLI knows about `--param`/`--params-file`, and passes them here. An
|
|
661
|
+
* in-process caller has no such flags to read, and before this fell
|
|
662
|
+
* through to the empty parameter object: the OPS* checks import each
|
|
663
|
+
* `*.op.ts`, an Op taking a step argument from `params.<name>` read
|
|
664
|
+
* `undefined` at module load, and OPS012 reported the activity contract
|
|
665
|
+
* violated for source `chant lint` on the command line passes. That made
|
|
666
|
+
* `lintCommand` disagree with its own CLI (the root-examples gate,
|
|
667
|
+
* `handleLint` over MCP, test-utils' example harness). Resolving the
|
|
668
|
+
* declared defaults + `env` mappings here is what the command line
|
|
669
|
+
* already gets; anything a flag would override still arrives in
|
|
670
|
+
* `options.buildParams` and wins. Best-effort like `projectConfig`:
|
|
671
|
+
* `resolveBuildParams` collects failures per parameter without throwing,
|
|
672
|
+
* and the ones that did resolve are still better than none.
|
|
673
|
+
*/
|
|
674
|
+
let buildParams = options.buildParams;
|
|
643
675
|
try {
|
|
644
676
|
const chantConfig = (await loadChantConfig(projectRoot)).config;
|
|
645
677
|
projectConfig = chantConfig as LintProjectConfig;
|
|
646
678
|
knowledgeBundle = await loadOkfBundle(resolveKnowledgeDir(chantConfig, projectRoot));
|
|
679
|
+
buildParams ??= resolveBuildParams(chantConfig.buildParams, { env: process.env }).provenance;
|
|
647
680
|
} catch {
|
|
648
681
|
projectConfig = undefined;
|
|
649
682
|
knowledgeBundle = undefined;
|
|
@@ -696,14 +729,14 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
696
729
|
// structurally distinct check family (whole-project, post-discovery,
|
|
697
730
|
// see ../../lint/component-checks.ts) but the same `chant lint` output and
|
|
698
731
|
// the same error-severity gating as every COR/EVL diagnostic.
|
|
699
|
-
const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox,
|
|
732
|
+
const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, buildParams);
|
|
700
733
|
diagnostics.push(...componentResult.diagnostics);
|
|
701
734
|
suppressed.push(...componentResult.suppressed);
|
|
702
735
|
|
|
703
736
|
// Run the OPS* Op-model post-synth checks (#2122) over every `*.op.ts`
|
|
704
737
|
// file under the lint target — see runOpCheckDiagnostics's doc for why
|
|
705
738
|
// this needs no lexicon or build to fire.
|
|
706
|
-
const opResult = await runOpCheckDiagnostics(infraPath, files);
|
|
739
|
+
const opResult = await runOpCheckDiagnostics(infraPath, files, buildParams);
|
|
707
740
|
diagnostics.push(...opResult.diagnostics);
|
|
708
741
|
suppressed.push(...opResult.suppressed);
|
|
709
742
|
|
|
@@ -751,13 +784,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
751
784
|
// `*.component.ts` file on their behalf), but a fix applied to another
|
|
752
785
|
// rule could still be in the same file a component was discovered from —
|
|
753
786
|
// re-run for consistency with the AST re-lint above.
|
|
754
|
-
const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox,
|
|
787
|
+
const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, buildParams);
|
|
755
788
|
diagnostics.push(...postComponentResult.diagnostics);
|
|
756
789
|
suppressed.push(...postComponentResult.suppressed);
|
|
757
790
|
|
|
758
791
|
// OPS* checks have no `.fix` either; re-run for the same consistency
|
|
759
792
|
// reason as the COMP* re-run just above.
|
|
760
|
-
const postOpResult = await runOpCheckDiagnostics(infraPath, files);
|
|
793
|
+
const postOpResult = await runOpCheckDiagnostics(infraPath, files, buildParams);
|
|
761
794
|
diagnostics.push(...postOpResult.diagnostics);
|
|
762
795
|
suppressed.push(...postOpResult.suppressed);
|
|
763
796
|
}
|
|
@@ -710,10 +710,10 @@ describe("runGraph", () => {
|
|
|
710
710
|
});
|
|
711
711
|
});
|
|
712
712
|
|
|
713
|
-
// The bug this branch fixes (#57): a multi-stack, per-component project
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
713
|
+
// The bug this branch fixes (#57): a multi-stack, per-component project has
|
|
714
|
+
// no stack literally named after the environment, so the live graph must
|
|
715
|
+
// resolve each component's own `cfn-deploy` stack(s) and pass them through
|
|
716
|
+
// to `observeResources` for the per-stack union.
|
|
717
717
|
test("multi-stack component project: resolves each component's cfn-deploy stack(s) and passes them to observeResources", async () => {
|
|
718
718
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
719
719
|
loadPluginsMock.mockResolvedValue([
|
|
@@ -253,17 +253,17 @@ async function runGraphLive(
|
|
|
253
253
|
return 1;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
// Multi-stack, per-component projects (
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
256
|
+
// Multi-stack, per-component projects (#57): AWS's single-stack convention
|
|
257
|
+
// (`describeResources` defaults to a stack named after the environment,
|
|
258
|
+
// lexicons/aws/src/plugin.ts) never matches a per-component layout (e.g.
|
|
259
|
+
// `loom-local-a-<component>`), so the plain single call always observes zero
|
|
260
|
+
// nodes. Resolve every discovered component's `cfn-deploy` stack(s) — the
|
|
261
|
+
// same walk `chant components status --live` uses (`cfnDeployStacks`,
|
|
262
|
+
// ./components.ts) — and hand them to `observeResources`, which queries
|
|
263
|
+
// `describeResources` once per stack and unions the results. A project with
|
|
264
|
+
// no components (or whose discovery errors) yields no stacks, so
|
|
265
|
+
// `observeResources` falls back to its original single-stack call —
|
|
266
|
+
// unchanged.
|
|
267
267
|
const componentsDiscovery = await discoverComponents(resolve(args.src ?? config.sourceDir ?? "."), {
|
|
268
268
|
sandbox: args.sandbox,
|
|
269
269
|
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2251 — `chant lint` resolves this invocation's declared build-time
|
|
3
|
+
* parameters before it lints.
|
|
4
|
+
*
|
|
5
|
+
* The gap this covers: `runLint` built its `LintOptions` without ever
|
|
6
|
+
* touching `chant.config.ts`'s `buildParams`, so the OPS* checks imported
|
|
7
|
+
* every `*.op.ts` file with `params` (`@intentius/chant/params`) still empty.
|
|
8
|
+
* An Op taking a step argument from `params.<name>` therefore read
|
|
9
|
+
* `undefined` and OPS012 reported the activity contract violated on source
|
|
10
|
+
* that builds and runs — reproducible on any project with an Op that reads a
|
|
11
|
+
* build parameter, `--param` and the declared `env` mapping alike (the
|
|
12
|
+
* resolution never ran at all, so no input could reach it).
|
|
13
|
+
*
|
|
14
|
+
* Mocks `lintCommand` and `loadChantConfigUpward` and drives the exported
|
|
15
|
+
* `runLint` dispatcher, the same shape ./build.test.ts uses for the matching
|
|
16
|
+
* #1108 gap in generate mode.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
19
|
+
import type { ParsedArgs } from "../registry";
|
|
20
|
+
|
|
21
|
+
const lintCommandMock = vi.fn();
|
|
22
|
+
const loadChantConfigUpwardMock = vi.fn();
|
|
23
|
+
|
|
24
|
+
vi.mock("../commands/lint", async () => {
|
|
25
|
+
const actual = await vi.importActual<typeof import("../commands/lint")>("../commands/lint");
|
|
26
|
+
return {
|
|
27
|
+
...actual,
|
|
28
|
+
lintCommand: (...args: unknown[]) => lintCommandMock(...args),
|
|
29
|
+
printLintResult: () => {},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
vi.mock("../../config", async () => {
|
|
33
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
34
|
+
return { ...actual, loadChantConfigUpward: (...args: unknown[]) => loadChantConfigUpwardMock(...args) };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const { runLint } = await import("./lint");
|
|
38
|
+
|
|
39
|
+
function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
|
|
40
|
+
return {
|
|
41
|
+
command: "lint",
|
|
42
|
+
path: ".",
|
|
43
|
+
format: "",
|
|
44
|
+
fix: false,
|
|
45
|
+
watch: false,
|
|
46
|
+
verbose: false,
|
|
47
|
+
help: false,
|
|
48
|
+
live: false,
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("runLint build-time parameters (#2251)", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
lintCommandMock.mockReset().mockResolvedValue({ success: true, errorCount: 0, warningCount: 0, diagnostics: [] });
|
|
56
|
+
loadChantConfigUpwardMock.mockReset().mockResolvedValue({ config: {} });
|
|
57
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a project declaring no buildParams lints with an empty provenance array", async () => {
|
|
65
|
+
const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
|
|
66
|
+
|
|
67
|
+
expect(exit).toBe(0);
|
|
68
|
+
expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("declared buildParams resolve from their defaults and reach lintCommand", async () => {
|
|
72
|
+
loadChantConfigUpwardMock.mockResolvedValue({
|
|
73
|
+
config: { buildParams: { env: { type: "string", default: "local" } } },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
|
|
77
|
+
|
|
78
|
+
expect(exit).toBe(0);
|
|
79
|
+
expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([
|
|
80
|
+
{ name: "env", value: "local", source: "default" },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("--param overrides the declared default, the same precedence chant build applies", async () => {
|
|
85
|
+
loadChantConfigUpwardMock.mockResolvedValue({
|
|
86
|
+
config: { buildParams: { env: { type: "string", default: "local" } } },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const exit = await runLint({ args: makeArgs({ param: ["env=pr-42"] }), plugins: [], serializers: [] });
|
|
90
|
+
|
|
91
|
+
expect(exit).toBe(0);
|
|
92
|
+
expect(lintCommandMock.mock.calls[0][0].buildParams).toEqual([
|
|
93
|
+
{ name: "env", value: "pr-42", source: "cli" },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("an unresolvable parameter exits non-zero without linting", async () => {
|
|
98
|
+
loadChantConfigUpwardMock.mockResolvedValue({
|
|
99
|
+
config: { buildParams: { env: { type: "string", required: true } } },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const exit = await runLint({ args: makeArgs(), plugins: [], serializers: [] });
|
|
103
|
+
|
|
104
|
+
expect(exit).toBe(1);
|
|
105
|
+
expect(lintCommandMock).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/cli/handlers/lint.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { lintCommand, lintCommandWatch, printLintResult } from "../commands/lint";
|
|
2
3
|
import { formatError, formatInfo } from "../format";
|
|
3
4
|
import type { CommandContext } from "../registry";
|
|
5
|
+
import { commandBuildParams } from "../build-params-cli";
|
|
6
|
+
import { loadChantConfigUpward, type ChantConfig } from "../../config";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* chant #2251 — `chant lint` resolves this invocation's declared build-time
|
|
10
|
+
* parameters (`chant.config.ts`'s `buildParams`) before it lints, the same
|
|
11
|
+
* way `chant build` and the lifecycle family do (`commandBuildParams`,
|
|
12
|
+
* ../build-params-cli.ts).
|
|
13
|
+
*
|
|
14
|
+
* The OPS* checks import every `*.op.ts` file to read the Op it declares
|
|
15
|
+
* (../commands/lint.ts's `runOpCheckDiagnostics`), and an Op that takes a
|
|
16
|
+
* step argument from `params.<name>` (`@intentius/chant/params`) evaluates
|
|
17
|
+
* that read at module load. With no parameters resolved, `params` is the
|
|
18
|
+
* empty object every such argument reads `undefined` out of, and OPS012
|
|
19
|
+
* reports the activity contract violated — `args.env: expected string,
|
|
20
|
+
* received undefined` — for source that builds and runs correctly. The
|
|
21
|
+
* config is loaded by walking up from the lint path, so linting a
|
|
22
|
+
* subdirectory still sees the project root's declarations.
|
|
23
|
+
*/
|
|
24
|
+
async function lintBuildParams(args: { path: string; param?: string[]; paramsFile?: string }) {
|
|
25
|
+
const { config } = await loadChantConfigUpward(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
|
|
26
|
+
if (!config.buildParams) return [];
|
|
27
|
+
return commandBuildParams(config.buildParams, args);
|
|
28
|
+
}
|
|
4
29
|
|
|
5
30
|
export async function runLint(ctx: CommandContext): Promise<number> {
|
|
6
31
|
const { args } = ctx;
|
|
@@ -11,12 +36,16 @@ export async function runLint(ctx: CommandContext): Promise<number> {
|
|
|
11
36
|
return 1;
|
|
12
37
|
}
|
|
13
38
|
|
|
39
|
+
const buildParams = await lintBuildParams(args);
|
|
40
|
+
if (buildParams === undefined) return 1;
|
|
41
|
+
|
|
14
42
|
if (args.watch) {
|
|
15
43
|
const cleanup = lintCommandWatch({
|
|
16
44
|
path: args.path,
|
|
17
45
|
fix: args.fix,
|
|
18
46
|
format: lintFormat,
|
|
19
47
|
sandbox: args.sandbox,
|
|
48
|
+
buildParams,
|
|
20
49
|
});
|
|
21
50
|
process.on("SIGINT", () => {
|
|
22
51
|
cleanup();
|
|
@@ -31,6 +60,7 @@ export async function runLint(ctx: CommandContext): Promise<number> {
|
|
|
31
60
|
fix: args.fix,
|
|
32
61
|
format: lintFormat,
|
|
33
62
|
sandbox: args.sandbox,
|
|
63
|
+
buildParams,
|
|
34
64
|
});
|
|
35
65
|
|
|
36
66
|
printLintResult(result);
|
|
@@ -16,16 +16,16 @@ generic `runInterpretDriver` (#556, [`../driver.ts`](./driver.ts)), unchanged.
|
|
|
16
16
|
## Before / after: the ALB/ECS pipeline glue this replaces
|
|
17
17
|
|
|
18
18
|
The component model's whole reason for existing is visible in one concrete
|
|
19
|
-
diff. [`examples/gitlab-aws-alb-
|
|
20
|
-
hand-rolls a `
|
|
19
|
+
diff. [`examples/gitlab-aws-alb-services/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-services/src/pipeline.ts)
|
|
20
|
+
hand-rolls a `deployServices` job that shells out to CloudFormation and greps
|
|
21
21
|
its own infra stack's outputs before it can deploy:
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
|
-
// before — examples/gitlab-aws-alb-
|
|
24
|
+
// before — examples/gitlab-aws-alb-services/src/pipeline.ts (deployServices job)
|
|
25
25
|
`OUTPUTS=$(aws cloudformation describe-stacks --stack-name ${INFRA_STACK} --query 'Stacks[0].Outputs' --output json)`,
|
|
26
26
|
`PARAMS=$(echo "$OUTPUTS" | jq -r '[(.[] | select(.OutputKey == "ClusterArn") | "clusterArn=" + .OutputValue), (.[] | select(.OutputKey == "ListenerArn") | "listenerArn=" + .OutputValue), ...] | join(" ")')`,
|
|
27
|
-
`
|
|
28
|
-
`aws cloudformation deploy --template-file templates/template.json --stack-name ${STACK_NAME} ... --parameter-overrides $PARAMS
|
|
27
|
+
`API_IMAGE_URI=$(echo "$OUTPUTS" | jq -r '.[] | select(.OutputKey == "ApiRepoUri") | .OutputValue'):${CI_COMMIT_REF_SLUG}`,
|
|
28
|
+
`aws cloudformation deploy --template-file templates/template.json --stack-name ${STACK_NAME} ... --parameter-overrides $PARAMS apiImage=$API_IMAGE_URI uiImage=$UI_IMAGE_URI`,
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
That `describe-stacks | jq` line is bespoke per pipeline: every component that
|
|
@@ -13,7 +13,7 @@ The epic ([#551](https://github.com/INTENTIUS/chant/issues/551)) picked these th
|
|
|
13
13
|
|---|---|---|---|---|---|---|
|
|
14
14
|
| **Neo4j per-instance fan-out** | [`neo4j-fanout.pilot.ts`](./neo4j-fanout.pilot.ts) / [`neo4j-fanout.json`](../__fixtures__/neo4j-fanout.json) | no-build (`infra`, applies pre-built templates) | **fan-out** — one component composes 3 per-instance mini-compositions (`cfn-deploy` + `code-deploy` + `wait-cluster-healthy`), seed-first then rolling, gated at node 1 | simple apply (no `onReplace`/`stageGsi` — no sticky CFN concerns here) | none (self-contained cluster, no shared-stack imports) | **auto** — `code-deploy` (AWS CodeDeploy) rollback is native/automatic on failure, declared once inside the capability, never scripted per node |
|
|
15
15
|
| **DynamoDB table** | [`dynamodb.pilot.ts`](./dynamodb.pilot.ts) / [`dynamodb-infra.json`](../__fixtures__/dynamodb-infra.json) | no-build (`infra`, applies an existing table template) | single (one `cfn-deploy`, no fan-out) | **sticky** — `onReplace: "block"` refuses a replacing changeset (data loss guard); `stageGsi: true` stages the GSI add→backfill→remove instead of an in-place replace | none | no rollback declared — a blocked replacement is not something to compensate, it is a stop |
|
|
16
|
-
| **ALB/ECS target** | [`alb-ecs.pilot.ts`](./alb-ecs.pilot.ts) / [`alb-ecs-service.json`](../__fixtures__/alb-ecs-service.json) | **build** — `docker-build` → `publish-image` (promote by digest at deploy time) | single (one service, one `cfn-deploy` + `ecs-update-service`) | simple apply (no replacement-sensitive resource here) | **cross-stack** — imports `shared-alb`'s `ListenerArn`/`ClusterArn`/`Subnets` via `stackOutput()`, replacing the `describe-stacks \| jq` glue in [`examples/gitlab-aws-alb-
|
|
16
|
+
| **ALB/ECS target** | [`alb-ecs.pilot.ts`](./alb-ecs.pilot.ts) / [`alb-ecs-service.json`](../__fixtures__/alb-ecs-service.json) | **build** — `docker-build` → `publish-image` (promote by digest at deploy time) | single (one service, one `cfn-deploy` + `ecs-update-service`) | simple apply (no replacement-sensitive resource here) | **cross-stack** — imports `shared-alb`'s `ListenerArn`/`ClusterArn`/`Subnets` via `stackOutput()`, replacing the `describe-stacks \| jq` glue in [`examples/gitlab-aws-alb-services/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-services/src/pipeline.ts) | **no** (component-declared) — `ecs-update-service`/`cfn-deploy` have no native automatic rollback for an already-running service swap, so the component supplies an explicit `rollback` phase (`rollback-previous`) rather than relying on capability compensation |
|
|
17
17
|
|
|
18
18
|
Read together, the three cover every cell at least once: build only shows up for ALB/ECS, fan-out only for Neo4j, sticky-apply only for DynamoDB, cross-stack only for ALB/ECS, and both rollback styles (capability-native vs component-declared) appear once each.
|
|
19
19
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* `ecs-update-service` → `wait-steady-state` + `health-gate`.
|
|
10
10
|
*
|
|
11
11
|
* This is the direct component-native replacement for the hand-rolled GitLab
|
|
12
|
-
* pipeline in `examples/gitlab-aws-alb-
|
|
13
|
-
* `describe-stacks`/`jq` glue in that pipeline's `
|
|
12
|
+
* pipeline in `examples/gitlab-aws-alb-services/src/pipeline.ts`: the
|
|
13
|
+
* `describe-stacks`/`jq` glue in that pipeline's `deployServices` job is
|
|
14
14
|
* exactly the cross-stack `stackOutput()` wiring below, and its
|
|
15
15
|
* `docker build`/`docker push` steps are the `docker-build` + `publish-image`
|
|
16
16
|
* capabilities. `service` archetype: build → publish → apply → verify, the
|
package/src/config.ts
CHANGED
|
@@ -481,10 +481,10 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
|
|
|
481
481
|
* lives at the project root, one or more levels up. Before this, callers
|
|
482
482
|
* either read `startDir` alone or bolted on a single `dirname()` fallback —
|
|
483
483
|
* fine for a one-level-deep stack, silently blind to anything deeper
|
|
484
|
-
* (
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
484
|
+
* (a `src/<stack>` layout is exactly one level too deep: `buildParams`' declared
|
|
485
|
+
* `env:` mappings never resolved, so the env vars they named were inert under
|
|
486
|
+
* every `npm run synth:*` for two releases). Uses the same walk `chant
|
|
487
|
+
* lint`/`chant graph` already used ({@link findProjectConfig},
|
|
488
488
|
* shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
|
|
489
489
|
* contract for the whole CLI.
|
|
490
490
|
*
|
|
@@ -1009,7 +1009,7 @@ describe("tryFoldFile — build-time parameters (chant #1064)", () => {
|
|
|
1009
1009
|
expect((entity as unknown as { props: { name: unknown } }).props.name).toBe("staging");
|
|
1010
1010
|
});
|
|
1011
1011
|
|
|
1012
|
-
test("a nullish-coalesced default still folds to a literal (
|
|
1012
|
+
test("a nullish-coalesced default still folds to a literal (the `params.x ?? \"default\"` pattern)", async () => {
|
|
1013
1013
|
const file = join(testDir, "main.ts");
|
|
1014
1014
|
await writeFile(
|
|
1015
1015
|
file,
|
|
@@ -1697,9 +1697,9 @@ async function resolveCallArguments(
|
|
|
1697
1697
|
// 4. **Its body is a single expression, or a block of `const` declarations
|
|
1698
1698
|
// followed by one `return`** — and nothing else. No `if`, no `throw`, no
|
|
1699
1699
|
// loop, no `let`/`var`, no nested function declaration, no bare expression
|
|
1700
|
-
// statement. This is the line
|
|
1701
|
-
// (module-level `buildXxx()` helpers with `if`/`throw` and
|
|
1702
|
-
// they are meant to: they keep invoking, exactly as before.
|
|
1700
|
+
// statement. This is the line a project's hand-rolled composite modules
|
|
1701
|
+
// fall outside (module-level `buildXxx()` helpers with `if`/`throw` and
|
|
1702
|
+
// `.map()`), and they are meant to: they keep invoking, exactly as before.
|
|
1703
1703
|
// 5. **Every expression in it is inside the fold subset, extended with the
|
|
1704
1704
|
// two things a factory body exists to do**: `new Type(...)` in ANY value
|
|
1705
1705
|
// position (a member, a nested property object, an array element), and a
|
package/src/lexicon.ts
CHANGED
|
@@ -494,10 +494,11 @@ export interface ComponentPipelineResult {
|
|
|
494
494
|
* the generated job needs to act on a finding — elevated write access for
|
|
495
495
|
* `issue`/`comment`/`pull-request`/`merge-request`, none for `report`.
|
|
496
496
|
*
|
|
497
|
-
* `comment` posts the finding on the pull request
|
|
498
|
-
* (#
|
|
499
|
-
*
|
|
500
|
-
*
|
|
497
|
+
* `comment` posts the finding on the pull request — or, on GitLab, the merge
|
|
498
|
+
* request (#2256) — that triggered the run, so unlike every other mode it
|
|
499
|
+
* constrains the trigger: the github and gitlab generators both refuse it by
|
|
500
|
+
* name on anything but `pull_request`. The forgejo generator refuses it
|
|
501
|
+
* outright, having no forge client to post the equivalent comment with.
|
|
501
502
|
*/
|
|
502
503
|
export type OpFindingMode = "report" | "issue" | "comment" | "pull-request" | "merge-request";
|
|
503
504
|
|
|
@@ -525,7 +526,9 @@ export type OpTrigger =
|
|
|
525
526
|
*
|
|
526
527
|
* A CI provider with no action concept degrades by name rather than
|
|
527
528
|
* silently: the gitlab generator refuses a `uses` entry at build time and
|
|
528
|
-
* emits a `run` entry as an ordinary script line.
|
|
529
|
+
* emits a `run` entry as an ordinary script line. The additive `permissions`
|
|
530
|
+
* map degrades the same way there, with one exception: `id-token: write`
|
|
531
|
+
* becomes GitLab's own `id_tokens:` declaration (#2256).
|
|
529
532
|
*/
|
|
530
533
|
export type OpSetupStep = OpSetupUsesStep | OpSetupRunStep;
|
|
531
534
|
|
|
@@ -551,6 +554,38 @@ export interface OpSetupRunStep {
|
|
|
551
554
|
env?: Record<string, string>;
|
|
552
555
|
}
|
|
553
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The deployment environment a generated Op job runs in (#2257) — a forge
|
|
559
|
+
* object rather than a chant one. On GitHub Actions an environment carries
|
|
560
|
+
* its own protection rules (required reviewers, a wait timer, a branch
|
|
561
|
+
* restriction) and its own secrets and variables, so naming one on a job is
|
|
562
|
+
* how a generated apply is put behind a human before the job starts. GitLab
|
|
563
|
+
* has the same key with the same two fields and its own protected-environment
|
|
564
|
+
* approvals behind it. Forgejo Actions has no environments at all, so its
|
|
565
|
+
* dialect drops the key and says so in the generated file's header.
|
|
566
|
+
*
|
|
567
|
+
* This is not a chant gate and does not replace one. The environment reviewer
|
|
568
|
+
* stops the job before any step runs; chant's own gate (#2119) stops the apply
|
|
569
|
+
* inside a run that already started, on a fact recorded on the
|
|
570
|
+
* `chant/lifecycle` branch, and is cleared by `chant approve`. A project may
|
|
571
|
+
* have either, both, or neither per environment.
|
|
572
|
+
*/
|
|
573
|
+
export interface OpEnvironment {
|
|
574
|
+
/**
|
|
575
|
+
* The environment's name, exactly as the forge spells it. Nothing creates
|
|
576
|
+
* it: an environment is repository configuration, and a job naming one that
|
|
577
|
+
* does not exist yet gets an unprotected environment created on first run
|
|
578
|
+
* rather than an error, which is precisely why the name is not guessed here.
|
|
579
|
+
*/
|
|
580
|
+
name: string;
|
|
581
|
+
/**
|
|
582
|
+
* The URL shown against the resulting deployment. Absolute, or an
|
|
583
|
+
* expression the forge resolves (`${{ ... }}`) — a relative path renders as
|
|
584
|
+
* a dead link on the deployments page rather than failing anywhere.
|
|
585
|
+
*/
|
|
586
|
+
url?: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
554
589
|
/** One scheduled Op to generate CI for — the cron-triggered counterpart to a component (generate mode). */
|
|
555
590
|
export interface ScheduledOpSpec {
|
|
556
591
|
/** Op name (`*.op.ts`'s `Op({ name })`) — what `chant run <name>` targets. */
|
|
@@ -594,6 +629,15 @@ export interface ScheduledOpSpec {
|
|
|
594
629
|
* grants and OIDC cannot work without.
|
|
595
630
|
*/
|
|
596
631
|
permissions?: Record<string, "read" | "write">;
|
|
632
|
+
/**
|
|
633
|
+
* The forge deployment environment this Op's generated job runs in
|
|
634
|
+
* (#2257). Per Op, beside `setup` and `permissions`, and for the same
|
|
635
|
+
* reason: which environment a job deploys to is a property of that job — a
|
|
636
|
+
* pull-request plan touches none, and only the push apply belongs behind
|
|
637
|
+
* the reviewer. See {@link OpEnvironment} for how it composes with chant's
|
|
638
|
+
* own gate.
|
|
639
|
+
*/
|
|
640
|
+
environment?: OpEnvironment;
|
|
597
641
|
}
|
|
598
642
|
|
|
599
643
|
/**
|