@intentius/chant 0.41.20 → 0.44.1

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 (144) hide show
  1. package/dist/build.d.ts +15 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/init.d.ts +2 -0
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  13. package/dist/cli/handlers/components.d.ts.map +1 -1
  14. package/dist/cli/handlers/explain.d.ts +11 -0
  15. package/dist/cli/handlers/explain.d.ts.map +1 -0
  16. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  19. package/dist/cli/plugins.d.ts +8 -0
  20. package/dist/cli/plugins.d.ts.map +1 -1
  21. package/dist/cli/registry.d.ts +2 -0
  22. package/dist/cli/registry.d.ts.map +1 -1
  23. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  24. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  25. package/dist/codegen/okf-lexicon.d.ts +36 -0
  26. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  27. package/dist/codegen/package.d.ts +2 -1
  28. package/dist/codegen/package.d.ts.map +1 -1
  29. package/dist/components/deploy-units.d.ts.map +1 -1
  30. package/dist/components/verbs/sbom.d.ts +1 -1
  31. package/dist/components/verbs/sbom.d.ts.map +1 -1
  32. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  33. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  34. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  35. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  36. package/dist/config.d.ts +12 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/deep-observation.d.ts +13 -0
  39. package/dist/deep-observation.d.ts.map +1 -1
  40. package/dist/lexicon.d.ts +47 -0
  41. package/dist/lexicon.d.ts.map +1 -1
  42. package/dist/lifecycle/observe.d.ts.map +1 -1
  43. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  44. package/dist/okf.d.ts +57 -0
  45. package/dist/okf.d.ts.map +1 -0
  46. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  47. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  48. package/dist/terraform/adopt-state.d.ts +33 -1
  49. package/dist/terraform/adopt-state.d.ts.map +1 -1
  50. package/dist/terraform/aws-resources.d.ts.map +1 -1
  51. package/dist/terraform/bridge.d.ts +7 -1
  52. package/dist/terraform/bridge.d.ts.map +1 -1
  53. package/dist/terraform/carve.d.ts +12 -0
  54. package/dist/terraform/carve.d.ts.map +1 -1
  55. package/dist/terraform/excise.d.ts +31 -0
  56. package/dist/terraform/excise.d.ts.map +1 -0
  57. package/dist/terraform/graduate.d.ts +12 -0
  58. package/dist/terraform/graduate.d.ts.map +1 -1
  59. package/dist/terraform/graph.d.ts +35 -5
  60. package/dist/terraform/graph.d.ts.map +1 -1
  61. package/dist/terraform/manifest.d.ts +89 -0
  62. package/dist/terraform/manifest.d.ts.map +1 -0
  63. package/dist/terraform/parse.d.ts +9 -4
  64. package/dist/terraform/parse.d.ts.map +1 -1
  65. package/dist/terraform/tier-map.d.ts +4 -3
  66. package/dist/terraform/tier-map.d.ts.map +1 -1
  67. package/dist/terraform/types.d.ts +7 -0
  68. package/dist/terraform/types.d.ts.map +1 -1
  69. package/dist/terraform/unified-diff.d.ts +11 -0
  70. package/dist/terraform/unified-diff.d.ts.map +1 -0
  71. package/package.json +1 -1
  72. package/src/__snapshots__/okf.test.ts.snap +381 -0
  73. package/src/audit/rules-doc.ts +1 -1
  74. package/src/build.test.ts +95 -0
  75. package/src/build.ts +46 -1
  76. package/src/cli/commands/build.ts +9 -1
  77. package/src/cli/commands/carve-apply.test.ts +94 -2
  78. package/src/cli/commands/carve-apply.ts +62 -11
  79. package/src/cli/commands/carve-bridge.test.ts +82 -4
  80. package/src/cli/commands/carve-bridge.ts +79 -11
  81. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  82. package/src/cli/commands/carve-emit.ts +184 -8
  83. package/src/cli/commands/init.ts +1 -1
  84. package/src/cli/handlers/carve-apply.ts +1 -0
  85. package/src/cli/handlers/components.ts +4 -2
  86. package/src/cli/handlers/explain.test.ts +93 -0
  87. package/src/cli/handlers/explain.ts +60 -0
  88. package/src/cli/handlers/lifecycle.ts +16 -9
  89. package/src/cli/main.ts +21 -5
  90. package/src/cli/mcp/server.test.ts +17 -0
  91. package/src/cli/mcp/tools/explain.ts +15 -3
  92. package/src/cli/plugins.ts +17 -0
  93. package/src/cli/registry.ts +2 -0
  94. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  95. package/src/codegen/docs-rule-scanning.ts +35 -14
  96. package/src/codegen/okf-lexicon.test.ts +248 -0
  97. package/src/codegen/okf-lexicon.ts +303 -0
  98. package/src/codegen/package.ts +29 -4
  99. package/src/components/config-defaults.test.ts +30 -0
  100. package/src/components/deploy-units.test.ts +13 -0
  101. package/src/components/deploy-units.ts +5 -0
  102. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  103. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  104. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  105. package/src/components/verbs/sbom.ts +1 -1
  106. package/src/components/verbs/vuln-gate.test.ts +144 -1
  107. package/src/components/verbs/vuln-gate.ts +61 -10
  108. package/src/components/verbs/vuln-scan.test.ts +94 -0
  109. package/src/components/verbs/vuln-scan.ts +44 -9
  110. package/src/config.test.ts +21 -0
  111. package/src/config.ts +18 -0
  112. package/src/deep-observation.test.ts +19 -0
  113. package/src/deep-observation.ts +32 -13
  114. package/src/lexicon.ts +50 -0
  115. package/src/lifecycle/observe.test.ts +18 -0
  116. package/src/lifecycle/observe.ts +10 -3
  117. package/src/lifecycle/snapshot.ts +5 -4
  118. package/src/okf.test.ts +169 -0
  119. package/src/okf.ts +308 -0
  120. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  121. package/src/terraform/adopt-state.test.ts +34 -1
  122. package/src/terraform/adopt-state.ts +69 -2
  123. package/src/terraform/aws-resources.test.ts +52 -2
  124. package/src/terraform/aws-resources.ts +95 -4
  125. package/src/terraform/bridge.test.ts +38 -14
  126. package/src/terraform/bridge.ts +48 -14
  127. package/src/terraform/carve.test.ts +9 -7
  128. package/src/terraform/carve.ts +0 -0
  129. package/src/terraform/excise.test.ts +92 -0
  130. package/src/terraform/excise.ts +139 -0
  131. package/src/terraform/graduate.test.ts +73 -6
  132. package/src/terraform/graduate.ts +49 -0
  133. package/src/terraform/graph.test.ts +73 -10
  134. package/src/terraform/graph.ts +124 -47
  135. package/src/terraform/manifest.test.ts +105 -0
  136. package/src/terraform/manifest.ts +162 -0
  137. package/src/terraform/parse.test.ts +39 -1
  138. package/src/terraform/parse.ts +36 -9
  139. package/src/terraform/score.test.ts +8 -8
  140. package/src/terraform/state.test.ts +4 -4
  141. package/src/terraform/tier-map.ts +60 -8
  142. package/src/terraform/types.ts +7 -0
  143. package/src/terraform/unified-diff.test.ts +77 -0
  144. package/src/terraform/unified-diff.ts +146 -0
