@intentius/chant 0.41.19 → 0.42.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/cli/build-params-cli.d.ts +7 -4
- package/dist/cli/build-params-cli.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/import.d.ts +16 -0
- package/dist/cli/commands/import.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/handlers/misc.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/registry.d.ts +4 -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/governance.d.ts +37 -0
- package/dist/governance.d.ts.map +1 -0
- package/dist/lexicon.d.ts +3 -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/reconcile.d.ts +15 -0
- package/dist/reconcile.d.ts.map +1 -1
- 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/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/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/cli/build-params-cli.test.ts +47 -3
- package/src/cli/build-params-cli.ts +22 -5
- package/src/cli/commands/build.test.ts +44 -0
- package/src/cli/commands/build.ts +15 -2
- 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/import.ts +53 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/handlers/build.test.ts +3 -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 +9 -7
- package/src/cli/handlers/misc.ts +40 -1
- package/src/cli/handlers/run.test.ts +3 -1
- package/src/cli/main.ts +25 -5
- package/src/cli/mcp/server.test.ts +17 -0
- package/src/cli/mcp/tools/explain.ts +15 -3
- package/src/cli/registry.ts +4 -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/codegen/publish-order.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +7 -3
- package/src/components/config-defaults.test.ts +30 -0
- package/src/components/deploy-units.ts +3 -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/governance.test.ts +72 -0
- package/src/governance.ts +54 -0
- package/src/lexicon.ts +3 -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/reconcile.ts +32 -1
- 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/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 +56 -10
- package/src/terraform/graph.ts +112 -45
- 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/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
|
@@ -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,63 @@ 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
|
+
|
|
127
143
|
test("var / local references are not edges", () => {
|
|
128
144
|
const tree: Hcl2JsonTree = {
|
|
129
145
|
resource: {
|
|
130
146
|
aws_s3_bucket: { assets: [{ bucket: "${var.name}", tags: { env: "${local.env}" } }] },
|
|
131
147
|
},
|
|
132
148
|
};
|
|
133
|
-
const g =
|
|
149
|
+
const g = buildFixtureGraph(tree);
|
|
134
150
|
expect(g.edges).toEqual([]);
|
|
135
151
|
expect(g.nodes[0].hasDynamic).toBe(false); // var/local alone are not the dynamic markers
|
|
136
152
|
});
|
|
137
153
|
});
|
|
154
|
+
|
|
155
|
+
describe("refFromAccessor", () => {
|
|
156
|
+
test("classifies resource, module, and data accessors", () => {
|
|
157
|
+
expect(refFromAccessor("aws_s3_bucket.assets.bucket")).toEqual({
|
|
158
|
+
address: "aws_s3_bucket.assets",
|
|
159
|
+
attr: "bucket",
|
|
160
|
+
});
|
|
161
|
+
expect(refFromAccessor("aws_s3_bucket.assets")).toEqual({ address: "aws_s3_bucket.assets", attr: undefined });
|
|
162
|
+
expect(refFromAccessor("module.cdn.domain")).toEqual({ address: "module.cdn", attr: "domain" });
|
|
163
|
+
expect(refFromAccessor("data.aws_ami.ubuntu.id")).toEqual({ address: "data.aws_ami.ubuntu", attr: "id" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("non-resource heads are not references", () => {
|
|
167
|
+
for (const accessor of ["var.name", "local.env", "each.value", "count.index", "self.arn", "path.module", "terraform.workspace"]) {
|
|
168
|
+
expect(refFromAccessor(accessor)).toBeNull();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("quoted map keys and numeric indexes never become the attribute", () => {
|
|
173
|
+
// `var.m["aws_s3_bucket.assets.arn"]` → the AST renders `var.m."aws_s3_bucket.assets.arn"`
|
|
174
|
+
expect(refFromAccessor('var.m."aws_s3_bucket.assets.arn"')).toBeNull();
|
|
175
|
+
// `aws_instance.web[0].id` → `aws_instance.web.0.id`
|
|
176
|
+
expect(refFromAccessor("aws_instance.web.0.id")).toEqual({ address: "aws_instance.web", attr: "id" });
|
|
177
|
+
// `aws_s3_bucket.assets.tags["a.b"]` → the key must not shadow the attr
|
|
178
|
+
expect(refFromAccessor('aws_s3_bucket.assets.tags."a.b"')).toEqual({
|
|
179
|
+
address: "aws_s3_bucket.assets",
|
|
180
|
+
attr: "tags",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/terraform/graph.ts
CHANGED
|
@@ -1,65 +1,116 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure Terraform dependency-graph builder for the carve-out advisor (#214 T1).
|
|
3
3
|
*
|
|
4
|
-
* Input is the JSON tree `@cdktf/hcl2json` produces (`Hcl2JsonTree`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Input is the JSON tree `@cdktf/hcl2json` produces (`Hcl2JsonTree`) plus the
|
|
5
|
+
* traversal accessors its expression AST found per interpolated string
|
|
6
|
+
* (`ExpressionRefs`, resolved in `parse.ts` — #998). Tokenizing `${...}`
|
|
7
|
+
* expression bodies is the AST's job — a quoted address inside an expression
|
|
8
|
+
* (`var.m["aws_s3_bucket.assets.arn"]`) or an escaped `$${...}` literal is not
|
|
9
|
+
* a reference, which the regex scan this replaced could not tell. This module
|
|
10
|
+
* only classifies the accessors the AST produced: no wasm, no filesystem, so
|
|
11
|
+
* graph building stays unit-testable on hand-written fixtures.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import { IDENTITY_ATTR } from "./tier-map";
|
|
11
15
|
import type { Hcl2JsonTree, TfEdge, TfGraph, TfNode } from "./types";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Traversal accessors per interpolated string, as the hcl2json expression AST
|
|
19
|
+
* reports them (`getReferencesInExpression`): `"${aws_s3_bucket.assets.arn}"`
|
|
20
|
+
* maps to `["aws_s3_bucket.assets.arn"]`. Keys are the raw hcl2json string
|
|
21
|
+
* values; a missing key means the expression yielded no references.
|
|
16
22
|
*/
|
|
17
|
-
|
|
23
|
+
export type ExpressionRefs = ReadonlyMap<string, readonly string[]>;
|
|
18
24
|
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
/** `data.<type>.<name>` reference head. */
|
|
23
|
-
const DATA_REF = /\bdata\.([a-z][a-z0-9_]*)\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_]+))?/g;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* `<type>.<name>` resource reference head. Deliberately broad — it also
|
|
27
|
-
* matches `var.x`, `local.y`, `each.value`, function-ish tokens. Only heads
|
|
28
|
-
* whose address is a known node survive into an edge (see `buildGraph`).
|
|
29
|
-
*/
|
|
30
|
-
const RESOURCE_REF = /\b([a-z][a-z0-9_]*)\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_]+))?/g;
|
|
31
|
-
|
|
32
|
-
/** Non-resource reference prefixes that must never be read as a resource type. */
|
|
33
|
-
const NON_RESOURCE_HEADS = new Set(["var", "local", "module", "data", "each", "count", "self", "path", "terraform"]);
|
|
25
|
+
/** Non-resource reference heads that must never be read as a resource type. */
|
|
26
|
+
const NON_RESOURCE_HEADS = new Set(["var", "local", "each", "count", "self", "path", "terraform"]);
|
|
34
27
|
|
|
35
28
|
interface RawRef {
|
|
36
29
|
address: string;
|
|
37
30
|
attr?: string;
|
|
31
|
+
/** The referring block's top-level attribute the reference sits in (#998). */
|
|
32
|
+
via?: string;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Split a traversal accessor into segments. Dots inside a quoted map key
|
|
37
|
+
* (`var.m."a.b"` — how the AST renders `var.m["a.b"]`) do not split.
|
|
38
|
+
*/
|
|
39
|
+
function accessorSegments(accessor: string): string[] {
|
|
40
|
+
const segments: string[] = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < accessor.length) {
|
|
43
|
+
if (accessor[i] === '"') {
|
|
44
|
+
const close = accessor.indexOf('"', i + 1);
|
|
45
|
+
const end = close === -1 ? accessor.length : close + 1;
|
|
46
|
+
segments.push(accessor.slice(i, end));
|
|
47
|
+
i = end;
|
|
48
|
+
} else {
|
|
49
|
+
let j = i;
|
|
50
|
+
while (j < accessor.length && accessor[j] !== ".") j++;
|
|
51
|
+
segments.push(accessor.slice(i, j));
|
|
52
|
+
i = j;
|
|
53
|
+
}
|
|
54
|
+
if (accessor[i] === ".") i++;
|
|
46
55
|
}
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
return segments;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify one AST traversal accessor into a resource/module/data reference.
|
|
63
|
+
* `var.*`/`local.*`/`each.*`-headed accessors are not references; quoted map
|
|
64
|
+
* keys and numeric indexes never become the attribute.
|
|
65
|
+
*/
|
|
66
|
+
export function refFromAccessor(accessor: string): RawRef | null {
|
|
67
|
+
const parts = accessorSegments(accessor);
|
|
68
|
+
const isName = (s: string | undefined): s is string => s !== undefined && NAME.test(s);
|
|
69
|
+
const attrAfter = (idx: number): string | undefined => parts.slice(idx).find((p) => isName(p));
|
|
70
|
+
|
|
71
|
+
if (parts[0] === "module") {
|
|
72
|
+
return isName(parts[1]) ? { address: `module.${parts[1]}`, attr: attrAfter(2) } : null;
|
|
49
73
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
if (parts[0] === "data") {
|
|
75
|
+
return isName(parts[1]) && isName(parts[2])
|
|
76
|
+
? { address: `data.${parts[1]}.${parts[2]}`, attr: attrAfter(3) }
|
|
77
|
+
: null;
|
|
53
78
|
}
|
|
54
|
-
return
|
|
79
|
+
if (NON_RESOURCE_HEADS.has(parts[0]) || !isName(parts[0]) || !isName(parts[1])) return null;
|
|
80
|
+
return { address: `${parts[0]}.${parts[1]}`, attr: attrAfter(2) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every string value carrying an interpolation across the tree's resource and
|
|
85
|
+
* module blocks — exactly the expressions `parse.ts` must resolve through the
|
|
86
|
+
* AST before `buildGraph` can classify them.
|
|
87
|
+
*/
|
|
88
|
+
export function collectExpressions(tree: Hcl2JsonTree): string[] {
|
|
89
|
+
const exprs = new Set<string>();
|
|
90
|
+
const visit = (v: unknown): void => {
|
|
91
|
+
if (typeof v === "string") {
|
|
92
|
+
if (v.includes("${")) exprs.add(v);
|
|
93
|
+
} else if (Array.isArray(v)) {
|
|
94
|
+
v.forEach(visit);
|
|
95
|
+
} else if (v && typeof v === "object") {
|
|
96
|
+
for (const inner of Object.values(v as Record<string, unknown>)) visit(inner);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const named of Object.values(tree.resource ?? {})) for (const blocks of Object.values(named)) visit(blocks);
|
|
100
|
+
for (const blocks of Object.values(tree.module ?? {})) visit(blocks);
|
|
101
|
+
return [...exprs].sort();
|
|
55
102
|
}
|
|
56
103
|
|
|
57
|
-
/** Collect every
|
|
58
|
-
function refsInValue(value: unknown): RawRef[] {
|
|
104
|
+
/** Collect every reference reachable in a block's value tree, via the AST-resolved accessors. */
|
|
105
|
+
function refsInValue(value: unknown, exprRefs: ExpressionRefs): RawRef[] {
|
|
59
106
|
const refs: RawRef[] = [];
|
|
60
107
|
const visit = (v: unknown): void => {
|
|
61
108
|
if (typeof v === "string") {
|
|
62
|
-
|
|
109
|
+
if (!v.includes("${")) return;
|
|
110
|
+
for (const accessor of exprRefs.get(v) ?? []) {
|
|
111
|
+
const ref = refFromAccessor(accessor);
|
|
112
|
+
if (ref) refs.push(ref);
|
|
113
|
+
}
|
|
63
114
|
} else if (Array.isArray(v)) {
|
|
64
115
|
v.forEach(visit);
|
|
65
116
|
} else if (v && typeof v === "object") {
|
|
@@ -70,6 +121,20 @@ function refsInValue(value: unknown): RawRef[] {
|
|
|
70
121
|
return refs;
|
|
71
122
|
}
|
|
72
123
|
|
|
124
|
+
/**
|
|
125
|
+
* References in a whole block, each tagged with the top-level attribute it
|
|
126
|
+
* came in through — `via` is what a deferred outbound input gets named after
|
|
127
|
+
* when emit turns it into a build parameter (#998).
|
|
128
|
+
*/
|
|
129
|
+
function refsInBlock(block: unknown, exprRefs: ExpressionRefs): RawRef[] {
|
|
130
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) return refsInValue(block, exprRefs);
|
|
131
|
+
const refs: RawRef[] = [];
|
|
132
|
+
for (const [key, value] of Object.entries(block as Record<string, unknown>)) {
|
|
133
|
+
for (const ref of refsInValue(value, exprRefs)) refs.push({ ...ref, via: key });
|
|
134
|
+
}
|
|
135
|
+
return refs;
|
|
136
|
+
}
|
|
137
|
+
|
|
73
138
|
/** A block carries `count`/`for_each` → dynamic, single instance until state resolves it. */
|
|
74
139
|
function blockHasMeta(block: unknown, key: string): boolean {
|
|
75
140
|
return !!block && typeof block === "object" && key in (block as Record<string, unknown>);
|
|
@@ -93,7 +158,7 @@ function literalIdentity(block: unknown, type: string): string | undefined {
|
|
|
93
158
|
* resolves to a known resource/module node — references to `var`/`local`/data
|
|
94
159
|
* are dropped.
|
|
95
160
|
*/
|
|
96
|
-
export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
161
|
+
export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
|
|
97
162
|
const nodes: TfNode[] = [];
|
|
98
163
|
const dataAddresses = new Set<string>();
|
|
99
164
|
|
|
@@ -109,7 +174,7 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
109
174
|
const address = `${type}.${name}`;
|
|
110
175
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
111
176
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
112
|
-
const refs =
|
|
177
|
+
const refs = refsInBlock(block, exprRefs);
|
|
113
178
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
114
179
|
rawRefsByNode.set(address, refs);
|
|
115
180
|
nodes.push({
|
|
@@ -129,7 +194,7 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
129
194
|
const address = `module.${name}`;
|
|
130
195
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
131
196
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
132
|
-
const refs =
|
|
197
|
+
const refs = refsInBlock(block, exprRefs);
|
|
133
198
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
134
199
|
rawRefsByNode.set(address, refs);
|
|
135
200
|
nodes.push({
|
|
@@ -145,14 +210,16 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
145
210
|
const known = new Set(nodes.map((n) => n.address));
|
|
146
211
|
const edges: TfEdge[] = [];
|
|
147
212
|
for (const [from, refs] of rawRefsByNode) {
|
|
148
|
-
const byTarget = new Map<string, Set<string
|
|
213
|
+
const byTarget = new Map<string, { attrs: Set<string>; via: Set<string> }>();
|
|
149
214
|
for (const ref of refs) {
|
|
150
215
|
if (ref.address === from || !known.has(ref.address)) continue;
|
|
151
|
-
if (!byTarget.has(ref.address)) byTarget.set(ref.address, new Set());
|
|
152
|
-
|
|
216
|
+
if (!byTarget.has(ref.address)) byTarget.set(ref.address, { attrs: new Set(), via: new Set() });
|
|
217
|
+
const target = byTarget.get(ref.address)!;
|
|
218
|
+
if (ref.attr) target.attrs.add(ref.attr);
|
|
219
|
+
if (ref.via) target.via.add(ref.via);
|
|
153
220
|
}
|
|
154
|
-
for (const [to, attrs] of byTarget) {
|
|
155
|
-
edges.push({ from, to, attrs: [...attrs].sort() });
|
|
221
|
+
for (const [to, { attrs, via }] of byTarget) {
|
|
222
|
+
edges.push({ from, to, attrs: [...attrs].sort(), via: [...via].sort() });
|
|
156
223
|
}
|
|
157
224
|
}
|
|
158
225
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
carveManifestPath,
|
|
7
|
+
listCarveManifests,
|
|
8
|
+
readCarveManifest,
|
|
9
|
+
resolveCarveManifest,
|
|
10
|
+
updateCarveManifest,
|
|
11
|
+
writeCarveManifest,
|
|
12
|
+
type CarveManifest,
|
|
13
|
+
} from "./manifest";
|
|
14
|
+
import type { CarveReport } from "./carve";
|
|
15
|
+
|
|
16
|
+
function report(target: string): CarveReport {
|
|
17
|
+
return {
|
|
18
|
+
target,
|
|
19
|
+
carveSet: [{ address: target, type: target.split(".")[0] }],
|
|
20
|
+
peelability: 90,
|
|
21
|
+
inbound: [],
|
|
22
|
+
outbound: [],
|
|
23
|
+
reversible: true,
|
|
24
|
+
diagnostics: [],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function manifest(target: string): CarveManifest {
|
|
29
|
+
return { version: 1, target, from: "/estate", boundary: report(target) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withDir<T>(fn: (dir: string) => T): T {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-manifest-"));
|
|
34
|
+
try {
|
|
35
|
+
return fn(dir);
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("carve manifest", () => {
|
|
42
|
+
test("round-trips through the output dir, named by target slug", () => {
|
|
43
|
+
withDir((dir) => {
|
|
44
|
+
const path = writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
45
|
+
expect(path).toBe(join(dir, "aws_s3_bucket-assets.carve.json"));
|
|
46
|
+
expect(path).toBe(carveManifestPath(dir, "aws_s3_bucket.assets"));
|
|
47
|
+
const read = readCarveManifest(path)!;
|
|
48
|
+
expect(read.target).toBe("aws_s3_bucket.assets");
|
|
49
|
+
expect(read.boundary.peelability).toBe(90);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("rejects missing, malformed, and wrong-version files", () => {
|
|
54
|
+
withDir((dir) => {
|
|
55
|
+
expect(readCarveManifest(join(dir, "nope.carve.json"))).toBeNull();
|
|
56
|
+
writeFileSync(join(dir, "bad.carve.json"), "not json");
|
|
57
|
+
expect(readCarveManifest(join(dir, "bad.carve.json"))).toBeNull();
|
|
58
|
+
writeFileSync(join(dir, "v9.carve.json"), JSON.stringify({ version: 9, target: "x" }));
|
|
59
|
+
expect(readCarveManifest(join(dir, "v9.carve.json"))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("resolve: no select + exactly one manifest composes; none or several is a clear error", () => {
|
|
64
|
+
withDir((dir) => {
|
|
65
|
+
expect(resolveCarveManifest(dir).error).toContain("--select");
|
|
66
|
+
|
|
67
|
+
writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
68
|
+
const one = resolveCarveManifest(dir);
|
|
69
|
+
expect(one.manifest!.target).toBe("aws_s3_bucket.assets");
|
|
70
|
+
expect(one.error).toBeUndefined();
|
|
71
|
+
|
|
72
|
+
writeCarveManifest(dir, manifest("aws_sqs_queue.jobs"));
|
|
73
|
+
const many = resolveCarveManifest(dir);
|
|
74
|
+
expect(many.error).toContain("aws_s3_bucket.assets");
|
|
75
|
+
expect(many.error).toContain("aws_sqs_queue.jobs");
|
|
76
|
+
expect(listCarveManifests(dir)).toHaveLength(2);
|
|
77
|
+
|
|
78
|
+
// An explicit select still resolves its own manifest among several.
|
|
79
|
+
const picked = resolveCarveManifest(dir, "aws_sqs_queue.jobs");
|
|
80
|
+
expect(picked.manifest!.target).toBe("aws_sqs_queue.jobs");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("resolve with a select whose manifest is absent runs standalone (no error)", () => {
|
|
85
|
+
withDir((dir) => {
|
|
86
|
+
const res = resolveCarveManifest(dir, "aws_s3_bucket.assets");
|
|
87
|
+
expect(res.manifest).toBeUndefined();
|
|
88
|
+
expect(res.error).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("update patches an existing manifest and no-ops on a missing one", () => {
|
|
93
|
+
withDir((dir) => {
|
|
94
|
+
expect(updateCarveManifest(dir, "aws_s3_bucket.assets", { bridge: { written: [], appliedInPlace: false, at: "t" } })).toBeUndefined();
|
|
95
|
+
|
|
96
|
+
writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
97
|
+
const path = updateCarveManifest(dir, "aws_s3_bucket.assets", {
|
|
98
|
+
apply: { marker: { stack: "assets", env: "prod" }, ownershipTags: { "chant:managed-by": "chant" }, at: "t" },
|
|
99
|
+
})!;
|
|
100
|
+
const read = readCarveManifest(path)!;
|
|
101
|
+
expect(read.apply!.marker).toEqual({ stack: "assets", env: "prod" });
|
|
102
|
+
expect(read.boundary.target).toBe("aws_s3_bucket.assets"); // untouched sections survive
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|