@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.
Files changed (42) hide show
  1. package/dist/build-params.d.ts +2 -2
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/lint.d.ts.map +1 -1
  4. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  5. package/dist/config.d.ts +4 -4
  6. package/dist/lexicon.d.ts +48 -5
  7. package/dist/lexicon.d.ts.map +1 -1
  8. package/dist/lifecycle/observe.d.ts +4 -4
  9. package/dist/op/activities/index.d.ts +1 -1
  10. package/dist/op/activities/index.d.ts.map +1 -1
  11. package/dist/op/activities/reconcile.d.ts +79 -7
  12. package/dist/op/activities/reconcile.d.ts.map +1 -1
  13. package/dist/op/gate-summary.d.ts +16 -4
  14. package/dist/op/gate-summary.d.ts.map +1 -1
  15. package/dist/params.d.ts +1 -1
  16. package/dist/project-root.d.ts +2 -2
  17. package/package.json +1 -1
  18. package/src/build-params.ts +2 -2
  19. package/src/cli/commands/build.ts +8 -8
  20. package/src/cli/commands/lint.test.ts +151 -0
  21. package/src/cli/commands/lint.ts +37 -4
  22. package/src/cli/handlers/graph.test.ts +4 -4
  23. package/src/cli/handlers/graph.ts +11 -11
  24. package/src/cli/handlers/lint.test.ts +107 -0
  25. package/src/cli/handlers/lint.ts +30 -0
  26. package/src/components/SPRAWL-VALIDATION.md +5 -5
  27. package/src/components/pilots/README.md +1 -1
  28. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  29. package/src/config.ts +4 -4
  30. package/src/discovery/fold-import.test.ts +1 -1
  31. package/src/discovery/fold-import.ts +3 -3
  32. package/src/lexicon.ts +49 -5
  33. package/src/lifecycle/observe.test.ts +2 -2
  34. package/src/lifecycle/observe.ts +8 -8
  35. package/src/lifecycle/release-ledger.test.ts +2 -2
  36. package/src/op/activities/index.ts +8 -1
  37. package/src/op/activities/reconcile.test.ts +238 -0
  38. package/src/op/activities/reconcile.ts +267 -13
  39. package/src/op/gate-summary.test.ts +62 -0
  40. package/src/op/gate-summary.ts +17 -5
  41. package/src/params.ts +1 -1
  42. 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
+ });
@@ -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, options.buildParams);
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, options.buildParams);
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
- // (loomster/Floci) has no stack literally named after the environment, so
715
- // the live graph must resolve each component's own `cfn-deploy` stack(s)
716
- // and pass them through to `observeResources` for the per-stack union.
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 (loomster/Floci, #57): AWS's
257
- // single-stack convention (`describeResources` defaults to a stack named
258
- // after the environment, lexicons/aws/src/plugin.ts) never matches a
259
- // per-component layout (e.g. `loom-local-a-<component>`), so the plain
260
- // single call always observes zero nodes. Resolve every discovered
261
- // component's `cfn-deploy` stack(s) — the same walk `chant components
262
- // status --live` uses (`cfnDeployStacks`, ./components.ts) — and hand them
263
- // to `observeResources`, which queries `describeResources` once per stack
264
- // and unions the results. A project with no components (or whose discovery
265
- // errors) yields no stacks, so `observeResources` falls back to its
266
- // original single-stack call — unchanged.
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
+ });
@@ -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-api/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-api/src/pipeline.ts)
20
- hand-rolls a `deployService` job that shells out to CloudFormation and greps
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-api/src/pipeline.ts (deployService job)
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
- `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 image=$IMAGE_URI`,
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-api/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-api/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 |
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-api/src/pipeline.ts`: the
13
- * `describe-stacks`/`jq` glue in that pipeline's `deployService` job is
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
- * (loomster's `src/<stack>` layout is exactly one level too deep: `buildParams`'
485
- * declared `env:` mappings never resolved, so `LOOM_TIER`/`LOOM_ENV` were inert
486
- * under every `npm run synth:*` for two releases — loomster#162). Uses the
487
- * same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
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 (loomster's `params.x ?? \"default\"` pattern)", async () => {
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 loomster's `composites/*.ts` fall outside
1701
- // (module-level `buildXxx()` helpers with `if`/`throw` and `.map()`), 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 that triggered the run
498
- * (#2231), so unlike every other mode it constrains the trigger: the github
499
- * generator refuses it by name on anything but `pull_request`, and the gitlab
500
- * and forgejo generators refuse it outright.
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
  /**