package/src/okf.ts ADDED
@@ -0,0 +1,308 @@
1
+ import { relative, isAbsolute } from "node:path";
2
+ import type { Declarable } from "./declarable";
3
+ import { buildGraphIr } from "./graph-ir";
4
+ import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
5
+ import { getProvenance } from "./provenance";
6
+ import { emitYAML, parseYAML } from "./yaml";
7
+
8
+ /**
9
+ * OKF bundle emitter (#1058, epic #1057) — projects a discovered entity graph
10
+ * into an Open Knowledge Format v0.2 bundle: a directory of markdown files
11
+ * with YAML frontmatter, per the spec at
12
+ * https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
13
+ *
14
+ * One concept document per entity, `type` carrying the entity's resource type
15
+ * string, dependency edges as bundle-relative markdown links (the form the
16
+ * spec recommends for stability), and a root `index.md` grouping concepts into
17
+ * per-lexicon sections. Emit-only: OKF as project *input* is #1059.
18
+ */
19
+
20
+ export const OKF_VERSION = "0.2";
21
+
22
+ /** One file of an OKF bundle: a bundle-relative path and its full content. */
23
+ export interface OkfFile {
24
+ /** Bundle-relative path, e.g. "aws/myBucket.md" or "index.md". */
25
+ path: string;
26
+ content: string;
27
+ }
28
+
29
+ /** The subset of a discovery result the emitter reads. */
30
+ export interface OkfBundleInput {
31
+ entities: Map<string, Declarable>;
32
+ dependencies: Map<string, Set<string>>;
33
+ }
34
+
35
+ /** Make an entity or lexicon name safe as a single path segment. */
36
+ export function slug(name: string): string {
37
+ const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^\.+/, "");
38
+ return cleaned === "" ? "unnamed" : cleaned;
39
+ }
40
+
41
+ function relFile(file: string | undefined, projectPath?: string): string | undefined {
42
+ if (!file) return undefined;
43
+ if (projectPath && isAbsolute(file)) {
44
+ const rel = relative(projectPath, file);
45
+ return rel.startsWith("..") ? file : rel;
46
+ }
47
+ return file;
48
+ }
49
+
50
+ /**
51
+ * Render a flat frontmatter mapping as a YAML block. Keys with undefined
52
+ * values are dropped; scalar quoting is `emitYAML`'s.
53
+ */
54
+ export function frontmatter(fields: Record<string, string | undefined>): string {
55
+ const lines: string[] = ["---"];
56
+ for (const [key, value] of Object.entries(fields)) {
57
+ if (value === undefined) continue;
58
+ lines.push(`${key}: ${emitYAML(value, 0)}`);
59
+ }
60
+ lines.push("---");
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * Split a concept document into its frontmatter block and body. Returns
66
+ * undefined when the document does not start with a `---` delimited block.
67
+ * Exported for the conformance tests (and any future OKF consumer, #1059).
68
+ */
69
+ export function splitFrontmatter(content: string): { frontmatter: string; body: string } | undefined {
70
+ const lines = content.split("\n");
71
+ if (lines[0] !== "---") return undefined;
72
+ const end = lines.indexOf("---", 1);
73
+ if (end === -1) return undefined;
74
+ return {
75
+ frontmatter: lines.slice(1, end).join("\n"),
76
+ body: lines.slice(end + 1).join("\n"),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Check a bundle against the OKF v0.2 conformance criteria (spec §11) and
82
+ * return every violation found: parseable frontmatter on every non-reserved
83
+ * `.md`, a non-empty `type` everywhere, a well-formed root `index.md`, and
84
+ * generated cross-link entries that are bundle-absolute (or external URLs).
85
+ * Empty result means conformant. Shared by the project-bundle tests (#1058)
86
+ * and every lexicon's bundle tests (#1060).
87
+ */
88
+ export function okfConformanceProblems(files: OkfFile[]): string[] {
89
+ const problems: string[] = [];
90
+ const reserved = new Set(["index.md", "log.md"]);
91
+ const linkTarget = /\]\(([^)]+)\)/g;
92
+ const externalOrAbsolute = (target: string): boolean =>
93
+ target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target);
94
+
95
+ for (const file of files) {
96
+ if (!file.path.endsWith(".md")) problems.push(`${file.path}: not a markdown file`);
97
+ if (file.path.startsWith("/") || file.path.includes("..")) {
98
+ problems.push(`${file.path}: path is not bundle-relative`);
99
+ }
100
+ if (reserved.has(file.path.split("/").pop()!)) continue;
101
+
102
+ const split = splitFrontmatter(file.content);
103
+ if (!split) {
104
+ problems.push(`${file.path}: no frontmatter block`);
105
+ continue;
106
+ }
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = parseYAML(split.frontmatter);
110
+ } catch {
111
+ problems.push(`${file.path}: unparseable frontmatter`);
112
+ continue;
113
+ }
114
+ if (typeof parsed !== "object" || parsed === null) {
115
+ problems.push(`${file.path}: frontmatter is not a mapping`);
116
+ continue;
117
+ }
118
+ const type = (parsed as Record<string, unknown>).type;
119
+ if (typeof type !== "string" || type.length === 0) {
120
+ problems.push(`${file.path}: frontmatter has no non-empty type`);
121
+ }
122
+ // Cross-link entries (the "- [name](target)" lines the emitters generate)
123
+ // must point inside the bundle or at an external URL. Broken targets are
124
+ // permitted (not-yet-written knowledge); malformed ones are not. Free-text
125
+ // knowledge bodies may carry arbitrary links and are not checked.
126
+ for (const line of split.body.split("\n")) {
127
+ if (!line.trimStart().startsWith("- [")) continue;
128
+ for (const match of line.matchAll(linkTarget)) {
129
+ if (!externalOrAbsolute(match[1])) {
130
+ problems.push(`${file.path}: link target "${match[1]}" is neither bundle-absolute nor external`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ const index = files.find((f) => f.path === "index.md");
137
+ if (!index) {
138
+ problems.push("bundle has no root index.md");
139
+ return problems;
140
+ }
141
+ const split = splitFrontmatter(index.content);
142
+ if (!split) {
143
+ problems.push("index.md: no frontmatter block");
144
+ return problems;
145
+ }
146
+ const parsed = parseYAML(split.frontmatter);
147
+ for (const key of Object.keys(parsed ?? {})) {
148
+ if (key !== "okf_version") problems.push(`index.md: frontmatter carries "${key}" — okf_version is the only key an index is allowed`);
149
+ }
150
+ for (const line of split.body.split("\n")) {
151
+ if (line.startsWith("*") && !/^\* \[[^\]]+\]\([^)]+\) - .+$/.test(line)) {
152
+ problems.push(`index.md: malformed entry line: ${line}`);
153
+ }
154
+ }
155
+ return problems;
156
+ }
157
+
158
+ interface Concept {
159
+ name: string;
160
+ entity: Declarable;
161
+ /** Bundle-relative concept path, e.g. "aws/myBucket.md". */
162
+ path: string;
163
+ lexicon: string;
164
+ /** The frontmatter `type` — the entity's resource type string. */
165
+ type: string;
166
+ kind: string;
167
+ }
168
+
169
+ /**
170
+ * The `type`/`lexicon`/`kind` a concept carries. Most entities state all
171
+ * three; a `LexiconOutput` (an `output(...)` bridge) carries none of them
172
+ * directly, so its facts come from its producing side — `type` must be
173
+ * non-empty for the bundle to conform, never blank.
174
+ */
175
+ function conceptFacts(entity: Declarable): { type: string; lexicon: string; kind: string } {
176
+ if (isLexiconOutput(entity)) {
177
+ const out = entity as unknown as LexiconOutput;
178
+ return { type: "Output", lexicon: out.sourceLexicon || "unknown", kind: "output" };
179
+ }
180
+ return {
181
+ type: entity.entityType || "Entity",
182
+ lexicon: entity.lexicon || "unknown",
183
+ kind: entity.kind ?? "resource",
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Build the OKF bundle for a discovered graph. Pure and deterministic: the
189
+ * same entities yield byte-identical files, so bundles are diffable and
190
+ * snapshot-testable. `projectPath` relativizes source-file paths so the
191
+ * bundle is portable.
192
+ */
193
+ export function buildOkfBundle(input: OkfBundleInput, projectPath?: string): OkfFile[] {
194
+ // Assign each entity a stable concept path: <lexicon>/<name>.md, deduped
195
+ // when slugging collides two distinct names.
196
+ const concepts = new Map<string, Concept>();
197
+ const takenPaths = new Set<string>();
198
+ const names = [...input.entities.keys()].sort((a, b) => a.localeCompare(b));
199
+ for (const name of names) {
200
+ const entity = input.entities.get(name)!;
201
+ const facts = conceptFacts(entity);
202
+ const base = `${slug(facts.lexicon)}/${slug(name)}`;
203
+ let path = `${base}.md`;
204
+ for (let n = 2; takenPaths.has(path); n++) path = `${base}-${n}.md`;
205
+ takenPaths.add(path);
206
+ concepts.set(name, { name, entity, path, ...facts });
207
+ }
208
+
209
+ // Dependency edges: the graph IR's AttrRef/Ref-derived edges (the same
210
+ // derivation `chant graph --format ir` ships) unioned with discovery's
211
+ // declared dependency map — the map alone misses references that live only
212
+ // in attribute values.
213
+ const dependsOn = new Map<string, Set<string>>();
214
+ const addEdge = (from: string, to: string): void => {
215
+ if (!dependsOn.has(from)) dependsOn.set(from, new Set());
216
+ dependsOn.get(from)!.add(to);
217
+ };
218
+ for (const edge of buildGraphIr(input.entities, projectPath).edges) addEdge(edge.from, edge.to);
219
+ for (const [from, deps] of input.dependencies) for (const to of deps) addEdge(from, to);
220
+
221
+ // Reverse edges: who references each entity.
222
+ const referencedBy = new Map<string, string[]>();
223
+ for (const [from, deps] of dependsOn) {
224
+ for (const to of deps) {
225
+ if (!referencedBy.has(to)) referencedBy.set(to, []);
226
+ referencedBy.get(to)!.push(from);
227
+ }
228
+ }
229
+
230
+ const files: OkfFile[] = [];
231
+
232
+ for (const name of names) {
233
+ const concept = concepts.get(name)!;
234
+ const { entity } = concept;
235
+ const prov = getProvenance(entity);
236
+ const source = relFile(prov?.sourceFile, projectPath);
237
+
238
+ const head = frontmatter({
239
+ type: concept.type,
240
+ title: name,
241
+ description: `${concept.lexicon} ${concept.kind} of type ${concept.type}`,
242
+ name,
243
+ lexicon: concept.lexicon,
244
+ kind: concept.kind,
245
+ source,
246
+ composite: prov?.composite,
247
+ composite_instance: prov?.compositeInstance,
248
+ });
249
+
250
+ const body: string[] = [""];
251
+ body.push(source ? `Declared in \`${source}\`.` : `Declared by the ${concept.lexicon} lexicon.`);
252
+
253
+ // Cross-resource references become bundle-relative links. A dependency
254
+ // whose target is not in the bundle still emits a link — the spec permits
255
+ // broken links (not-yet-written knowledge), so unresolved never fails.
256
+ const deps = [...(dependsOn.get(name) ?? [])].sort((a, b) => a.localeCompare(b));
257
+ if (deps.length > 0) {
258
+ body.push("", "## Depends on", "");
259
+ for (const dep of deps) {
260
+ const target = concepts.get(dep)?.path ?? `${slug(dep)}.md`;
261
+ body.push(`- [${dep}](/${target})`);
262
+ }
263
+ }
264
+
265
+ const consumers = (referencedBy.get(name) ?? []).sort((a, b) => a.localeCompare(b));
266
+ if (consumers.length > 0) {
267
+ body.push("", "## Referenced by", "");
268
+ for (const consumer of consumers) {
269
+ const target = concepts.get(consumer)?.path ?? `${slug(consumer)}.md`;
270
+ body.push(`- [${consumer}](/${target})`);
271
+ }
272
+ }
273
+
274
+ body.push("");
275
+ files.push({ path: concept.path, content: head + body.join("\n") });
276
+ }
277
+
278
+ files.push({ path: "index.md", content: buildIndex(concepts, names) });
279
+ files.sort((a, b) => a.path.localeCompare(b.path));
280
+ return files;
281
+ }
282
+
283
+ /**
284
+ * The bundle-root `index.md` (spec §8): one section per lexicon, each entry a
285
+ * bundle-relative link plus the concept's description — the spec's
286
+ * progressive-disclosure mechanism. Root indexes may carry `okf_version`
287
+ * frontmatter (spec §12), the only frontmatter an index is allowed.
288
+ */
289
+ function buildIndex(concepts: Map<string, Concept>, names: string[]): string {
290
+ const byLexicon = new Map<string, Concept[]>();
291
+ for (const name of names) {
292
+ const concept = concepts.get(name)!;
293
+ if (!byLexicon.has(concept.lexicon)) byLexicon.set(concept.lexicon, []);
294
+ byLexicon.get(concept.lexicon)!.push(concept);
295
+ }
296
+
297
+ const lines: string[] = ["---", `okf_version: '${OKF_VERSION}'`, "---", ""];
298
+ const lexicons = [...byLexicon.keys()].sort((a, b) => a.localeCompare(b));
299
+ for (const lexicon of lexicons) {
300
+ lines.push(`# Lexicon: ${lexicon}`, "");
301
+ for (const concept of byLexicon.get(lexicon)!) {
302
+ lines.push(`* [${concept.name}](/${concept.path}) - ${lexicon} ${concept.kind} of type ${concept.type}`);
303
+ }
304
+ lines.push("");
305
+ }
306
+ if (lexicons.length === 0) lines.push("No entities discovered.", "");
307
+ return lines.join("\n");
308
+ }
@@ -0,0 +1,25 @@
1
+ import { buildGraph, collectExpressions, type ExpressionRefs } from "../graph";
2
+ import type { Hcl2JsonTree, TfGraph } from "../types";
3
+
4
+ /**
5
+ * Test-only stand-in for the hcl2json expression AST (`parse.ts`'s
6
+ * `resolveExpressionRefs`). Fixture expressions are simple `${<accessor>}`
7
+ * templates, so each one's accessor list is just its interpolation bodies.
8
+ * Production never takes this path — `parseTerraformDir` resolves every
9
+ * expression through `getReferencesInExpression`.
10
+ */
11
+ export function fixtureExprRefs(tree: Hcl2JsonTree): ExpressionRefs {
12
+ const refs = new Map<string, string[]>();
13
+ for (const expr of collectExpressions(tree)) {
14
+ refs.set(
15
+ expr,
16
+ [...expr.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1].trim()),
17
+ );
18
+ }
19
+ return refs;
20
+ }
21
+
22
+ /** `buildGraph` over a hand-written fixture tree, accessors derived per {@link fixtureExprRefs}. */
23
+ export function buildFixtureGraph(tree: Hcl2JsonTree): TfGraph {
24
+ return buildGraph(tree, fixtureExprRefs(tree));
25
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes } from "./adopt-state";
2
+ import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes, type DeferredParam } from "./adopt-state";
3
3
  import type { StateResource } from "./state";
4
4
 
5
5
  describe("adoptFromState", () => {
@@ -45,6 +45,39 @@ describe("adoptFromState", () => {
45
45
  expect(out.content).toContain("RetentionInDays: 30");
46
46
  });
47
47
 
48
+ test("a deferred input renders as a params reference, not the state literal (#998)", () => {
49
+ const subnet: StateResource = {
50
+ type: "aws_subnet",
51
+ name: "a",
52
+ attributes: { id: "subnet-0aa", vpc_id: "vpc-0abc", cidr_block: "10.0.1.0/24" },
53
+ };
54
+ const params: DeferredParam[] = [
55
+ { name: "vpc_id", tfAttr: "vpc_id", survivor: "aws_vpc.main", attrs: ["id"], default: "vpc-0abc" },
56
+ ];
57
+ const out = adoptFromState(subnet, params)!;
58
+ expect(out.parameterized).toEqual(["vpc_id"]);
59
+ expect(out.content).toContain('import { params } from "@intentius/chant/params";');
60
+ expect(out.content).toContain("VpcId: params.vpc_id as string,");
61
+ expect(out.content).not.toContain('VpcId: "vpc-0abc"');
62
+ // Non-deferred props keep their state literals.
63
+ expect(out.content).toContain('CidrBlock: "10.0.1.0/24"');
64
+ });
65
+
66
+ test("a deferred input on an unmapped attribute leaves the source alone", () => {
67
+ const lambda: StateResource = {
68
+ type: "aws_lambda_function",
69
+ name: "api",
70
+ attributes: { id: "myapp-api", function_name: "myapp-api", environment: [{ variables: { B: "b" } }] },
71
+ };
72
+ const params: DeferredParam[] = [
73
+ { name: "environment", tfAttr: "environment", survivor: "aws_s3_bucket.assets", attrs: ["bucket"] },
74
+ ];
75
+ const out = adoptFromState(lambda, params)!;
76
+ expect(out.parameterized).toEqual([]);
77
+ expect(out.content).not.toContain("@intentius/chant/params");
78
+ expect(out.content).toContain('FunctionName: "myapp-api"');
79
+ });
80
+
48
81
  test("canAdoptFromState gates on a known native constructor", () => {
49
82
  expect(canAdoptFromState("aws_s3_bucket")).toBe(true);
50
83
  expect(canAdoptFromState("random_pet")).toBe(false);
@@ -10,17 +10,47 @@
10
10
  * AWS carve-out table (`aws-resources.ts`) — the same table the advisor's tier
11
11
  * map derives from, so every AWS type advise ranks can be emitted. Attributes
12
12
  * without a mapping are preserved in a reference comment, never dropped.
13
+ *
14
+ * Deferred outbound inputs (#998): an attribute whose value the Terraform
15
+ * source read from a survivor (`vpc_id = aws_vpc.main.id`) is not a fact of
16
+ * the carved resource — it is a deploy-time input. Those props are emitted as
17
+ * `params.<name>` references instead of frozen literals, with the state's
18
+ * resolved value as the declared default (see `carve-emit.ts`'s scaffold).
13
19
  */
14
20
 
15
21
  import { AWS_CARVE_TYPES, AWS_LEXICON_IMPORT, awsCarveType, applyAwsMapper } from "./aws-resources";
16
22
  import type { StateResource } from "./state";
17
23
 
24
+ /** The core subpath the emitted source reads build parameters from. */
25
+ export const PARAMS_IMPORT = "@intentius/chant/params";
26
+
27
+ /**
28
+ * One deferred outbound input turned into a build parameter (#998). Derived
29
+ * from the boundary report's outbound edges + the state's resolved attributes
30
+ * in `carve-emit.ts`; consumed here (source substitution) and by the scaffold
31
+ * (`chant.config.ts` `buildParams` declaration).
32
+ */
33
+ export interface DeferredParam {
34
+ /** Build-parameter name, source-referenceable (`params.<name>`). */
35
+ name: string;
36
+ /** The carved resource's own Terraform attribute the value enters through. */
37
+ tfAttr: string;
38
+ /** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
39
+ survivor: string;
40
+ /** Survivor attribute(s) read, e.g. `["id"]`. */
41
+ attrs: string[];
42
+ /** The state-resolved value — the parameter's declared default. */
43
+ default?: string | number | boolean;
44
+ }
45
+
18
46
  export interface AdoptedSource {
19
47
  fileName: string;
20
48
  content: string;
21
49
  /** True when at least one attribute was mapped to a native prop. */
22
50
  mapped: boolean;
23
51
  nativeType: string;
52
+ /** Deferred params actually substituted into the emitted props (#998). */
53
+ parameterized: string[];
24
54
  }
25
55
 
26
56
  /** Is this Terraform type adoptable from state (has a native constructor)? */
@@ -33,16 +63,41 @@ export function supportedStateAdoptionTypes(): string[] {
33
63
  return AWS_CARVE_TYPES.map((t) => t.tfType).sort();
34
64
  }
35
65
 
66
+ /** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
67
+ class ParamRef {
68
+ constructor(
69
+ readonly name: string,
70
+ readonly type: "string" | "number" | "boolean",
71
+ ) {}
72
+ }
73
+
36
74
  /**
37
75
  * Render chant source for a state-adopted resource. Emits the native
38
76
  * constructor with mapped properties, plus a reference comment listing the
39
77
  * Terraform attributes that were not mapped so nothing is silently dropped.
78
+ *
79
+ * A mapped attribute named by a `DeferredParam` renders as a `params.<name>`
80
+ * reference (a real chant build parameter) instead of the state literal —
81
+ * the value came from a survivor, so it stays overridable per build.
40
82
  */
41
- export function adoptFromState(resource: StateResource): AdoptedSource | null {
83
+ export function adoptFromState(resource: StateResource, params: DeferredParam[] = []): AdoptedSource | null {
42
84
  const entry = awsCarveType(resource.type);
43
85
  if (!entry) return null;
44
86
 
45
87
  const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
88
+
89
+ // Substitute deferred inputs: only plain (untransformed) field mappings can
90
+ // carry a parameter reference — a transform ran against the literal at emit
91
+ // time and cannot re-run at build. Everything else keeps the state literal.
92
+ const parameterized: string[] = [];
93
+ for (const param of params) {
94
+ const spec = entry.fields[param.tfAttr];
95
+ if (typeof spec !== "string" || !(spec in props)) continue;
96
+ const type = typeof param.default === "number" ? "number" : typeof param.default === "boolean" ? "boolean" : "string";
97
+ props[spec] = new ParamRef(param.name, type);
98
+ parameterized.push(param.name);
99
+ }
100
+
46
101
  const unmapped: Record<string, unknown> = {};
47
102
  for (const [k, v] of Object.entries(resource.attributes)) {
48
103
  if (!mappedKeys.includes(k)) unmapped[k] = v;
@@ -52,6 +107,10 @@ export function adoptFromState(resource: StateResource): AdoptedSource | null {
52
107
  L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
53
108
  L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
54
109
  L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
110
+ if (parameterized.length) {
111
+ L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
112
+ L.push(`import { params } from "${PARAMS_IMPORT}";`);
113
+ }
55
114
  L.push("");
56
115
  L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
57
116
  if (Object.keys(unmapped).length) {
@@ -66,6 +125,7 @@ export function adoptFromState(resource: StateResource): AdoptedSource | null {
66
125
  content: L.join("\n") + "\n",
67
126
  mapped: Object.keys(props).length > 0,
68
127
  nativeType: entry.nativeType,
128
+ parameterized,
69
129
  };
70
130
  }
71
131
 
@@ -74,6 +134,13 @@ function renderObject(obj: Record<string, unknown>, indent: number): string {
74
134
  const keys = Object.keys(obj);
75
135
  if (keys.length === 0) return "{}";
76
136
  const pad = " ".repeat(indent);
77
- const body = keys.map((k) => `${pad}${k}: ${JSON.stringify(obj[k])},`).join("\n");
137
+ const body = keys
138
+ .map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
139
+ .join("\n");
78
140
  return `{\n${body}\n}`;
79
141
  }
142
+
143
+ /** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
144
+ function renderValue(value: unknown): string {
145
+ return value instanceof ParamRef ? `params.${value.name} as ${value.type}` : JSON.stringify(value);
146
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { AWS_CARVE_TYPES, awsCarveType, applyAwsMapper } from "./aws-resources";
3
- import { TIER_MAP } from "./tier-map";
3
+ import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR } from "./tier-map";
4
4
  import { canAdoptFromState } from "./adopt-state";
5
5
 
6
6
  describe("AWS carve-out table", () => {
@@ -21,10 +21,21 @@ describe("AWS carve-out table", () => {
21
21
  "aws_secretsmanager_secret", "aws_ssm_parameter", "aws_ecr_repository",
22
22
  "aws_vpc", "aws_subnet", "aws_security_group", "aws_route53_zone",
23
23
  "aws_cloudwatch_log_group",
24
+ // #998 coverage expansion: streaming, EKS, Lambda periphery, networking
25
+ // periphery, audit, DB groups/clusters, API stages, identity, EFS periphery.
26
+ "aws_kinesis_stream", "aws_eks_cluster", "aws_eks_node_group",
27
+ "aws_lambda_permission", "aws_lambda_event_source_mapping",
28
+ "aws_vpc_endpoint", "aws_route", "aws_route_table_association",
29
+ "aws_flow_log", "aws_cloudtrail", "aws_cloudwatch_dashboard",
30
+ "aws_rds_cluster", "aws_db_parameter_group", "aws_elasticache_subnet_group",
31
+ "aws_lb_listener_rule", "aws_api_gateway_stage", "aws_apigatewayv2_stage",
32
+ "aws_cognito_user_pool", "aws_appautoscaling_target",
33
+ "aws_efs_mount_target", "aws_efs_access_point",
34
+ "aws_sqs_queue_policy", "aws_sns_topic_policy",
24
35
  ]) {
25
36
  expect(types.has(t)).toBe(true);
26
37
  }
27
- expect(AWS_CARVE_TYPES.length).toBeGreaterThanOrEqual(20);
38
+ expect(AWS_CARVE_TYPES.length).toBeGreaterThanOrEqual(60);
28
39
  });
29
40
 
30
41
  test("constructors are non-empty and consistent per native type", () => {
@@ -78,4 +89,43 @@ describe("applyAwsMapper", () => {
78
89
  expect(props).toEqual({ VpcId: "vpc-1", CidrBlock: "10.0.1.0/24" });
79
90
  expect(props).not.toHaveProperty("AvailabilityZone");
80
91
  });
92
+
93
+ test("wraps a queue policy's queue_url and parses its policy document", () => {
94
+ const { props } = applyAwsMapper(awsCarveType("aws_sqs_queue_policy")!, {
95
+ queue_url: "https://sqs.us-east-1.amazonaws.com/1/q",
96
+ policy: '{"Version":"2012-10-17","Statement":[]}',
97
+ });
98
+ expect(props.Queues).toEqual(["https://sqs.us-east-1.amazonaws.com/1/q"]);
99
+ expect(props.PolicyDocument).toEqual({ Version: "2012-10-17", Statement: [] });
100
+ });
101
+ });
102
+
103
+ describe("tier + identity coverage maps (#998)", () => {
104
+ test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
105
+ expect(TIER_MAP.kubernetes_manifest).toEqual({ tier: 1, mapsTo: "k8s:manifest" });
106
+ for (const t of ["kubernetes_deployment", "kubernetes_config_map", "kubernetes_service", "kubernetes_namespace"]) {
107
+ expect(TIER_MAP[t]).toMatchObject({ tier: 2 });
108
+ expect(TIER_MAP[`${t}_v1`]).toEqual(TIER_MAP[t]);
109
+ }
110
+ expect(TIER_MAP.kubernetes_horizontal_pod_autoscaler_v2).toEqual(TIER_MAP.kubernetes_horizontal_pod_autoscaler);
111
+ });
112
+
113
+ test("S3 and ECR sub-resources fold into their parent", () => {
114
+ for (const t of [
115
+ "aws_s3_bucket_website_configuration", "aws_s3_bucket_cors_configuration", "aws_s3_bucket_logging",
116
+ "aws_s3_bucket_ownership_controls", "aws_s3_bucket_notification",
117
+ ]) {
118
+ expect(FOLDS_INTO[t]).toBe("aws_s3_bucket");
119
+ }
120
+ expect(FOLDS_INTO.aws_ecr_lifecycle_policy).toBe("aws_ecr_repository");
121
+ expect(FOLDS_INTO.aws_ecr_repository_policy).toBe("aws_ecr_repository");
122
+ });
123
+
124
+ test("identity attrs cover the expanded types, including a dotted non-AWS path", () => {
125
+ expect(IDENTITY_ATTR.aws_elasticache_cluster).toBe("cluster_id");
126
+ expect(IDENTITY_ATTR.aws_ecs_task_definition).toBe("family");
127
+ expect(IDENTITY_ATTR.aws_route53_record).toBe("name");
128
+ expect(IDENTITY_ATTR.aws_eks_node_group).toBe("node_group_name");
129
+ expect(IDENTITY_ATTR.kubernetes_manifest).toBe("manifest.metadata.name");
130
+ });
81
131
  });