@intentius/chant-lexicon-terraform 0.57.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 (131) hide show
  1. package/README.md +53 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +11 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate.d.ts +28 -0
  9. package/dist/codegen/generate.d.ts.map +1 -0
  10. package/dist/codegen/package.d.ts +17 -0
  11. package/dist/codegen/package.d.ts.map +1 -0
  12. package/dist/composites/terraform-apply-op.d.ts +88 -0
  13. package/dist/composites/terraform-apply-op.d.ts.map +1 -0
  14. package/dist/composites/terraform-watch-op.d.ts +124 -0
  15. package/dist/composites/terraform-watch-op.d.ts.map +1 -0
  16. package/dist/config.d.ts +83 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/describe-resources.d.ts +127 -0
  19. package/dist/describe-resources.d.ts.map +1 -0
  20. package/dist/generated/index.d.ts +2 -0
  21. package/dist/generated/index.d.ts.map +1 -0
  22. package/dist/hcl/parse.d.ts +87 -0
  23. package/dist/hcl/parse.d.ts.map +1 -0
  24. package/dist/hcl/roots.d.ts +37 -0
  25. package/dist/hcl/roots.d.ts.map +1 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/integrity.json +12 -0
  29. package/dist/lint/audit-catalog.d.ts +12 -0
  30. package/dist/lint/audit-catalog.d.ts.map +1 -0
  31. package/dist/lint/audit-lineage.d.ts +21 -0
  32. package/dist/lint/audit-lineage.d.ts.map +1 -0
  33. package/dist/lint/post-synth/index.d.ts +3 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -0
  35. package/dist/lint/post-synth/tf001.d.ts +17 -0
  36. package/dist/lint/post-synth/tf001.d.ts.map +1 -0
  37. package/dist/lint/rules/index.d.ts +5 -0
  38. package/dist/lint/rules/index.d.ts.map +1 -0
  39. package/dist/lint/rules/plan-before-apply.d.ts +30 -0
  40. package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
  41. package/dist/lsp/completions.d.ts +26 -0
  42. package/dist/lsp/completions.d.ts.map +1 -0
  43. package/dist/lsp/context.d.ts +70 -0
  44. package/dist/lsp/context.d.ts.map +1 -0
  45. package/dist/lsp/hover.d.ts +12 -0
  46. package/dist/lsp/hover.d.ts.map +1 -0
  47. package/dist/lsp/option-keys.d.ts +34 -0
  48. package/dist/lsp/option-keys.d.ts.map +1 -0
  49. package/dist/manifest.json +6 -0
  50. package/dist/meta.json +1 -0
  51. package/dist/okf/index.md +8 -0
  52. package/dist/okf/rules/TF001.md +11 -0
  53. package/dist/okf/rules/TF101.md +11 -0
  54. package/dist/op/activities/index.d.ts +22 -0
  55. package/dist/op/activities/index.d.ts.map +1 -0
  56. package/dist/op/activities/terraform.d.ts +213 -0
  57. package/dist/op/activities/terraform.d.ts.map +1 -0
  58. package/dist/op/builders.d.ts +56 -0
  59. package/dist/op/builders.d.ts.map +1 -0
  60. package/dist/package-cli.d.ts +3 -0
  61. package/dist/package-cli.d.ts.map +1 -0
  62. package/dist/plugin.d.ts +11 -0
  63. package/dist/plugin.d.ts.map +1 -0
  64. package/dist/rules/plan-before-apply.ts +143 -0
  65. package/dist/rules/tf001.ts +66 -0
  66. package/dist/serializer.d.ts +19 -0
  67. package/dist/serializer.d.ts.map +1 -0
  68. package/dist/skills/chant-terraform.md +92 -0
  69. package/dist/state-ownership.d.ts +27 -0
  70. package/dist/state-ownership.d.ts.map +1 -0
  71. package/dist/types/index.d.ts +2 -0
  72. package/dist/validate-cli.d.ts +3 -0
  73. package/dist/validate-cli.d.ts.map +1 -0
  74. package/dist/validate.d.ts +15 -0
  75. package/dist/validate.d.ts.map +1 -0
  76. package/package.json +75 -0
  77. package/src/__fixtures__/no-backend/main.tf +24 -0
  78. package/src/__fixtures__/show-state.json +68 -0
  79. package/src/__fixtures__/with-backend/main.tf +28 -0
  80. package/src/__fixtures__/with-module/main.tf +49 -0
  81. package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
  82. package/src/codegen/docs-cli.ts +4 -0
  83. package/src/codegen/docs.ts +50 -0
  84. package/src/codegen/generate-cli.ts +10 -0
  85. package/src/codegen/generate.ts +68 -0
  86. package/src/codegen/package.ts +50 -0
  87. package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
  88. package/src/composites/terraform-apply-op.test.ts +193 -0
  89. package/src/composites/terraform-apply-op.ts +166 -0
  90. package/src/composites/terraform-watch-op.test.ts +184 -0
  91. package/src/composites/terraform-watch-op.ts +204 -0
  92. package/src/config.ts +76 -0
  93. package/src/describe-resources.test.ts +342 -0
  94. package/src/describe-resources.ts +357 -0
  95. package/src/generated/index.d.ts +2 -0
  96. package/src/generated/index.ts +4 -0
  97. package/src/generated/lexicon-terraform.json +1 -0
  98. package/src/hcl/parse.ts +235 -0
  99. package/src/hcl/roots.ts +67 -0
  100. package/src/index.ts +65 -0
  101. package/src/lint/audit-catalog.ts +30 -0
  102. package/src/lint/audit-lineage.ts +21 -0
  103. package/src/lint/audit.test.ts +45 -0
  104. package/src/lint/post-synth/index.ts +7 -0
  105. package/src/lint/post-synth/post-synth.test.ts +95 -0
  106. package/src/lint/post-synth/tf001.ts +66 -0
  107. package/src/lint/rules/index.ts +7 -0
  108. package/src/lint/rules/plan-before-apply.test.ts +111 -0
  109. package/src/lint/rules/plan-before-apply.ts +143 -0
  110. package/src/lsp/completions.test.ts +120 -0
  111. package/src/lsp/completions.ts +101 -0
  112. package/src/lsp/context.test.ts +152 -0
  113. package/src/lsp/context.ts +349 -0
  114. package/src/lsp/hover.test.ts +82 -0
  115. package/src/lsp/hover.ts +44 -0
  116. package/src/lsp/option-keys.ts +106 -0
  117. package/src/op/activities/index.ts +48 -0
  118. package/src/op/activities/registry.test.ts +29 -0
  119. package/src/op/activities/terraform.test.ts +445 -0
  120. package/src/op/activities/terraform.ts +469 -0
  121. package/src/op/builders.test.ts +90 -0
  122. package/src/op/builders.ts +96 -0
  123. package/src/package-cli.ts +21 -0
  124. package/src/plugin.test.ts +271 -0
  125. package/src/plugin.ts +157 -0
  126. package/src/serializer.test.ts +26 -0
  127. package/src/serializer.ts +26 -0
  128. package/src/skills/chant-terraform.md +92 -0
  129. package/src/state-ownership.ts +32 -0
  130. package/src/validate-cli.ts +5 -0
  131. package/src/validate.ts +28 -0
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Plugin
2
+ export { terraformPlugin } from "./plugin";
3
+
4
+ // Serializer
5
+ export { terraformSerializer } from "./serializer";
6
+
7
+ // The `terraform` config namespace. Importing this package is what brings the
8
+ // `terraform` key into `ChantConfig` (#1344), so the augmentation must be
9
+ // reachable from the entry point.
10
+ export {
11
+ terraformConfigSchema,
12
+ terraformRootSchema,
13
+ type TerraformConfig,
14
+ type TerraformRootConfig,
15
+ type TerraformConfigNamespace,
16
+ } from "./config";
17
+
18
+ // HCL parse: one entity per block, shared by the build path and (from #2085)
19
+ // the audit path.
20
+ export {
21
+ blocksToEntities,
22
+ parseTerraformRootContent,
23
+ parseTerraformRootDir,
24
+ splitBundleContent,
25
+ terraformEntity,
26
+ DATA_TYPE,
27
+ LOCALS_TYPE,
28
+ MODULE_TYPE,
29
+ OUTPUT_TYPE,
30
+ PROVIDER_TYPE,
31
+ RESOURCE_TYPE,
32
+ TERRAFORM_TYPE,
33
+ VARIABLE_TYPE,
34
+ type BlockBody,
35
+ type TerraformEntity,
36
+ type TerraformFile,
37
+ } from "./hcl/parse";
38
+
39
+ export { renderTerraformRoots, type TerraformRootsResult } from "./hcl/roots";
40
+
41
+ // Typed Op step builders (#2086). Same names as the activities they wrap, and
42
+ // like k3s's they are opt-in from the lexicon root — the activities themselves
43
+ // are reached by the registry through `./op/activities`, never from here.
44
+ export { terraformInit, terraformPlan, terraformApply, terraformShow } from "./op/builders";
45
+
46
+ // The Init/Plan/Gate/Apply composite (#2086).
47
+ export { TerraformApplyOp } from "./composites/terraform-apply-op";
48
+ export type {
49
+ TerraformApplyOpConfig,
50
+ TerraformApplyOpResources,
51
+ TerraformGateMode,
52
+ } from "./composites/terraform-apply-op";
53
+
54
+ // The Init/Plan/[Report] drift composite (#2087).
55
+ export { TerraformWatchOp } from "./composites/terraform-watch-op";
56
+ export type {
57
+ TerraformWatchOpConfig,
58
+ TerraformWatchOpResources,
59
+ TerraformFindingMode,
60
+ } from "./composites/terraform-watch-op";
61
+
62
+ // Live observation over `terraform show -json` (#2087). The plugin reaches
63
+ // `describeResources` through a dynamic import; the ownership keys are here
64
+ // because `ownershipChannel` declares them eagerly.
65
+ export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The terraform lexicon's chant audit catalog: metadata for its post-synth
3
+ * checks, contributed through `terraformPlugin.auditCatalog()` (#687, #1346).
4
+ * Every post-synth check needs an entry or it contributes nothing to
5
+ * `chant audit`, silently, and `packages/core/src/audit/catalog.test.ts` fails.
6
+ *
7
+ * TF001 reads the chant model (`ctx.entities`), never emitted output, so
8
+ * `yamlBased` is false. Prior-art lineage lives in ./audit-lineage.ts.
9
+ */
10
+ import type { RuleMeta } from "@intentius/chant/audit/catalog";
11
+ import { applyLineage } from "@intentius/chant/audit/catalog";
12
+ import { terraformAuditLineage } from "./audit-lineage";
13
+
14
+ export const terraformAuditCatalog: Record<string, RuleMeta> = {
15
+ TF001: {
16
+ id: "TF001",
17
+ tier: "merge-worthy",
18
+ fixKind: "guidance",
19
+ category: "correctness",
20
+ title: "Root module declares no remote backend",
21
+ remediation:
22
+ 'Add a `backend "<type>"` block (s3, gcs, azurerm, http) or a `cloud {}` block to the root ' +
23
+ "module's terraform block, then `terraform init -migrate-state`.",
24
+ yamlBased: false,
25
+ },
26
+ };
27
+
28
+ // Prior art credits, if any, live beside the rules in ./audit-lineage.ts (see
29
+ // core audit/prior-art.ts).
30
+ applyLineage(terraformAuditCatalog, terraformAuditLineage);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Prior art for the terraform lexicon's audit rules: the open-source tools
3
+ * whose checks cover the same condition, credited per rule. See
4
+ * packages/core/src/audit/prior-art.ts for the registry, the relation
5
+ * vocabulary, and why this is credit rather than authority. Kept by hand; the
6
+ * prior-art sweep (scripts/prior-art-sweep.ts) reports when a credited tool's
7
+ * index no longer lists a rule cited here.
8
+ *
9
+ * TF001 (no remote backend) has no entry here. Checkov's `CKV_TF_*` family
10
+ * (checkov.io) has no rule for a missing backend block, and
11
+ * tflint-ruleset-terraform's own rule index
12
+ * (github.com/terraform-linters/tflint-ruleset-terraform/blob/main/docs/rules/README.md)
13
+ * has none either: its closest neighbors are `terraform_required_providers`
14
+ * (unconstrained provider versions, TF002's territory, not TF001's) and
15
+ * `terraform_workspace_remote` (a `terraform.workspace` compatibility check
16
+ * against remote execution, a different condition than "no backend is
17
+ * configured at all"). Ship with no lineage rather than invent a credit.
18
+ */
19
+ import type { Lineage } from "@intentius/chant/audit/catalog";
20
+
21
+ export const terraformAuditLineage: Record<string, Lineage[]> = {};
@@ -0,0 +1,45 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, test } from "vitest";
4
+ import { auditFiles, type ChecksProvider, type EntitiesProvider } from "@intentius/chant/audit/core";
5
+ import { discoverByDetection, type DetectPlugin } from "@intentius/chant/audit/discover";
6
+ import { terraformPlugin } from "../plugin";
7
+
8
+ /**
9
+ * End-to-end audit test (#2085 acceptance): `chant audit` (`auditFiles` on top
10
+ * of real filesystem discovery, `discoverByDetection`) against the #2083
11
+ * fixture root modules. Real terraform plugin methods are wired in directly
12
+ * as `checksProvider`/`entitiesProvider` (the same seam `packages/core/src/
13
+ * audit/core.test.ts` uses) rather than through `loadPlugin`'s package-name
14
+ * resolution, so this runs against source, not a built `dist/`.
15
+ */
16
+ const fixtures = join(dirname(fileURLToPath(import.meta.url)), "..", "__fixtures__");
17
+
18
+ const terraformDetectPlugin: DetectPlugin = { name: "terraform" };
19
+
20
+ const checksProvider: ChecksProvider = async (lexicon) =>
21
+ lexicon === "terraform" ? (terraformPlugin.postSynthChecks?.() ?? []) : [];
22
+
23
+ const entitiesProvider: EntitiesProvider = async (lexicon) =>
24
+ lexicon === "terraform" ? terraformPlugin.auditEntities?.bind(terraformPlugin) : undefined;
25
+
26
+ describe("chant audit against a discovered terraform root module", () => {
27
+ test("reports TF001 once for the no-backend root", async () => {
28
+ const inputs = discoverByDetection(join(fixtures, "no-backend"), [terraformDetectPlugin]);
29
+ expect(inputs).toHaveLength(1);
30
+ expect(inputs[0]!.lexicon).toBe("terraform");
31
+
32
+ const findings = await auditFiles(inputs, { checksProvider, entitiesProvider });
33
+ const tf001 = findings.filter((f) => f.checkId === "TF001");
34
+ expect(tf001).toHaveLength(1);
35
+ expect(tf001[0]!.lexicon).toBe("terraform");
36
+ });
37
+
38
+ test("reports nothing for the with-backend root", async () => {
39
+ const inputs = discoverByDetection(join(fixtures, "with-backend"), [terraformDetectPlugin]);
40
+ expect(inputs).toHaveLength(1);
41
+
42
+ const findings = await auditFiles(inputs, { checksProvider, entitiesProvider });
43
+ expect(findings.filter((f) => f.checkId === "TF001")).toHaveLength(0);
44
+ });
45
+ });
@@ -0,0 +1,7 @@
1
+ // Code generated by chant generate. DO NOT EDIT.
2
+ import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
3
+ import { tf001 } from "./tf001";
4
+
5
+ export const postSynthChecks: PostSynthCheck[] = [
6
+ tf001,
7
+ ];
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { tf001 } from "./tf001";
4
+ import { TERRAFORM_TYPE, terraformEntity } from "../../hcl/parse";
5
+
6
+ /**
7
+ * The local context builder the k3s checks use. `createPostSynthContext` from
8
+ * the test-utils package hands back an empty entity map, and every check here
9
+ * reads `ctx.entities`.
10
+ */
11
+ function makeCtx(entities: Record<string, ReturnType<typeof terraformEntity>>): PostSynthContext {
12
+ return {
13
+ outputs: new Map(),
14
+ entities: new Map(Object.entries(entities)),
15
+ } as unknown as PostSynthContext;
16
+ }
17
+
18
+ /** A `terraform` block entity for root `name`, carrying `body` verbatim. */
19
+ function terraformBlock(root: string, body: Record<string, unknown>): ReturnType<typeof terraformEntity> {
20
+ return terraformEntity(TERRAFORM_TYPE, "terraform", body, "main.tf", root);
21
+ }
22
+
23
+ describe("TF001: root module declares no remote backend", () => {
24
+ test("flags a root whose terraform block has no backend", () => {
25
+ const diags = tf001.check(
26
+ makeCtx({ "legacy/terraform": terraformBlock("legacy", { required_version: ">= 1.5.0" }) }),
27
+ );
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].checkId).toBe("TF001");
30
+ expect(diags[0].entity).toBe("legacy/terraform");
31
+ expect(diags[0].lexicon).toBe("terraform");
32
+ expect(diags[0].message).toContain("legacy");
33
+ });
34
+
35
+ test("passes a root with a backend block", () => {
36
+ const diags = tf001.check(
37
+ makeCtx({
38
+ "app/terraform": terraformBlock("app", { backend: { local: [{ path: "terraform.tfstate" }] } }),
39
+ }),
40
+ );
41
+ expect(diags).toHaveLength(0);
42
+ });
43
+
44
+ test("passes a root using Terraform Cloud's cloud block", () => {
45
+ const diags = tf001.check(
46
+ makeCtx({
47
+ "app/terraform": terraformBlock("app", {
48
+ cloud: [{ organization: "acme", workspaces: { name: "app" } }],
49
+ }),
50
+ }),
51
+ );
52
+ expect(diags).toHaveLength(0);
53
+ });
54
+
55
+ test("fires once per root, not once per terraform block", () => {
56
+ const diags = tf001.check(
57
+ makeCtx({
58
+ "legacy/terraform": terraformBlock("legacy", {}),
59
+ "legacy/terraform~2": terraformBlock("legacy", { required_version: ">= 1.5.0" }),
60
+ }),
61
+ );
62
+ expect(diags).toHaveLength(1);
63
+ });
64
+
65
+ test("reports each backendless root separately", () => {
66
+ const diags = tf001.check(
67
+ makeCtx({
68
+ "legacy/terraform": terraformBlock("legacy", {}),
69
+ "other/terraform": terraformBlock("other", {}),
70
+ "app/terraform": terraformBlock("app", { backend: { s3: [{ bucket: "tfstate" }] } }),
71
+ }),
72
+ );
73
+ expect(diags.map((d) => d.entity).sort()).toEqual(["legacy/terraform", "other/terraform"]);
74
+ });
75
+
76
+ test("ignores a root with no terraform block at all", () => {
77
+ const diags = tf001.check(
78
+ makeCtx({
79
+ "app/null_resource.first": terraformEntity(
80
+ "Terraform::Resource",
81
+ "null_resource.first",
82
+ {},
83
+ "main.tf",
84
+ "app",
85
+ ),
86
+ }),
87
+ );
88
+ expect(diags).toHaveLength(0);
89
+ });
90
+
91
+ test("treats an empty backend value as absent", () => {
92
+ const diags = tf001.check(makeCtx({ "app/terraform": terraformBlock("app", { backend: {} }) }));
93
+ expect(diags).toHaveLength(1);
94
+ });
95
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TF001: a root module keeps its state on local disk.
3
+ *
4
+ * A `terraform` block with no `backend "<type>"` and no `cloud {}` falls back
5
+ * to the local backend, which puts `terraform.tfstate` in the working
6
+ * directory. That file holds every attribute of every managed resource,
7
+ * secrets included, and it is not shared, not locked, and not versioned. The
8
+ * first apply from a second machine or a CI runner starts from an empty state
9
+ * and proposes to create the estate again.
10
+ *
11
+ * One diagnostic per root, fired from the root's `terraform` block. A root
12
+ * with no `terraform` block at all is not flagged: it declares no version
13
+ * constraints either, and the missing block is a different finding.
14
+ */
15
+
16
+ import type {
17
+ PostSynthCheck,
18
+ PostSynthContext,
19
+ PostSynthDiagnostic,
20
+ } from "@intentius/chant/lint/post-synth";
21
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
22
+ import { TERRAFORM_TYPE, type BlockBody } from "../../hcl/parse";
23
+
24
+ /** `backend`/`cloud` are blocks, so hcl2json encodes them as a value under the key. */
25
+ function hasBlock(body: BlockBody, key: string): boolean {
26
+ const value = body[key];
27
+ if (value === undefined || value === null) return false;
28
+ if (Array.isArray(value)) return value.length > 0;
29
+ return typeof value === "object" ? Object.keys(value as object).length > 0 : true;
30
+ }
31
+
32
+ export const tf001: PostSynthCheck = {
33
+ id: "TF001",
34
+ description: "Root module declares no remote backend",
35
+
36
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
37
+ const diagnostics: PostSynthDiagnostic[] = [];
38
+ /** Root name to the entity key of the first `terraform` block seen for it. */
39
+ const flagged = new Set<string>();
40
+
41
+ for (const [name, entity] of ctx.entities) {
42
+ if (entity.entityType !== TERRAFORM_TYPE) continue;
43
+ if (!isResourceDeclarable(entity)) continue;
44
+ const props = entity.props as { root?: unknown; body?: unknown };
45
+ const root = typeof props.root === "string" ? props.root : "";
46
+ if (flagged.has(root)) continue;
47
+
48
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {}) as BlockBody;
49
+ if (hasBlock(body, "backend") || hasBlock(body, "cloud")) continue;
50
+
51
+ flagged.add(root);
52
+ diagnostics.push({
53
+ checkId: "TF001",
54
+ severity: "warning",
55
+ message:
56
+ `Root module "${root}" declares no remote backend. State falls back to a local ` +
57
+ "terraform.tfstate, which is unshared, unlocked and holds every resource attribute in " +
58
+ 'plaintext. Add a `backend "<type>"` or a `cloud {}` block to the terraform block.',
59
+ entity: name,
60
+ lexicon: "terraform",
61
+ });
62
+ }
63
+
64
+ return diagnostics;
65
+ },
66
+ };
@@ -0,0 +1,7 @@
1
+ import type { LintRule } from "@intentius/chant/lint/rule";
2
+ import { planBeforeApplyRule } from "./plan-before-apply";
3
+
4
+ export { planBeforeApplyRule } from "./plan-before-apply";
5
+
6
+ /** All lint rules provided by this lexicon (imported by plugin.ts's lintRules()). */
7
+ export const rules: LintRule[] = [planBeforeApplyRule];
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import * as ts from "typescript";
3
+ import type { LintContext } from "@intentius/chant/lint/rule";
4
+ import { planBeforeApplyRule } from "./plan-before-apply";
5
+
6
+ function createContext(code: string, fileName = "infra.op.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("TF101: plan-before-apply", () => {
12
+ test("flags a literal plan path", () => {
13
+ const diags = planBeforeApplyRule.check(
14
+ createContext(`
15
+ const plan = terraformPlan({ root: "app" });
16
+ const apply = terraformApply({ planFile: "/tmp/plan.out" });
17
+ `),
18
+ );
19
+ expect(diags).toHaveLength(1);
20
+ expect(diags[0].ruleId).toBe("TF101");
21
+ expect(diags[0].severity).toBe("error");
22
+ expect(diags[0].message).toContain("literal path");
23
+ });
24
+
25
+ test("flags a template literal plan path", () => {
26
+ const diags = planBeforeApplyRule.check(
27
+ createContext(`
28
+ const plan = terraformPlan({ root: "app" });
29
+ const dir = "/tmp";
30
+ const apply = terraformApply({ planFile: \`\${dir}/plan.out\` });
31
+ `),
32
+ );
33
+ expect(diags).toHaveLength(1);
34
+ expect(diags[0].message).toContain("literal path");
35
+ });
36
+
37
+ test("flags a reference to a non-plan step", () => {
38
+ const diags = planBeforeApplyRule.check(
39
+ createContext(`
40
+ const notAPlan = someOtherStep({ root: "app" });
41
+ const apply = terraformApply({ planFile: stepOutput(notAPlan, "planFile") });
42
+ `),
43
+ );
44
+ expect(diags).toHaveLength(1);
45
+ expect(diags[0].message).toContain("not bound to a `terraformPlan");
46
+ });
47
+
48
+ test("passes stepOutput(plan, \"planFile\")", () => {
49
+ const diags = planBeforeApplyRule.check(
50
+ createContext(`
51
+ const plan = terraformPlan({ root: "app" });
52
+ const apply = terraformApply({ planFile: stepOutput(plan, "planFile") });
53
+ `),
54
+ );
55
+ expect(diags).toHaveLength(0);
56
+ });
57
+
58
+ test("passes plan.out.planFile", () => {
59
+ const diags = planBeforeApplyRule.check(
60
+ createContext(`
61
+ const plan = terraformPlan({ root: "app" });
62
+ const apply = terraformApply({ planFile: plan.out.planFile });
63
+ `),
64
+ );
65
+ expect(diags).toHaveLength(0);
66
+ });
67
+
68
+ test("does not flag when the target cannot be resolved statically", () => {
69
+ const diags = planBeforeApplyRule.check(
70
+ createContext(`
71
+ const apply = terraformApply({ planFile: plan.out.planFile });
72
+ `),
73
+ );
74
+ expect(diags).toHaveLength(0);
75
+ });
76
+
77
+ test("checks the options object when it follows a positional root argument (k3s-shaped builder)", () => {
78
+ const flagged = planBeforeApplyRule.check(
79
+ createContext(`
80
+ const plan = terraformPlan("app", { planFile: "plan.out" });
81
+ const apply = terraformApply("app", { planFile: "/tmp/plan.out" });
82
+ `),
83
+ );
84
+ expect(flagged).toHaveLength(1);
85
+ const passing = planBeforeApplyRule.check(
86
+ createContext(`
87
+ const plan = terraformPlan("app", { id: "plan" });
88
+ const apply = terraformApply("app", { planFile: plan.out.planFile });
89
+ `),
90
+ );
91
+ expect(passing).toHaveLength(0);
92
+ });
93
+
94
+ test("does not flag terraformApply calls with no planFile property", () => {
95
+ const diags = planBeforeApplyRule.check(
96
+ createContext(`
97
+ const apply = terraformApply({ root: "app" });
98
+ `),
99
+ );
100
+ expect(diags).toHaveLength(0);
101
+ });
102
+
103
+ test("does not flag planFile-shaped keys outside terraformApply", () => {
104
+ const diags = planBeforeApplyRule.check(
105
+ createContext(`
106
+ const config = { planFile: "/tmp/plan.out" };
107
+ `),
108
+ );
109
+ expect(diags).toHaveLength(0);
110
+ });
111
+ });
@@ -0,0 +1,143 @@
1
+ import * as ts from "typescript";
2
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
3
+
4
+ /**
5
+ * TF101: terraformApply's planFile must reference a preceding terraformPlan step.
6
+ *
7
+ * Applying a plan file chosen by a string means the applied plan can be
8
+ * stale, hand-edited, or simply the wrong one — the entire point of the
9
+ * plan/apply split (review exactly what will change, then apply exactly
10
+ * that) is defeated the moment the path is spelled out in source instead of
11
+ * carried forward from the `terraformPlan` step that produced it.
12
+ *
13
+ * The two builders this rule is written against (`terraformPlan`,
14
+ * `terraformApply`) don't exist yet (#2086 ships them in parallel), so this
15
+ * matches purely on callee identifier name — same posture as
16
+ * `lexicons/k3s/src/lint/rules/token-literal.ts`.
17
+ *
18
+ * Accepts exactly the two authoring forms that produce a genuine step-output
19
+ * reference (`packages/core/src/op/step-output-ref.ts`):
20
+ * - `stepOutput(<ident>, "planFile")`
21
+ * - `<ident>.out.planFile`
22
+ * where `<ident>` is bound (`const <ident> = terraformPlan(...)`) in the same
23
+ * file. Flags a string literal, a template literal, or one of the two
24
+ * accepted reference forms pointing at an identifier that resolves to
25
+ * something other than a `terraformPlan` call. When `<ident>` cannot be
26
+ * resolved at all (not declared anywhere in the file), this rule stays
27
+ * silent — the same deliberately shallow "bail rather than guess" posture
28
+ * `validateStepOutputRefs` takes, since the runtime brand on a real
29
+ * `StepOutputRef` is invisible to a source-level AST match.
30
+ */
31
+ export const planBeforeApplyRule: LintRule = {
32
+ id: "TF101",
33
+ severity: "error",
34
+ category: "correctness",
35
+ description: "terraformApply's planFile must reference a preceding terraformPlan step",
36
+
37
+ check(context: LintContext): LintDiagnostic[] {
38
+ const diagnostics: LintDiagnostic[] = [];
39
+ const source = context.sourceFile;
40
+
41
+ /** Identifier name bound to a `terraformPlan(...)` call, to whether it truly is one. */
42
+ const bindings = new Map<string, boolean>();
43
+
44
+ const calleeName = (expr: ts.Expression): string | undefined =>
45
+ ts.isIdentifier(expr) ? expr.text : ts.isPropertyAccessExpression(expr) ? expr.name.text : undefined;
46
+
47
+ const collectBindings = (node: ts.Node) => {
48
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
49
+ const init = node.initializer;
50
+ if (ts.isCallExpression(init) || ts.isNewExpression(init)) {
51
+ bindings.set(node.name.text, calleeName(init.expression) === "terraformPlan");
52
+ }
53
+ }
54
+ ts.forEachChild(node, collectBindings);
55
+ };
56
+ collectBindings(source);
57
+
58
+ const flag = (node: ts.Node, message: string) => {
59
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
60
+ diagnostics.push({
61
+ ruleId: "TF101",
62
+ severity: "error",
63
+ message,
64
+ file: context.filePath,
65
+ line: line + 1,
66
+ column: character + 1,
67
+ });
68
+ };
69
+
70
+ /**
71
+ * `stepOutput(<ident>, "planFile")` or `<ident>.out.planFile`, returning
72
+ * the referenced identifier's name, or `undefined` if `initializer`
73
+ * isn't one of those two shapes.
74
+ */
75
+ const referencedIdent = (initializer: ts.Expression): string | undefined => {
76
+ if (ts.isCallExpression(initializer) && calleeName(initializer.expression) === "stepOutput") {
77
+ const arg = initializer.arguments[0];
78
+ return arg && ts.isIdentifier(arg) ? arg.text : undefined;
79
+ }
80
+ if (
81
+ ts.isPropertyAccessExpression(initializer) &&
82
+ initializer.name.text === "planFile" &&
83
+ ts.isPropertyAccessExpression(initializer.expression) &&
84
+ initializer.expression.name.text === "out" &&
85
+ ts.isIdentifier(initializer.expression.expression)
86
+ ) {
87
+ return initializer.expression.expression.text;
88
+ }
89
+ return undefined;
90
+ };
91
+
92
+ const checkPlanFile = (obj: ts.ObjectLiteralExpression) => {
93
+ for (const prop of obj.properties) {
94
+ if (!ts.isPropertyAssignment(prop)) continue;
95
+ const name = ts.isIdentifier(prop.name)
96
+ ? prop.name.text
97
+ : ts.isStringLiteral(prop.name)
98
+ ? prop.name.text
99
+ : undefined;
100
+ if (name !== "planFile") continue;
101
+
102
+ const value = prop.initializer;
103
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value) || ts.isTemplateExpression(value)) {
104
+ flag(
105
+ value,
106
+ "`planFile` is a literal path in source. Pass the preceding `terraformPlan` step's output " +
107
+ '(`stepOutput(plan, "planFile")` or `plan.out.planFile`), not a hardcoded path.',
108
+ );
109
+ continue;
110
+ }
111
+
112
+ const ident = referencedIdent(value);
113
+ if (ident === undefined) continue; // not one of the two accepted forms — out of this rule's reach
114
+ const isPlan = bindings.get(ident);
115
+ if (isPlan === false) {
116
+ flag(
117
+ value,
118
+ `\`planFile\` references "${ident}", which is not bound to a \`terraformPlan(...)\` call. ` +
119
+ "Apply only a plan this Op itself produced.",
120
+ );
121
+ }
122
+ // isPlan === true: paired correctly.
123
+ // isPlan === undefined: `ident` isn't declared anywhere in this file — cannot resolve
124
+ // statically, so stay silent rather than guess.
125
+ }
126
+ };
127
+
128
+ const visit = (node: ts.Node) => {
129
+ if (ts.isCallExpression(node) && calleeName(node.expression) === "terraformApply") {
130
+ // The options object may be the first argument (`terraformApply({ planFile })`)
131
+ // or follow a positional one (`terraformApply("app", { planFile })`, the
132
+ // k3s-shaped builder signature); inspect every object-literal argument.
133
+ for (const arg of node.arguments) {
134
+ if (ts.isObjectLiteralExpression(arg)) checkPlanFile(arg);
135
+ }
136
+ }
137
+ ts.forEachChild(node, visit);
138
+ };
139
+
140
+ visit(source);
141
+ return diagnostics;
142
+ },
143
+ };