@intentius/chant 0.58.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 (169) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/build-params.d.ts +2 -2
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/build.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts +13 -0
  10. package/dist/cli/commands/lint.d.ts.map +1 -1
  11. package/dist/cli/handlers/lint.d.ts.map +1 -1
  12. package/dist/cli/handlers/operator.d.ts.map +1 -1
  13. package/dist/cli/handlers/run.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts +0 -16
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/plugins.d.ts +22 -0
  17. package/dist/cli/plugins.d.ts.map +1 -1
  18. package/dist/cli/registry.d.ts +14 -0
  19. package/dist/cli/registry.d.ts.map +1 -1
  20. package/dist/components/cli-support.d.ts +4 -1
  21. package/dist/components/cli-support.d.ts.map +1 -1
  22. package/dist/components/component.d.ts +19 -4
  23. package/dist/components/component.d.ts.map +1 -1
  24. package/dist/components/driver.d.ts +8 -2
  25. package/dist/components/driver.d.ts.map +1 -1
  26. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  27. package/dist/components/verbs/run-agent.d.ts +1 -7
  28. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  29. package/dist/config.d.ts +4 -4
  30. package/dist/detectLexicon.d.ts +13 -0
  31. package/dist/detectLexicon.d.ts.map +1 -1
  32. package/dist/lexicon.d.ts +213 -3
  33. package/dist/lexicon.d.ts.map +1 -1
  34. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  35. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts +4 -4
  37. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  38. package/dist/op/activities/index.d.ts +2 -2
  39. package/dist/op/activities/index.d.ts.map +1 -1
  40. package/dist/op/activities/reconcile.d.ts +134 -2
  41. package/dist/op/activities/reconcile.d.ts.map +1 -1
  42. package/dist/op/builders.d.ts +2 -2
  43. package/dist/op/builders.d.ts.map +1 -1
  44. package/dist/op/change-signal.d.ts +91 -0
  45. package/dist/op/change-signal.d.ts.map +1 -0
  46. package/dist/op/composites/apply-op.d.ts +7 -2
  47. package/dist/op/composites/apply-op.d.ts.map +1 -1
  48. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  49. package/dist/op/gate-name.d.ts +40 -0
  50. package/dist/op/gate-name.d.ts.map +1 -0
  51. package/dist/op/gate-summary.d.ts +55 -0
  52. package/dist/op/gate-summary.d.ts.map +1 -0
  53. package/dist/op/index.d.ts +6 -2
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/op-ir.d.ts +2 -1
  57. package/dist/op/op-ir.d.ts.map +1 -1
  58. package/dist/op/operator.d.ts +63 -0
  59. package/dist/op/operator.d.ts.map +1 -1
  60. package/dist/op/types.d.ts +18 -3
  61. package/dist/op/types.d.ts.map +1 -1
  62. package/dist/params.d.ts +1 -1
  63. package/dist/project-root.d.ts +2 -2
  64. package/dist/terraform/bridge.d.ts +26 -7
  65. package/dist/terraform/bridge.d.ts.map +1 -1
  66. package/dist/terraform/carve-provider.d.ts +28 -2
  67. package/dist/terraform/carve-provider.d.ts.map +1 -1
  68. package/dist/terraform/data-source-shape.d.ts +77 -0
  69. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  70. package/dist/terraform/graph.d.ts.map +1 -1
  71. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  72. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  73. package/dist/terraform/tier-map.d.ts +16 -4
  74. package/dist/terraform/tier-map.d.ts.map +1 -1
  75. package/dist/terraform/types.d.ts +8 -0
  76. package/dist/terraform/types.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/audit/core.ts +25 -3
  79. package/src/audit/discover.ts +122 -14
  80. package/src/build-params.ts +2 -2
  81. package/src/cli/commands/audit.test.ts +5 -3
  82. package/src/cli/commands/audit.ts +5 -1
  83. package/src/cli/commands/build.test.ts +39 -1
  84. package/src/cli/commands/build.ts +22 -8
  85. package/src/cli/commands/carve-bridge.test.ts +237 -14
  86. package/src/cli/commands/carve-bridge.ts +21 -17
  87. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  88. package/src/cli/commands/lint.test.ts +297 -1
  89. package/src/cli/commands/lint.ts +119 -11
  90. package/src/cli/handlers/graph.test.ts +4 -4
  91. package/src/cli/handlers/graph.ts +11 -11
  92. package/src/cli/handlers/lint.test.ts +107 -0
  93. package/src/cli/handlers/lint.ts +30 -0
  94. package/src/cli/handlers/operator.ts +81 -0
  95. package/src/cli/handlers/run.test.ts +132 -3
  96. package/src/cli/handlers/run.ts +87 -3
  97. package/src/cli/main.test.ts +9 -11
  98. package/src/cli/main.ts +5 -27
  99. package/src/cli/plugins.test.ts +68 -2
  100. package/src/cli/plugins.ts +39 -0
  101. package/src/cli/registry.ts +14 -0
  102. package/src/components/README.md +2 -2
  103. package/src/components/SPRAWL-VALIDATION.md +5 -5
  104. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  105. package/src/components/cli-support.test.ts +18 -7
  106. package/src/components/cli-support.ts +8 -3
  107. package/src/components/component-schema.test.ts +18 -2
  108. package/src/components/component.schema.json +17 -4
  109. package/src/components/component.test.ts +2 -2
  110. package/src/components/component.ts +25 -5
  111. package/src/components/config-defaults.test.ts +2 -2
  112. package/src/components/driver.test.ts +20 -1
  113. package/src/components/driver.ts +12 -5
  114. package/src/components/pilots/README.md +1 -1
  115. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  116. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  117. package/src/components/verbs/run-agent.test.ts +19 -0
  118. package/src/components/verbs/run-agent.ts +1 -7
  119. package/src/config.ts +4 -4
  120. package/src/detectLexicon.ts +18 -1
  121. package/src/discovery/fold-import.test.ts +2 -2
  122. package/src/discovery/fold-import.ts +3 -3
  123. package/src/fold/foldable-helpers.ts +1 -1
  124. package/src/graph-ops.test.ts +1 -1
  125. package/src/lexicon.ts +221 -3
  126. package/src/lifecycle/gate-ledger.ts +12 -1
  127. package/src/lifecycle/observe.test.ts +2 -2
  128. package/src/lifecycle/observe.ts +8 -8
  129. package/src/lifecycle/release-ledger.test.ts +2 -2
  130. package/src/lint/pipeline-change-gate.test.ts +2 -2
  131. package/src/lint/rules/comp/comp.test.ts +26 -0
  132. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  133. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  134. package/src/op/activities/index.ts +9 -2
  135. package/src/op/activities/reconcile.test.ts +320 -2
  136. package/src/op/activities/reconcile.ts +423 -2
  137. package/src/op/builders.ts +3 -3
  138. package/src/op/change-signal.test.ts +117 -0
  139. package/src/op/change-signal.ts +169 -0
  140. package/src/op/composites/apply-op.ts +14 -4
  141. package/src/op/composites/composites.test.ts +17 -4
  142. package/src/op/composites/reconcile-op.ts +7 -4
  143. package/src/op/effect-step.test.ts +3 -3
  144. package/src/op/gate-name.test.ts +65 -0
  145. package/src/op/gate-name.ts +60 -0
  146. package/src/op/gate-summary.test.ts +62 -0
  147. package/src/op/gate-summary.ts +96 -0
  148. package/src/op/index.ts +9 -2
  149. package/src/op/local-executor.test.ts +16 -1
  150. package/src/op/local-executor.ts +4 -3
  151. package/src/op/op-ir.test.ts +12 -1
  152. package/src/op/op-ir.ts +5 -3
  153. package/src/op/op-verb-class.test.ts +2 -2
  154. package/src/op/op.test.ts +2 -2
  155. package/src/op/operator.test.ts +368 -0
  156. package/src/op/operator.ts +141 -5
  157. package/src/op/runtimes/local.test.ts +1 -1
  158. package/src/op/types.ts +23 -3
  159. package/src/params.ts +1 -1
  160. package/src/project-root.ts +2 -2
  161. package/src/terraform/aws-resources.test.ts +13 -4
  162. package/src/terraform/bridge.test.ts +22 -9
  163. package/src/terraform/bridge.ts +89 -28
  164. package/src/terraform/carve-provider.ts +38 -2
  165. package/src/terraform/data-source-shape.ts +95 -0
  166. package/src/terraform/graph.ts +35 -7
  167. package/src/terraform/providers/kubernetes.ts +48 -2
  168. package/src/terraform/tier-map.ts +21 -6
  169. package/src/terraform/types.ts +8 -0
