@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.
- package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
- package/dist/__fixtures__/terragrunt/record.js +57 -0
- package/dist/behaviour/index.js +6 -0
- package/dist/behaviour/kinds.js +194 -0
- package/dist/behaviour/predict.js +297 -0
- package/dist/behaviour/reference-catalog.js +178 -0
- package/dist/behaviour/request.js +482 -0
- package/dist/change-set.js +234 -0
- package/dist/changed-roots.js +97 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +44 -0
- package/dist/codegen/generate-cli.js +9 -0
- package/dist/codegen/generate.js +59 -0
- package/dist/codegen/package.js +43 -0
- package/dist/commands.js +106 -0
- package/dist/components/capability-plugin.d.ts +38 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +260 -0
- package/dist/composites/terraform-adopt-op.js +152 -0
- package/dist/composites/terraform-apply-op.js +183 -0
- package/dist/composites/terraform-pin-rollout-op.js +45 -0
- package/dist/composites/terraform-watch-op.js +219 -0
- package/dist/config.js +118 -0
- package/dist/describe-resources.js +984 -0
- package/dist/generated/index.js +1 -0
- package/dist/hcl/descend.js +208 -0
- package/dist/hcl/edges.js +287 -0
- package/dist/hcl/jsonencode.js +442 -0
- package/dist/hcl/marker.js +212 -0
- package/dist/hcl/parse.js +372 -0
- package/dist/hcl/references.js +156 -0
- package/dist/hcl/roots.js +98 -0
- package/dist/hcl/suppressions.js +189 -0
- package/dist/hcl/value.js +72 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +501 -0
- package/dist/lint/audit-lineage.js +442 -0
- package/dist/lint/post-synth/blocks.js +87 -0
- package/dist/lint/post-synth/fixtures/load.js +94 -0
- package/dist/lint/post-synth/flat-attributes.js +135 -0
- package/dist/lint/post-synth/index.js +90 -0
- package/dist/lint/post-synth/module-source.js +153 -0
- package/dist/lint/post-synth/scope.js +104 -0
- package/dist/lint/post-synth/terragrunt.js +63 -0
- package/dist/lint/post-synth/tf001.js +138 -0
- package/dist/lint/post-synth/tf002.js +143 -0
- package/dist/lint/post-synth/tf003.js +60 -0
- package/dist/lint/post-synth/tf004.js +54 -0
- package/dist/lint/post-synth/tf005.js +68 -0
- package/dist/lint/post-synth/tf006.js +43 -0
- package/dist/lint/post-synth/tf007.js +66 -0
- package/dist/lint/post-synth/tf008.js +64 -0
- package/dist/lint/post-synth/tf009.js +49 -0
- package/dist/lint/post-synth/tf010.js +40 -0
- package/dist/lint/post-synth/tf011.js +38 -0
- package/dist/lint/post-synth/tf012.js +36 -0
- package/dist/lint/post-synth/tf013.js +53 -0
- package/dist/lint/post-synth/tf014.js +61 -0
- package/dist/lint/post-synth/tf015.js +62 -0
- package/dist/lint/post-synth/tf016.js +66 -0
- package/dist/lint/post-synth/tf017.js +39 -0
- package/dist/lint/post-synth/tf018.js +63 -0
- package/dist/lint/post-synth/tf019.js +60 -0
- package/dist/lint/post-synth/tf020.js +103 -0
- package/dist/lint/post-synth/tf021.js +117 -0
- package/dist/lint/post-synth/tf022.js +51 -0
- package/dist/lint/post-synth/tf024.js +62 -0
- package/dist/lint/post-synth/tf025.js +83 -0
- package/dist/lint/post-synth/tf026.js +74 -0
- package/dist/lint/post-synth/tf027.js +66 -0
- package/dist/lint/post-synth/tf028.js +83 -0
- package/dist/lint/post-synth/tf029.js +73 -0
- package/dist/lint/post-synth/tf030.js +253 -0
- package/dist/lint/post-synth/tf031.js +332 -0
- package/dist/lint/post-synth/tf032.js +201 -0
- package/dist/lint/post-synth/tf033.js +126 -0
- package/dist/lint/post-synth/tf034.js +45 -0
- package/dist/lint/post-synth/tf035.js +63 -0
- package/dist/lint/post-synth/tf036.js +97 -0
- package/dist/lint/post-synth/tf037.js +80 -0
- package/dist/lint/post-synth/tf038.js +63 -0
- package/dist/lint/post-synth/tf039.js +55 -0
- package/dist/lint/post-synth/tf040.js +67 -0
- package/dist/lint/post-synth/tf041.js +53 -0
- package/dist/lint/post-synth/tf042.js +38 -0
- package/dist/lint/post-synth/tf043.js +45 -0
- package/dist/lint/post-synth/tf044.js +45 -0
- package/dist/lint/post-synth/tf045.js +44 -0
- package/dist/lint/rules/fixtures/load.js +50 -0
- package/dist/lint/rules/index.js +4 -0
- package/dist/lint/rules/plan-before-apply.js +136 -0
- package/dist/lint/secret-shape.js +149 -0
- package/dist/live-ownership.js +57 -0
- package/dist/lsp/completions.js +95 -0
- package/dist/lsp/context.js +358 -0
- package/dist/lsp/hover.js +45 -0
- package/dist/lsp/option-keys.js +93 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.js +30 -0
- package/dist/op/activities/live-detect.js +143 -0
- package/dist/op/activities/pin-rollout.js +45 -0
- package/dist/op/activities/terraform.js +1270 -0
- package/dist/op/activity-contracts.js +162 -0
- package/dist/op/adoption.js +272 -0
- package/dist/op/builders.js +125 -0
- package/dist/op/resolve-root-mode.js +77 -0
- package/dist/package-cli.js +16 -0
- package/dist/pin/edit-ts.js +76 -0
- package/dist/pin/edit.js +118 -0
- package/dist/pin/forge.js +65 -0
- package/dist/pin/index.js +16 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +382 -0
- package/dist/pin/scan.js +287 -0
- package/dist/pin/source.js +171 -0
- package/dist/pin/waves.js +109 -0
- package/dist/plan-digest.js +67 -0
- package/dist/plugin.js +323 -0
- package/dist/serializer.js +23 -0
- package/dist/state-ownership.js +29 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +290 -0
- package/dist/terragrunt/index.js +10 -0
- package/dist/terragrunt/mocks.js +270 -0
- package/dist/terragrunt/run.js +264 -0
- package/dist/terragrunt/units.js +195 -0
- package/dist/terragrunt/wave.js +125 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +31 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/components/capability-plugin.test.ts +97 -2
- package/src/components/capability-plugin.ts +117 -12
- package/src/components/pr-loop.tofu.test.ts +51 -11
- package/src/index.ts +1 -0
- package/src/pin/rollout.ts +2 -1
- package/src/terragrunt/affected.test.ts +11 -0
- package/src/terragrunt/affected.ts +2 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two heuristics the secrets rules of the TF family share: does this
|
|
3
|
+
* attribute's NAME read like a credential, and does its VALUE look like one.
|
|
4
|
+
*
|
|
5
|
+
* tfsec shipped `general-secrets-sensitive-in-variable`,
|
|
6
|
+
* `-in-local` and `-in-attribute`, and trivy dropped all three when it
|
|
7
|
+
* absorbed tfsec, so no maintained tool checks them today (#2107's survey).
|
|
8
|
+
* TF007, TF008, TF009 and TF022 are chant's versions, and they only stay
|
|
9
|
+
* consistent with each other if "secret-shaped" means one thing. That is what
|
|
10
|
+
* this module is.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here re-implements a detector `packages/core/src/audit/secrets.ts`
|
|
13
|
+
* already has. The value heuristic runs a candidate through `scanForSecrets`
|
|
14
|
+
* first, so every vendor prefix the SEC family knows (AWS key ids, GitHub,
|
|
15
|
+
* Slack, Google, Stripe), the PEM block shape and the connection-string shape
|
|
16
|
+
* are recognised here for free, and it reuses that module's placeholder
|
|
17
|
+
* vocabulary, its identifier-slug filter and its entropy function. What it
|
|
18
|
+
* adds is what a Terraform attribute needs and a raw text scan does not: the
|
|
19
|
+
* JWT and bare PEM-header shapes, and a lower length floor than SEC010's 24
|
|
20
|
+
* characters, because an attribute value is already a single token rather
|
|
21
|
+
* than a line of prose to fish tokens out of.
|
|
22
|
+
*/
|
|
23
|
+
import { looksLikeIdentifierSlug, looksLikePlaceholder, scanForSecrets, shannonEntropy, } from "@intentius/chant/audit/secrets";
|
|
24
|
+
/**
|
|
25
|
+
* Credential words, matched against the whole attribute name on `_`
|
|
26
|
+
* boundaries, so `db_password` and `access_key` match but `keystore` and
|
|
27
|
+
* `tokenizer` do not.
|
|
28
|
+
*/
|
|
29
|
+
const SECRET_NAME_RE = /(^|_)(password|passwd|secret|token|api_key|private_key|access_key)(_|$)/;
|
|
30
|
+
/**
|
|
31
|
+
* Suffixes that turn a credential word into a REFERENCE to a credential:
|
|
32
|
+
* `secret_arn`, `db_password_file`, `api_key_id`, `private_key_path`,
|
|
33
|
+
* `token_name`. The value under one of these is a locator, and a locator is
|
|
34
|
+
* exactly what the fix for the other rules asks for, so flagging it would
|
|
35
|
+
* flag the remediation.
|
|
36
|
+
*/
|
|
37
|
+
const LOCATOR_SUFFIX_RE = /_(file|path|arn|id|name)$/;
|
|
38
|
+
/** Minimum length for a value to be worth judging at all. */
|
|
39
|
+
export const MIN_SECRET_LENGTH = 8;
|
|
40
|
+
/** Minimum length for the generic high-entropy shape (below it, entropy says nothing). */
|
|
41
|
+
const MIN_ENTROPY_LENGTH = 16;
|
|
42
|
+
/** Bits per character above which a token of that length reads as random. */
|
|
43
|
+
const ENTROPY_THRESHOLD = 3.5;
|
|
44
|
+
/** `eyJ...header.eyJ...payload.signature`. An unsigned JWT still carries claims. */
|
|
45
|
+
const JWT_RE = /^ey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(\.[A-Za-z0-9_-]*)?$/;
|
|
46
|
+
/** A PEM header on its own, without the closing block `scanForSecrets` requires. */
|
|
47
|
+
const PEM_HEADER_RE = /-----BEGIN ([A-Z]+ )?PRIVATE KEY-----/;
|
|
48
|
+
/** Normalize an attribute or variable name for the name heuristic. */
|
|
49
|
+
function normalizeName(name) {
|
|
50
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Does this attribute or variable name read like a credential? Case-insensitive
|
|
54
|
+
* and word-bounded, with the locator suffixes excluded.
|
|
55
|
+
*/
|
|
56
|
+
export function isSecretName(name) {
|
|
57
|
+
const normalized = normalizeName(name);
|
|
58
|
+
if (LOCATOR_SUFFIX_RE.test(normalized))
|
|
59
|
+
return false;
|
|
60
|
+
return SECRET_NAME_RE.test(normalized);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Is this a plain string literal, as opposed to an expression? `hcl2json`
|
|
64
|
+
* renders every expression as a `"${...}"` template, so a value carrying one
|
|
65
|
+
* is a reference (`var.db_password`, `data.aws_secretsmanager_secret_version.x.secret_string`)
|
|
66
|
+
* or an interpolation, never a committed constant. Those are the fix, not the
|
|
67
|
+
* finding.
|
|
68
|
+
*/
|
|
69
|
+
export function isLiteralString(value) {
|
|
70
|
+
return typeof value === "string" && !value.includes("${");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A placeholder rather than a credential. Core's list is checked twice: once
|
|
74
|
+
* as written, once with separators stripped, so the `CHANGE_ME` and
|
|
75
|
+
* `YOUR_API_KEY` spellings land on the same `changeme` / `your-api-key`
|
|
76
|
+
* entries as the unseparated ones.
|
|
77
|
+
*/
|
|
78
|
+
export function isPlaceholderValue(value) {
|
|
79
|
+
return looksLikePlaceholder(value) || looksLikePlaceholder(value.replace(/[-_\s]/g, ""));
|
|
80
|
+
}
|
|
81
|
+
/** At least 3 of {lower, upper, digit, symbol}, which cuts plain words and pure hex. */
|
|
82
|
+
function hasClassDiversity(value) {
|
|
83
|
+
let classes = 0;
|
|
84
|
+
if (/[a-z]/.test(value))
|
|
85
|
+
classes++;
|
|
86
|
+
if (/[A-Z]/.test(value))
|
|
87
|
+
classes++;
|
|
88
|
+
if (/[0-9]/.test(value))
|
|
89
|
+
classes++;
|
|
90
|
+
if (/[^A-Za-z0-9]/.test(value))
|
|
91
|
+
classes++;
|
|
92
|
+
return classes >= 3;
|
|
93
|
+
}
|
|
94
|
+
/** The generic shape: a long, diverse, high-entropy token that isn't a slug. */
|
|
95
|
+
function isHighEntropyToken(value) {
|
|
96
|
+
if (value.length < MIN_ENTROPY_LENGTH)
|
|
97
|
+
return false;
|
|
98
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(value))
|
|
99
|
+
return false;
|
|
100
|
+
if (!hasClassDiversity(value))
|
|
101
|
+
return false;
|
|
102
|
+
if (looksLikeIdentifierSlug(value))
|
|
103
|
+
return false;
|
|
104
|
+
return shannonEntropy(value) >= ENTROPY_THRESHOLD;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Does this value look like a live credential on its own, whatever it is
|
|
108
|
+
* called? A known vendor shape (through core's detectors), a JWT, a PEM key,
|
|
109
|
+
* or a high-entropy token.
|
|
110
|
+
*/
|
|
111
|
+
export function isCredentialShapedValue(value) {
|
|
112
|
+
if (!isLiteralString(value))
|
|
113
|
+
return false;
|
|
114
|
+
if (value.length < MIN_SECRET_LENGTH)
|
|
115
|
+
return false;
|
|
116
|
+
if (isPlaceholderValue(value))
|
|
117
|
+
return false;
|
|
118
|
+
if (JWT_RE.test(value))
|
|
119
|
+
return true;
|
|
120
|
+
if (PEM_HEADER_RE.test(value))
|
|
121
|
+
return true;
|
|
122
|
+
// Core's own scan, over the single value: every SEC00x detector plus its
|
|
123
|
+
// high-entropy catch-all, with no file to walk.
|
|
124
|
+
if (scanForSecrets([{ path: "value", content: value }]).length > 0)
|
|
125
|
+
return true;
|
|
126
|
+
return isHighEntropyToken(value);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The combined test the rules use: is `value`, assigned to an attribute called
|
|
130
|
+
* `name`, a committed secret? Either heuristic is enough, but both sides of the
|
|
131
|
+
* gate are the same, since the value has to be a literal, long enough to be a
|
|
132
|
+
* credential, and not a placeholder, so `sensitive = true`-style
|
|
133
|
+
* remediation advice is never given for `password = "changeme"`.
|
|
134
|
+
*
|
|
135
|
+
* Findings never carry the value. Callers get the reason and the length.
|
|
136
|
+
*/
|
|
137
|
+
export function secretShapedAssignment(name, value) {
|
|
138
|
+
if (!isLiteralString(value))
|
|
139
|
+
return undefined;
|
|
140
|
+
if (value.length < MIN_SECRET_LENGTH)
|
|
141
|
+
return undefined;
|
|
142
|
+
if (isPlaceholderValue(value))
|
|
143
|
+
return undefined;
|
|
144
|
+
if (isCredentialShapedValue(value))
|
|
145
|
+
return { reason: "value", length: value.length };
|
|
146
|
+
if (isSecretName(name))
|
|
147
|
+
return { reason: "name", length: value.length };
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live root's ownership channel (#2104): choudoufu's two marker tags.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `./state-ownership.ts`, and here for the same mechanical reason:
|
|
5
|
+
* `plugin.ts` declares `ownershipChannel` eagerly while `describeResources`
|
|
6
|
+
* is behind a dynamic import, so the key names cannot live in the reader
|
|
7
|
+
* without dragging the reader, and `node:child_process` with it, into every
|
|
8
|
+
* plugin load.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the keys are declared and the verdicts are set by hand
|
|
11
|
+
*
|
|
12
|
+
* The issue that added this asked which of two ways to go: widen core's
|
|
13
|
+
* `OwnershipChannel` so a lexicon can supply its own classifier, or declare
|
|
14
|
+
* the keys for discoverability and set the verdicts in the reader, the way
|
|
15
|
+
* `lexicons/fly/src/ownership.ts` does for its two tiers. The second, and the
|
|
16
|
+
* reason is that the first buys nothing.
|
|
17
|
+
*
|
|
18
|
+
* Core's `classifyOwnership` tests one thing: does `tags[keys.managedBy]`
|
|
19
|
+
* equal the literal `"chant"`. Nothing in core ever calls it on a lexicon's
|
|
20
|
+
* behalf. `packages/core/src/ownership.ts` exports it, every lexicon calls it
|
|
21
|
+
* itself where the test happens to fit, and the ones it does not fit, fly's
|
|
22
|
+
* app boundary and aws's stack tags, already set verdicts directly. Adding a
|
|
23
|
+
* `classify` member to `OwnershipChannel` would give core a field only this
|
|
24
|
+
* lexicon passes and only this lexicon reads back, which is a longer way of
|
|
25
|
+
* writing the function `./describe-resources.ts` already has.
|
|
26
|
+
*
|
|
27
|
+
* What the declaration is still for is the claim it makes. Declaring
|
|
28
|
+
* `describeResources` on the channel is what turns the observation
|
|
29
|
+
* conformance suite's ownership assertion from a shape check into a
|
|
30
|
+
* behavioural one, and that claim holds on a live root exactly as it does on
|
|
31
|
+
* a stock one, from a different reading. The suite runs for both modes in
|
|
32
|
+
* `./describe-resources.test.ts`.
|
|
33
|
+
*
|
|
34
|
+
* ## The keys, and the slot that does not fit
|
|
35
|
+
*
|
|
36
|
+
* `ChannelKeys` is a managed-by / stack / env triple, because everywhere else
|
|
37
|
+
* chant stamps three tags. choudoufu's marker is two tags and no managed-by
|
|
38
|
+
* key at all: per `live/MARKERS.md`'s "Ownership semantics", a resource
|
|
39
|
+
* carrying `tofu-estate` belongs to that estate and there is no secondary
|
|
40
|
+
* check, so `tofu-estate` is both the managed-by claim and the stack
|
|
41
|
+
* identity. `tofu-address` takes the third slot because it is the marker's
|
|
42
|
+
* other half, not because it is an environment: a choudoufu marker has no
|
|
43
|
+
* environment in it, which is why `ResourceMetadata.marker` on a live read
|
|
44
|
+
* carries the estate as its `stack` and leaves `env` absent rather than
|
|
45
|
+
* filling it with an address.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* The two tag keys `live/MARKERS.md` spec version 1 defines, named here so a
|
|
49
|
+
* reader of the plugin can see what a live root's verdict is read from.
|
|
50
|
+
* `./hcl/marker.ts` holds the constants the reader itself uses, along with
|
|
51
|
+
* the escaping and the ownership semantics that go with them.
|
|
52
|
+
*/
|
|
53
|
+
export const TERRAFORM_LIVE_MARKER_KEYS = {
|
|
54
|
+
managedBy: "tofu-estate",
|
|
55
|
+
stack: "tofu-estate",
|
|
56
|
+
env: "tofu-address",
|
|
57
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP completions for the terraform lexicon (#2088).
|
|
3
|
+
*
|
|
4
|
+
* k3s's shape (a generated `LexiconIndex` over typed resource classes) does
|
|
5
|
+
* not fit here: terraform's declarables are lightweight per-HCL-block
|
|
6
|
+
* entities `buildRoots()` produces at build time, not authored TypeScript
|
|
7
|
+
* types — there is nothing to generate a completion index from. What an
|
|
8
|
+
* ops-only lexicon can honestly complete is the surface an `*.op.ts` author
|
|
9
|
+
* actually types:
|
|
10
|
+
*
|
|
11
|
+
* - `terraform.roots` names, read from the reachable `chant.config.*` (see
|
|
12
|
+
* `./context.ts`) — completed inside a root-name position, e.g.
|
|
13
|
+
* `terraformPlan("` or `root: "`.
|
|
14
|
+
* - the composite/builder option keys (`./option-keys.ts`) — completed
|
|
15
|
+
* inside the relevant call's options object.
|
|
16
|
+
*
|
|
17
|
+
* Root names complete only when a `chant.config.*` is reachable from the
|
|
18
|
+
* document's own `uri` and declares at least one; there is no synchronous,
|
|
19
|
+
* safe way to evaluate `chant.config.ts` (project-authored code) from inside
|
|
20
|
+
* a completion request, so an unreachable config yields no root-name
|
|
21
|
+
* completions rather than a guessed one. Option keys need no config at all
|
|
22
|
+
* and complete unconditionally.
|
|
23
|
+
*/
|
|
24
|
+
import { contentUpToCursor, enclosingCallName, enclosingKeyPath, innermostObjectKey, resolveRootsForUri } from "./context.js";
|
|
25
|
+
import { APPLY_OP_KEYS, BUILDER_OPTS_BY_CALL, COMPENSATE_KEYS, CONFIG_NAMESPACE_KEYS, ROOT_ENTRY_KEYS } from "./option-keys.js";
|
|
26
|
+
/** `terraformPlan("` / `terraformApply(` — the first positional string arg, which every builder types as `root`. */
|
|
27
|
+
const ROOT_POSITIONAL_ARG = new RegExp(`\\b(${Object.keys(BUILDER_OPTS_BY_CALL).join("|")})\\(\\s*["'](\\w*)$`);
|
|
28
|
+
/** `root: "` — the named field, in `TerraformApplyOp({ ... })` or a raw `activity("terraformPlan", { root: "..." })`. */
|
|
29
|
+
const ROOT_NAMED_FIELD = /\broot\s*:\s*["'](\w*)$/;
|
|
30
|
+
function keyCompletions(keys, prefix) {
|
|
31
|
+
const lower = prefix.toLowerCase();
|
|
32
|
+
return keys
|
|
33
|
+
.filter((k) => !lower || k.key.toLowerCase().startsWith(lower))
|
|
34
|
+
.map((k) => ({
|
|
35
|
+
label: k.key,
|
|
36
|
+
insertText: k.key,
|
|
37
|
+
kind: "property",
|
|
38
|
+
documentation: k.detail,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
function rootCompletions(uri, prefix) {
|
|
42
|
+
const roots = resolveRootsForUri(uri);
|
|
43
|
+
if (!roots)
|
|
44
|
+
return [];
|
|
45
|
+
const lower = prefix.toLowerCase();
|
|
46
|
+
return Object.entries(roots)
|
|
47
|
+
.filter(([name]) => !lower || name.toLowerCase().startsWith(lower))
|
|
48
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
49
|
+
.map(([name, root]) => ({
|
|
50
|
+
label: name,
|
|
51
|
+
insertText: name,
|
|
52
|
+
kind: "value",
|
|
53
|
+
detail: root.dir,
|
|
54
|
+
documentation: [
|
|
55
|
+
root.dir ? `dir: ${root.dir}` : undefined,
|
|
56
|
+
root.workspace ? `workspace: ${root.workspace}` : undefined,
|
|
57
|
+
root.varFiles?.length ? `varFiles: ${root.varFiles.join(", ")}` : undefined,
|
|
58
|
+
]
|
|
59
|
+
.filter((line) => Boolean(line))
|
|
60
|
+
.join("\n"),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
export function completions(ctx) {
|
|
64
|
+
const linePrefix = ctx.linePrefix ?? "";
|
|
65
|
+
const positional = ROOT_POSITIONAL_ARG.exec(linePrefix);
|
|
66
|
+
if (positional)
|
|
67
|
+
return rootCompletions(ctx.uri, positional[2]);
|
|
68
|
+
const namedField = ROOT_NAMED_FIELD.exec(linePrefix);
|
|
69
|
+
if (namedField)
|
|
70
|
+
return rootCompletions(ctx.uri, namedField[1]);
|
|
71
|
+
const upToCursor = contentUpToCursor(ctx.content ?? "", ctx.position?.line ?? 0);
|
|
72
|
+
const prefix = ctx.wordAtCursor ?? "";
|
|
73
|
+
const call = enclosingCallName(upToCursor);
|
|
74
|
+
if (call === "TerraformApplyOp")
|
|
75
|
+
return keyCompletions(APPLY_OP_KEYS, prefix);
|
|
76
|
+
if (call && call in BUILDER_OPTS_BY_CALL)
|
|
77
|
+
return keyCompletions(BUILDER_OPTS_BY_CALL[call], prefix);
|
|
78
|
+
// `compensate: { <cursor>` — checked independent of the call chain above,
|
|
79
|
+
// since `compensate` itself sits inside `TerraformApplyOp({ ... })`'s own
|
|
80
|
+
// argument object, which `enclosingKeyPath` below refuses to walk through.
|
|
81
|
+
if (innermostObjectKey(upToCursor) === "compensate")
|
|
82
|
+
return keyCompletions(COMPENSATE_KEYS, prefix);
|
|
83
|
+
// The remaining contexts are all plain nested object literals with no call
|
|
84
|
+
// in the chain — `chant.config.ts`'s own `terraform: { ... }` namespace.
|
|
85
|
+
const path = enclosingKeyPath(upToCursor);
|
|
86
|
+
if (path) {
|
|
87
|
+
const last = path[path.length - 1];
|
|
88
|
+
const parent = path[path.length - 2];
|
|
89
|
+
if (parent === "roots")
|
|
90
|
+
return keyCompletions(ROOT_ENTRY_KEYS, prefix);
|
|
91
|
+
if (last === "terraform")
|
|
92
|
+
return keyCompletions(CONFIG_NAMESPACE_KEYS, prefix);
|
|
93
|
+
}
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsing for the terraform LSP pair (#2088).
|
|
3
|
+
*
|
|
4
|
+
* There is no generated `LexiconIndex` here (see `completions.ts`'s module
|
|
5
|
+
* doc for why), so both providers work off two different sources instead:
|
|
6
|
+
*
|
|
7
|
+
* - `terraform.roots`, read out of the project's `chant.config.{ts,json}`.
|
|
8
|
+
* `chant.config.ts` is project-authored code (`config.ts`'s own doc:
|
|
9
|
+
* "where it is evaluated is a security question"), and evaluating it is
|
|
10
|
+
* an async, sandboxed operation — `loadChantConfigUpward` — while
|
|
11
|
+
* `completionProvider`/`hoverProvider` are synchronous. So this reads the
|
|
12
|
+
* file's *text* and extracts the `terraform.roots` object literal with a
|
|
13
|
+
* small brace-matching scanner, never `eval`/`import()`. A shape the
|
|
14
|
+
* scanner cannot follow (roots built by a loop, spread from a helper)
|
|
15
|
+
* yields no roots rather than a wrong guess — the same "reachable or
|
|
16
|
+
* nothing" rule the config-loading code documents elsewhere.
|
|
17
|
+
* - the option keys a `*.op.ts` author types: `TerraformApplyOpConfig`
|
|
18
|
+
* (`composites/terraform-apply-op.ts`) and the four builders' own opts
|
|
19
|
+
* (`op/builders.ts`, `op/activities/terraform.ts`). Those are a fixed
|
|
20
|
+
* table, not read from any file.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
/**
|
|
26
|
+
* Every unmatched opener in `text`, in nesting order (outermost first),
|
|
27
|
+
* ignoring anything inside a string or template literal. This is the one
|
|
28
|
+
* pass every context-detection helper below walks backward over — cheap
|
|
29
|
+
* enough per keystroke since an `*.op.ts` file and a `chant.config.ts` are
|
|
30
|
+
* both small.
|
|
31
|
+
*/
|
|
32
|
+
function unmatchedOpeners(text) {
|
|
33
|
+
const stack = [];
|
|
34
|
+
let inString = null;
|
|
35
|
+
for (let i = 0; i < text.length; i++) {
|
|
36
|
+
const ch = text[i];
|
|
37
|
+
if (inString) {
|
|
38
|
+
if (ch === "\\") {
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
else if (ch === inString) {
|
|
42
|
+
inString = null;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
47
|
+
inString = ch;
|
|
48
|
+
}
|
|
49
|
+
else if (ch === "{" || ch === "(" || ch === "[") {
|
|
50
|
+
stack.push({ char: ch, index: i });
|
|
51
|
+
}
|
|
52
|
+
else if (ch === "}" || ch === ")" || ch === "]") {
|
|
53
|
+
stack.pop();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return stack;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The `{ ... }` body starting at `text[openIndex] === "{"`, and the index of
|
|
60
|
+
* its matching `}`. A single forward pass tracking overall nesting depth —
|
|
61
|
+
* `{`/`(`/`[` all count the same way, which is safe here because the only
|
|
62
|
+
* question this asks is "where does *this* brace close," never which bracket
|
|
63
|
+
* kind matched which.
|
|
64
|
+
*/
|
|
65
|
+
function braceBody(text, openIndex) {
|
|
66
|
+
if (text[openIndex] !== "{")
|
|
67
|
+
return undefined;
|
|
68
|
+
let depth = 0;
|
|
69
|
+
let inString = null;
|
|
70
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
71
|
+
const ch = text[i];
|
|
72
|
+
if (inString) {
|
|
73
|
+
if (ch === "\\")
|
|
74
|
+
i++;
|
|
75
|
+
else if (ch === inString)
|
|
76
|
+
inString = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
80
|
+
inString = ch;
|
|
81
|
+
}
|
|
82
|
+
else if (ch === "{" || ch === "(" || ch === "[") {
|
|
83
|
+
depth++;
|
|
84
|
+
}
|
|
85
|
+
else if (ch === "}" || ch === ")" || ch === "]") {
|
|
86
|
+
depth--;
|
|
87
|
+
if (depth === 0)
|
|
88
|
+
return { body: text.slice(openIndex + 1, i), closeIndex: i };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/** The `{ ... }` body immediately after the first top-level `key\s*:\s*` in `text`. */
|
|
94
|
+
function findKeyBlock(text, key) {
|
|
95
|
+
const re = new RegExp(`(?:^|[\\s,{])${key}\\s*:\\s*\\{`, "m");
|
|
96
|
+
const m = re.exec(text);
|
|
97
|
+
if (!m)
|
|
98
|
+
return undefined;
|
|
99
|
+
const openIndex = m.index + m[0].length - 1;
|
|
100
|
+
return braceBody(text, openIndex)?.body;
|
|
101
|
+
}
|
|
102
|
+
/** One key/value pair per top-level entry of an object-literal body. */
|
|
103
|
+
function parseEntries(objText) {
|
|
104
|
+
const entries = new Map();
|
|
105
|
+
let i = 0;
|
|
106
|
+
const len = objText.length;
|
|
107
|
+
while (i < len) {
|
|
108
|
+
while (i < len && /[\s,]/.test(objText[i]))
|
|
109
|
+
i++;
|
|
110
|
+
if (i >= len)
|
|
111
|
+
break;
|
|
112
|
+
let key;
|
|
113
|
+
if (objText[i] === '"' || objText[i] === "'") {
|
|
114
|
+
const quote = objText[i];
|
|
115
|
+
let j = i + 1;
|
|
116
|
+
while (j < len && objText[j] !== quote)
|
|
117
|
+
j++;
|
|
118
|
+
key = objText.slice(i + 1, j);
|
|
119
|
+
i = j + 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const m = /^[A-Za-z_$][\w$]*/.exec(objText.slice(i));
|
|
123
|
+
if (!m)
|
|
124
|
+
break;
|
|
125
|
+
key = m[0];
|
|
126
|
+
i += m[0].length;
|
|
127
|
+
}
|
|
128
|
+
while (i < len && /\s/.test(objText[i]))
|
|
129
|
+
i++;
|
|
130
|
+
if (objText[i] !== ":")
|
|
131
|
+
break;
|
|
132
|
+
i++;
|
|
133
|
+
while (i < len && /\s/.test(objText[i]))
|
|
134
|
+
i++;
|
|
135
|
+
if (objText[i] === "{") {
|
|
136
|
+
const block = braceBody(objText, i);
|
|
137
|
+
if (!block)
|
|
138
|
+
break;
|
|
139
|
+
// Stored without the braces: every caller re-parses an object value as
|
|
140
|
+
// another set of entries (`parseTerraformRoots` does, one root at a
|
|
141
|
+
// time), and `parseEntries` itself only knows how to walk a flat
|
|
142
|
+
// `key: value` sequence, not a single `{ ... }`-wrapped one.
|
|
143
|
+
entries.set(key, block.body);
|
|
144
|
+
i = block.closeIndex + 1;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
let depth = 0;
|
|
148
|
+
let inString = null;
|
|
149
|
+
let j = i;
|
|
150
|
+
while (j < len) {
|
|
151
|
+
const ch = objText[j];
|
|
152
|
+
if (inString) {
|
|
153
|
+
if (ch === "\\")
|
|
154
|
+
j++;
|
|
155
|
+
else if (ch === inString)
|
|
156
|
+
inString = null;
|
|
157
|
+
}
|
|
158
|
+
else if (ch === '"' || ch === "'" || ch === "`") {
|
|
159
|
+
inString = ch;
|
|
160
|
+
}
|
|
161
|
+
else if (ch === "[" || ch === "(") {
|
|
162
|
+
depth++;
|
|
163
|
+
}
|
|
164
|
+
else if (ch === "]" || ch === ")") {
|
|
165
|
+
depth--;
|
|
166
|
+
}
|
|
167
|
+
else if (ch === "," && depth === 0) {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
j++;
|
|
171
|
+
}
|
|
172
|
+
entries.set(key, objText.slice(i, j));
|
|
173
|
+
i = j;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
function unquote(raw) {
|
|
179
|
+
if (raw === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const trimmed = raw.trim();
|
|
182
|
+
const m = /^["'`]([\s\S]*)["'`]$/.exec(trimmed);
|
|
183
|
+
return m ? m[1] : trimmed;
|
|
184
|
+
}
|
|
185
|
+
function parseStringArray(raw) {
|
|
186
|
+
if (raw === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
189
|
+
const values = inner
|
|
190
|
+
.split(",")
|
|
191
|
+
.map((s) => unquote(s))
|
|
192
|
+
.filter((s) => typeof s === "string" && s.length > 0);
|
|
193
|
+
return values.length > 0 ? values : undefined;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* `terraform.roots`, extracted from a `chant.config.ts`/`.json`'s raw text.
|
|
197
|
+
* Never executes the file. `format` distinguishes the two because JSON's
|
|
198
|
+
* `roots` value is already a plain object — no brace-matching needed.
|
|
199
|
+
*/
|
|
200
|
+
export function parseTerraformRoots(configText, format) {
|
|
201
|
+
if (format === "json") {
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(configText);
|
|
204
|
+
const roots = parsed.terraform?.roots;
|
|
205
|
+
if (!roots || typeof roots !== "object" || Array.isArray(roots))
|
|
206
|
+
return undefined;
|
|
207
|
+
const result = {};
|
|
208
|
+
for (const [name, entry] of Object.entries(roots)) {
|
|
209
|
+
const e = (entry ?? {});
|
|
210
|
+
result[name] = {
|
|
211
|
+
dir: typeof e.dir === "string" ? e.dir : undefined,
|
|
212
|
+
workspace: typeof e.workspace === "string" ? e.workspace : undefined,
|
|
213
|
+
varFiles: Array.isArray(e.varFiles) ? e.varFiles.filter((v) => typeof v === "string") : undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const terraformBlock = findKeyBlock(configText, "terraform");
|
|
223
|
+
if (terraformBlock === undefined)
|
|
224
|
+
return undefined;
|
|
225
|
+
const rootsBlock = findKeyBlock(terraformBlock, "roots");
|
|
226
|
+
if (rootsBlock === undefined)
|
|
227
|
+
return undefined;
|
|
228
|
+
const result = {};
|
|
229
|
+
for (const [name, body] of parseEntries(rootsBlock)) {
|
|
230
|
+
const fields = parseEntries(body);
|
|
231
|
+
result[name] = {
|
|
232
|
+
dir: unquote(fields.get("dir")),
|
|
233
|
+
workspace: unquote(fields.get("workspace")),
|
|
234
|
+
varFiles: parseStringArray(fields.get("varFiles")),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
238
|
+
}
|
|
239
|
+
function directoryFromUri(uri) {
|
|
240
|
+
if (!uri.startsWith("file://"))
|
|
241
|
+
return undefined;
|
|
242
|
+
try {
|
|
243
|
+
return dirname(fileURLToPath(uri));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Walk upward from `startDir` for the nearest `chant.config.{ts,json}`. */
|
|
250
|
+
function findConfigUpward(startDir) {
|
|
251
|
+
let dir = startDir;
|
|
252
|
+
for (let i = 0; i < 32; i++) {
|
|
253
|
+
const tsPath = join(dir, "chant.config.ts");
|
|
254
|
+
if (existsSync(tsPath))
|
|
255
|
+
return tsPath;
|
|
256
|
+
const jsonPath = join(dir, "chant.config.json");
|
|
257
|
+
if (existsSync(jsonPath))
|
|
258
|
+
return jsonPath;
|
|
259
|
+
const parent = dirname(dir);
|
|
260
|
+
if (parent === dir)
|
|
261
|
+
return undefined;
|
|
262
|
+
dir = parent;
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* `terraform.roots`, resolved from the project config reachable from the
|
|
268
|
+
* document at `uri`. `undefined` when `uri` is not a `file://` URI, no
|
|
269
|
+
* `chant.config.*` is reachable by walking up from it, or the namespace
|
|
270
|
+
* declares no roots — every case where root-name completion has nothing
|
|
271
|
+
* honest to offer, so callers fall back to the option-key completions
|
|
272
|
+
* instead of fabricating a root list.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveRootsForUri(uri) {
|
|
275
|
+
const startDir = directoryFromUri(uri);
|
|
276
|
+
if (!startDir)
|
|
277
|
+
return undefined;
|
|
278
|
+
const configPath = findConfigUpward(startDir);
|
|
279
|
+
if (!configPath)
|
|
280
|
+
return undefined;
|
|
281
|
+
try {
|
|
282
|
+
const text = readFileSync(configPath, "utf-8");
|
|
283
|
+
return parseTerraformRoots(text, configPath.endsWith(".json") ? "json" : "ts");
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ── Call/field context detection ────────────────────────────────────────────
|
|
290
|
+
/** Content of the open document up to (and a little past) the cursor line, mirroring core's `lexiconCompletions`. */
|
|
291
|
+
export function contentUpToCursor(content, line) {
|
|
292
|
+
return content.split("\n").slice(0, line + 1).join("\n");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The name of the call whose parenthesized argument list holds the innermost
|
|
296
|
+
* unmatched `{` before the cursor — `TerraformApplyOp({ ↵ name: "x", ↵ <cursor>`
|
|
297
|
+
* and `terraformPlan("app", { ↵ <cursor>` both resolve to their call name,
|
|
298
|
+
* regardless of which positional argument the object is.
|
|
299
|
+
*/
|
|
300
|
+
export function enclosingCallName(text) {
|
|
301
|
+
const stack = unmatchedOpeners(text);
|
|
302
|
+
const top = stack[stack.length - 1];
|
|
303
|
+
if (!top || top.char !== "{")
|
|
304
|
+
return undefined;
|
|
305
|
+
const parent = stack[stack.length - 2];
|
|
306
|
+
if (!parent || parent.char !== "(")
|
|
307
|
+
return undefined;
|
|
308
|
+
let k = parent.index - 1;
|
|
309
|
+
while (k >= 0 && /\s/.test(text[k]))
|
|
310
|
+
k--;
|
|
311
|
+
const end = k + 1;
|
|
312
|
+
while (k >= 0 && /[\w$]/.test(text[k]))
|
|
313
|
+
k--;
|
|
314
|
+
const name = text.slice(k + 1, end);
|
|
315
|
+
return name.length > 0 ? name : undefined;
|
|
316
|
+
}
|
|
317
|
+
/** The `key` immediately before the `{` at `braceIndex`, from `key: {`. */
|
|
318
|
+
function precedingObjectKey(text, braceIndex) {
|
|
319
|
+
const before = text.slice(0, braceIndex);
|
|
320
|
+
const m = /(?:^|[\s,{])(["'`]?)([\w$-]+)\1\s*:\s*$/.exec(before);
|
|
321
|
+
return m?.[2];
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The key of the innermost enclosing object literal, regardless of what
|
|
325
|
+
* encloses *that* — `compensate: { <cursor>` resolves to `"compensate"` even
|
|
326
|
+
* though the whole thing sits inside `TerraformApplyOp({ ... })`'s own
|
|
327
|
+
* argument object, which `enclosingKeyPath` would refuse to walk through (a
|
|
328
|
+
* call anywhere in the chain ends it there).
|
|
329
|
+
*/
|
|
330
|
+
export function innermostObjectKey(text) {
|
|
331
|
+
const stack = unmatchedOpeners(text);
|
|
332
|
+
const top = stack[stack.length - 1];
|
|
333
|
+
if (!top || top.char !== "{")
|
|
334
|
+
return undefined;
|
|
335
|
+
return precedingObjectKey(text, top.index);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The chain of object-literal keys enclosing the cursor, outermost first —
|
|
339
|
+
* `["terraform", "roots", "app"]` inside `terraform: { roots: { app: { <cursor>` —
|
|
340
|
+
* for the plain-object contexts (`chant.config.ts`) that `enclosingCallName`
|
|
341
|
+
* does not cover, since there is no call there at all. A brace with no `key:`
|
|
342
|
+
* before it (the module's own `export default {`) contributes nothing to the
|
|
343
|
+
* path rather than ending it — only a `(` anywhere in the chain does, since
|
|
344
|
+
* `chant.config.ts` builds one object literal and calling anything
|
|
345
|
+
* mid-declaration is not a shape this needs to follow.
|
|
346
|
+
*/
|
|
347
|
+
export function enclosingKeyPath(text) {
|
|
348
|
+
const stack = unmatchedOpeners(text);
|
|
349
|
+
const path = [];
|
|
350
|
+
for (const frame of stack) {
|
|
351
|
+
if (frame.char !== "{")
|
|
352
|
+
return undefined;
|
|
353
|
+
const key = precedingObjectKey(text, frame.index);
|
|
354
|
+
if (key)
|
|
355
|
+
path.push(key);
|
|
356
|
+
}
|
|
357
|
+
return path;
|
|
358
|
+
}
|