@intentius/chant 0.27.0 → 0.28.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 (38) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  4. package/dist/discovery/sandbox/fork.d.ts +25 -0
  5. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  6. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  7. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  8. package/dist/kubectl-context.d.ts +73 -0
  9. package/dist/kubectl-context.d.ts.map +1 -0
  10. package/dist/lint/config.d.ts +80 -0
  11. package/dist/lint/config.d.ts.map +1 -1
  12. package/dist/lint/policy.d.ts +8 -2
  13. package/dist/lint/policy.d.ts.map +1 -1
  14. package/dist/lint/post-synth.d.ts +18 -1
  15. package/dist/lint/post-synth.d.ts.map +1 -1
  16. package/dist/stack-output.d.ts +9 -4
  17. package/dist/stack-output.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/commands/build.test.ts +190 -0
  20. package/src/cli/commands/build.ts +34 -2
  21. package/src/cli/commands/lint.ts +17 -25
  22. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  23. package/src/discovery/sandbox/config-run.ts +3 -0
  24. package/src/discovery/sandbox/fork.ts +75 -1
  25. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  26. package/src/discovery/sandbox/policy-run.ts +15 -1
  27. package/src/discovery/sandbox/run.test.ts +85 -1
  28. package/src/discovery/sandbox/run.ts +3 -0
  29. package/src/kubectl-context.test.ts +94 -0
  30. package/src/kubectl-context.ts +126 -0
  31. package/src/lint/config.test.ts +93 -1
  32. package/src/lint/config.ts +108 -0
  33. package/src/lint/policy.test.ts +90 -0
  34. package/src/lint/policy.ts +17 -5
  35. package/src/lint/post-synth.test.ts +4 -0
  36. package/src/lint/post-synth.ts +30 -1
  37. package/src/stack-output.test.ts +21 -3
  38. package/src/stack-output.ts +21 -9
