@intentius/chant 0.45.0 → 0.49.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 (223) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -0,0 +1,93 @@
1
+ import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
2
+ import { isEffectReceipt } from "../effect-receipt";
3
+ import { isSensitiveKey } from "../deep-observation";
4
+ import { isResourceDeclarable } from "../declarable";
5
+
6
+ /**
7
+ * COR023: Receipt Materializes Into a Plain Store (#1833, epic #1703)
8
+ *
9
+ * A receipt's materialization target must be a plain store — never a
10
+ * SecureString, a Secret, or any secret-capable kind. A receipt value is a
11
+ * witness (an existence marker or a `sha256:` digest,
12
+ * ../effect-receipt.ts), not a secret: parking it in a secret-capable store
13
+ * invites masking, rotation, and access-control semantics that defeat the
14
+ * observe-and-compare loop the receipt exists for, and quietly teaches
15
+ * readers that the value is sensitive when it must never be.
16
+ *
17
+ * This is the honest core half of the guard. Core recognizes receipts
18
+ * lexicon-independently through the marker (#1831) but cannot know what any
19
+ * lexicon's kinds mean, so it checks the two signals it does have, both
20
+ * reusing the secret-kind knowledge core already carries
21
+ * (SENSITIVE_KEY_PATTERNS, ../deep-observation.ts):
22
+ *
23
+ * - the materialized `entityType` names a secret-capable kind
24
+ * (`K8s::Core::Secret`, `AWS::SecretsManager::Secret`, ...);
25
+ * - the declared props select a secret-capable variant of an otherwise
26
+ * plain kind (a `Type`/`Kind` prop whose value matches secret/secure —
27
+ * SSM's `Type: "SecureString"` is the canonical case).
28
+ *
29
+ * The concrete per-kind enforcement is each materialization row's job — the
30
+ * aws row (#1835) pins SSM to plain `String` at the source.
31
+ */
32
+
33
+ /** Prop names that select a store variant. */
34
+ const VARIANT_PROP = /^(type|kind)$/i;
35
+ /** Variant values that make the store secret-capable. */
36
+ const SECRET_VARIANT = /secret|secure/i;
37
+
38
+ export const RECEIPT_PLAIN_STORE_CHECK_ID = "COR023";
39
+
40
+ const receiptPlainStoreCheck: PostSynthCheck = {
41
+ id: RECEIPT_PLAIN_STORE_CHECK_ID,
42
+ description:
43
+ "An effect receipt's materialization target must be a plain store — never a secret-capable kind or variant",
44
+ check(ctx) {
45
+ const diagnostics: PostSynthDiagnostic[] = [];
46
+ for (const [name, entity] of ctx.entities) {
47
+ if (!isEffectReceipt(entity)) continue;
48
+
49
+ if (isSensitiveKey(entity.entityType)) {
50
+ diagnostics.push({
51
+ checkId: RECEIPT_PLAIN_STORE_CHECK_ID,
52
+ severity: "error",
53
+ entity: name,
54
+ lexicon: entity.lexicon,
55
+ message:
56
+ `Effect receipt "${name}" materializes into secret-capable kind "${entity.entityType}" — ` +
57
+ `a receipt's target must be a plain store. The receipt value is a witness ` +
58
+ `(an existence marker or a sha256 digest), not a secret; a secret-capable store adds ` +
59
+ `masking and rotation semantics that defeat the receipt's observe-and-compare loop.`,
60
+ });
61
+ continue;
62
+ }
63
+
64
+ if (isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null) {
65
+ for (const [key, value] of Object.entries(entity.props as Record<string, unknown>)) {
66
+ if (VARIANT_PROP.test(key) && typeof value === "string" && SECRET_VARIANT.test(value)) {
67
+ diagnostics.push({
68
+ checkId: RECEIPT_PLAIN_STORE_CHECK_ID,
69
+ severity: "error",
70
+ entity: name,
71
+ lexicon: entity.lexicon,
72
+ message:
73
+ `Effect receipt "${name}" (${entity.entityType}) declares ${key}: "${value}" — ` +
74
+ `a secret-capable store variant. A receipt's target must be a plain store; ` +
75
+ `use the plain variant (e.g. SSM Type: "String") so the witness value stays ` +
76
+ `readable to the observe-and-compare loop.`,
77
+ });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return diagnostics;
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Core's own post-synth checks over effect receipts, run by `chant build`
88
+ * over the full build result (plugin checks are lexicon-scoped; receipts are
89
+ * recognized by marker, so this set is deliberately not).
90
+ */
91
+ export function coreReceiptChecks(): PostSynthCheck[] {
92
+ return [receiptPlainStoreCheck];
93
+ }
package/src/lint/rule.ts CHANGED
@@ -49,6 +49,21 @@ export interface LintDiagnostic {
49
49
  fix?: LintFix;
50
50
  }
51
51
 
52
+ /**
53
+ * The slice of the project's `chant.config` a config-aware rule reads
54
+ * (#1221) — threaded into {@link LintContext} by `runLint` when the caller
55
+ * resolved the project's config (`chant lint` does; a bare unit test or the
56
+ * LSP's single-file lint may not). Structurally mirrors the corresponding
57
+ * `ChantConfig` fields (../config.ts) without importing them, so `rule.ts`
58
+ * stays dependency-light for lexicon rule authors.
59
+ */
60
+ export interface LintProjectConfig {
61
+ /** Declared environments — a bare name or `{ name, endpoint }` (#1166). */
62
+ environments?: Array<string | { name: string; endpoint?: string }>;
63
+ /** Ownership marking config — `env` is a literal or a build-parameter reference (#1396). */
64
+ ownership?: { stack?: string; env?: string | { param: string }; enabled?: boolean };
65
+ }
66
+
52
67
  /**
53
68
  * Context provided to lint rules during checking
54
69
  */
@@ -74,6 +89,14 @@ export interface LintContext {
74
89
  * call is a violation.
75
90
  */
76
91
  intrinsics?: readonly IntrinsicDef[];
92
+ /**
93
+ * chant #1221 — the project's resolved config slice for config-aware rules
94
+ * (COR021 reads `environments` + `ownership`). Threaded from `runLint`;
95
+ * undefined when the caller never loaded a project config (a unit test
96
+ * constructing a context directly, the LSP's single-file path), in which
97
+ * case config-aware rules stay silent.
98
+ */
99
+ projectConfig?: LintProjectConfig;
77
100
  }
78
101
 
79
102
  /**
@@ -0,0 +1,128 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
4
+ import type { LintContext, LintProjectConfig } from "../rule";
5
+
6
+ const MULTI_ENV_PARAM_BOUND: LintProjectConfig = {
7
+ environments: ["dev", "prod"],
8
+ ownership: { stack: "billing", env: { param: "env" } },
9
+ };
10
+
11
+ function createContext(code: string, projectConfig?: LintProjectConfig, filePath = "test.ts"): LintContext {
12
+ const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
13
+ return { sourceFile, entities: [], filePath, lexicon: undefined, projectConfig };
14
+ }
15
+
16
+ describe("COR021: literal name in a multi-environment project (#1221)", () => {
17
+ test("rule metadata", () => {
18
+ expect(cor021EnvLiteralNameRule.id).toBe("COR021");
19
+ expect(cor021EnvLiteralNameRule.severity).toBe("warning");
20
+ expect(cor021EnvLiteralNameRule.category).toBe("correctness");
21
+ });
22
+
23
+ test("flags a bare string literal in a *Name property", () => {
24
+ const ctx = createContext(
25
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
26
+ MULTI_ENV_PARAM_BOUND,
27
+ );
28
+ const diags = cor021EnvLiteralNameRule.check(ctx);
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].ruleId).toBe("COR021");
31
+ expect(diags[0].severity).toBe("warning");
32
+ expect(diags[0].message).toContain('"billing-uploads"');
33
+ expect(diags[0].message).toContain("params.env");
34
+ });
35
+
36
+ test("flags a bare `name` property, nested objects included", () => {
37
+ const ctx = createContext(
38
+ `export const svc = new Service({ metadata: { name: "web" } });`,
39
+ MULTI_ENV_PARAM_BOUND,
40
+ );
41
+ const diags = cor021EnvLiteralNameRule.check(ctx);
42
+ expect(diags).toHaveLength(1);
43
+ expect(diags[0].message).toContain('name: "web"');
44
+ });
45
+
46
+ test("flags a no-substitution template literal the same as a string literal", () => {
47
+ const ctx = createContext(
48
+ "export const uploads = new Bucket({ bucketName: `billing-uploads` });",
49
+ MULTI_ENV_PARAM_BOUND,
50
+ );
51
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(1);
52
+ });
53
+
54
+ test("passes a template literal interpolating the env parameter", () => {
55
+ const ctx = createContext(
56
+ "export const uploads = new Bucket({ bucketName: `billing-${params.env}-uploads` });",
57
+ MULTI_ENV_PARAM_BOUND,
58
+ );
59
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(0);
60
+ });
61
+
62
+ test("passes non-name properties and non-literal name values", () => {
63
+ const ctx = createContext(
64
+ `
65
+ const shared = { prefix: "billing" };
66
+ export const uploads = new Bucket({ bucketName: shared.prefix, region: "us-east-1" });
67
+ `,
68
+ MULTI_ENV_PARAM_BOUND,
69
+ );
70
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(0);
71
+ });
72
+
73
+ test("silent when ownership.env is a literal", () => {
74
+ const ctx = createContext(
75
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
76
+ { environments: ["dev", "prod"], ownership: { stack: "billing", env: "prod" } },
77
+ );
78
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(0);
79
+ });
80
+
81
+ test("silent when ownership.env is absent", () => {
82
+ const ctx = createContext(
83
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
84
+ { environments: ["dev", "prod"], ownership: { stack: "billing" } },
85
+ );
86
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(0);
87
+ });
88
+
89
+ test("silent with fewer than two declared environments", () => {
90
+ const oneEnv = createContext(
91
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
92
+ { environments: ["prod"], ownership: { stack: "billing", env: { param: "env" } } },
93
+ );
94
+ expect(cor021EnvLiteralNameRule.check(oneEnv)).toHaveLength(0);
95
+
96
+ const noEnvs = createContext(
97
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
98
+ { ownership: { stack: "billing", env: { param: "env" } } },
99
+ );
100
+ expect(cor021EnvLiteralNameRule.check(noEnvs)).toHaveLength(0);
101
+ });
102
+
103
+ test("silent without a project config on the context", () => {
104
+ const ctx = createContext(`export const uploads = new Bucket({ bucketName: "billing-uploads" });`);
105
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(0);
106
+ });
107
+
108
+ test("names the bound parameter, whatever it is called", () => {
109
+ const ctx = createContext(
110
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
111
+ { environments: ["dev", "prod"], ownership: { stack: "billing", env: { param: "stage" } } },
112
+ );
113
+ const diags = cor021EnvLiteralNameRule.check(ctx);
114
+ expect(diags).toHaveLength(1);
115
+ expect(diags[0].message).toContain("params.stage");
116
+ });
117
+
118
+ test("object-form environments entries count toward the threshold", () => {
119
+ const ctx = createContext(
120
+ `export const uploads = new Bucket({ bucketName: "billing-uploads" });`,
121
+ {
122
+ environments: ["prod", { name: "floci", endpoint: "http://localhost:4566" }],
123
+ ownership: { stack: "billing", env: { param: "env" } },
124
+ },
125
+ );
126
+ expect(cor021EnvLiteralNameRule.check(ctx)).toHaveLength(1);
127
+ });
128
+ });
@@ -0,0 +1,114 @@
1
+ import * as ts from "typescript";
2
+ import type { LintRule, LintContext, LintDiagnostic, LintProjectConfig } from "../rule";
3
+
4
+ /**
5
+ * COR021: Literal Name in a Multi-Environment Project (#1221)
6
+ *
7
+ * A project that declares two or more `environments` and binds its ownership
8
+ * marker to a build parameter (`ownership.env: { param: "env" }`) is built
9
+ * once per environment — and every physical name that does not vary with
10
+ * that parameter is the SAME name in every build. The collision is silent at
11
+ * build time and only surfaces at apply time, when the second environment's
12
+ * deploy walks over the first's resources.
13
+ *
14
+ * This rule warns on the declaration: a name-bearing property (`name`, or a
15
+ * `*Name` property like `bucketName`) whose value is a bare string literal,
16
+ * in a project shaped for per-environment builds. The fix is interpolation —
17
+ * `` `billing-${params.env}-uploads` `` — which folds to a per-environment
18
+ * literal because build parameters resolve before any file is imported
19
+ * (#1064). See the resource-naming guide's multi-environment section.
20
+ *
21
+ * Deliberately silent when:
22
+ * - no project config was threaded (a bare unit test, the LSP single-file path),
23
+ * - the project declares fewer than two environments (nothing to collide),
24
+ * - `ownership.env` is not param-bound — a literal `ownership.env` (or none)
25
+ * means the project is not doing per-environment builds from one source
26
+ * tree, so per-instance names are presumably managed another way (the
27
+ * layered-config all-in-one pattern hand-names each instance),
28
+ * - the value is anything other than a bare string literal — a template
29
+ * interpolating `params.<name>` is the fixed shape, and other non-literal
30
+ * values are EVL territory, not this rule's.
31
+ */
32
+
33
+ /** Does this property name carry a physical resource name — `name` or a camelCase `*Name`? */
34
+ function isNameBearingProperty(propName: string): boolean {
35
+ return propName === "name" || /^[a-z][A-Za-z0-9]*Name$/.test(propName);
36
+ }
37
+
38
+ /** The property's declared name, for Identifier and string-literal keys; undefined for computed keys. */
39
+ function propertyName(prop: ts.PropertyAssignment): string | undefined {
40
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text;
41
+ return undefined;
42
+ }
43
+
44
+ /** Whether the rule's preconditions hold: 2+ declared environments and a param-bound ownership.env. */
45
+ function projectIsMultiEnvParamBound(config: LintProjectConfig | undefined): string | undefined {
46
+ const env = config?.ownership?.env;
47
+ const paramBound = typeof env === "object" && env !== null && typeof env.param === "string";
48
+ if (!paramBound) return undefined;
49
+ if ((config?.environments?.length ?? 0) < 2) return undefined;
50
+ return env.param;
51
+ }
52
+
53
+ /** Walk an object literal (nested included, e.g. k8s `metadata: { name }`), flagging literal name-bearing values. */
54
+ function checkObjectLiteral(
55
+ obj: ts.ObjectLiteralExpression,
56
+ paramName: string,
57
+ context: LintContext,
58
+ diagnostics: LintDiagnostic[],
59
+ ): void {
60
+ for (const prop of obj.properties) {
61
+ if (!ts.isPropertyAssignment(prop)) continue;
62
+ const name = propertyName(prop);
63
+ const value = prop.initializer;
64
+ if (
65
+ name !== undefined &&
66
+ isNameBearingProperty(name) &&
67
+ (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
68
+ ) {
69
+ const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
70
+ value.getStart(context.sourceFile),
71
+ );
72
+ diagnostics.push({
73
+ file: context.filePath,
74
+ line: line + 1,
75
+ column: character + 1,
76
+ ruleId: "COR021",
77
+ severity: "warning",
78
+ message:
79
+ `Literal ${name}: "${value.text}" in a multi-environment project — every environment's build ` +
80
+ `produces this same physical name, so two deployed environments collide. Interpolate the env ` +
81
+ `parameter: \`${value.text}-\${params.${paramName}}\` (see the resource-naming guide's ` +
82
+ `multi-environment section).`,
83
+ });
84
+ }
85
+ if (ts.isObjectLiteralExpression(value)) {
86
+ checkObjectLiteral(value, paramName, context, diagnostics);
87
+ }
88
+ }
89
+ }
90
+
91
+ function checkNode(node: ts.Node, paramName: string, context: LintContext, diagnostics: LintDiagnostic[]): void {
92
+ if (ts.isNewExpression(node) && node.arguments && node.arguments.length > 0) {
93
+ const firstArg = node.arguments[0];
94
+ if (ts.isObjectLiteralExpression(firstArg)) {
95
+ checkObjectLiteral(firstArg, paramName, context, diagnostics);
96
+ }
97
+ }
98
+ ts.forEachChild(node, (child) => checkNode(child, paramName, context, diagnostics));
99
+ }
100
+
101
+ export const cor021EnvLiteralNameRule: LintRule = {
102
+ id: "COR021",
103
+ severity: "warning",
104
+ category: "correctness",
105
+ description:
106
+ "In a multi-environment project with a param-bound ownership.env, name-bearing properties should interpolate the env parameter, not hold a bare literal",
107
+ check(context: LintContext): LintDiagnostic[] {
108
+ const paramName = projectIsMultiEnvParamBound(context.projectConfig);
109
+ if (paramName === undefined) return [];
110
+ const diagnostics: LintDiagnostic[] = [];
111
+ checkNode(context.sourceFile, paramName, context, diagnostics);
112
+ return diagnostics;
113
+ },
114
+ };
@@ -0,0 +1,116 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import { cor022ReceiptLeafRule, collectReceiptVariables } from "./cor022-receipt-leaf";
4
+ import type { LintContext } from "../rule";
5
+
6
+ function createContext(code: string, filePath = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath, lexicon: undefined };
9
+ }
10
+
11
+ describe("COR022: effect receipt is a leaf (#1833)", () => {
12
+ test("rule metadata", () => {
13
+ expect(cor022ReceiptLeafRule.id).toBe("COR022");
14
+ expect(cor022ReceiptLeafRule.severity).toBe("error");
15
+ expect(cor022ReceiptLeafRule.category).toBe("correctness");
16
+ });
17
+
18
+ test("flags a property access on a receipt inside another declarable", () => {
19
+ const ctx = createContext(`
20
+ export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
21
+ export const app = new Service({ marker: seeded.effect });
22
+ `);
23
+ const diags = cor022ReceiptLeafRule.check(ctx);
24
+ expect(diags).toHaveLength(1);
25
+ expect(diags[0].ruleId).toBe("COR022");
26
+ expect(diags[0].severity).toBe("error");
27
+ expect(diags[0].message).toContain('"seeded" is an effect receipt');
28
+ expect(diags[0].message).toContain('"effect"');
29
+ expect(diags[0].message).toContain("sole writer");
30
+ });
31
+
32
+ test("const indirection fires: const r = EffectReceipt(...); other.prop = r.something", () => {
33
+ const ctx = createContext(`
34
+ const r = EffectReceipt("migrated", { effect: "schema-migrate", flavor: "hash" });
35
+ export const other = new Store({});
36
+ other.prop = r.something;
37
+ `);
38
+ const diags = cor022ReceiptLeafRule.check(ctx);
39
+ expect(diags).toHaveLength(1);
40
+ expect(diags[0].message).toContain('"r" is an effect receipt');
41
+ expect(diags[0].message).toContain('"something"');
42
+ });
43
+
44
+ test("an alias of a receipt is still the receipt", () => {
45
+ const ctx = createContext(`
46
+ const r = EffectReceipt("migrated", { effect: "schema-migrate", flavor: "existence" });
47
+ const alias = r;
48
+ export const svc = new Service({ tag: alias.name });
49
+ `);
50
+ const diags = cor022ReceiptLeafRule.check(ctx);
51
+ expect(diags).toHaveLength(1);
52
+ expect(diags[0].message).toContain('"alias"');
53
+ });
54
+
55
+ test("element access fires like property access", () => {
56
+ const ctx = createContext(`
57
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
58
+ export const svc = new Service({ tag: r["effect"] });
59
+ `);
60
+ const diags = cor022ReceiptLeafRule.check(ctx);
61
+ expect(diags).toHaveLength(1);
62
+ expect(diags[0].message).toContain('"effect"');
63
+ });
64
+
65
+ test("namespaced factory call is recognized", () => {
66
+ const ctx = createContext(`
67
+ const r = chant.EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
68
+ export const svc = new Service({ tag: r.effect });
69
+ `);
70
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(1);
71
+ });
72
+
73
+ test("passes: a receipt that nothing dereferences", () => {
74
+ const ctx = createContext(`
75
+ import { EffectReceipt } from "@intentius/chant";
76
+ export const seeded = EffectReceipt("seeded", {
77
+ effect: "db-seed",
78
+ flavor: "hash",
79
+ inputs: { schema: "v3" },
80
+ });
81
+ export const app = new Service({ image: "app:1" });
82
+ `);
83
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(0);
84
+ });
85
+
86
+ test("passes: referencing the receipt value itself (no attribute access)", () => {
87
+ const ctx = createContext(`
88
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
89
+ registerReceipt(r);
90
+ `);
91
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(0);
92
+ });
93
+
94
+ test("passes: property access on a non-receipt declarable", () => {
95
+ const ctx = createContext(`
96
+ export const db = new Database({ name: "main" });
97
+ export const app = new Service({ endpoint: db.endpoint });
98
+ `);
99
+ expect(cor022ReceiptLeafRule.check(ctx)).toHaveLength(0);
100
+ });
101
+
102
+ test("collectReceiptVariables resolves aliases declared before the receipt", () => {
103
+ const sf = ts.createSourceFile(
104
+ "t.ts",
105
+ `
106
+ const early = late;
107
+ const late = EffectReceipt("r", { effect: "e", flavor: "existence" });
108
+ `,
109
+ ts.ScriptTarget.Latest,
110
+ true,
111
+ );
112
+ const vars = collectReceiptVariables(sf);
113
+ expect(vars.has("late")).toBe(true);
114
+ expect(vars.has("early")).toBe(true);
115
+ });
116
+ });
@@ -0,0 +1,130 @@
1
+ import * as ts from "typescript";
2
+ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
3
+
4
+ /**
5
+ * COR022: Effect Receipt Is a Leaf (#1833, epic #1703)
6
+ *
7
+ * Nothing may reference an effect receipt's attributes. A receipt is the
8
+ * declared witness that an out-of-band effect ran — the `effect()` step
9
+ * (#1834) is its sole writer, on success, last (../../effect-receipt.ts).
10
+ * Any resource that derives a property from a receipt couples itself to a
11
+ * value only the effect controls: the coupling is invisible at synthesis,
12
+ * and the receipt's late write (or its absence after a crash) would ripple
13
+ * into resources that were supposed to be independent of whether the effect
14
+ * has fired yet.
15
+ *
16
+ * The rule walks the file's reference graph the way COR011 does: it collects
17
+ * every variable initialized from an `EffectReceipt(...)` call (aliases
18
+ * included — `const alias = receipt` is still the receipt), then flags every
19
+ * property or element access rooted at one of them. Const indirection fires:
20
+ *
21
+ * const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
22
+ * other.prop = r.something; // COR022
23
+ *
24
+ * Referencing the receipt VALUE (passing `r` itself around) is not flagged —
25
+ * the leaf constraint is about attributes, and lexicon materialization rows
26
+ * legitimately take the whole declaration.
27
+ *
28
+ * Core recognizes the factory call by name; a lexicon-materialized receipt
29
+ * row (#1835) is recognized at build time by its marker instead — this rule
30
+ * is the source-level half.
31
+ */
32
+
33
+ /** Simple callee name of a call: `EffectReceipt(...)` or `chant.EffectReceipt(...)`. */
34
+ function calleeName(expr: ts.CallExpression): string | undefined {
35
+ if (ts.isIdentifier(expr.expression)) return expr.expression.text;
36
+ if (ts.isPropertyAccessExpression(expr.expression)) return expr.expression.name.text;
37
+ return undefined;
38
+ }
39
+
40
+ /** A variable declaration's `EffectReceipt(...)` initializer, if that is what it is. */
41
+ export function receiptFactoryCall(decl: ts.VariableDeclaration): ts.CallExpression | undefined {
42
+ if (!decl.initializer || !ts.isCallExpression(decl.initializer)) return undefined;
43
+ return calleeName(decl.initializer) === "EffectReceipt" ? decl.initializer : undefined;
44
+ }
45
+
46
+ /**
47
+ * Every variable name bound to an effect receipt in this file: direct
48
+ * `EffectReceipt(...)` initializers plus identifier aliases, resolved to a
49
+ * fixpoint so declaration order does not matter. Scope-naive by design, the
50
+ * same trade COR011 makes — a chant source file is flat declarations.
51
+ */
52
+ export function collectReceiptVariables(sourceFile: ts.SourceFile): Set<string> {
53
+ const receipts = new Set<string>();
54
+ const aliases: Array<[string, string]> = [];
55
+
56
+ function visit(node: ts.Node): void {
57
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
58
+ if (receiptFactoryCall(node)) {
59
+ receipts.add(node.name.text);
60
+ } else if (ts.isIdentifier(node.initializer)) {
61
+ aliases.push([node.name.text, node.initializer.text]);
62
+ }
63
+ }
64
+ ts.forEachChild(node, visit);
65
+ }
66
+ visit(sourceFile);
67
+
68
+ let grew = true;
69
+ while (grew) {
70
+ grew = false;
71
+ for (const [name, source] of aliases) {
72
+ if (receipts.has(source) && !receipts.has(name)) {
73
+ receipts.add(name);
74
+ grew = true;
75
+ }
76
+ }
77
+ }
78
+ return receipts;
79
+ }
80
+
81
+ /** The accessed attribute's name, for the message: `r.something` → "something". */
82
+ function accessedAttribute(node: ts.PropertyAccessExpression | ts.ElementAccessExpression): string {
83
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
84
+ const arg = node.argumentExpression;
85
+ return ts.isStringLiteralLike(arg) ? arg.text : "<computed>";
86
+ }
87
+
88
+ export const cor022ReceiptLeafRule: LintRule = {
89
+ id: "COR022",
90
+ severity: "error",
91
+ category: "correctness",
92
+ description:
93
+ "An effect receipt is a leaf — nothing may reference its attributes; the effect() step is the receipt's sole writer",
94
+ check(context: LintContext): LintDiagnostic[] {
95
+ const receipts = collectReceiptVariables(context.sourceFile);
96
+ if (receipts.size === 0) return [];
97
+
98
+ const diagnostics: LintDiagnostic[] = [];
99
+
100
+ function visit(node: ts.Node): void {
101
+ if (
102
+ (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
103
+ ts.isIdentifier(node.expression) &&
104
+ receipts.has(node.expression.text)
105
+ ) {
106
+ const receipt = node.expression.text;
107
+ const attribute = accessedAttribute(node);
108
+ const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
109
+ node.getStart(context.sourceFile),
110
+ );
111
+ diagnostics.push({
112
+ file: context.filePath,
113
+ line: line + 1,
114
+ column: character + 1,
115
+ ruleId: "COR022",
116
+ severity: "error",
117
+ message:
118
+ `"${receipt}" is an effect receipt — a receipt is a leaf, and nothing may reference its ` +
119
+ `attributes ("${attribute}" here). The effect() step is the receipt's sole writer, so a ` +
120
+ `property derived from it couples this resource to a value only the effect controls. ` +
121
+ `Reference the effect's inputs (or the resources they come from) directly instead.`,
122
+ });
123
+ }
124
+ ts.forEachChild(node, visit);
125
+ }
126
+ visit(context.sourceFile);
127
+
128
+ return diagnostics;
129
+ },
130
+ };