@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.
- package/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +10 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +223 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +132 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +4 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +134 -8
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +211 -0
- package/src/cli/commands/build.ts +126 -8
- package/src/cli/commands/check-lexicon.test.ts +45 -1
- package/src/cli/commands/check-lexicon.ts +45 -0
- package/src/cli/commands/lexicon-surface-diff.ts +9 -0
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +32 -7
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +4 -4
- package/src/cli/handlers/lifecycle.test.ts +345 -1
- package/src/cli/handlers/lifecycle.ts +300 -11
- package/src/cli/handlers/search.ts +5 -2
- package/src/cli/main.ts +12 -1
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/registry.ts +7 -0
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/codegen/lexicon-regen.ts +19 -1
- package/src/codegen/validate.test.ts +33 -0
- package/src/codegen/validate.ts +21 -2
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +55 -0
- package/src/config.ts +88 -1
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +227 -2
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +568 -0
- package/src/lifecycle/teardown.ts +359 -0
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +10 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +179 -8
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
- package/src/yaml.test.ts +88 -0
- 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
|
+
};
|