@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
@@ -0,0 +1,349 @@
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
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ // ── Root resolution ─────────────────────────────────────────────────────────
28
+
29
+ export interface ResolvedTerraformRoot {
30
+ dir?: string;
31
+ workspace?: string;
32
+ varFiles?: string[];
33
+ }
34
+
35
+ /** One `{`/`(`/`[` this scanner has not yet seen the matching closer for. */
36
+ interface Opener {
37
+ char: "{" | "(" | "[";
38
+ index: number;
39
+ }
40
+
41
+ /**
42
+ * Every unmatched opener in `text`, in nesting order (outermost first),
43
+ * ignoring anything inside a string or template literal. This is the one
44
+ * pass every context-detection helper below walks backward over — cheap
45
+ * enough per keystroke since an `*.op.ts` file and a `chant.config.ts` are
46
+ * both small.
47
+ */
48
+ function unmatchedOpeners(text: string): Opener[] {
49
+ const stack: Opener[] = [];
50
+ let inString: string | null = null;
51
+ for (let i = 0; i < text.length; i++) {
52
+ const ch = text[i];
53
+ if (inString) {
54
+ if (ch === "\\") {
55
+ i++;
56
+ } else if (ch === inString) {
57
+ inString = null;
58
+ }
59
+ continue;
60
+ }
61
+ if (ch === '"' || ch === "'" || ch === "`") {
62
+ inString = ch;
63
+ } else if (ch === "{" || ch === "(" || ch === "[") {
64
+ stack.push({ char: ch, index: i });
65
+ } else if (ch === "}" || ch === ")" || ch === "]") {
66
+ stack.pop();
67
+ }
68
+ }
69
+ return stack;
70
+ }
71
+
72
+ /**
73
+ * The `{ ... }` body starting at `text[openIndex] === "{"`, and the index of
74
+ * its matching `}`. A single forward pass tracking overall nesting depth —
75
+ * `{`/`(`/`[` all count the same way, which is safe here because the only
76
+ * question this asks is "where does *this* brace close," never which bracket
77
+ * kind matched which.
78
+ */
79
+ function braceBody(text: string, openIndex: number): { body: string; closeIndex: number } | undefined {
80
+ if (text[openIndex] !== "{") return undefined;
81
+ let depth = 0;
82
+ let inString: string | null = null;
83
+ for (let i = openIndex; i < text.length; i++) {
84
+ const ch = text[i];
85
+ if (inString) {
86
+ if (ch === "\\") i++;
87
+ else if (ch === inString) inString = null;
88
+ continue;
89
+ }
90
+ if (ch === '"' || ch === "'" || ch === "`") {
91
+ inString = ch;
92
+ } else if (ch === "{" || ch === "(" || ch === "[") {
93
+ depth++;
94
+ } else if (ch === "}" || ch === ")" || ch === "]") {
95
+ depth--;
96
+ if (depth === 0) return { body: text.slice(openIndex + 1, i), closeIndex: i };
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ /** The `{ ... }` body immediately after the first top-level `key\s*:\s*` in `text`. */
103
+ function findKeyBlock(text: string, key: string): string | undefined {
104
+ const re = new RegExp(`(?:^|[\\s,{])${key}\\s*:\\s*\\{`, "m");
105
+ const m = re.exec(text);
106
+ if (!m) return undefined;
107
+ const openIndex = m.index + m[0].length - 1;
108
+ return braceBody(text, openIndex)?.body;
109
+ }
110
+
111
+ /** One key/value pair per top-level entry of an object-literal body. */
112
+ function parseEntries(objText: string): Map<string, string> {
113
+ const entries = new Map<string, string>();
114
+ let i = 0;
115
+ const len = objText.length;
116
+ while (i < len) {
117
+ while (i < len && /[\s,]/.test(objText[i])) i++;
118
+ if (i >= len) break;
119
+
120
+ let key: string | undefined;
121
+ if (objText[i] === '"' || objText[i] === "'") {
122
+ const quote = objText[i];
123
+ let j = i + 1;
124
+ while (j < len && objText[j] !== quote) j++;
125
+ key = objText.slice(i + 1, j);
126
+ i = j + 1;
127
+ } else {
128
+ const m = /^[A-Za-z_$][\w$]*/.exec(objText.slice(i));
129
+ if (!m) break;
130
+ key = m[0];
131
+ i += m[0].length;
132
+ }
133
+ while (i < len && /\s/.test(objText[i])) i++;
134
+ if (objText[i] !== ":") break;
135
+ i++;
136
+ while (i < len && /\s/.test(objText[i])) i++;
137
+
138
+ if (objText[i] === "{") {
139
+ const block = braceBody(objText, i);
140
+ if (!block) break;
141
+ // Stored without the braces: every caller re-parses an object value as
142
+ // another set of entries (`parseTerraformRoots` does, one root at a
143
+ // time), and `parseEntries` itself only knows how to walk a flat
144
+ // `key: value` sequence, not a single `{ ... }`-wrapped one.
145
+ entries.set(key, block.body);
146
+ i = block.closeIndex + 1;
147
+ } else {
148
+ let depth = 0;
149
+ let inString: string | null = null;
150
+ let j = i;
151
+ while (j < len) {
152
+ const ch = objText[j];
153
+ if (inString) {
154
+ if (ch === "\\") j++;
155
+ else if (ch === inString) inString = null;
156
+ } else if (ch === '"' || ch === "'" || ch === "`") {
157
+ inString = ch;
158
+ } else if (ch === "[" || ch === "(") {
159
+ depth++;
160
+ } else if (ch === "]" || ch === ")") {
161
+ depth--;
162
+ } else if (ch === "," && depth === 0) {
163
+ break;
164
+ }
165
+ j++;
166
+ }
167
+ entries.set(key, objText.slice(i, j));
168
+ i = j;
169
+ }
170
+ }
171
+ return entries;
172
+ }
173
+
174
+ function unquote(raw: string | undefined): string | undefined {
175
+ if (raw === undefined) return undefined;
176
+ const trimmed = raw.trim();
177
+ const m = /^["'`]([\s\S]*)["'`]$/.exec(trimmed);
178
+ return m ? m[1] : trimmed;
179
+ }
180
+
181
+ function parseStringArray(raw: string | undefined): string[] | undefined {
182
+ if (raw === undefined) return undefined;
183
+ const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
184
+ const values = inner
185
+ .split(",")
186
+ .map((s) => unquote(s))
187
+ .filter((s): s is string => typeof s === "string" && s.length > 0);
188
+ return values.length > 0 ? values : undefined;
189
+ }
190
+
191
+ /**
192
+ * `terraform.roots`, extracted from a `chant.config.ts`/`.json`'s raw text.
193
+ * Never executes the file. `format` distinguishes the two because JSON's
194
+ * `roots` value is already a plain object — no brace-matching needed.
195
+ */
196
+ export function parseTerraformRoots(
197
+ configText: string,
198
+ format: "ts" | "json",
199
+ ): Record<string, ResolvedTerraformRoot> | undefined {
200
+ if (format === "json") {
201
+ try {
202
+ const parsed = JSON.parse(configText) as { terraform?: { roots?: unknown } };
203
+ const roots = parsed.terraform?.roots;
204
+ if (!roots || typeof roots !== "object" || Array.isArray(roots)) return undefined;
205
+ const result: Record<string, ResolvedTerraformRoot> = {};
206
+ for (const [name, entry] of Object.entries(roots as Record<string, unknown>)) {
207
+ const e = (entry ?? {}) as { dir?: unknown; workspace?: unknown; varFiles?: unknown };
208
+ result[name] = {
209
+ dir: typeof e.dir === "string" ? e.dir : undefined,
210
+ workspace: typeof e.workspace === "string" ? e.workspace : undefined,
211
+ varFiles: Array.isArray(e.varFiles) ? e.varFiles.filter((v): v is string => typeof v === "string") : undefined,
212
+ };
213
+ }
214
+ return Object.keys(result).length > 0 ? result : undefined;
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ const terraformBlock = findKeyBlock(configText, "terraform");
221
+ if (terraformBlock === undefined) return undefined;
222
+ const rootsBlock = findKeyBlock(terraformBlock, "roots");
223
+ if (rootsBlock === undefined) return undefined;
224
+
225
+ const result: Record<string, ResolvedTerraformRoot> = {};
226
+ for (const [name, body] of parseEntries(rootsBlock)) {
227
+ const fields = parseEntries(body);
228
+ result[name] = {
229
+ dir: unquote(fields.get("dir")),
230
+ workspace: unquote(fields.get("workspace")),
231
+ varFiles: parseStringArray(fields.get("varFiles")),
232
+ };
233
+ }
234
+ return Object.keys(result).length > 0 ? result : undefined;
235
+ }
236
+
237
+ function directoryFromUri(uri: string): string | undefined {
238
+ if (!uri.startsWith("file://")) return undefined;
239
+ try {
240
+ return dirname(fileURLToPath(uri));
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ /** Walk upward from `startDir` for the nearest `chant.config.{ts,json}`. */
247
+ function findConfigUpward(startDir: string): string | undefined {
248
+ let dir = startDir;
249
+ for (let i = 0; i < 32; i++) {
250
+ const tsPath = join(dir, "chant.config.ts");
251
+ if (existsSync(tsPath)) return tsPath;
252
+ const jsonPath = join(dir, "chant.config.json");
253
+ if (existsSync(jsonPath)) return jsonPath;
254
+ const parent = dirname(dir);
255
+ if (parent === dir) return undefined;
256
+ dir = parent;
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ /**
262
+ * `terraform.roots`, resolved from the project config reachable from the
263
+ * document at `uri`. `undefined` when `uri` is not a `file://` URI, no
264
+ * `chant.config.*` is reachable by walking up from it, or the namespace
265
+ * declares no roots — every case where root-name completion has nothing
266
+ * honest to offer, so callers fall back to the option-key completions
267
+ * instead of fabricating a root list.
268
+ */
269
+ export function resolveRootsForUri(uri: string): Record<string, ResolvedTerraformRoot> | undefined {
270
+ const startDir = directoryFromUri(uri);
271
+ if (!startDir) return undefined;
272
+ const configPath = findConfigUpward(startDir);
273
+ if (!configPath) return undefined;
274
+ try {
275
+ const text = readFileSync(configPath, "utf-8");
276
+ return parseTerraformRoots(text, configPath.endsWith(".json") ? "json" : "ts");
277
+ } catch {
278
+ return undefined;
279
+ }
280
+ }
281
+
282
+ // ── Call/field context detection ────────────────────────────────────────────
283
+
284
+ /** Content of the open document up to (and a little past) the cursor line, mirroring core's `lexiconCompletions`. */
285
+ export function contentUpToCursor(content: string, line: number): string {
286
+ return content.split("\n").slice(0, line + 1).join("\n");
287
+ }
288
+
289
+ /**
290
+ * The name of the call whose parenthesized argument list holds the innermost
291
+ * unmatched `{` before the cursor — `TerraformApplyOp({ ↵ name: "x", ↵ <cursor>`
292
+ * and `terraformPlan("app", { ↵ <cursor>` both resolve to their call name,
293
+ * regardless of which positional argument the object is.
294
+ */
295
+ export function enclosingCallName(text: string): string | undefined {
296
+ const stack = unmatchedOpeners(text);
297
+ const top = stack[stack.length - 1];
298
+ if (!top || top.char !== "{") return undefined;
299
+ const parent = stack[stack.length - 2];
300
+ if (!parent || parent.char !== "(") return undefined;
301
+ let k = parent.index - 1;
302
+ while (k >= 0 && /\s/.test(text[k])) k--;
303
+ const end = k + 1;
304
+ while (k >= 0 && /[\w$]/.test(text[k])) k--;
305
+ const name = text.slice(k + 1, end);
306
+ return name.length > 0 ? name : undefined;
307
+ }
308
+
309
+ /** The `key` immediately before the `{` at `braceIndex`, from `key: {`. */
310
+ function precedingObjectKey(text: string, braceIndex: number): string | undefined {
311
+ const before = text.slice(0, braceIndex);
312
+ const m = /(?:^|[\s,{])(["'`]?)([\w$-]+)\1\s*:\s*$/.exec(before);
313
+ return m?.[2];
314
+ }
315
+
316
+ /**
317
+ * The key of the innermost enclosing object literal, regardless of what
318
+ * encloses *that* — `compensate: { <cursor>` resolves to `"compensate"` even
319
+ * though the whole thing sits inside `TerraformApplyOp({ ... })`'s own
320
+ * argument object, which `enclosingKeyPath` would refuse to walk through (a
321
+ * call anywhere in the chain ends it there).
322
+ */
323
+ export function innermostObjectKey(text: string): string | undefined {
324
+ const stack = unmatchedOpeners(text);
325
+ const top = stack[stack.length - 1];
326
+ if (!top || top.char !== "{") return undefined;
327
+ return precedingObjectKey(text, top.index);
328
+ }
329
+
330
+ /**
331
+ * The chain of object-literal keys enclosing the cursor, outermost first —
332
+ * `["terraform", "roots", "app"]` inside `terraform: { roots: { app: { <cursor>` —
333
+ * for the plain-object contexts (`chant.config.ts`) that `enclosingCallName`
334
+ * does not cover, since there is no call there at all. A brace with no `key:`
335
+ * before it (the module's own `export default {`) contributes nothing to the
336
+ * path rather than ending it — only a `(` anywhere in the chain does, since
337
+ * `chant.config.ts` builds one object literal and calling anything
338
+ * mid-declaration is not a shape this needs to follow.
339
+ */
340
+ export function enclosingKeyPath(text: string): string[] | undefined {
341
+ const stack = unmatchedOpeners(text);
342
+ const path: string[] = [];
343
+ for (const frame of stack) {
344
+ if (frame.char !== "{") return undefined;
345
+ const key = precedingObjectKey(text, frame.index);
346
+ if (key) path.push(key);
347
+ }
348
+ return path;
349
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * LSP hover tests (#2088). Root hover needs a reachable `chant.config.*`,
3
+ * same as completions — written to a temp directory rather than mocked.
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import type { HoverContext } from "@intentius/chant/lsp/types";
12
+ import { hover } from "./hover";
13
+
14
+ function ctx(overrides: Partial<HoverContext>): HoverContext {
15
+ return {
16
+ uri: "untitled:Untitled-1",
17
+ content: "",
18
+ position: { line: 0, character: 0 },
19
+ word: "",
20
+ lineText: "",
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("LSP hover", () => {
26
+ it("returns undefined for an empty context", () => {
27
+ expect(hover(ctx({}))).toBeUndefined();
28
+ });
29
+
30
+ it("shows a root's dir/workspace/varFiles when the word is quoted next to a known root", () => {
31
+ const dir = mkdtempSync(join(tmpdir(), "chant-terraform-lsp-"));
32
+ try {
33
+ writeFileSync(
34
+ join(dir, "chant.config.ts"),
35
+ `export default { terraform: { roots: { app: { dir: "./terraform/app", workspace: "prod", varFiles: ["prod.tfvars"] } } } };`,
36
+ );
37
+ const uri = pathToFileURL(join(dir, "infra.op.ts")).toString();
38
+ const info = hover(ctx({ uri, word: "app", lineText: 'terraformPlan("app")' }));
39
+ expect(info?.contents).toContain("app");
40
+ expect(info?.contents).toContain("./terraform/app");
41
+ expect(info?.contents).toContain("prod");
42
+ expect(info?.contents).toContain("prod.tfvars");
43
+ } finally {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ it("does not treat a bare identifier as a root reference, even if it matches a root name", () => {
49
+ const dir = mkdtempSync(join(tmpdir(), "chant-terraform-lsp-"));
50
+ try {
51
+ writeFileSync(join(dir, "chant.config.ts"), `export default { terraform: { roots: { app: { dir: "./tf" } } } };`);
52
+ const uri = pathToFileURL(join(dir, "infra.op.ts")).toString();
53
+ const info = hover(ctx({ uri, word: "app", lineText: "const app = 1;" }));
54
+ expect(info).toBeUndefined();
55
+ } finally {
56
+ rmSync(dir, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ it("returns undefined for a quoted word that names no declared root", () => {
61
+ const dir = mkdtempSync(join(tmpdir(), "chant-terraform-lsp-"));
62
+ try {
63
+ writeFileSync(join(dir, "chant.config.ts"), `export default { terraform: { roots: { app: { dir: "./tf" } } } };`);
64
+ const uri = pathToFileURL(join(dir, "infra.op.ts")).toString();
65
+ const info = hover(ctx({ uri, word: "ghost", lineText: 'terraformPlan("ghost")' }));
66
+ expect(info).toBeUndefined();
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ it("documents a known option key even with no reachable config", () => {
73
+ const info = hover(ctx({ word: "gate", lineText: ' gate: "always",' }));
74
+ expect(info?.contents).toContain("gate");
75
+ expect(info?.contents).toContain("on-destroy");
76
+ });
77
+
78
+ it("returns undefined for a word that is neither a reachable root nor a known option key", () => {
79
+ const info = hover(ctx({ word: "somethingElse", lineText: "somethingElse" }));
80
+ expect(info).toBeUndefined();
81
+ });
82
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * LSP hover for the terraform lexicon (#2088). See `./completions.ts`'s
3
+ * module doc for why this reads `chant.config.*` as text rather than a
4
+ * generated index, and completes/documents the same two things: a
5
+ * `terraform.roots` name (when the word under the cursor is quoted next to
6
+ * it, so an ordinary identifier never gets mistaken for a root) and one of
7
+ * the composite/builder option keys (`./option-keys.ts`), the latter needing
8
+ * no reachable config at all.
9
+ */
10
+
11
+ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
12
+ import { resolveRootsForUri } from "./context";
13
+ import { ALL_OPTION_KEYS } from "./option-keys";
14
+
15
+ /** `"app"` or `'app'` — the word is only a root reference if it sits inside a quote pair on the line. */
16
+ function isQuoted(lineText: string, word: string): boolean {
17
+ if (!word) return false;
18
+ return new RegExp(`["']${word}["']`).test(lineText);
19
+ }
20
+
21
+ function rootHover(uri: string, word: string, lineText: string): HoverInfo | undefined {
22
+ if (!isQuoted(lineText, word)) return undefined;
23
+ const roots = resolveRootsForUri(uri);
24
+ const root = roots?.[word];
25
+ if (!root) return undefined;
26
+
27
+ const lines = [`**${word}**`, "", "terraform root (`terraform.roots." + word + "`)"];
28
+ if (root.dir) lines.push("", `\`dir\`: \`${root.dir}\``);
29
+ if (root.workspace) lines.push("", `\`workspace\`: \`${root.workspace}\``);
30
+ if (root.varFiles?.length) lines.push("", `\`varFiles\`: ${root.varFiles.map((f) => `\`${f}\``).join(", ")}`);
31
+ return { contents: lines.join("\n") };
32
+ }
33
+
34
+ function optionKeyHover(word: string): HoverInfo | undefined {
35
+ const detail = ALL_OPTION_KEYS.get(word);
36
+ if (!detail) return undefined;
37
+ return { contents: `**${word}**\n\n${detail}` };
38
+ }
39
+
40
+ export function hover(ctx: HoverContext): HoverInfo | undefined {
41
+ const word = ctx.word ?? "";
42
+ if (!word) return undefined;
43
+ return rootHover(ctx.uri, word, ctx.lineText ?? "") ?? optionKeyHover(word);
44
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The option keys a `*.op.ts` or `chant.config.ts` author types against this
3
+ * lexicon's config schema (`../config.ts`), its composite
4
+ * (`../composites/terraform-apply-op.ts`) and its four builders
5
+ * (`../op/builders.ts`, `../op/activities/terraform.ts`). A fixed table, not
6
+ * read from any generated registry — there is no upstream schema here (see
7
+ * `plugin.ts`'s module doc), so this is the same set of fields those files
8
+ * already document in their own TSDoc, restated once for the editor.
9
+ */
10
+
11
+ export interface OptionKey {
12
+ key: string;
13
+ detail: string;
14
+ }
15
+
16
+ /** `TerraformConfig`'s own top-level keys (`terraform: { <here> }`). */
17
+ export const CONFIG_NAMESPACE_KEYS: OptionKey[] = [
18
+ { key: "binary", detail: '"terraform" | "tofu" — which CLI drives the roots. Default: "terraform".' },
19
+ { key: "roots", detail: "Named root modules. The name is the entity-key prefix." },
20
+ ];
21
+
22
+ /** `TerraformRootConfig`'s keys — one entry of `terraform.roots.<name>: { <here> }`. */
23
+ export const ROOT_ENTRY_KEYS: OptionKey[] = [
24
+ { key: "dir", detail: "Root module directory, relative to the project root. Required." },
25
+ { key: "workspace", detail: "Terraform workspace to select for this root. Omitted means `default`." },
26
+ { key: "varFiles", detail: "`-var-file` arguments, in order, relative to `dir`." },
27
+ { key: "backendConfig", detail: "`-backend-config` key/value pairs handed to `init`." },
28
+ ];
29
+
30
+ /** `TerraformApplyOpConfig`'s keys (`TerraformApplyOp({ <here> })`). */
31
+ export const APPLY_OP_KEYS: OptionKey[] = [
32
+ { key: "name", detail: "Op name (kebab-case). Also the default task queue and gate signal suffix." },
33
+ { key: "root", detail: "Key into the project's `terraform.roots`." },
34
+ { key: "planFile", detail: "Plan file written by Plan and consumed by Apply. Default: `chant.tfplan`." },
35
+ { key: "gate", detail: '"on-destroy" | "always" | "never" — when to emit the approval gate. Default: "on-destroy".' },
36
+ { key: "signalName", detail: "Gate signal name. Default: `approve-<name>`." },
37
+ { key: "gateTimeout", detail: "Temporal duration the gate waits before timing out. Default: core's own (48h)." },
38
+ { key: "gateDescription", detail: "Override the gate description shown to the approver." },
39
+ { key: "upgrade", detail: "`-upgrade` on the Init step: re-resolve provider and module versions." },
40
+ { key: "cwd", detail: "Directory each step starts the `chant.config.*` search from." },
41
+ { key: "compensate", detail: "Saga-style rollback on a failed apply: `true` with no command throws; supply `{ command }`." },
42
+ { key: "taskQueue", detail: "Override the task queue. Defaults to `name`." },
43
+ ];
44
+
45
+ /** `compensate`'s own shape when it is an object: `compensate: { <here> }`. */
46
+ export const COMPENSATE_KEYS: OptionKey[] = [
47
+ { key: "command", detail: "Shell command that undoes a failed apply — terraform has no automatic rollback." },
48
+ ];
49
+
50
+ /** Extra opts every builder wrapper accepts alongside its activity's own fields. */
51
+ const STEP_OPTS: OptionKey[] = [
52
+ { key: "profile", detail: "Activity profile override (defaults are set per builder)." },
53
+ { key: "id", detail: 'Step id — required for `.out` references, e.g. `plan.out.planFile`.' },
54
+ ];
55
+
56
+ /** `terraformInit(root, { <here> })`'s opts — `TerraformInitArgs` minus `root`, plus step opts. */
57
+ export const INIT_OPTS_KEYS: OptionKey[] = [
58
+ { key: "upgrade", detail: "`-upgrade`: re-resolve provider and module versions within constraints." },
59
+ { key: "reconfigure", detail: "`-reconfigure`: ignore any existing backend state and configure afresh." },
60
+ { key: "cwd", detail: "Directory to start the `chant.config.*` search from." },
61
+ ...STEP_OPTS,
62
+ ];
63
+
64
+ /** `terraformPlan(root, { <here> })`'s opts — `TerraformPlanArgs` minus `root`, plus step opts. */
65
+ export const PLAN_OPTS_KEYS: OptionKey[] = [
66
+ { key: "planFile", detail: "Plan file to write, relative to the root directory. Default: `chant.tfplan`." },
67
+ { key: "destroy", detail: "`-destroy`: plan the removal of everything the root manages." },
68
+ { key: "cwd", detail: "Directory to start the `chant.config.*` search from." },
69
+ ...STEP_OPTS,
70
+ ];
71
+
72
+ /** `terraformApply(root, { <here> })`'s opts — `TerraformApplyArgs` minus `root`, plus step opts. */
73
+ export const APPLY_OPTS_KEYS: OptionKey[] = [
74
+ { key: "planFile", detail: "The saved plan file to apply, relative to the root directory. Required." },
75
+ { key: "cwd", detail: "Directory to start the `chant.config.*` search from." },
76
+ ...STEP_OPTS,
77
+ ];
78
+
79
+ /** `terraformShow(root, { <here> })`'s opts — `TerraformShowArgs` minus `root`, plus step opts. */
80
+ export const SHOW_OPTS_KEYS: OptionKey[] = [
81
+ { key: "planFile", detail: "Show this saved plan file. Omitted, the activity shows current state." },
82
+ { key: "cwd", detail: "Directory to start the `chant.config.*` search from." },
83
+ ...STEP_OPTS,
84
+ ];
85
+
86
+ /** Every builder call name mapped to its opts table, for completion inside `(root, { <here> })`. */
87
+ export const BUILDER_OPTS_BY_CALL: Record<string, OptionKey[]> = {
88
+ terraformInit: INIT_OPTS_KEYS,
89
+ terraformPlan: PLAN_OPTS_KEYS,
90
+ terraformApply: APPLY_OPTS_KEYS,
91
+ terraformShow: SHOW_OPTS_KEYS,
92
+ };
93
+
94
+ /** Every option key across every table, keyed by name, for hover lookup. Last writer wins on a name shared across tables (`cwd`, `planFile`) — the detail text is the same fact restated per activity, so any one of them is a correct hover. */
95
+ export const ALL_OPTION_KEYS: Map<string, string> = new Map(
96
+ [
97
+ ...CONFIG_NAMESPACE_KEYS,
98
+ ...ROOT_ENTRY_KEYS,
99
+ ...APPLY_OP_KEYS,
100
+ ...COMPENSATE_KEYS,
101
+ ...INIT_OPTS_KEYS,
102
+ ...PLAN_OPTS_KEYS,
103
+ ...APPLY_OPTS_KEYS,
104
+ ...SHOW_OPTS_KEYS,
105
+ ].map((o) => [o.key, o.detail]),
106
+ );
@@ -0,0 +1,48 @@
1
+ /**
2
+ * terraform Op activities — resolved by the core activity registry when a
3
+ * project's `chant.config.ts` lists the `terraform` lexicon. `loadActivities`
4
+ * (`packages/core/src/op/activity-registry.ts`) imports
5
+ * `@intentius/chant-lexicon-terraform/op/activities` and keys every exported
6
+ * function by its name, so there is no plugin member to register:
7
+ *
8
+ * - terraformInit — `init` with the root's `backendConfig`.
9
+ * - terraformPlan — `plan -detailed-exitcode -out=<planFile>`, plus both
10
+ * `show` renders of the written plan.
11
+ * - terraformApply — `apply <planFile>`, a saved plan only.
12
+ * - terraformShow — state, or a saved plan, as JSON and as text.
13
+ *
14
+ * The module is dependency-light on purpose: it shells out to the configured
15
+ * binary and reads the `terraform` config namespace, and never touches the
16
+ * lexicon's HCL parse or serializer, so a Temporal worker loads it cheaply.
17
+ * The pure command and environment builders are exported alongside the
18
+ * activities because they are what the tests assert on.
19
+ */
20
+ export {
21
+ terraformInit,
22
+ terraformPlan,
23
+ terraformApply,
24
+ terraformShow,
25
+ terraformInitCommand,
26
+ terraformPlanCommand,
27
+ terraformApplyCommand,
28
+ terraformShowCommand,
29
+ terraformEnvironment,
30
+ terraformBinary,
31
+ countPlanChanges,
32
+ quoteArg,
33
+ DEFAULT_PLAN_FILE,
34
+ DEFAULT_TERRAFORM_BINARY,
35
+ } from "./terraform";
36
+
37
+ export type {
38
+ TerraformRootArgs,
39
+ TerraformInitArgs,
40
+ TerraformPlanArgs,
41
+ TerraformApplyArgs,
42
+ TerraformShowArgs,
43
+ TerraformInitResult,
44
+ TerraformPlanResult,
45
+ TerraformApplyResult,
46
+ TerraformShowResult,
47
+ PlanChangeCounts,
48
+ } from "./terraform";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The registry contract (#2086): activities are discovered by convention, not
3
+ * by a plugin member. `loadActivities` (`packages/core/src/op/
4
+ * activity-registry.ts`) imports
5
+ * `@intentius/chant-lexicon-terraform/op/activities` for every configured
6
+ * lexicon and keys each exported function by name, so the `"./op/activities"`
7
+ * entry in this lexicon's package.json is load-bearing and this test is what
8
+ * notices if it goes missing.
9
+ *
10
+ * Nothing here runs terraform: resolving a name is importing a module, not
11
+ * calling it.
12
+ */
13
+
14
+ import { describe, test, expect } from "vitest";
15
+ import { loadActivities, resolveActivity } from "@intentius/chant/op";
16
+
17
+ describe("loadActivities([\"terraform\"]) (#2086)", () => {
18
+ test("resolves all four activity names", async () => {
19
+ const activities = await loadActivities(["terraform"]);
20
+ for (const name of ["terraformInit", "terraformPlan", "terraformApply", "terraformShow"]) {
21
+ expect(typeof resolveActivity(activities, name)).toBe("function");
22
+ }
23
+ });
24
+
25
+ test("a project that does not list terraform gets none of them", async () => {
26
+ const activities = await loadActivities([]);
27
+ expect(activities.has("terraformApply")).toBe(false);
28
+ });
29
+ });