@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32

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 (141) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.js +152 -0
  20. package/dist/composites/terraform-apply-op.js +183 -0
  21. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  22. package/dist/composites/terraform-watch-op.js +219 -0
  23. package/dist/config.js +118 -0
  24. package/dist/describe-resources.js +984 -0
  25. package/dist/generated/index.js +1 -0
  26. package/dist/hcl/descend.js +208 -0
  27. package/dist/hcl/edges.js +287 -0
  28. package/dist/hcl/jsonencode.js +442 -0
  29. package/dist/hcl/marker.js +212 -0
  30. package/dist/hcl/parse.js +372 -0
  31. package/dist/hcl/references.js +156 -0
  32. package/dist/hcl/roots.js +98 -0
  33. package/dist/hcl/suppressions.js +189 -0
  34. package/dist/hcl/value.js +72 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +46 -0
  38. package/dist/integrity.json +2 -2
  39. package/dist/lint/audit-catalog.js +501 -0
  40. package/dist/lint/audit-lineage.js +442 -0
  41. package/dist/lint/post-synth/blocks.js +87 -0
  42. package/dist/lint/post-synth/fixtures/load.js +94 -0
  43. package/dist/lint/post-synth/flat-attributes.js +135 -0
  44. package/dist/lint/post-synth/index.js +90 -0
  45. package/dist/lint/post-synth/module-source.js +153 -0
  46. package/dist/lint/post-synth/scope.js +104 -0
  47. package/dist/lint/post-synth/terragrunt.js +63 -0
  48. package/dist/lint/post-synth/tf001.js +138 -0
  49. package/dist/lint/post-synth/tf002.js +143 -0
  50. package/dist/lint/post-synth/tf003.js +60 -0
  51. package/dist/lint/post-synth/tf004.js +54 -0
  52. package/dist/lint/post-synth/tf005.js +68 -0
  53. package/dist/lint/post-synth/tf006.js +43 -0
  54. package/dist/lint/post-synth/tf007.js +66 -0
  55. package/dist/lint/post-synth/tf008.js +64 -0
  56. package/dist/lint/post-synth/tf009.js +49 -0
  57. package/dist/lint/post-synth/tf010.js +40 -0
  58. package/dist/lint/post-synth/tf011.js +38 -0
  59. package/dist/lint/post-synth/tf012.js +36 -0
  60. package/dist/lint/post-synth/tf013.js +53 -0
  61. package/dist/lint/post-synth/tf014.js +61 -0
  62. package/dist/lint/post-synth/tf015.js +62 -0
  63. package/dist/lint/post-synth/tf016.js +66 -0
  64. package/dist/lint/post-synth/tf017.js +39 -0
  65. package/dist/lint/post-synth/tf018.js +63 -0
  66. package/dist/lint/post-synth/tf019.js +60 -0
  67. package/dist/lint/post-synth/tf020.js +103 -0
  68. package/dist/lint/post-synth/tf021.js +117 -0
  69. package/dist/lint/post-synth/tf022.js +51 -0
  70. package/dist/lint/post-synth/tf024.js +62 -0
  71. package/dist/lint/post-synth/tf025.js +83 -0
  72. package/dist/lint/post-synth/tf026.js +74 -0
  73. package/dist/lint/post-synth/tf027.js +66 -0
  74. package/dist/lint/post-synth/tf028.js +83 -0
  75. package/dist/lint/post-synth/tf029.js +73 -0
  76. package/dist/lint/post-synth/tf030.js +253 -0
  77. package/dist/lint/post-synth/tf031.js +332 -0
  78. package/dist/lint/post-synth/tf032.js +201 -0
  79. package/dist/lint/post-synth/tf033.js +126 -0
  80. package/dist/lint/post-synth/tf034.js +45 -0
  81. package/dist/lint/post-synth/tf035.js +63 -0
  82. package/dist/lint/post-synth/tf036.js +97 -0
  83. package/dist/lint/post-synth/tf037.js +80 -0
  84. package/dist/lint/post-synth/tf038.js +63 -0
  85. package/dist/lint/post-synth/tf039.js +55 -0
  86. package/dist/lint/post-synth/tf040.js +67 -0
  87. package/dist/lint/post-synth/tf041.js +53 -0
  88. package/dist/lint/post-synth/tf042.js +38 -0
  89. package/dist/lint/post-synth/tf043.js +45 -0
  90. package/dist/lint/post-synth/tf044.js +45 -0
  91. package/dist/lint/post-synth/tf045.js +44 -0
  92. package/dist/lint/rules/fixtures/load.js +50 -0
  93. package/dist/lint/rules/index.js +4 -0
  94. package/dist/lint/rules/plan-before-apply.js +136 -0
  95. package/dist/lint/secret-shape.js +149 -0
  96. package/dist/live-ownership.js +57 -0
  97. package/dist/lsp/completions.js +95 -0
  98. package/dist/lsp/context.js +358 -0
  99. package/dist/lsp/hover.js +45 -0
  100. package/dist/lsp/option-keys.js +93 -0
  101. package/dist/manifest.json +1 -1
  102. package/dist/op/activities/index.js +30 -0
  103. package/dist/op/activities/live-detect.js +143 -0
  104. package/dist/op/activities/pin-rollout.js +45 -0
  105. package/dist/op/activities/terraform.js +1270 -0
  106. package/dist/op/activity-contracts.js +162 -0
  107. package/dist/op/adoption.js +272 -0
  108. package/dist/op/builders.js +125 -0
  109. package/dist/op/resolve-root-mode.js +77 -0
  110. package/dist/package-cli.js +16 -0
  111. package/dist/pin/edit-ts.js +76 -0
  112. package/dist/pin/edit.js +118 -0
  113. package/dist/pin/forge.js +65 -0
  114. package/dist/pin/index.js +16 -0
  115. package/dist/pin/rollout.d.ts.map +1 -1
  116. package/dist/pin/rollout.js +382 -0
  117. package/dist/pin/scan.js +287 -0
  118. package/dist/pin/source.js +171 -0
  119. package/dist/pin/waves.js +109 -0
  120. package/dist/plan-digest.js +67 -0
  121. package/dist/plugin.js +323 -0
  122. package/dist/serializer.js +23 -0
  123. package/dist/state-ownership.js +29 -0
  124. package/dist/terragrunt/affected.d.ts.map +1 -1
  125. package/dist/terragrunt/affected.js +290 -0
  126. package/dist/terragrunt/index.js +10 -0
  127. package/dist/terragrunt/mocks.js +270 -0
  128. package/dist/terragrunt/run.js +264 -0
  129. package/dist/terragrunt/units.js +195 -0
  130. package/dist/terragrunt/wave.js +125 -0
  131. package/dist/validate-cli.js +4 -0
  132. package/dist/validate.js +31 -0
  133. package/package.json +10 -10
  134. package/src/codegen/package.ts +2 -2
  135. package/src/components/capability-plugin.test.ts +97 -2
  136. package/src/components/capability-plugin.ts +117 -12
  137. package/src/components/pr-loop.tofu.test.ts +51 -11
  138. package/src/index.ts +1 -0
  139. package/src/pin/rollout.ts +2 -1
  140. package/src/terragrunt/affected.test.ts +11 -0
  141. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,63 @@
