@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.
- package/dist/build.d.ts +15 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts +9 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts +7 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +7 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/explain.d.ts +11 -0
- package/dist/cli/handlers/explain.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +8 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts +11 -0
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/okf-lexicon.d.ts +36 -0
- package/dist/codegen/okf-lexicon.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +2 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/verbs/sbom.d.ts +1 -1
- package/dist/components/verbs/sbom.d.ts.map +1 -1
- package/dist/components/verbs/vuln-gate.d.ts +15 -4
- package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +18 -6
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +13 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/okf.d.ts +57 -0
- package/dist/okf.d.ts.map +1 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
- package/dist/terraform/adopt-state.d.ts +33 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +7 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve.d.ts +12 -0
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/excise.d.ts +31 -0
- package/dist/terraform/excise.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +12 -0
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +35 -5
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +89 -0
- package/dist/terraform/manifest.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +9 -4
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +4 -3
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/terraform/unified-diff.d.ts +11 -0
- package/dist/terraform/unified-diff.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +381 -0
- package/src/audit/rules-doc.ts +1 -1
- package/src/build.test.ts +95 -0
- package/src/build.ts +46 -1
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/carve-apply.test.ts +94 -2
- package/src/cli/commands/carve-apply.ts +62 -11
- package/src/cli/commands/carve-bridge.test.ts +82 -4
- package/src/cli/commands/carve-bridge.ts +79 -11
- package/src/cli/commands/carve-emit-state.test.ts +110 -1
- package/src/cli/commands/carve-emit.ts +184 -8
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/handlers/carve-apply.ts +1 -0
- package/src/cli/handlers/components.ts +4 -2
- package/src/cli/handlers/explain.test.ts +93 -0
- package/src/cli/handlers/explain.ts +60 -0
- package/src/cli/handlers/lifecycle.ts +16 -9
- package/src/cli/main.ts +21 -5
- package/src/cli/mcp/server.test.ts +17 -0
- package/src/cli/mcp/tools/explain.ts +15 -3
- package/src/cli/plugins.ts +17 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
- package/src/codegen/docs-rule-scanning.ts +35 -14
- package/src/codegen/okf-lexicon.test.ts +248 -0
- package/src/codegen/okf-lexicon.ts +303 -0
- package/src/codegen/package.ts +29 -4
- package/src/components/config-defaults.test.ts +30 -0
- package/src/components/deploy-units.test.ts +13 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
- package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
- package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
- package/src/components/verbs/sbom.ts +1 -1
- package/src/components/verbs/vuln-gate.test.ts +144 -1
- package/src/components/verbs/vuln-gate.ts +61 -10
- package/src/components/verbs/vuln-scan.test.ts +94 -0
- package/src/components/verbs/vuln-scan.ts +44 -9
- package/src/config.test.ts +21 -0
- package/src/config.ts +18 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +32 -13
- package/src/lexicon.ts +50 -0
- package/src/lifecycle/observe.test.ts +18 -0
- package/src/lifecycle/observe.ts +10 -3
- package/src/lifecycle/snapshot.ts +5 -4
- package/src/okf.test.ts +169 -0
- package/src/okf.ts +308 -0
- package/src/terraform/__fixtures__/build-graph.ts +25 -0
- package/src/terraform/adopt-state.test.ts +34 -1
- package/src/terraform/adopt-state.ts +69 -2
- package/src/terraform/aws-resources.test.ts +52 -2
- package/src/terraform/aws-resources.ts +95 -4
- package/src/terraform/bridge.test.ts +38 -14
- package/src/terraform/bridge.ts +48 -14
- package/src/terraform/carve.test.ts +9 -7
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/excise.test.ts +92 -0
- package/src/terraform/excise.ts +139 -0
- package/src/terraform/graduate.test.ts +73 -6
- package/src/terraform/graduate.ts +49 -0
- package/src/terraform/graph.test.ts +73 -10
- package/src/terraform/graph.ts +124 -47
- package/src/terraform/manifest.test.ts +105 -0
- package/src/terraform/manifest.ts +162 -0
- package/src/terraform/parse.test.ts +39 -1
- package/src/terraform/parse.ts +36 -9
- package/src/terraform/score.test.ts +8 -8
- package/src/terraform/state.test.ts +4 -4
- package/src/terraform/tier-map.ts +60 -8
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excision of a carved resource's own `resource "<type>" "<name>"` block from
|
|
3
|
+
* the surviving Terraform source (#998 — closes the "auto-excise" limitation).
|
|
4
|
+
*
|
|
5
|
+
* After `terraform state rm`, the block left behind would re-CREATE the
|
|
6
|
+
* resource on the next apply — the one edit the bridge previously left to
|
|
7
|
+
* hand-work. This removes the carve set's own blocks as part of the survivor
|
|
8
|
+
* rewrite, so the one bridge `.patch` (#1589) carries the whole handoff edit.
|
|
9
|
+
*
|
|
10
|
+
* Pure text transform. The scanner tracks brace depth outside of strings,
|
|
11
|
+
* comments, template interpolations, and heredocs — enough for real `.tf`
|
|
12
|
+
* files without pulling in the wasm parser for what is a subtractive edit.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ExciseTarget {
|
|
16
|
+
/** Terraform address, e.g. `aws_s3_bucket.assets`. */
|
|
17
|
+
address: string;
|
|
18
|
+
type: string;
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExciseResult {
|
|
23
|
+
content: string;
|
|
24
|
+
/** Addresses whose block was found and removed, in file order. */
|
|
25
|
+
excised: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HEADER = /^\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remove the targets' own `resource` blocks from one `.tf` file's text. A
|
|
32
|
+
* comment line directly above a removed block stays (it may describe more
|
|
33
|
+
* than the block); the blank line the removal leaves behind is collapsed.
|
|
34
|
+
*/
|
|
35
|
+
export function exciseResourceBlocks(content: string, targets: ExciseTarget[]): ExciseResult {
|
|
36
|
+
if (targets.length === 0) return { content, excised: [] };
|
|
37
|
+
const wanted = new Map(targets.map((t) => [`${t.type}.${t.name}`, t.address]));
|
|
38
|
+
const lines = content.split("\n");
|
|
39
|
+
const excised: string[] = [];
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < lines.length) {
|
|
44
|
+
const m = lines[i].match(HEADER);
|
|
45
|
+
const key = m ? `${m[1]}.${m[2]}` : undefined;
|
|
46
|
+
if (!m || !wanted.has(key!)) {
|
|
47
|
+
out.push(lines[i]);
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const end = blockEnd(lines, i);
|
|
53
|
+
if (end === -1) {
|
|
54
|
+
// Unbalanced block — leave the file alone rather than corrupt it.
|
|
55
|
+
out.push(lines[i]);
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
excised.push(wanted.get(key!)!);
|
|
60
|
+
i = end + 1;
|
|
61
|
+
// Swallow the separator blank line so the removal leaves no doubled gap.
|
|
62
|
+
if (i < lines.length && lines[i].trim() === "") i++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { content: out.join("\n"), excised };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Index of the line closing the block opened on `start`, or -1 if unbalanced. */
|
|
69
|
+
function blockEnd(lines: string[], start: number): number {
|
|
70
|
+
// Context stack: block/object braces, strings, `${` interpolations, block
|
|
71
|
+
// comments. The header's `{` pushes the first "brace"; the block closes when
|
|
72
|
+
// the stack empties.
|
|
73
|
+
const stack: Array<"brace" | "string" | "interp" | "comment"> = [];
|
|
74
|
+
let opened = false;
|
|
75
|
+
let heredoc: string | null = null;
|
|
76
|
+
|
|
77
|
+
for (let ln = start; ln < lines.length; ln++) {
|
|
78
|
+
const line = lines[ln];
|
|
79
|
+
if (heredoc !== null) {
|
|
80
|
+
if (line.trim() === heredoc) heredoc = null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let c = 0; c < line.length; c++) {
|
|
85
|
+
const top = stack[stack.length - 1];
|
|
86
|
+
const ch = line[c];
|
|
87
|
+
const next = line[c + 1];
|
|
88
|
+
|
|
89
|
+
if (top === "comment") {
|
|
90
|
+
if (ch === "*" && next === "/") {
|
|
91
|
+
stack.pop();
|
|
92
|
+
c++;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (top === "string") {
|
|
98
|
+
if (ch === "\\") {
|
|
99
|
+
c++;
|
|
100
|
+
} else if (ch === "$" && next === "{" && line[c - 1] !== "$") {
|
|
101
|
+
stack.push("interp");
|
|
102
|
+
c++;
|
|
103
|
+
} else if (ch === '"') {
|
|
104
|
+
stack.pop();
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Code position (top of file block, a nested brace, or inside `${...}`).
|
|
110
|
+
if (ch === "#" || (ch === "/" && next === "/")) break; // line comment
|
|
111
|
+
if (ch === "/" && next === "*") {
|
|
112
|
+
stack.push("comment");
|
|
113
|
+
c++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === '"') {
|
|
117
|
+
stack.push("string");
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (ch === "<" && next === "<") {
|
|
121
|
+
const hd = line.slice(c).match(/^<<-?([A-Za-z_][A-Za-z0-9_-]*)/);
|
|
122
|
+
if (hd) {
|
|
123
|
+
heredoc = hd[1];
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (ch === "{") {
|
|
128
|
+
stack.push("brace");
|
|
129
|
+
opened = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (ch === "}") {
|
|
133
|
+
if (top === "brace" || top === "interp") stack.pop();
|
|
134
|
+
if (opened && stack.length === 0) return ln;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return -1;
|
|
139
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
3
|
import { boundaryReport } from "./carve";
|
|
4
|
-
import { graduationPlan, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
|
|
4
|
+
import { graduationPlan, stampOwnershipIntoSource, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
|
|
5
5
|
import type { Hcl2JsonTree } from "./types";
|
|
6
6
|
|
|
7
7
|
const bucketTree: Hcl2JsonTree = {
|
|
@@ -13,7 +13,7 @@ const bucketTree: Hcl2JsonTree = {
|
|
|
13
13
|
|
|
14
14
|
describe("graduationPlan", () => {
|
|
15
15
|
test("resolves the ownership marker + tags (chant-owned)", () => {
|
|
16
|
-
const report = boundaryReport(
|
|
16
|
+
const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
17
17
|
const plan = graduationPlan(report, { stack: "assets", env: "prod" });
|
|
18
18
|
|
|
19
19
|
expect(plan.marker).toEqual({ stack: "assets", env: "prod" });
|
|
@@ -25,14 +25,14 @@ describe("graduationPlan", () => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
test("stack defaults to the resource's local name", () => {
|
|
28
|
-
const report = boundaryReport(
|
|
28
|
+
const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
29
29
|
const plan = graduationPlan(report);
|
|
30
30
|
expect(plan.marker.stack).toBe("assets");
|
|
31
31
|
expect(plan.ownershipTags).not.toHaveProperty(DEFAULT_TAG_OWNERSHIP_KEYS.env); // no env → omitted
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test("runbook is reversible and BYOL (import rollback, no chant-runs-apply)", () => {
|
|
35
|
-
const report = boundaryReport(
|
|
35
|
+
const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
36
36
|
const plan = graduationPlan(report, { env: "prod" });
|
|
37
37
|
const runbook = plan.steps.join("\n");
|
|
38
38
|
expect(runbook).toMatch(/terraform import aws_s3_bucket\.assets/);
|
|
@@ -42,8 +42,75 @@ describe("graduationPlan", () => {
|
|
|
42
42
|
|
|
43
43
|
test("warns when outbound edges leave deferred inputs to wire", () => {
|
|
44
44
|
// Carve the Lambda: it depends on the bucket (survivor) → outbound/deferred.
|
|
45
|
-
const report = boundaryReport(
|
|
45
|
+
const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_lambda_function.api")!;
|
|
46
46
|
const plan = graduationPlan(report, { env: "prod" });
|
|
47
47
|
expect(plan.warnings.join(" ")).toMatch(/deferred deploy-time input/i);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
const TAGS = { "chant:managed-by": "chant", "chant:stack": "assets", "chant:env": "prod" };
|
|
52
|
+
|
|
53
|
+
describe("stampOwnershipIntoSource", () => {
|
|
54
|
+
test("merges into an existing Tags prop, replacing stale chant keys", () => {
|
|
55
|
+
const src = [
|
|
56
|
+
'import { Bucket } from "@intentius/chant-lexicon-aws";',
|
|
57
|
+
"",
|
|
58
|
+
"export const assets = new Bucket({",
|
|
59
|
+
' BucketName: "myapp-assets-prod",',
|
|
60
|
+
' Tags: [{"Key":"Team","Value":"web"},{"Key":"chant:stack","Value":"old"}],',
|
|
61
|
+
"});",
|
|
62
|
+
"",
|
|
63
|
+
].join("\n");
|
|
64
|
+
|
|
65
|
+
const res = stampOwnershipIntoSource(src, TAGS)!;
|
|
66
|
+
expect(res.changed).toBe(true);
|
|
67
|
+
const tagsLine = res.content.split("\n").find((l) => l.includes("Tags:"))!;
|
|
68
|
+
const tags = JSON.parse(tagsLine.trim().replace(/^Tags: /, "").replace(/,$/, "")) as Array<{ Key: string; Value: string }>;
|
|
69
|
+
expect(tags).toEqual([
|
|
70
|
+
{ Key: "Team", Value: "web" },
|
|
71
|
+
{ Key: "chant:managed-by", Value: "chant" },
|
|
72
|
+
{ Key: "chant:stack", Value: "assets" },
|
|
73
|
+
{ Key: "chant:env", Value: "prod" },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("inserts a Tags prop when the constructor has none (unmapped comment untouched)", () => {
|
|
78
|
+
const src = [
|
|
79
|
+
"export const api = new LogGroup({",
|
|
80
|
+
' LogGroupName: "/myapp/api",',
|
|
81
|
+
"});",
|
|
82
|
+
"",
|
|
83
|
+
"/* Unmapped Terraform attributes (reconcile to native props before building):",
|
|
84
|
+
'{ "skip_destroy": false }',
|
|
85
|
+
"*/",
|
|
86
|
+
"",
|
|
87
|
+
].join("\n");
|
|
88
|
+
|
|
89
|
+
const res = stampOwnershipIntoSource(src, TAGS)!;
|
|
90
|
+
expect(res.changed).toBe(true);
|
|
91
|
+
const lines = res.content.split("\n");
|
|
92
|
+
expect(lines[2]).toBe(` Tags: ${JSON.stringify(Object.entries(TAGS).map(([Key, Value]) => ({ Key, Value })))},`);
|
|
93
|
+
expect(lines[3]).toBe("});");
|
|
94
|
+
expect(res.content).toContain("skip_destroy"); // the comment survives
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("handles an empty props object", () => {
|
|
98
|
+
const src = "export const igw = new InternetGateway({});\n";
|
|
99
|
+
const res = stampOwnershipIntoSource(src, TAGS)!;
|
|
100
|
+
expect(res.changed).toBe(true);
|
|
101
|
+
expect(res.content).toContain("new InternetGateway({\n Tags: [");
|
|
102
|
+
expect(res.content).toMatch(/\}\);\n$/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("is idempotent: a second stamp changes nothing", () => {
|
|
106
|
+
const src = 'export const assets = new Bucket({\n BucketName: "b",\n});\n';
|
|
107
|
+
const first = stampOwnershipIntoSource(src, TAGS)!;
|
|
108
|
+
const second = stampOwnershipIntoSource(first.content, TAGS)!;
|
|
109
|
+
expect(second.changed).toBe(false);
|
|
110
|
+
expect(second.content).toBe(first.content);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("returns null when there is no constructor to stamp", () => {
|
|
114
|
+
expect(stampOwnershipIntoSource("// nothing here\n", TAGS)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -76,3 +76,52 @@ export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}
|
|
|
76
76
|
|
|
77
77
|
return { target: report.target, marker, ownershipTags, steps, warnings };
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stamp the ownership tags into an emitted chant source file (`carve apply
|
|
82
|
+
* --write-source`). The emitted source is machine-generated (`adoptFromState`),
|
|
83
|
+
* so its shape is known: one prop per line, `Tags` as a single-line JSON array.
|
|
84
|
+
* Merges into an existing `Tags` prop (replacing stale chant keys) or inserts
|
|
85
|
+
* one into the constructor's props object. Returns null when the file has no
|
|
86
|
+
* recognizable constructor to stamp.
|
|
87
|
+
*/
|
|
88
|
+
export function stampOwnershipIntoSource(
|
|
89
|
+
content: string,
|
|
90
|
+
tags: Record<string, string>,
|
|
91
|
+
): { content: string; changed: boolean } | null {
|
|
92
|
+
const entries = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
|
|
93
|
+
const keys = new Set(Object.keys(tags));
|
|
94
|
+
const lines = content.split("\n");
|
|
95
|
+
|
|
96
|
+
// Merge into an existing Tags prop.
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const m = lines[i].match(/^(\s*)Tags: (\[.*\]),$/);
|
|
99
|
+
if (!m) continue;
|
|
100
|
+
let existing: Array<{ Key: string; Value: unknown }>;
|
|
101
|
+
try {
|
|
102
|
+
existing = JSON.parse(m[2]) as Array<{ Key: string; Value: unknown }>;
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const merged = [...existing.filter((t) => !keys.has(t.Key)), ...entries];
|
|
107
|
+
const rendered = `${m[1]}Tags: ${JSON.stringify(merged)},`;
|
|
108
|
+
if (rendered === lines[i]) return { content, changed: false };
|
|
109
|
+
lines[i] = rendered;
|
|
110
|
+
return { content: lines.join("\n"), changed: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// No Tags prop yet: insert one into the constructor's props object.
|
|
114
|
+
const tagsLine = ` Tags: ${JSON.stringify(entries)},`;
|
|
115
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
116
|
+
if (/^\}\);\s*$/.test(lines[i])) {
|
|
117
|
+
lines.splice(i, 0, tagsLine);
|
|
118
|
+
return { content: lines.join("\n"), changed: true };
|
|
119
|
+
}
|
|
120
|
+
const empty = lines[i].match(/^(export const .+ = new \w+\()\{\}(\);)\s*$/);
|
|
121
|
+
if (empty) {
|
|
122
|
+
lines[i] = `${empty[1]}{\n${tagsLine}\n}${empty[2]}`;
|
|
123
|
+
return { content: lines.join("\n"), changed: true };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { inboundEdges, outboundEdges, refFromAccessor } from "./graph";
|
|
3
|
+
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
4
|
import type { Hcl2JsonTree } from "./types";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -37,7 +38,7 @@ const workedExample: Hcl2JsonTree = {
|
|
|
37
38
|
|
|
38
39
|
describe("buildGraph", () => {
|
|
39
40
|
test("extracts resource nodes with addresses", () => {
|
|
40
|
-
const g =
|
|
41
|
+
const g = buildFixtureGraph(workedExample);
|
|
41
42
|
expect(g.nodes.map((n) => n.address)).toEqual([
|
|
42
43
|
"aws_lambda_function.api",
|
|
43
44
|
"aws_s3_bucket.assets",
|
|
@@ -47,16 +48,16 @@ describe("buildGraph", () => {
|
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
test("records directed edges with referenced attributes, deduped per target", () => {
|
|
50
|
-
const g =
|
|
51
|
+
const g = buildFixtureGraph(workedExample);
|
|
51
52
|
// versioning → bucket (.id), lambda → bucket (.bucket, .arn)
|
|
52
53
|
expect(g.edges).toEqual([
|
|
53
|
-
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"] },
|
|
54
|
-
{ from: "aws_s3_bucket_versioning.assets", to: "aws_s3_bucket.assets", attrs: ["id"] },
|
|
54
|
+
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"], via: ["environment"] },
|
|
55
|
+
{ from: "aws_s3_bucket_versioning.assets", to: "aws_s3_bucket.assets", attrs: ["id"], via: ["bucket"] },
|
|
55
56
|
]);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
test("inbound/outbound classification", () => {
|
|
59
|
-
const g =
|
|
60
|
+
const g = buildFixtureGraph(workedExample);
|
|
60
61
|
// Two survivors depend on the bucket; the bucket depends on nothing.
|
|
61
62
|
expect(inboundEdges(g, "aws_s3_bucket.assets").map((e) => e.from)).toEqual([
|
|
62
63
|
"aws_lambda_function.api",
|
|
@@ -78,7 +79,7 @@ describe("buildGraph", () => {
|
|
|
78
79
|
aws_route53_record: { cdn: [{ name: "${module.cdn.domain}" }] },
|
|
79
80
|
},
|
|
80
81
|
};
|
|
81
|
-
const g =
|
|
82
|
+
const g = buildFixtureGraph(tree);
|
|
82
83
|
const cdn = g.nodes.find((n) => n.address === "module.cdn");
|
|
83
84
|
expect(cdn).toMatchObject({ kind: "module", name: "cdn" });
|
|
84
85
|
// module → bucket, and record → module
|
|
@@ -86,11 +87,13 @@ describe("buildGraph", () => {
|
|
|
86
87
|
from: "module.cdn",
|
|
87
88
|
to: "aws_s3_bucket.assets",
|
|
88
89
|
attrs: ["arn"],
|
|
90
|
+
via: ["bucket_arn"],
|
|
89
91
|
});
|
|
90
92
|
expect(g.edges).toContainEqual({
|
|
91
93
|
from: "aws_route53_record.cdn",
|
|
92
94
|
to: "module.cdn",
|
|
93
95
|
attrs: ["domain"],
|
|
96
|
+
via: ["name"],
|
|
94
97
|
});
|
|
95
98
|
});
|
|
96
99
|
|
|
@@ -104,7 +107,7 @@ describe("buildGraph", () => {
|
|
|
104
107
|
},
|
|
105
108
|
},
|
|
106
109
|
};
|
|
107
|
-
const g =
|
|
110
|
+
const g = buildFixtureGraph(tree);
|
|
108
111
|
const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
|
|
109
112
|
expect(byAddr["aws_instance.web"]).toMatchObject({ hasDynamic: true, instances: 1 });
|
|
110
113
|
expect(byAddr["aws_instance.worker"]).toMatchObject({ hasDynamic: true, instances: 1 });
|
|
@@ -118,20 +121,80 @@ describe("buildGraph", () => {
|
|
|
118
121
|
aws_instance: { web: [{ ami: "${data.aws_ami.ubuntu.id}" }] },
|
|
119
122
|
},
|
|
120
123
|
};
|
|
121
|
-
const g =
|
|
124
|
+
const g = buildFixtureGraph(tree);
|
|
122
125
|
expect(g.nodes.map((n) => n.address)).toEqual(["aws_instance.web"]); // data is not a node
|
|
123
126
|
expect(g.edges).toEqual([]); // ref to data → no resource edge
|
|
124
127
|
expect(g.nodes[0].hasDynamic).toBe(true);
|
|
125
128
|
});
|
|
126
129
|
|
|
130
|
+
test("an expression whose AST found no references contributes no edge", () => {
|
|
131
|
+
// `${var.m["aws_s3_bucket.assets.arn"]}` — the AST reports only `var.m`;
|
|
132
|
+
// the quoted address inside the brackets is a map key, not a reference.
|
|
133
|
+
const tree: Hcl2JsonTree = {
|
|
134
|
+
resource: {
|
|
135
|
+
aws_s3_bucket: { assets: [{ bucket: "x" }] },
|
|
136
|
+
aws_lambda_function: { api: [{ handler: '${var.m["aws_s3_bucket.assets.arn"]}' }] },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const g = buildFixtureGraph(tree);
|
|
140
|
+
expect(g.edges).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a dotted identity attr resolves through nested blocks (#998)", () => {
|
|
144
|
+
const tree: Hcl2JsonTree = {
|
|
145
|
+
resource: {
|
|
146
|
+
kubernetes_manifest: {
|
|
147
|
+
app_config: [{ manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "app-config", namespace: "web" } } }],
|
|
148
|
+
templated: [{ manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "${var.name}" } } }],
|
|
149
|
+
},
|
|
150
|
+
aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const g = buildFixtureGraph(tree);
|
|
154
|
+
const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
|
|
155
|
+
expect(byAddr["kubernetes_manifest.app_config"].identity).toBe("app-config");
|
|
156
|
+
expect(byAddr["kubernetes_manifest.templated"].identity).toBeUndefined(); // interpolated
|
|
157
|
+
expect(byAddr["aws_s3_bucket.assets"].identity).toBe("myapp-assets-prod"); // flat attr unaffected
|
|
158
|
+
});
|
|
159
|
+
|
|
127
160
|
test("var / local references are not edges", () => {
|
|
128
161
|
const tree: Hcl2JsonTree = {
|
|
129
162
|
resource: {
|
|
130
163
|
aws_s3_bucket: { assets: [{ bucket: "${var.name}", tags: { env: "${local.env}" } }] },
|
|
131
164
|
},
|
|
132
165
|
};
|
|
133
|
-
const g =
|
|
166
|
+
const g = buildFixtureGraph(tree);
|
|
134
167
|
expect(g.edges).toEqual([]);
|
|
135
168
|
expect(g.nodes[0].hasDynamic).toBe(false); // var/local alone are not the dynamic markers
|
|
136
169
|
});
|
|
137
170
|
});
|
|
171
|
+
|
|
172
|
+
describe("refFromAccessor", () => {
|
|
173
|
+
test("classifies resource, module, and data accessors", () => {
|
|
174
|
+
expect(refFromAccessor("aws_s3_bucket.assets.bucket")).toEqual({
|
|
175
|
+
address: "aws_s3_bucket.assets",
|
|
176
|
+
attr: "bucket",
|
|
177
|
+
});
|
|
178
|
+
expect(refFromAccessor("aws_s3_bucket.assets")).toEqual({ address: "aws_s3_bucket.assets", attr: undefined });
|
|
179
|
+
expect(refFromAccessor("module.cdn.domain")).toEqual({ address: "module.cdn", attr: "domain" });
|
|
180
|
+
expect(refFromAccessor("data.aws_ami.ubuntu.id")).toEqual({ address: "data.aws_ami.ubuntu", attr: "id" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("non-resource heads are not references", () => {
|
|
184
|
+
for (const accessor of ["var.name", "local.env", "each.value", "count.index", "self.arn", "path.module", "terraform.workspace"]) {
|
|
185
|
+
expect(refFromAccessor(accessor)).toBeNull();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("quoted map keys and numeric indexes never become the attribute", () => {
|
|
190
|
+
// `var.m["aws_s3_bucket.assets.arn"]` → the AST renders `var.m."aws_s3_bucket.assets.arn"`
|
|
191
|
+
expect(refFromAccessor('var.m."aws_s3_bucket.assets.arn"')).toBeNull();
|
|
192
|
+
// `aws_instance.web[0].id` → `aws_instance.web.0.id`
|
|
193
|
+
expect(refFromAccessor("aws_instance.web.0.id")).toEqual({ address: "aws_instance.web", attr: "id" });
|
|
194
|
+
// `aws_s3_bucket.assets.tags["a.b"]` → the key must not shadow the attr
|
|
195
|
+
expect(refFromAccessor('aws_s3_bucket.assets.tags."a.b"')).toEqual({
|
|
196
|
+
address: "aws_s3_bucket.assets",
|
|
197
|
+
attr: "tags",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|