@@ -1,9 +1,11 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { lintCommand, isLintRule, loadPluginRules, type LintOptions } from "./lint";
2
+ import { lintCommand, isLintRule, loadPluginRules, LEXICON_RESOLUTION_RULE_ID, type LintOptions } from "./lint";
3
+ import { loadPlugins, resolveProjectLexicons } from "../plugins";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { join, resolve } from "node:path";
5
6
  import { tmpdir } from "node:os";
6
7
  import { execFileSync } from "node:child_process";
8
+ import { params, setBuildParams } from "../../params";
7
9
 
8
10
  describe("lintCommand", () => {
9
11
  let testDir: string;
@@ -117,6 +119,69 @@ export default {
117
119
  ).toBe(true);
118
120
  });
119
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
+
120
185
  test("formats output as JSON", async () => {
121
186
  await writeFile(
122
187
  join(testDir, "nested.ts"),
@@ -541,3 +606,234 @@ describe("lintCommand — git-ignored files are not linted", () => {
541
606
  expect(result.diagnostics.some((d) => d.file.includes("authored.ts") && d.ruleId === "EVL003")).toBe(true);
542
607
  });
543
608
  });
609
+
610
+ /**
611
+ * chant #2222 (epic #2215 sub-issue 7) — `chant lint` used to print "No
612
+ * problems found" and exit 0 on a project whose declared lexicon could not be
613
+ * resolved, the state `chant build` refuses outright. These pin both halves:
614
+ * the failure is now reported, and the one case the old `catch` legitimately
615
+ * covered (a project that declares no lexicon at all) still lints quietly.
616
+ */
617
+ describe("lintCommand — a declared lexicon that cannot be resolved (#2222)", () => {
618
+ let testDir: string;
619
+
620
+ beforeEach(async () => {
621
+ testDir = join(tmpdir(), `chant-lint-lexicon-${Date.now()}-${Math.random()}`);
622
+ await mkdir(join(testDir, "src"), { recursive: true });
623
+ await writeFile(join(testDir, "src", "clean.ts"), `export const x = 1;\n`);
624
+ process.env.NO_COLOR = "1";
625
+ });
626
+
627
+ afterEach(async () => {
628
+ await rm(testDir, { recursive: true, force: true });
629
+ delete process.env.NO_COLOR;
630
+ });
631
+
632
+ /** The message `chant build` prints after `error: ` for the same project. */
633
+ async function buildsMessageFor(dir: string): Promise<string> {
634
+ try {
635
+ await loadPlugins(await resolveProjectLexicons(dir));
636
+ } catch (err) {
637
+ return err instanceof Error ? err.message : String(err);
638
+ }
639
+ throw new Error(`expected ${dir} to fail lexicon resolution`);
640
+ }
641
+
642
+ test("a declared lexicon whose package is not installed fails the lint", async () => {
643
+ await writeFile(
644
+ join(testDir, "chant.config.ts"),
645
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
646
+ );
647
+
648
+ const result = await lintCommand({ path: testDir, format: "stylish" });
649
+
650
+ expect(result.success).toBe(false);
651
+ expect(result.errorCount).toBe(1);
652
+ const diag = result.diagnostics.find((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID);
653
+ expect(diag).toBeDefined();
654
+ expect(diag!.severity).toBe("error");
655
+ expect(diag!.file).toBe(join(testDir, "chant.config.ts"));
656
+ // Byte-for-byte what `chant build` reports, not a second wording.
657
+ expect(diag!.message).toBe(await buildsMessageFor(testDir));
658
+ expect(result.output).not.toContain("No problems found");
659
+ });
660
+
661
+ test("a chant.config that cannot be loaded at all fails the lint with its own error", async () => {
662
+ // The shape found in the wild: the config imports the lexicon package, so
663
+ // it throws before `lexicons` is ever read.
664
+ await writeFile(
665
+ join(testDir, "chant.config.ts"),
666
+ `import { thing } from "@intentius/chant-lexicon-not-a-real-lexicon";\n` +
667
+ `export default { lexicons: ["aws"], thing };\n`,
668
+ );
669
+
670
+ const result = await lintCommand({ path: testDir, format: "stylish" });
671
+
672
+ expect(result.success).toBe(false);
673
+ const diag = result.diagnostics.find((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID);
674
+ expect(diag).toBeDefined();
675
+ expect(diag!.message).toBe(await buildsMessageFor(testDir));
676
+ expect(diag!.message).toContain("@intentius/chant-lexicon-not-a-real-lexicon");
677
+ });
678
+
679
+ test("the failure reaches --format json", async () => {
680
+ await writeFile(
681
+ join(testDir, "chant.config.ts"),
682
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
683
+ );
684
+
685
+ const result = await lintCommand({ path: testDir, format: "json" });
686
+
687
+ const parsed = JSON.parse(result.output) as Array<Record<string, unknown>>;
688
+ const entry = parsed.find((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID);
689
+ expect(entry).toBeDefined();
690
+ expect(entry!.severity).toBe("error");
691
+ expect(entry!.message).toBe(await buildsMessageFor(testDir));
692
+ });
693
+
694
+ test("the failure reaches --format sarif as an error-level result", async () => {
695
+ await writeFile(
696
+ join(testDir, "chant.config.ts"),
697
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
698
+ );
699
+
700
+ const result = await lintCommand({ path: testDir, format: "sarif" });
701
+
702
+ const sarif = JSON.parse(result.output) as {
703
+ runs: Array<{ results: Array<{ ruleId: string; level: string; message: { text: string } }> }>;
704
+ };
705
+ const found = sarif.runs[0].results.find((r) => r.ruleId === LEXICON_RESOLUTION_RULE_ID);
706
+ expect(found).toBeDefined();
707
+ expect(found!.level).toBe("error");
708
+ expect(found!.message.text).toBe(await buildsMessageFor(testDir));
709
+ });
710
+
711
+ test("--fix does not drop the diagnostic", async () => {
712
+ await writeFile(
713
+ join(testDir, "chant.config.ts"),
714
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
715
+ );
716
+
717
+ const result = await lintCommand({ path: testDir, format: "stylish", fix: true });
718
+
719
+ expect(result.success).toBe(false);
720
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(true);
721
+ });
722
+
723
+ // The case the swallowed `catch` was written for, and the one that must not
724
+ // change: no `chant.config.*`, no lexicon import in any source file. The
725
+ // detection fallback throws "No lexicon detected in infrastructure files",
726
+ // which is a project that lints under the core rules alone.
727
+ test("a project with no chant.config at all still lints clean", async () => {
728
+ const result = await lintCommand({ path: testDir, format: "stylish" });
729
+
730
+ expect(result.success).toBe(true);
731
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
732
+ expect(result.output).toContain("No problems found");
733
+ });
734
+
735
+ test("a chant.config that declares no lexicons still lints clean", async () => {
736
+ await writeFile(join(testDir, "chant.config.ts"), `export default {};\n`);
737
+
738
+ const result = await lintCommand({ path: testDir, format: "stylish" });
739
+
740
+ expect(result.success).toBe(true);
741
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
742
+ });
743
+
744
+ test("a declared lexicon that does resolve lints clean", async () => {
745
+ await writeFile(join(testDir, "chant.config.ts"), `export default { lexicons: ["aws"] };\n`);
746
+
747
+ const result = await lintCommand({ path: testDir, format: "stylish" });
748
+
749
+ expect(result.success).toBe(true);
750
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
751
+ });
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
+ });
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
4
4
  import { execFileSync } from "child_process";
5
5
  import { runLint, parseDisableComments } from "../../lint/engine";
6
6
  import type { LintRule, LintDiagnostic, LintFix } from "../../lint/rule";
7
- import type { IntrinsicDef } from "../../lexicon";
7
+ import type { IntrinsicDef, LexiconPlugin } from "../../lexicon";
8
8
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
9
9
  import { formatStylish, formatJson, formatSarif } from "../reporters/stylish";
10
10
  import { loadLocalRules } from "../../lint/rule-loader";
@@ -20,10 +20,14 @@ 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";
25
+ import { isNoLexiconDetected } from "../../detectLexicon";
23
26
 
24
27
  // Import config loader
25
28
  import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
26
29
  import { loadChantConfig, resolveKnowledgeDir } from "../../config";
30
+ import { findProjectConfig } from "../../project-root";
27
31
  import type { LintProjectConfig } from "../../lint/rule";
28
32
  import { loadOkfBundle, type OkfBundle } from "../../okf-read";
29
33
 
@@ -91,6 +95,40 @@ export async function loadPluginRules(
91
95
  return pluginRules;
92
96
  }
93
97
 
98
+ /**
99
+ * The diagnostic id `chant lint` reports a lexicon it could not resolve under
100
+ * (chant #2222).
101
+ *
102
+ * Deliberately outside the COR/EVL/COMP/OPS families: those are *rules*, each
103
+ * with a `check()` the engine runs per file, a documented page, a configurable
104
+ * severity and a `chant-disable` escape hatch. This is none of those. It is
105
+ * the lint run reporting that it could not assemble the rule set the project
106
+ * asked for, so it is not silenceable through `rules: { ... : "off" }` or a
107
+ * disable comment. Silencing it would put back the exact "green on a broken
108
+ * project" this id exists to prevent.
109
+ */
110
+ export const LEXICON_RESOLUTION_RULE_ID = "LEX001";
111
+
112
+ /**
113
+ * Turn a lexicon-resolution failure into the error diagnostic `chant lint`
114
+ * reports it as. The message is the underlying error's own, unwrapped, so it
115
+ * is character-for-character what `chant build` prints after `error: ` for the
116
+ * same project (`loadPluginsOrExit`, ../main.ts): one failure, one wording.
117
+ *
118
+ * Attributed to the project's `chant.config.*` where there is one, since that
119
+ * is the file that names the lexicon; to the project root otherwise.
120
+ */
121
+ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDiagnostic {
122
+ return {
123
+ file: findProjectConfig(projectRoot).configPath ?? projectRoot,
124
+ line: 1,
125
+ column: 1,
126
+ ruleId: LEXICON_RESOLUTION_RULE_ID,
127
+ severity: "error",
128
+ message: error.message,
129
+ };
130
+ }
131
+
94
132
  /**
95
133
  * Load all lint rules: core COR/EVL rules, then lexicon plugin rules.
96
134
  *
@@ -103,7 +141,7 @@ export async function loadPluginRules(
103
141
  */
104
142
  async function loadAllPluginRules(
105
143
  projectPath: string,
106
- ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[] }> {
144
+ ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[]; lexiconError?: Error }> {
107
145
  const rules = new Map<string, LintRule>();
108
146
 
109
147
  // Load core COR/EVL rules directly
@@ -111,16 +149,47 @@ async function loadAllPluginRules(
111
149
  rules.set(r.id, r);
112
150
  }
113
151
 
114
- // Resolve project lexicons (e.g. ["aws"]) from config or detection
152
+ // Resolve project lexicons (e.g. ["aws"]) from config or detection, then
153
+ // load their plugins.
154
+ //
155
+ // chant #2222: a failure in either step is handed back to `lintCommand`
156
+ // (which reports it as {@link LEXICON_RESOLUTION_RULE_ID}) rather than
157
+ // swallowed or thrown. Both steps fail on the same project state: a
158
+ // `chant.config.ts` that names a lexicon whose package is not installed
159
+ // throws out of `loadPlugins`, and one that *imports* that package throws
160
+ // out of `resolveProjectLexicons` before the names are ever read. Before
161
+ // this, the first case crashed `chant lint` with a stack trace and the
162
+ // second was caught here and discarded, so `chant lint` printed "No
163
+ // problems found" and exited 0 on a project `chant build` refuses to
164
+ // build. A CI job that lints before it builds reported green on a project
165
+ // whose lexicon was missing.
166
+ //
167
+ // The one failure that stays quiet is the one the original `catch` was
168
+ // written for and named in its comment: a project that declares no
169
+ // `lexicons` and imports none from its source files, where the detection
170
+ // fallback throws `NO_LEXICON_DETECTED_MESSAGE`. That project lints under
171
+ // the core rules alone and always has. It is told apart by the exported
172
+ // sentinel rather than by a string literal copied to this file, so the two
173
+ // cannot drift apart.
115
174
  let lexiconNames: string[] = [];
175
+ let lexiconError: Error | undefined;
116
176
  try {
117
177
  lexiconNames = await resolveProjectLexicons(projectPath);
118
- } catch {
119
- // No lexicons detected — core rules only
178
+ } catch (err) {
179
+ if (!isNoLexiconDetected(err)) {
180
+ lexiconError = err instanceof Error ? err : new Error(String(err));
181
+ }
120
182
  }
121
183
 
122
184
  // Load only project lexicon plugins (no "chant" injection)
123
- const plugins = await loadPlugins(lexiconNames);
185
+ let plugins: LexiconPlugin[] = [];
186
+ if (!lexiconError) {
187
+ try {
188
+ plugins = await loadPlugins(lexiconNames);
189
+ } catch (err) {
190
+ lexiconError = err instanceof Error ? err : new Error(String(err));
191
+ }
192
+ }
124
193
 
125
194
  // chant #1106 — the same plugins' registered intrinsics (`Ref`, `GetAtt`,
126
195
  // ...), so EVL001 can answer "does this call fold?" exactly like fold()
@@ -150,7 +219,7 @@ async function loadAllPluginRules(
150
219
  rules.set(r.id, r);
151
220
  }
152
221
 
153
- return { rules, intrinsics };
222
+ return { rules, intrinsics, ...(lexiconError ? { lexiconError } : {}) };
154
223
  }
155
224
 
156
225
  /**
@@ -468,11 +537,21 @@ async function runComponentCheckDiagnostics(
468
537
  async function runOpCheckDiagnostics(
469
538
  infraPath: string,
470
539
  files: string[],
540
+ buildParams?: BuildParamProvenance[],
471
541
  ): Promise<{ diagnostics: LintDiagnostic[]; suppressed: Array<LintDiagnostic & { reason?: string }> }> {
472
542
  const config = loadConfig(findProjectRoot(infraPath));
473
543
  const opFiles = files.filter((f) => f.endsWith(".op.ts"));
474
544
  if (opFiles.length === 0) return { diagnostics: [], suppressed: [] };
475
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
+
476
555
  const entities = new Map<string, unknown>();
477
556
  const fileByOpName = new Map<string, string>();
478
557
  for (const filePath of opFiles) {
@@ -573,10 +652,31 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
573
652
  // than a failure — `loadOkfBundle` already treats a missing directory as
574
653
  // an empty bundle.
575
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;
576
675
  try {
577
676
  const chantConfig = (await loadChantConfig(projectRoot)).config;
578
677
  projectConfig = chantConfig as LintProjectConfig;
579
678
  knowledgeBundle = await loadOkfBundle(resolveKnowledgeDir(chantConfig, projectRoot));
679
+ buildParams ??= resolveBuildParams(chantConfig.buildParams, { env: process.env }).provenance;
580
680
  } catch {
581
681
  projectConfig = undefined;
582
682
  knowledgeBundle = undefined;
@@ -629,14 +729,14 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
629
729
  // structurally distinct check family (whole-project, post-discovery,
630
730
  // see ../../lint/component-checks.ts) but the same `chant lint` output and
631
731
  // the same error-severity gating as every COR/EVL diagnostic.
632
- const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, options.buildParams);
732
+ const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, buildParams);
633
733
  diagnostics.push(...componentResult.diagnostics);
634
734
  suppressed.push(...componentResult.suppressed);
635
735
 
636
736
  // Run the OPS* Op-model post-synth checks (#2122) over every `*.op.ts`
637
737
  // file under the lint target — see runOpCheckDiagnostics's doc for why
638
738
  // this needs no lexicon or build to fire.
639
- const opResult = await runOpCheckDiagnostics(infraPath, files);
739
+ const opResult = await runOpCheckDiagnostics(infraPath, files, buildParams);
640
740
  diagnostics.push(...opResult.diagnostics);
641
741
  suppressed.push(...opResult.suppressed);
642
742
 
@@ -684,17 +784,25 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
684
784
  // `*.component.ts` file on their behalf), but a fix applied to another
685
785
  // rule could still be in the same file a component was discovered from —
686
786
  // re-run for consistency with the AST re-lint above.
687
- const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, options.buildParams);
787
+ const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox, buildParams);
688
788
  diagnostics.push(...postComponentResult.diagnostics);
689
789
  suppressed.push(...postComponentResult.suppressed);
690
790
 
691
791
  // OPS* checks have no `.fix` either; re-run for the same consistency
692
792
  // reason as the COMP* re-run just above.
693
- const postOpResult = await runOpCheckDiagnostics(infraPath, files);
793
+ const postOpResult = await runOpCheckDiagnostics(infraPath, files, buildParams);
694
794
  diagnostics.push(...postOpResult.diagnostics);
695
795
  suppressed.push(...postOpResult.suppressed);
696
796
  }
697
797
 
798
+ // chant #2222: a lexicon the project declared but this run could not
799
+ // resolve. Appended here, after the `--fix` re-lint block above has finished
800
+ // reassigning `diagnostics`, so it survives every path; it is a property of
801
+ // the run, not of any file a fix could touch.
802
+ if (loaded.lexiconError) {
803
+ diagnostics.push(lexiconResolutionDiagnostic(projectRoot, loaded.lexiconError));
804
+ }
805
+
698
806
  // Count errors and warnings
699
807
  let errorCount = 0;
700
808
  let warningCount = 0;
@@ -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
  });