1
+ /**
2
+ * TF038: an `oci://` module source names no tag and no digest, or a mutable
3
+ * tag.
4
+ *
5
+ * OpenTofu resolves an OCI module source with no `tag` to the `latest` tag
6
+ * (https://opentofu.org/docs/language/modules/sources/), so an unpinned source
7
+ * and `?tag=latest` are the same thing: a push to the registry changes what
8
+ * the root provisions with no diff to review. A digest, or an exact version
9
+ * tag, holds it. `classifyModuleSource` (`./module-source.ts`) reads the tag
10
+ * and digest from either spelling.
11
+ *
12
+ * A warning rather than an error: a registry can make a version tag
13
+ * immutable, and a source that names `?tag=1.4.0` is read as pinned here
14
+ * whether or not the registry enforces that. A digest is the only pin the
15
+ * registry cannot move, which the message says. A source that names a digest
16
+ * is never flagged, whatever tag sits beside it.
17
+ *
18
+ * Scope: root and child modules alike (#2112), same as TF004 and TF005.
19
+ */
20
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
21
+ import { MODULE_TYPE } from "../../hcl/parse.js";
22
+ import { classifyModuleSource, isMutableOciTag } from "./module-source.js";
23
+ export const tf038 = {
24
+ id: "TF038",
25
+ description: "OCI module source has no tag or digest, or a mutable tag",
26
+ check(ctx) {
27
+ const diagnostics = [];
28
+ for (const [key, entity] of ctx.entities) {
29
+ if (entity.entityType !== MODULE_TYPE || !isResourceDeclarable(entity))
30
+ continue;
31
+ const props = entity.props;
32
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
33
+ const source = typeof body.source === "string" ? body.source : undefined;
34
+ if (source === undefined)
35
+ continue;
36
+ const classified = classifyModuleSource(source);
37
+ if (classified.kind !== "oci" || classified.digest)
38
+ continue;
39
+ const address = typeof props.address === "string" ? props.address : key;
40
+ let message;
41
+ if (!classified.tag) {
42
+ message =
43
+ `Module "${address}" sources "${source}" with no tag or digest, so OpenTofu pulls the \`latest\` tag. ` +
44
+ "Pin it to a digest (`?digest=sha256:...`) or an exact version tag (`?tag=1.4.0`).";
45
+ }
46
+ else if (isMutableOciTag(classified.tag)) {
47
+ message =
48
+ `Module "${address}" sources "${source}" with the mutable tag "${classified.tag}". ` +
49
+ "Pin it to a digest (`?digest=sha256:...`) or an exact version tag (`?tag=1.4.0`).";
50
+ }
51
+ if (!message)
52
+ continue;
53
+ diagnostics.push({
54
+ checkId: "TF038",
55
+ severity: "warning",
56
+ message,
57
+ entity: key,
58
+ lexicon: "terraform",
59
+ });
60
+ }
61
+ return diagnostics;
62
+ },
63
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * TF039: a registry module's `version` is a range, not an exact version.
3
+ *
4
+ * TF004 flags a registry module with no `version`; a `version` of `~> 1.4`,
5
+ * `>= 1.4` or `>= 1.0, < 2.0` passes it, yet an upstream release inside the
6
+ * range changes what the root provisions with no diff to review. This rule is
7
+ * for projects that pin modules to one version (`1.4.0` or `= 1.4.0`).
8
+ *
9
+ * Off by default: it is a `report-only` catalog entry, so the `recommended`
10
+ * preset leaves it out and `lint.presets: { terraform: "all" }` (or a
11
+ * `lint.rules` entry for TF039) turns it on. Ranges are common and often
12
+ * intended, so a default warning would be noise for most roots. It is a
13
+ * separate rule from TF004 because a post-synth check receives no options
14
+ * (`PostSynthCheck.check(ctx)`), while the preset mechanism already carries
15
+ * exactly this on/off choice per rule id (#3190).
16
+ *
17
+ * A module that has no `version` is TF004's finding and is skipped here.
18
+ *
19
+ * Scope: root and child modules alike (#2112).
20
+ */
21
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
22
+ import { MODULE_TYPE } from "../../hcl/parse.js";
23
+ import { classifyModuleSource, isExactVersionConstraint } from "./module-source.js";
24
+ export const tf039 = {
25
+ id: "TF039",
26
+ description: "Registry module version is a range, not an exact version",
27
+ check(ctx) {
28
+ const diagnostics = [];
29
+ for (const [key, entity] of ctx.entities) {
30
+ if (entity.entityType !== MODULE_TYPE || !isResourceDeclarable(entity))
31
+ continue;
32
+ const props = entity.props;
33
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
34
+ const source = typeof body.source === "string" ? body.source : undefined;
35
+ if (source === undefined || classifyModuleSource(source).kind !== "registry")
36
+ continue;
37
+ const version = body.version;
38
+ if (typeof version !== "string" || version.trim() === "")
39
+ continue;
40
+ if (isExactVersionConstraint(version))
41
+ continue;
42
+ const address = typeof props.address === "string" ? props.address : key;
43
+ diagnostics.push({
44
+ checkId: "TF039",
45
+ severity: "warning",
46
+ message: `Module "${address}" sources "${source}" with the version range "${version}". ` +
47
+ "A release inside the range changes what the root provisions with no diff to review. " +
48
+ 'Pin an exact version (`version = "1.4.0"`).',
49
+ entity: key,
50
+ lexicon: "terraform",
51
+ });
52
+ }
53
+ return diagnostics;
54
+ },
55
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * TF040: a root that uses providers has no `.terraform.lock.hcl`.
3
+ *
4
+ * The lock file records the provider versions and hashes `terraform init`
5
+ * selected. Without it, each `init` resolves `required_providers` constraints
6
+ * afresh, so a provider release changes a plan with no diff. None of
7
+ * TF001 to TF029 looks at it.
8
+ *
9
+ * "Committed" is decided by presence: the file sits beside the root's `.tf`
10
+ * files in the source tree being parsed (`props.lockFile`, stamped by
11
+ * `parseTerraformRootDir` and by the local audit path). Whether git tracks it
12
+ * is not asked, so a lock file that is present but gitignored counts as
13
+ * committed. A parse with no directory to look in (a fetched repository,
14
+ * inline source) leaves `lockFile` unset and the rule reports nothing: not
15
+ * determined, not clean.
16
+ *
17
+ * A root that implies no provider (no `provider` block and no resource or data
18
+ * type) gets no lock file from `init`, so it is skipped, as is a live
19
+ * choudoufu root, which runs no `init`. One diagnostic per root, anchored on
20
+ * the first block that implies a provider.
21
+ *
22
+ * Off by default in both places: the `report-only` tier keeps it out of a
23
+ * build's `recommended` preset, and `auditOptIn` keeps it out of `chant audit`.
24
+ * Many estates run `init` in CI and never commit the file.
25
+ *
26
+ * Scope: root modules only (#2112); a child module has no lock file of its own.
27
+ */
28
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
29
+ import { LOCK_FILENAME } from "../../hcl/parse.js";
30
+ import { isRootScoped } from "./scope.js";
31
+ import { impliedProviders } from "./tf002.js";
32
+ export const tf040 = {
33
+ id: "TF040",
34
+ description: "Root has no committed .terraform.lock.hcl",
35
+ // `chant audit` runs every rule, report-only ones included, so without this a
36
+ // new rule would change every project's audit output. A `lint.rules` entry
37
+ // for TF040 turns it on in the audit as well as in a build (#3190).
38
+ auditOptIn: true,
39
+ check(ctx) {
40
+ const diagnostics = [];
41
+ const missing = new Set();
42
+ for (const entity of ctx.entities.values()) {
43
+ if (!isResourceDeclarable(entity) || !isRootScoped(entity))
44
+ continue;
45
+ const props = entity.props;
46
+ if (props.lockFile !== false || props.mode === "live" || typeof props.root !== "string")
47
+ continue;
48
+ missing.add(props.root);
49
+ }
50
+ for (const root of [...missing].sort()) {
51
+ const implied = impliedProviders(ctx.entities, root);
52
+ if (implied.size === 0)
53
+ continue;
54
+ const names = [...implied.keys()].sort();
55
+ diagnostics.push({
56
+ checkId: "TF040",
57
+ severity: "warning",
58
+ message: `Root module "${root}" uses ${names.length === 1 ? "provider" : "providers"} ${names.map((n) => `"${n}"`).join(", ")} ` +
59
+ `but has no ${LOCK_FILENAME} beside its .tf files. Run \`terraform init\` and commit the file, ` +
60
+ "so every init selects the same provider versions.",
61
+ entity: implied.get(names[0]),
62
+ lexicon: "terraform",
63
+ });
64
+ }
65
+ return diagnostics;
66
+ },
67
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * TF041: a Terragrunt `dependency` has `mock_outputs` and lets them stand in
3
+ * for `apply`.
4
+ *
5
+ * `mock_outputs` is what a dependency returns when it has no outputs yet.
6
+ * `mock_outputs_allowed_terraform_commands` limits which commands may use
7
+ * them. Terragrunt reads a missing list and an empty list the same way, as no
8
+ * limit (`shouldReturnMockOutputs` in `pkg/config/dependency.go`, v1.1.6:
9
+ * `nil || len == 0 || slices.Contains(list, command)`). So a dependency with
10
+ * mocks and no list, or a list that names `apply`, can apply the mock values
11
+ * to real state when the upstream unit has not been applied, and the apply
12
+ * reports success. The usual list is `["validate", "plan"]`.
13
+ *
14
+ * A list that is an expression (`local.mock_commands`) is not determined and
15
+ * passes. A dependency with `enabled = false` returns its mocks whatever the
16
+ * list says; that is a different decision and is not reported here.
17
+ */
18
+ import { TERRAGRUNT_DEPENDENCY_TYPE } from "../../hcl/parse.js";
19
+ import { isExpression, terragruntEntities } from "./terragrunt.js";
20
+ export const tf041 = {
21
+ id: "TF041",
22
+ description: "Terragrunt dependency lets mock_outputs stand in for apply",
23
+ check(ctx) {
24
+ const diagnostics = [];
25
+ for (const dep of terragruntEntities(ctx.entities, TERRAGRUNT_DEPENDENCY_TYPE)) {
26
+ if (dep.body.mock_outputs === undefined)
27
+ continue;
28
+ const allowed = dep.body.mock_outputs_allowed_terraform_commands;
29
+ if (isExpression(allowed))
30
+ continue;
31
+ let problem;
32
+ if (allowed === undefined || (Array.isArray(allowed) && allowed.length === 0)) {
33
+ problem =
34
+ "sets no `mock_outputs_allowed_terraform_commands`, and Terragrunt reads a missing or empty list as every command";
35
+ }
36
+ else if (Array.isArray(allowed) && allowed.includes("apply")) {
37
+ problem = 'lists "apply" in `mock_outputs_allowed_terraform_commands`';
38
+ }
39
+ if (problem === undefined)
40
+ continue;
41
+ diagnostics.push({
42
+ checkId: "TF041",
43
+ severity: "warning",
44
+ message: `Dependency "${dep.address.replace(/^dependency\./, "")}" in ${dep.file} sets \`mock_outputs\` and ${problem}. ` +
45
+ "An apply that runs before the upstream unit is applied writes the mock values to real state. " +
46
+ 'Set `mock_outputs_allowed_terraform_commands = ["validate", "plan"]`.',
47
+ entity: dep.key,
48
+ lexicon: "terraform",
49
+ });
50
+ }
51
+ return diagnostics;
52
+ },
53
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * TF042: a Terragrunt `dependency` sets `skip_outputs = true` and
3
+ * `mock_outputs`.
4
+ *
5
+ * With `skip_outputs = true` Terragrunt never reads the upstream unit's
6
+ * outputs (`shouldGetOutputs` in `pkg/config/dependency.go`, v1.1.6), so the
7
+ * only values the dependency can return are the mocks. Every plan and apply
8
+ * the mocks are allowed for runs on them, however far the upstream unit has
9
+ * been applied. The pair is sometimes meant, to break a cycle or to avoid
10
+ * reading state, and then the mock values are the configuration; that is
11
+ * worth writing as inputs, which a reader of the unit can see.
12
+ *
13
+ * Only a literal `skip_outputs = true` is reported. A `skip_outputs` built
14
+ * from an expression is not determined.
15
+ */
16
+ import { TERRAGRUNT_DEPENDENCY_TYPE } from "../../hcl/parse.js";
17
+ import { terragruntEntities } from "./terragrunt.js";
18
+ export const tf042 = {
19
+ id: "TF042",
20
+ description: "Terragrunt dependency sets skip_outputs together with mock_outputs",
21
+ check(ctx) {
22
+ const diagnostics = [];
23
+ for (const dep of terragruntEntities(ctx.entities, TERRAGRUNT_DEPENDENCY_TYPE)) {
24
+ if (dep.body.skip_outputs !== true || dep.body.mock_outputs === undefined)
25
+ continue;
26
+ diagnostics.push({
27
+ checkId: "TF042",
28
+ severity: "warning",
29
+ message: `Dependency "${dep.address.replace(/^dependency\./, "")}" in ${dep.file} sets \`skip_outputs = true\` with \`mock_outputs\`. ` +
30
+ "Terragrunt then never reads the upstream unit's outputs, so every plan and apply runs on the mock values. " +
31
+ "Drop `skip_outputs`, or pass the values as `inputs` so they are visible as configuration.",
32
+ entity: dep.key,
33
+ lexicon: "terraform",
34
+ });
35
+ }
36
+ return diagnostics;
37
+ },
38
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TF043: a Terragrunt unit's `terraform.source` names no version.
3
+ *
4
+ * A git source with no `?ref=` follows the default branch, a `tfr://` source
5
+ * with no `?version=` follows the registry's latest release, and an `oci://`
6
+ * source with no tag or digest pulls `latest`. A push or a release changes
7
+ * what the unit provisions with no diff to review. TF005 and TF038 do the same
8
+ * for `module` blocks; a Terragrunt unit has no `module` block, its source is
9
+ * the `source` argument of the `terraform` block.
10
+ *
11
+ * A local path and a source built from an expression (`${local.base}//x`) are
12
+ * not determined and pass. A mutable `?ref=main` is not judged
13
+ * here: the rule asks only whether a version is named.
14
+ */
15
+ import { TERRAGRUNT_SOURCE_TYPE } from "../../hcl/parse.js";
16
+ import { classifyTerragruntSource, terragruntEntities } from "./terragrunt.js";
17
+ const FIX = {
18
+ git: "Add `?ref=` with a tag or commit SHA.",
19
+ tfr: "Add `?version=` with an exact version.",
20
+ oci: "Add `?digest=sha256:...` or `?tag=` with an exact version tag.",
21
+ };
22
+ export const tf043 = {
23
+ id: "TF043",
24
+ description: "Terragrunt terraform.source names no version (git ref, tfr version or oci tag)",
25
+ check(ctx) {
26
+ const diagnostics = [];
27
+ for (const block of terragruntEntities(ctx.entities, TERRAGRUNT_SOURCE_TYPE)) {
28
+ const source = block.body.source;
29
+ if (typeof source !== "string")
30
+ continue;
31
+ const classified = classifyTerragruntSource(source);
32
+ if (classified.pinned !== false)
33
+ continue;
34
+ diagnostics.push({
35
+ checkId: "TF043",
36
+ severity: "warning",
37
+ message: `The \`terraform\` block in ${block.file} sources "${source}" with no version, so a push or a release changes the unit with no diff to review. ` +
38
+ FIX[classified.kind],
39
+ entity: block.key,
40
+ lexicon: "terraform",
41
+ });
42
+ }
43
+ return diagnostics;
44
+ },
45
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TF044: a Terragrunt config keeps state in a local backend.
3
+ *
4
+ * Read from `remote_state { backend = "local" }` and from a `generate` block
5
+ * whose `contents` declare `backend "local"`. State on the runner's disk is
6
+ * gone when the job ends, a second job cannot read what the first applied, and
7
+ * a git-range run, which checks out each side in a temporary directory, never
8
+ * sees state at all. A shared backend (S3, GCS, azurerm, a Terraform HTTP
9
+ * backend) holds it.
10
+ *
11
+ * A backend named by an expression is not determined and passes. A local
12
+ * backend is sometimes right for a throwaway sandbox; suppress the rule there
13
+ * with `# chant-ignore TF044`.
14
+ */
15
+ import { TERRAGRUNT_GENERATE_TYPE, TERRAGRUNT_REMOTE_STATE_TYPE } from "../../hcl/parse.js";
16
+ import { terragruntEntities } from "./terragrunt.js";
17
+ const LOCAL_BACKEND_BLOCK = /\bbackend\s+"local"/;
18
+ export const tf044 = {
19
+ id: "TF044",
20
+ description: "Terragrunt config keeps state in a local backend",
21
+ check(ctx) {
22
+ const diagnostics = [];
23
+ const report = (key, file, how) => {
24
+ diagnostics.push({
25
+ checkId: "TF044",
26
+ severity: "warning",
27
+ message: `${how} in ${file} keeps state in a local backend. The state lives on the runner's disk, so a later job cannot read it ` +
28
+ "and a git-range run, which checks out each side in a temporary directory, never sees it. Use a shared backend such as S3 or GCS.",
29
+ entity: key,
30
+ lexicon: "terraform",
31
+ });
32
+ };
33
+ for (const block of terragruntEntities(ctx.entities, TERRAGRUNT_REMOTE_STATE_TYPE)) {
34
+ if (block.body.backend === "local")
35
+ report(block.key, block.file, "`remote_state` sets `backend = \"local\"`");
36
+ }
37
+ for (const block of terragruntEntities(ctx.entities, TERRAGRUNT_GENERATE_TYPE)) {
38
+ const contents = block.body.contents;
39
+ if (typeof contents === "string" && LOCAL_BACKEND_BLOCK.test(contents)) {
40
+ report(block.key, block.file, `\`generate "${block.address.replace(/^generate\./, "")}"\` writes a \`backend "local"\` block`);
41
+ }
42
+ }
43
+ return diagnostics;
44
+ },
45
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * TF045: no Terragrunt config sets `terragrunt_version_constraint`.
3
+ *
4
+ * The attribute makes Terragrunt refuse to run on a release outside the range.
5
+ * Without it each machine and each CI image runs whatever Terragrunt it
6
+ * installed, and a behaviour change between releases reaches a plan with no
7
+ * diff. It is usually set once, in the root config every unit includes.
8
+ *
9
+ * The rule looks at the whole set of configs in the scan and reports once. It
10
+ * reports only when the set holds a config that includes no other file (the
11
+ * root config, or a self-contained unit) and none of the configs sets the
12
+ * attribute. A set made only of units that include a parent it did not read,
13
+ * such as one unit directory passed alone, is not determined: the parent may
14
+ * set it. The finding is anchored on `root.hcl` when the set has one.
15
+ *
16
+ * Off by default: the `report-only` tier keeps it out of the `recommended`
17
+ * preset, since many repos pin Terragrunt in the toolchain (`.tool-versions`,
18
+ * a CI image) rather than in config.
19
+ */
20
+ import { TERRAGRUNT_CONFIG_TYPE } from "../../hcl/parse.js";
21
+ import { terragruntEntities } from "./terragrunt.js";
22
+ export const tf045 = {
23
+ id: "TF045",
24
+ description: "No Terragrunt config sets terragrunt_version_constraint",
25
+ check(ctx) {
26
+ const configs = terragruntEntities(ctx.entities, TERRAGRUNT_CONFIG_TYPE);
27
+ if (configs.some((c) => c.body.terragrunt_version_constraint !== undefined))
28
+ return [];
29
+ const tops = configs.filter((c) => Array.isArray(c.body.includes) && c.body.includes.length === 0);
30
+ if (tops.length === 0)
31
+ return [];
32
+ const anchor = [...tops].sort((a, b) => Number(b.file === "root.hcl") - Number(a.file === "root.hcl") || a.key.localeCompare(b.key))[0];
33
+ return [
34
+ {
35
+ checkId: "TF045",
36
+ severity: "warning",
37
+ message: `No Terragrunt config sets \`terragrunt_version_constraint\` (${anchor.file} is the top config read). ` +
38
+ 'Set it once in the root config, for example `terragrunt_version_constraint = ">= 1.1.0"`, so every machine runs a release the repo was tested with.',
39
+ entity: anchor.key,
40
+ lexicon: "terraform",
41
+ },
42
+ ];
43
+ },
44
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared lint-rule fixture loader, the `lint/rules/` sibling of
3
+ * `../../post-synth/fixtures/load.ts`.
4
+ *
5
+ * A post-synth check's fixtures are `.tf`, a real Terraform root. A lint
6
+ * rule's fixtures are Op-shaped source, the kind `chant lint` runs a rule's
7
+ * `check(context)` against directly. TF101's live under
8
+ * `fixtures/TF101/positive-op.ts` (triggers) and `negative-op.ts` (passes),
9
+ * deliberately unimported and unresolved at the identifier level: TF101
10
+ * itself matches purely on callee name (see `plan-before-apply.ts`'s doc
11
+ * comment), so a fixture that only needs to parse, not typecheck or run as a
12
+ * standalone program, stays honest about what the rule actually reads.
13
+ *
14
+ * On disk these are named `<name>-op.ts`, not `<name>.op.ts`: a real
15
+ * `*.op.ts` file is exactly what `packages/core/src/op/discover.ts`'s
16
+ * `discoverOps()` walks the whole repository (down from the git root, absent
17
+ * a `chant.config.*`) to find and `import()`, and it does that unconditionally
18
+ * in tests like `generate-pipeline.test.ts` that exercise real discovery. A
19
+ * fixture named `positive.op.ts` would be swept into that scan, imported,
20
+ * and fail at the top-level `const plan = terraformPlan(...)` reference,
21
+ * turning into a discovery error that breaks an unrelated, already-passing
22
+ * test. `loadRuleFixture` still hands the rule a logical `.op.ts` filename
23
+ * (below) so parsing and diagnostics see the file the way a real one would
24
+ * appear; only the on-disk name dodges the scan. Both physical files are
25
+ * also excluded from every tsconfig that would otherwise try to compile them
26
+ * as real source (root `tsconfig.json`, `tsconfig.typecheck.json`, and this
27
+ * lexicon's `tsconfig.build.json`).
28
+ */
29
+ import { readFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { ts } from "@intentius/chant/lint/typescript";
33
+ const FIXTURES_DIR = dirname(fileURLToPath(import.meta.url));
34
+ /**
35
+ * Parse `fixtures/<ruleId>/<name>-op.ts` into a {@link LintContext} whose
36
+ * `filePath` is the logical `<name>.op.ts` a real Op file would carry.
37
+ *
38
+ * Three lines per fixture in a test:
39
+ * ```ts
40
+ * const diags = planBeforeApplyRule.check(loadRuleFixture("TF101", "positive"));
41
+ * expect(diags).toHaveLength(1);
42
+ * ```
43
+ */
44
+ export function loadRuleFixture(ruleId, name) {
45
+ const onDisk = `${name}-op.ts`;
46
+ const logical = `${name}.op.ts`;
47
+ const code = readFileSync(join(FIXTURES_DIR, ruleId, onDisk), "utf-8");
48
+ const sourceFile = ts.createSourceFile(logical, code, ts.ScriptTarget.Latest, true);
49
+ return { sourceFile, entities: [], filePath: logical };
50
+ }
@@ -0,0 +1,4 @@
1
+ import { planBeforeApplyRule } from "./plan-before-apply.js";
2
+ export { planBeforeApplyRule } from "./plan-before-apply.js";
3
+ /** All lint rules provided by this lexicon (imported by plugin.ts's lintRules()). */
4
+ export const rules = [planBeforeApplyRule];
@@ -0,0 +1,136 @@
1
+ import { ts } from "@intentius/chant/lint/typescript";
2
+ /**
3
+ * TF101: terraformApply's planFile must reference a preceding terraformPlan step.
4
+ *
5
+ * Applying a plan file chosen by a string means the applied plan can be
6
+ * stale, hand-edited, or simply the wrong one — the entire point of the
7
+ * plan/apply split (review exactly what will change, then apply exactly
8
+ * that) is defeated the moment the path is spelled out in source instead of
9
+ * carried forward from the `terraformPlan` step that produced it.
10
+ *
11
+ * The two builders this rule is written against (`terraformPlan`,
12
+ * `terraformApply`) don't exist yet (#2086 ships them in parallel), so this
13
+ * matches purely on callee identifier name — same posture as
14
+ * `lexicons/k3s/src/lint/rules/token-literal.ts`.
15
+ *
16
+ * Accepts exactly the two authoring forms that produce a genuine step-output
17
+ * reference (`packages/core/src/op/step-output-ref.ts`):
18
+ * - `stepOutput(<ident>, "planFile")`
19
+ * - `<ident>.out.planFile`
20
+ * where `<ident>` is bound (`const <ident> = terraformPlan(...)`) in the same
21
+ * file. Flags a string literal, a template literal, or one of the two
22
+ * accepted reference forms pointing at an identifier that resolves to
23
+ * something other than a `terraformPlan` call. When `<ident>` cannot be
24
+ * resolved at all (not declared anywhere in the file), this rule stays
25
+ * silent — the same deliberately shallow "bail rather than guess" posture
26
+ * `validateStepOutputRefs` takes, since the runtime brand on a real
27
+ * `StepOutputRef` is invisible to a source-level AST match.
28
+ *
29
+ * Fires on every `terraformApply` call, live root or stock. #2106 exempted
30
+ * live roots for one release, because choudoufu refused `-out` and
31
+ * `apply <planfile>` and the activity ran a bare `apply -auto-approve` with no
32
+ * plan file to pair. choudoufu v0.13.0 admits both under a `live` block
33
+ * ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878)), and
34
+ * there the pairing matters more than on a stock root, not less: the apply
35
+ * re-plans the live system and compares its fresh plan against the file it was
36
+ * handed, so a plan file spelled out as a literal is an approval for a run
37
+ * nobody can trace back to a plan step. The exemption is gone with the
38
+ * condition that produced it, and this rule reads no project config at all.
39
+ */
40
+ export const planBeforeApplyRule = {
41
+ id: "TF101",
42
+ severity: "error",
43
+ category: "correctness",
44
+ description: "terraformApply's planFile must reference a preceding terraformPlan step",
45
+ check(context) {
46
+ const diagnostics = [];
47
+ const source = context.sourceFile;
48
+ /** Identifier name bound to a `terraformPlan(...)` call, to whether it truly is one. */
49
+ const bindings = new Map();
50
+ const calleeName = (expr) => ts.isIdentifier(expr) ? expr.text : ts.isPropertyAccessExpression(expr) ? expr.name.text : undefined;
51
+ const collectBindings = (node) => {
52
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
53
+ const init = node.initializer;
54
+ if (ts.isCallExpression(init) || ts.isNewExpression(init)) {
55
+ bindings.set(node.name.text, calleeName(init.expression) === "terraformPlan");
56
+ }
57
+ }
58
+ ts.forEachChild(node, collectBindings);
59
+ };
60
+ collectBindings(source);
61
+ const flag = (node, message) => {
62
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
63
+ diagnostics.push({
64
+ ruleId: "TF101",
65
+ severity: "error",
66
+ message,
67
+ file: context.filePath,
68
+ line: line + 1,
69
+ column: character + 1,
70
+ });
71
+ };
72
+ /**
73
+ * `stepOutput(<ident>, "planFile")` or `<ident>.out.planFile`, returning
74
+ * the referenced identifier's name, or `undefined` if `initializer`
75
+ * isn't one of those two shapes.
76
+ */
77
+ const referencedIdent = (initializer) => {
78
+ if (ts.isCallExpression(initializer) && calleeName(initializer.expression) === "stepOutput") {
79
+ const arg = initializer.arguments[0];
80
+ return arg && ts.isIdentifier(arg) ? arg.text : undefined;
81
+ }
82
+ if (ts.isPropertyAccessExpression(initializer) &&
83
+ initializer.name.text === "planFile" &&
84
+ ts.isPropertyAccessExpression(initializer.expression) &&
85
+ initializer.expression.name.text === "out" &&
86
+ ts.isIdentifier(initializer.expression.expression)) {
87
+ return initializer.expression.expression.text;
88
+ }
89
+ return undefined;
90
+ };
91
+ const checkPlanFile = (obj) => {
92
+ for (const prop of obj.properties) {
93
+ if (!ts.isPropertyAssignment(prop))
94
+ 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")
101
+ continue;
102
+ const value = prop.initializer;
103
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value) || ts.isTemplateExpression(value)) {
104
+ flag(value, "`planFile` is a literal path in source. Pass the preceding `terraformPlan` step's output " +
105
+ '(`stepOutput(plan, "planFile")` or `plan.out.planFile`), not a hardcoded path.');
106
+ continue;
107
+ }
108
+ const ident = referencedIdent(value);
109
+ if (ident === undefined)
110
+ continue; // not one of the two accepted forms — out of this rule's reach
111
+ const isPlan = bindings.get(ident);
112
+ if (isPlan === false) {
113
+ flag(value, `\`planFile\` references "${ident}", which is not bound to a \`terraformPlan(...)\` call. ` +
114
+ "Apply only a plan this Op itself produced.");
115
+ }
116
+ // isPlan === true: paired correctly.
117
+ // isPlan === undefined: `ident` isn't declared anywhere in this file — cannot resolve
118
+ // statically, so stay silent rather than guess.
119
+ }
120
+ };
121
+ const visit = (node) => {
122
+ if (ts.isCallExpression(node) && calleeName(node.expression) === "terraformApply") {
123
+ // The options object may be the first argument (`terraformApply({ planFile })`)
124
+ // or follow a positional one (`terraformApply("app", { planFile })`, the
125
+ // k3s-shaped builder signature); inspect every object-literal argument.
126
+ for (const arg of node.arguments) {
127
+ if (ts.isObjectLiteralExpression(arg))
128
+ checkPlanFile(arg);
129
+ }
130
+ }
131
+ ts.forEachChild(node, visit);
132
+ };
133
+ visit(source);
134
+ return diagnostics;
135
+ },
136
+ };