@@ -0,0 +1,126 @@
1
+ /**
2
+ * K8s environment→cluster binding — chant #1100.
3
+ *
4
+ * Every cloud lexicon binds an environment to a scope: AWS resolves `<env>`
5
+ * to a CloudFormation stack, Azure treats `<env>` as the resource group,
6
+ * Temporal looks up `temporal.profiles.<env>` in `chant.config.ts`. K8s (and
7
+ * GCP-via-Config-Connector, which observes through the same kubectl path)
8
+ * bound nothing — `describeResources` shelled out to `kubectl get` with no
9
+ * `--context`, so it read whatever cluster `kubectl config current-context`
10
+ * happened to point at. Point `prod` at a dev cluster and every declared
11
+ * resource reads as missing — a wrong-cluster diff that looks like a
12
+ * confident list of deletions.
13
+ *
14
+ * This module is the shared resolver both the k8s and gcp lexicons'
15
+ * `describeResources` call, so they resolve a cluster identity the same way
16
+ * (see `lexicons/k8s/src/config.ts`'s `K8sChantConfig` for the declared
17
+ * shape). It is intentionally provider-agnostic and lives in core (like
18
+ * `./ownership.ts`) rather than in the k8s lexicon package, since gcp's
19
+ * Config Connector observation needs it too without taking a dependency on
20
+ * the k8s lexicon.
21
+ */
22
+
23
+ import { exec } from "node:child_process";
24
+ import { promisify } from "node:util";
25
+
26
+ const execAsync = promisify(exec);
27
+
28
+ /** A single environment's cluster binding — see `K8sChantConfig` in the k8s lexicon. */
29
+ export interface K8sClusterProfile {
30
+ /** kubectl context name this environment is bound to. */
31
+ context: string;
32
+ }
33
+
34
+ /** Shape of the `k8s` passthrough key in `chant.config.ts` that this resolver reads. */
35
+ export interface K8sConfigShape {
36
+ profiles?: Record<string, K8sClusterProfile>;
37
+ }
38
+
39
+ /**
40
+ * Thrown when an environment declares a cluster binding but the ambient
41
+ * kubectl context disagrees with it. Refusing here — instead of silently
42
+ * observing whichever cluster is ambient — is the fix for #1100: a
43
+ * wrong-cluster read reports every declared resource as missing, which #1089
44
+ * then classifies as a confident (and wrong) list of `create` actions.
45
+ */
46
+ export class ClusterBindingMismatchError extends Error {
47
+ constructor(
48
+ public readonly environment: string,
49
+ public readonly expectedContext: string,
50
+ public readonly ambientContext: string,
51
+ ) {
52
+ super(
53
+ `k8s: environment "${environment}" is bound to cluster context "${expectedContext}" ` +
54
+ `(k8s.profiles.${environment}.context), but the ambient kubectl context is ` +
55
+ `"${ambientContext}". Refusing to observe — reading the wrong cluster would misreport ` +
56
+ `every declared resource as missing. Run \`kubectl config use-context ${expectedContext}\` ` +
57
+ `to switch, or update the binding in chant.config.ts if "${ambientContext}" is actually correct.`,
58
+ );
59
+ this.name = "ClusterBindingMismatchError";
60
+ }
61
+ }
62
+
63
+ export interface ResolvedClusterTarget {
64
+ /**
65
+ * Explicit `--context` value to pass to every kubectl invocation. Present
66
+ * only when the environment has a declared binding — undefined means
67
+ * "no binding, keep today's ambient-context behavior".
68
+ */
69
+ context?: string;
70
+ /** Where the target came from. */
71
+ source: "bound" | "ambient";
72
+ }
73
+
74
+ /** Reads `kubectl config current-context`. Returns undefined if unset or kubectl fails. */
75
+ async function currentAmbientContext(): Promise<string | undefined> {
76
+ try {
77
+ const { stdout } = await execAsync("kubectl config current-context");
78
+ const trimmed = stdout.trim();
79
+ return trimmed.length > 0 ? trimmed : undefined;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Resolve the kubectl context an environment should be observed/applied
87
+ * against, reading `k8s.profiles.<environment>.context` from `chant.config.ts`
88
+ * (the `config` passed in is the passthrough `ChantConfig`, cast loosely since
89
+ * the `k8s` key isn't declared on the core schema — same pattern as
90
+ * `temporal.profiles`).
91
+ *
92
+ * - No binding declared: returns `{ source: "ambient" }` — unchanged
93
+ * behavior — but logs a visible warning identifying the caller and
94
+ * environment, so the fallback is never silent (#1100 acceptance).
95
+ * - Binding declared and the ambient context agrees (or ambient can't be
96
+ * determined): returns `{ context: bound, source: "bound" }`. Callers
97
+ * should pass this context explicitly on every kubectl invocation rather
98
+ * than relying on it also being ambient.
99
+ * - Binding declared and the ambient context disagrees: throws
100
+ * {@link ClusterBindingMismatchError} — a loud refusal instead of quietly
101
+ * reading the wrong cluster.
102
+ */
103
+ export async function resolveClusterTarget(
104
+ config: Record<string, unknown>,
105
+ environment: string,
106
+ lexiconName: string,
107
+ ): Promise<ResolvedClusterTarget> {
108
+ const k8sConfig = config.k8s as K8sConfigShape | undefined;
109
+ const bound = k8sConfig?.profiles?.[environment]?.context;
110
+
111
+ if (!bound) {
112
+ console.warn(
113
+ `[${lexiconName}] no cluster binding for environment "${environment}" ` +
114
+ `(k8s.profiles.${environment}.context in chant.config.ts) — observing whatever kubectl ` +
115
+ `context is ambient. Add a binding to pin this environment to a specific cluster (chant #1100).`,
116
+ );
117
+ return { source: "ambient" };
118
+ }
119
+
120
+ const ambient = await currentAmbientContext();
121
+ if (ambient && ambient !== bound) {
122
+ throw new ClusterBindingMismatchError(environment, bound, ambient);
123
+ }
124
+
125
+ return { context: bound, source: "bound" };
126
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { loadConfig, DEFAULT_CONFIG, findProjectRoot } from "./config";
2
+ import { loadConfig, DEFAULT_CONFIG, findProjectRoot, resolveConfiguredSeverity, applyConfiguredSeverity } from "./config";
3
+ import type { PostSynthDiagnostic } from "./post-synth";
3
4
  import { writeFileSync, mkdirSync, rmSync } from "fs";
4
5
  import { join, resolve } from "path";
5
6
 
@@ -717,3 +718,94 @@ describe("findProjectRoot", () => {
717
718
  expect(findProjectRoot(sub)).toBe(packageRoot);
718
719
  });
719
720
  });
721
+
722
+ /**
723
+ * chant #1138 — the one severity-resolution path AST lint rules
724
+ * (`../cli/commands/lint.ts`'s `getDefaultRules`), COMP* checks
725
+ * (`runComponentCheckDiagnostics`), and post-synth checks/policies
726
+ * (`applyConfiguredSeverity`, below) all now call, keyed by whichever id the
727
+ * caller has (a `LintRule.id`, a `ComponentCheck.id`, or a
728
+ * `PostSynthDiagnostic.checkId`) — a rule id behaves the same regardless of
729
+ * which phase produced it.
730
+ */
731
+ describe("resolveConfiguredSeverity", () => {
732
+ test("an id with no config entry falls back to the caller's default severity, with no options", () => {
733
+ expect(resolveConfiguredSeverity(undefined, "COR001", "error")).toEqual({ severity: "error" });
734
+ expect(resolveConfiguredSeverity({}, "COR001", "warning")).toEqual({ severity: "warning" });
735
+ expect(resolveConfiguredSeverity({ OTHER: "off" }, "COR001", "error")).toEqual({ severity: "error" });
736
+ });
737
+
738
+ test("a bare severity string overrides the default", () => {
739
+ expect(resolveConfiguredSeverity({ COR001: "warning" }, "COR001", "error")).toEqual({ severity: "warning" });
740
+ });
741
+
742
+ test('"off" suppresses regardless of the default severity', () => {
743
+ expect(resolveConfiguredSeverity({ WAW019: "off" }, "WAW019", "error")).toEqual({ severity: "off" });
744
+ });
745
+
746
+ test("a [severity, options] tuple carries options through", () => {
747
+ expect(resolveConfiguredSeverity({ COR009: ["warning", { max: 12 }] }, "COR009", "error")).toEqual({
748
+ severity: "warning",
749
+ options: { max: 12 },
750
+ });
751
+ });
752
+
753
+ test("an invalid severity in a [severity, options] tuple throws, naming the bad value", () => {
754
+ expect(() =>
755
+ resolveConfiguredSeverity({ COR009: ["fatal" as never, { max: 12 }] }, "COR009", "error"),
756
+ ).toThrow(/severity "fatal"/);
757
+ });
758
+ });
759
+
760
+ /**
761
+ * chant #1138 — `lint.rules` severity overrides apply to a post-synth check
762
+ * id (`diag.checkId`) through the identical `resolveConfiguredSeverity` an
763
+ * AST rule id or a COMP* check id goes through, so
764
+ * `lint.rules: { WAW019: "off" }` suppresses a post-synth finding just like a
765
+ * pre-synth one — the bug this issue reports.
766
+ */
767
+ describe("applyConfiguredSeverity", () => {
768
+ function diag(overrides: Partial<PostSynthDiagnostic> = {}): PostSynthDiagnostic {
769
+ return { checkId: "WAW019", severity: "error", message: "open ingress", ...overrides };
770
+ }
771
+
772
+ test("an unconfigured check id passes through unchanged — no drift for the common case", () => {
773
+ const result = applyConfiguredSeverity([diag()], undefined);
774
+ expect(result.diagnostics).toEqual([diag()]);
775
+ expect(result.suppressed).toEqual([]);
776
+ });
777
+
778
+ test('"off" suppresses the finding — moved to `suppressed`, not dropped, so it stays countable', () => {
779
+ const result = applyConfiguredSeverity([diag()], { WAW019: "off" });
780
+ expect(result.diagnostics).toEqual([]);
781
+ expect(result.suppressed).toEqual([diag()]);
782
+ });
783
+
784
+ test('"warning" downgrades an error-severity finding', () => {
785
+ const result = applyConfiguredSeverity([diag({ severity: "error" })], { WAW019: "warning" });
786
+ expect(result.diagnostics).toEqual([diag({ severity: "warning" })]);
787
+ expect(result.suppressed).toEqual([]);
788
+ });
789
+
790
+ test('"error" upgrades a warning-severity finding', () => {
791
+ const result = applyConfiguredSeverity([diag({ severity: "warning" })], { WAW019: "error" });
792
+ expect(result.diagnostics).toEqual([diag({ severity: "error" })]);
793
+ });
794
+
795
+ test("resolves each diagnostic by its own checkId — one config, independent ids", () => {
796
+ const diags = [
797
+ diag({ checkId: "WAW019" }),
798
+ diag({ checkId: "WAW049", message: "no logging" }),
799
+ diag({ checkId: "WAW099", message: "untouched" }),
800
+ ];
801
+ const result = applyConfiguredSeverity(diags, { WAW019: "off", WAW049: "warning" });
802
+ expect(result.suppressed.map((d) => d.checkId)).toEqual(["WAW019"]);
803
+ expect(result.diagnostics.map((d) => d.checkId)).toEqual(["WAW049", "WAW099"]);
804
+ expect(result.diagnostics.find((d) => d.checkId === "WAW049")?.severity).toBe("warning");
805
+ expect(result.diagnostics.find((d) => d.checkId === "WAW099")?.severity).toBe("error");
806
+ });
807
+
808
+ test("an empty diagnostics list is a no-op", () => {
809
+ expect(applyConfiguredSeverity([], { WAW019: "off" })).toEqual({ diagnostics: [], suppressed: [] });
810
+ });
811
+ });
@@ -3,6 +3,7 @@ import { join, dirname, resolve } from "path";
3
3
  import { z } from "zod";
4
4
  import { evaluateProjectConfigSync } from "../config-sandbox";
5
5
  import type { Severity, RuleConfig } from "./rule";
6
+ import type { PostSynthDiagnostic } from "./post-synth";
6
7
  import { moduleDir, getRuntime } from "../runtime-adapter";
7
8
  import strictPreset from "./presets/strict.json";
8
9
 
@@ -202,6 +203,40 @@ export function parseRuleConfig(value: RuleConfig): ParsedRuleConfig {
202
203
  return { severity, options };
203
204
  }
204
205
 
206
+ /**
207
+ * Resolve one check/rule id's effective severity (and options) against an
208
+ * already-resolved `lint.rules` map — the ONE place `"off"`/severity-override
209
+ * resolution happens, so a rule id behaves identically regardless of which
210
+ * phase produced it.
211
+ *
212
+ * chant #1138 — before this, the same `lint.rules: { ID: "off" }` config was
213
+ * resolved by two independent call sites that had grown their own copy of
214
+ * this logic (`../cli/commands/lint.ts`'s `getDefaultRules` for AST COR/EVL
215
+ * rules, and its `runComponentCheckDiagnostics` for whole-component COMP*
216
+ * checks) — identical in effect, but a rule id's suppression having two
217
+ * places to (potentially, eventually) diverge is itself the bug class #1138
218
+ * is about. Both were converted to call this instead, and post-synth checks/
219
+ * policies (`./post-synth.ts`'s `applyConfiguredSeverity`) now go through it
220
+ * too, closing the gap the issue reports: a post-synth check id honors
221
+ * `lint.rules` exactly like an AST rule id does.
222
+ *
223
+ * `rules` takes the already-resolved map (`config.rules`, or
224
+ * `resolveRulesForFile`'s per-file merge) rather than a whole `LintConfig` —
225
+ * callers that need per-file `overrides` resolve that first; post-synth
226
+ * checks have no per-file scope to begin with (see {@link
227
+ * ./post-synth.ts!PostSynthDiagnostic}'s doc for why), so they always pass
228
+ * `config.rules` directly.
229
+ */
230
+ export function resolveConfiguredSeverity(
231
+ rules: Record<string, RuleConfig> | undefined,
232
+ id: string,
233
+ defaultSeverity: Severity,
234
+ ): ParsedRuleConfig {
235
+ const configValue = rules?.[id];
236
+ if (configValue === undefined) return { severity: defaultSeverity };
237
+ return parseRuleConfig(configValue);
238
+ }
239
+
205
240
  /**
206
241
  * Default configuration with all rules enabled at strict preset severities
207
242
  */
@@ -411,3 +446,76 @@ export function resolveRulesForFile(config: LintConfig, filePath: string): Recor
411
446
 
412
447
  return rules;
413
448
  }
449
+
450
+ /** Result of applying `lint.rules` to a set of post-synth diagnostics. */
451
+ export interface PostSynthSeverityResult {
452
+ /** Diagnostics after config resolution — `"off"`-suppressed ones removed, everything else at its resolved severity. */
453
+ diagnostics: PostSynthDiagnostic[];
454
+ /**
455
+ * Diagnostics `lint.rules` turned `"off"`, unaltered — present so a caller
456
+ * can report a count (chant #1138) rather than the finding simply
457
+ * vanishing, mirroring `../lint/engine.ts`'s `LintRunResult.suppressed` for
458
+ * AST rules.
459
+ */
460
+ suppressed: PostSynthDiagnostic[];
461
+ }
462
+
463
+ /**
464
+ * Apply `lint.rules` severity overrides to already-produced post-synth
465
+ * diagnostics — one lexicon-shipped check's findings, or one project's
466
+ * `lint.policies` findings, it doesn't matter which: both are plain
467
+ * `PostSynthDiagnostic[]` by the time they reach this function, so both go
468
+ * through the identical resolution an AST rule id or a COMP* check id gets
469
+ * (`resolveConfiguredSeverity`, above), keyed by `diag.checkId` instead of
470
+ * `LintRule.id`/`ComponentCheck.id`. `"off"` suppresses (moved to
471
+ * `suppressed`); any other configured severity replaces `diag.severity`,
472
+ * exactly as a config override changes an AST diagnostic's reported level.
473
+ *
474
+ * Lives here rather than in `./post-synth.ts` (where `PostSynthDiagnostic` is
475
+ * declared) on purpose: `post-synth.ts` is a leaf every lexicon's checks
476
+ * import as a real runtime module, and this file resolves built-in preset
477
+ * paths via the runtime adapter at module scope — pulling that into every
478
+ * lexicon's check barrel merely to share one filter function would be the
479
+ * wrong trade. Only this file's TYPE (`PostSynthDiagnostic`) crosses back,
480
+ * which costs nothing at runtime.
481
+ *
482
+ * chant #1138 — deliberately does NOT also honor `chant-disable` source
483
+ * comments. `PostSynthDiagnostic` has no source anchor to disable AT — see
484
+ * its doc comment (`./post-synth.ts`) for why `entity` doesn't supply one —
485
+ * so there is no coherent site to check for a directive. Even in the one case
486
+ * a real anchor sometimes exists (a live, in-process `ctx.entities` value
487
+ * stamped with build provenance, `../provenance.ts`'s `getProvenance`), it
488
+ * would not generalize: not every check sets `entity`, `entity` isn't
489
+ * guaranteed to be an entities-map key (it's a name in the synthesized
490
+ * OUTPUT — a CFN logical id, a k8s `metadata.name` — which a serializer is
491
+ * free to have derived, prefixed, or renamed from the source-level entity
492
+ * name), and that provenance never crosses the `--sandbox` policy child's
493
+ * JSON wire (`../discovery/entity-wire-codec.ts` doesn't carry it, and
494
+ * re-deriving it on the far side would mean sending source file paths into a
495
+ * channel that's supposed to carry only the resolved build). Building
496
+ * directive suppression on a sometimes-present, sometimes-not anchor would
497
+ * make `chant build` and `chant build --sandbox` disagree about the
498
+ * identical finding depending on which path happened to still have the
499
+ * entity object around — precisely the kind of inconsistency #1138 exists to
500
+ * remove. Config severity (`"off"`) is the one suppression surface this can
501
+ * offer uniformly; a check that wants a per-instance escape hatch can read
502
+ * `ctx.env`/its own options to decide not to emit a diagnostic at all.
503
+ */
504
+ export function applyConfiguredSeverity(
505
+ diagnostics: readonly PostSynthDiagnostic[],
506
+ rules: Record<string, RuleConfig> | undefined,
507
+ ): PostSynthSeverityResult {
508
+ const kept: PostSynthDiagnostic[] = [];
509
+ const suppressed: PostSynthDiagnostic[] = [];
510
+
511
+ for (const diag of diagnostics) {
512
+ const resolved = resolveConfiguredSeverity(rules, diag.checkId, diag.severity);
513
+ if (resolved.severity === "off") {
514
+ suppressed.push(diag);
515
+ continue;
516
+ }
517
+ kept.push(resolved.severity === diag.severity ? diag : { ...diag, severity: resolved.severity });
518
+ }
519
+
520
+ return { diagnostics: kept, suppressed };
521
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * chant #1138 — `evaluateProjectPolicies` (the `policyGate` Op step's entry
3
+ * point, ../../lexicons/temporal/src/op/activities/policy.ts) applies
4
+ * `lint.rules` severity overrides to policy diagnostics the same way `chant
5
+ * build` does (`../cli/commands/build.ts`), so a check `lint.rules` turns
6
+ * "off" no longer gates an apply either — before this fix, only `chant
7
+ * build`'s own error list was affected (and, before #1138, not even that).
8
+ *
9
+ * No lexicon is declared: the fixture project has no source files, so
10
+ * `resolveProjectLexicons` detects none and `build()` succeeds trivially with
11
+ * zero entities — the policy pack is the only thing under test.
12
+ */
13
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
14
+ import { mkdir, rm, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { evaluateProjectPolicies } from "./policy";
18
+
19
+ describe("evaluateProjectPolicies honors lint.rules (chant #1138)", () => {
20
+ let testDir: string;
21
+
22
+ beforeEach(async () => {
23
+ testDir = join(tmpdir(), `chant-policy-eval-test-${Date.now()}-${Math.random()}`);
24
+ await mkdir(join(testDir, "policies"), { recursive: true });
25
+ });
26
+
27
+ afterEach(async () => {
28
+ await rm(testDir, { recursive: true, force: true });
29
+ });
30
+
31
+ async function writePolicyProject(checkId: string, severity: "error" | "warning", rules: Record<string, string>): Promise<void> {
32
+ await writeFile(
33
+ join(testDir, "policies", "org.ts"),
34
+ `export const check = {\n` +
35
+ ` id: ${JSON.stringify(checkId)},\n` +
36
+ ` description: "test policy",\n` +
37
+ ` check: () => [{ checkId: ${JSON.stringify(checkId)}, severity: ${JSON.stringify(severity)}, message: ${JSON.stringify(`${checkId} triggered`)} }],\n` +
38
+ `};\n`,
39
+ );
40
+ await writeFile(
41
+ join(testDir, "chant.config.ts"),
42
+ // `lexicons: ["k8s"]` avoids `resolveProjectLexicons`'s source-import
43
+ // auto-detection, which throws on a project with no lexicon-importing
44
+ // source file at all (this fixture has none — the policy pack is the
45
+ // only thing under test).
46
+ `export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"], rules: ${JSON.stringify(rules)} } };\n`,
47
+ );
48
+ }
49
+
50
+ test('lint.rules "off" removes a check from violations and reports it as suppressed', async () => {
51
+ await writePolicyProject("ORG-OFF", "error", { "ORG-OFF": "off" });
52
+
53
+ const evaluation = await evaluateProjectPolicies({ path: testDir });
54
+
55
+ expect(evaluation.violations).toEqual([]);
56
+ expect(evaluation.diagnostics).toEqual([]);
57
+ expect(evaluation.suppressed).toHaveLength(1);
58
+ expect(evaluation.suppressed[0].checkId).toBe("ORG-OFF");
59
+ });
60
+
61
+ test('lint.rules "warning" downgrades an error-severity check out of violations', async () => {
62
+ await writePolicyProject("ORG-DOWN", "error", { "ORG-DOWN": "warning" });
63
+
64
+ const evaluation = await evaluateProjectPolicies({ path: testDir });
65
+
66
+ expect(evaluation.violations).toEqual([]);
67
+ expect(evaluation.diagnostics).toHaveLength(1);
68
+ expect(evaluation.diagnostics[0].severity).toBe("warning");
69
+ expect(evaluation.suppressed).toEqual([]);
70
+ });
71
+
72
+ test('lint.rules "error" upgrades a warning-severity check INTO violations', async () => {
73
+ await writePolicyProject("ORG-UP", "warning", { "ORG-UP": "error" });
74
+
75
+ const evaluation = await evaluateProjectPolicies({ path: testDir });
76
+
77
+ expect(evaluation.violations).toHaveLength(1);
78
+ expect(evaluation.violations[0].checkId).toBe("ORG-UP");
79
+ expect(evaluation.violations[0].severity).toBe("error");
80
+ });
81
+
82
+ test("an unconfigured check id is unaffected — no drift for the common case", async () => {
83
+ await writePolicyProject("ORG-PLAIN", "error", {});
84
+
85
+ const evaluation = await evaluateProjectPolicies({ path: testDir });
86
+
87
+ expect(evaluation.violations).toHaveLength(1);
88
+ expect(evaluation.suppressed).toEqual([]);
89
+ });
90
+ });
@@ -9,6 +9,7 @@ import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
9
9
  import { build } from "../build";
10
10
  import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
11
11
  import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
12
+ import { applyConfiguredSeverity } from "./config";
12
13
  import { importPolicyModule, isSandboxPolicyExecutionArmed } from "./policy-import";
13
14
 
14
15
  /**
@@ -49,12 +50,18 @@ export async function loadPolicyChecks(paths: string[], configDir: string): Prom
49
50
  }
50
51
 
51
52
  export interface PolicyEvaluation {
52
- /** All policy diagnostics (errors + warnings). */
53
+ /** All policy diagnostics (errors + warnings), after `lint.rules` severity overrides. */
53
54
  diagnostics: PostSynthDiagnostic[];
54
- /** The error-severity subset — these are policy *violations* that gate. */
55
+ /** The error-severity subset (post-override) — these are policy *violations* that gate. */
55
56
  violations: PostSynthDiagnostic[];
56
57
  /** The environment policies were evaluated against (if any). */
57
58
  env?: string;
59
+ /**
60
+ * chant #1138 — diagnostics `lint.rules` turned `"off"`, present here rather
61
+ * than dropped so a caller (`policyGate`, below) can report a count instead
62
+ * of the finding just vanishing.
63
+ */
64
+ suppressed: PostSynthDiagnostic[];
58
65
  }
59
66
 
60
67
  /**
@@ -90,10 +97,15 @@ export async function evaluateProjectPolicies(opts: {
90
97
  ? await loadPolicyChecks(config.lint.policies, configDir)
91
98
  : [];
92
99
  if (checks.length === 0) {
93
- return { diagnostics: [], violations: [], env };
100
+ return { diagnostics: [], violations: [], env, suppressed: [] };
94
101
  }
95
102
 
96
- const diagnostics = runPostSynthChecks(checks, result, env);
103
+ const raw = runPostSynthChecks(checks, result, env);
104
+ // chant #1138 — same `lint.rules` resolution `chant build` applies
105
+ // (`../cli/commands/build.ts`), so a check `lint.rules` turned "off"/
106
+ // "warning" doesn't gate an apply here even though `chant build` no longer
107
+ // fails on it either, and vice versa for a check turned UP to "error".
108
+ const { diagnostics, suppressed } = applyConfiguredSeverity(raw, config.lint?.rules);
97
109
  const violations = diagnostics.filter((d) => d.severity === "error");
98
- return { diagnostics, violations, env };
110
+ return { diagnostics, violations, env, suppressed };
99
111
  }
@@ -141,3 +141,7 @@ describe("isPostSynthCheck", () => {
141
141
  expect(isPostSynthCheck("nope")).toBe(false);
142
142
  });
143
143
  });
144
+
145
+ // chant #1138 — `applyConfiguredSeverity` (the `lint.rules` severity-override
146
+ // pass over `PostSynthDiagnostic`s) is tested in `./config.test.ts`, where the
147
+ // function itself now lives — see `./config.ts`'s doc comment for why.
@@ -35,6 +35,19 @@ export function getPrimaryOutput(output: string | SerializerResult): string {
35
35
 
36
36
  /**
37
37
  * A diagnostic from a post-synthesis check.
38
+ *
39
+ * chant #1138 — deliberately carries no `file`/`line` the way `LintDiagnostic`
40
+ * (`./rule.ts`) does. A post-synth check runs over `ctx.outputs` — the
41
+ * SYNTHESIZED output text (a CloudFormation template, a Kubernetes manifest) —
42
+ * not a `ts.SourceFile`, so there is no AST position to report in the first
43
+ * place. `entity` (below) is the closest thing to a locator and is NOT a
44
+ * substitute: it names a resource in that synthesized output (a CFN logical
45
+ * id, a k8s `metadata.name`), which several checks in this repo never even
46
+ * set (a cross-cutting check with no single implicated resource), and which
47
+ * is not guaranteed to match a `ctx.entities` map key. This is why source-
48
+ * comment (`chant-disable`) suppression is out of scope for post-synth
49
+ * findings — see `./config.ts`'s `applyConfiguredSeverity` doc for the full
50
+ * reasoning and what suppression surface post-synth findings get instead.
38
51
  */
39
52
  export interface PostSynthDiagnostic {
40
53
  /** ID of the check that produced this diagnostic */
@@ -43,7 +56,11 @@ export interface PostSynthDiagnostic {
43
56
  severity: Severity;
44
57
  /** Human-readable message */
45
58
  message: string;
46
- /** Optional entity name related to this diagnostic */
59
+ /**
60
+ * Optional resource name related to this diagnostic — a name from the
61
+ * SYNTHESIZED OUTPUT (a CFN logical id, a k8s `metadata.name`), not a
62
+ * source file/line. See this interface's doc comment.
63
+ */
47
64
  entity?: string;
48
65
  /** Optional lexicon related to this diagnostic */
49
66
  lexicon?: string;
@@ -96,3 +113,15 @@ export function runPostSynthChecks(
96
113
  }
97
114
  return diagnostics;
98
115
  }
116
+
117
+ // chant #1138 — `applyConfiguredSeverity` (the `lint.rules` severity-override
118
+ // pass over a set of `PostSynthDiagnostic`s) lives in `./config.ts`, not here,
119
+ // even though it operates on this module's own type. This file is a leaf:
120
+ // every lexicon's post-synth checks import it as a real runtime module (not
121
+ // just for types — `getPrimaryOutput` above is a plain function several
122
+ // checks call directly), so it has to stay cheap to load. `./config.ts` is
123
+ // not cheap — it resolves built-in preset paths via the runtime adapter at
124
+ // module scope — and pulling that into every lexicon's check barrel merely to
125
+ // share one filter function is the wrong trade. `applyConfiguredSeverity`
126
+ // only needs this module's TYPE (`PostSynthDiagnostic`), which costs nothing
127
+ // at runtime, so the dependency runs the other way instead.
@@ -41,9 +41,9 @@ describe("stackOutput", () => {
41
41
  expect(out.description).toBe("Primary VPC id");
42
42
  });
43
43
 
44
- test("throws for a value that is neither AttrRef-like nor an Intrinsic", () => {
45
- expect(() => stackOutput("not-a-ref" as unknown as AttrRef)).toThrow(
46
- "stackOutput(ref): ref must be an attribute reference or an intrinsic wrapping one",
44
+ test("throws for a value that is neither AttrRef-like, Intrinsic, nor a string literal", () => {
45
+ expect(() => stackOutput(42 as unknown as AttrRef)).toThrow(
46
+ "stackOutput(ref): ref must be an attribute reference, an intrinsic wrapping one, or a literal string",
47
47
  );
48
48
  });
49
49
 
@@ -116,3 +116,21 @@ describe("isStackOutput", () => {
116
116
  expect(isStackOutput(42)).toBe(false);
117
117
  });
118
118
  });
119
+
120
+ describe("stackOutput export names and literals", () => {
121
+ test("carries exportName through to the declaration", () => {
122
+ const ref = new AttrRef(vpc, "VpcId");
123
+ const out = stackOutput(ref, { exportName: "my-stack-VpcId" });
124
+ expect(out.exportName).toBe("my-stack-VpcId");
125
+ });
126
+
127
+ test("accepts a literal string with an explicit lexicon", () => {
128
+ const out = stackOutput("22", { lexicon: "aws", exportName: "my-stack-OpenSSHPort" });
129
+ expect(out.sourceRef).toBe("22");
130
+ expect(out.lexicon).toBe("aws");
131
+ });
132
+
133
+ test("rejects a literal without a lexicon", () => {
134
+ expect(() => stackOutput("22")).toThrow(/options\.lexicon/);
135
+ });
136
+ });
@@ -26,10 +26,13 @@ export interface StackOutput extends Declarable {
26
26
  readonly lexicon: string;
27
27
  readonly entityType: string;
28
28
  readonly kind: "output";
29
- /** The exported value: a bare attribute reference, or an intrinsic wrapping
30
- * one (e.g. `Join(",", zone.NameServers)`). */
31
- readonly sourceRef: AttrRef | Intrinsic;
29
+ /** The exported value: a bare attribute reference, an intrinsic wrapping
30
+ * one (e.g. `Join(",", zone.NameServers)`), or a literal string. */
31
+ readonly sourceRef: AttrRef | Intrinsic | string;
32
32
  readonly description?: string;
33
+ /** When set, the serializer emits a cross-stack export under this name
34
+ * (CloudFormation `Output.Export.Name`) in addition to the plain output. */
35
+ readonly exportName?: string;
33
36
  }
34
37
 
35
38
  /** Find the first AttrRef anywhere inside a value (walking intrinsics/objects/
@@ -82,8 +85,8 @@ export function isStackOutput(value: unknown): value is StackOutput {
82
85
  * ```
83
86
  */
84
87
  export function stackOutput(
85
- ref: AttrRef | Intrinsic,
86
- options?: { description?: string },
88
+ ref: AttrRef | Intrinsic | string,
89
+ options?: { description?: string; exportName?: string; lexicon?: string },
87
90
  ): StackOutput {
88
91
  // Duck-type, not `instanceof` (chant #1137): AttrRef also implements
89
92
  // Intrinsic (a global-symbol marker), so `isIntrinsic(ref)` alone already
@@ -92,19 +95,27 @@ export function stackOutput(
92
95
  // so the guard's own logic states the invariant explicitly ("ref must be
93
96
  // AttrRef-like or Intrinsic") rather than relying on that coincidence,
94
97
  // matching the anchor selection right below it, which does misbehave.
95
- if (!isAttrRefLike(ref) && !isIntrinsic(ref)) {
96
- throw new Error("stackOutput(ref): ref must be an attribute reference or an intrinsic wrapping one");
98
+ if (typeof ref !== "string" && !isAttrRefLike(ref) && !isIntrinsic(ref)) {
99
+ throw new Error(
100
+ "stackOutput(ref): ref must be an attribute reference, an intrinsic wrapping one, or a literal string",
101
+ );
102
+ }
103
+ if (typeof ref === "string" && !options?.lexicon) {
104
+ throw new Error(
105
+ "stackOutput(literal): a literal output has no entity to derive its lexicon from — pass options.lexicon",
106
+ );
97
107
  }
98
108
  // Derive lexicon from the referenced entity — for a bare AttrRef, its parent;
99
109
  // for an intrinsic (Join etc.), the first AttrRef nested inside it. A
100
110
  // foreign-copy AttrRef failing raw `instanceof` here would fall to
101
111
  // `firstAttrRef`, which (before its own #1137 fix) would also miss it —
102
112
  // silently anchoring on nothing and recording `lexicon: "unknown"`.
103
- const anchor = isAttrRefLike(ref) ? ref : firstAttrRef(ref);
113
+ const anchor = typeof ref === "string" ? undefined : isAttrRefLike(ref) ? ref : firstAttrRef(ref);
104
114
  const parent = anchor?.parent.deref();
105
- const lexicon = parent && typeof (parent as Record<string, unknown>).lexicon === "string"
115
+ const derived = parent && typeof (parent as Record<string, unknown>).lexicon === "string"
106
116
  ? (parent as Record<string, unknown>).lexicon as string
107
117
  : "unknown";
118
+ const lexicon = options?.lexicon ?? derived;
108
119
 
109
120
  const output: StackOutput = {
110
121
  [STACK_OUTPUT_MARKER]: true,
@@ -114,6 +125,7 @@ export function stackOutput(
114
125
  kind: "output",
115
126
  sourceRef: ref,
116
127
  description: options?.description,
128
+ exportName: options?.exportName,
117
129
  };
118
130
 
119
131
  return output;