@intentius/chant 0.41.20 → 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/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/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/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 +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/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/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 +9 -7
- 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/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/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 +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/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
package/src/terraform/bridge.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* is reversible via `terraform import`.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type
|
|
22
|
+
import { deferredParamName, type CarveReport } from "./carve";
|
|
23
|
+
import { exciseResourceBlocks, type ExciseTarget } from "./excise";
|
|
23
24
|
|
|
24
25
|
export interface CarvedIdentity {
|
|
25
26
|
/** The HCL identity attribute, e.g. `bucket` or `name`. */
|
|
@@ -41,12 +42,16 @@ export interface FileRewrite {
|
|
|
41
42
|
original: string;
|
|
42
43
|
rewritten: string;
|
|
43
44
|
changed: boolean;
|
|
45
|
+
/** Carved addresses whose own `resource` block was removed from this file (#998). */
|
|
46
|
+
excised: string[];
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export interface DeferredInput {
|
|
47
50
|
survivor: string;
|
|
48
51
|
carved: string;
|
|
49
52
|
attrs: string[];
|
|
53
|
+
/** Build-parameter name(s) the input becomes in the emitted project (#998). */
|
|
54
|
+
params: string[];
|
|
50
55
|
note: string;
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -55,6 +60,8 @@ export interface BridgePlan {
|
|
|
55
60
|
dataSources: DataSourceBlock[];
|
|
56
61
|
rewrites: FileRewrite[];
|
|
57
62
|
deferredInputs: DeferredInput[];
|
|
63
|
+
/** Every carved address whose own block the rewrites remove, across files. */
|
|
64
|
+
excised: string[];
|
|
58
65
|
runbook: string;
|
|
59
66
|
}
|
|
60
67
|
|
|
@@ -99,33 +106,56 @@ export function generateBridge(
|
|
|
99
106
|
hcl: dataSourceHcl(type, name, identities.get(address) ?? {}),
|
|
100
107
|
}));
|
|
101
108
|
|
|
102
|
-
//
|
|
109
|
+
// Excise the carve set's own blocks (#998): after `terraform state rm`, a
|
|
110
|
+
// block left behind would re-create the resource on the next apply. Then
|
|
111
|
+
// rewrite every remaining survivor reference into a data ref.
|
|
112
|
+
const exciseTargets: ExciseTarget[] = report.carveSet
|
|
113
|
+
.filter((m) => !m.address.startsWith("module."))
|
|
114
|
+
.map((m) => {
|
|
115
|
+
const [type, ...rest] = m.address.split(".");
|
|
116
|
+
return { address: m.address, type, name: rest.join(".") };
|
|
117
|
+
});
|
|
103
118
|
const rewrites: FileRewrite[] = files.map(({ path, content }) => {
|
|
104
|
-
|
|
119
|
+
const excision = exciseResourceBlocks(content, exciseTargets);
|
|
120
|
+
let rewritten = excision.content;
|
|
105
121
|
for (const address of carvedWithInbound.keys()) {
|
|
106
122
|
rewritten = rewritten.replace(referenceRegex(address), `data.${address}`);
|
|
107
123
|
}
|
|
108
|
-
return { path, original: content, rewritten, changed: rewritten !== content };
|
|
124
|
+
return { path, original: content, rewritten, changed: rewritten !== content, excised: excision.excised };
|
|
109
125
|
});
|
|
126
|
+
const excised = rewrites.flatMap((r) => r.excised);
|
|
110
127
|
|
|
111
|
-
const deferredInputs: DeferredInput[] = report.outbound.map((e) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
const deferredInputs: DeferredInput[] = report.outbound.map((e) => {
|
|
129
|
+
const params = (e.via ?? []).map(deferredParamName);
|
|
130
|
+
const wiring = params.length
|
|
131
|
+
? `emit declares it as build param ${params.map((p) => `\`${p}\``).join(", ")} — override with --param at build`
|
|
132
|
+
: "supply this as a deploy-time input to the carved component at apply";
|
|
133
|
+
return {
|
|
134
|
+
survivor: e.survivor,
|
|
135
|
+
carved: e.carved,
|
|
136
|
+
attrs: e.attrs,
|
|
137
|
+
params,
|
|
138
|
+
note: `${e.carved} reads ${e.survivor}.${e.attrs.join(", ")} — ${wiring}.`,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
117
141
|
|
|
118
142
|
return {
|
|
119
143
|
target: report.target,
|
|
120
144
|
dataSources,
|
|
121
145
|
rewrites,
|
|
122
146
|
deferredInputs,
|
|
123
|
-
|
|
147
|
+
excised,
|
|
148
|
+
runbook: buildRunbook(report, dataSources, deferredInputs, excised),
|
|
124
149
|
};
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
/** The reversible, observe-first handoff runbook (#197). */
|
|
128
|
-
function buildRunbook(
|
|
153
|
+
function buildRunbook(
|
|
154
|
+
report: CarveReport,
|
|
155
|
+
dataSources: DataSourceBlock[],
|
|
156
|
+
deferred: DeferredInput[],
|
|
157
|
+
excised: string[],
|
|
158
|
+
): string {
|
|
129
159
|
const carvedAddrs = report.carveSet.map((m) => m.address);
|
|
130
160
|
const L: string[] = [];
|
|
131
161
|
L.push(`# Carve-out: ${report.target} → chant [observe-first, reversible]`);
|
|
@@ -140,10 +170,14 @@ function buildRunbook(report: CarveReport, dataSources: DataSourceBlock[], defer
|
|
|
140
170
|
L.push("");
|
|
141
171
|
L.push("## 3. Confirm no destroy, then patch the survivors");
|
|
142
172
|
L.push(" terraform plan # expect 0 to destroy; refs to the carved resource now error");
|
|
173
|
+
if (excised.length) {
|
|
174
|
+
L.push(` # the generated bridge patch removes the carved block(s): ${excised.join(", ")}`);
|
|
175
|
+
L.push(" # (without this, the next apply would re-create what state rm released)");
|
|
176
|
+
}
|
|
143
177
|
if (dataSources.length) {
|
|
144
|
-
L.push(" #
|
|
178
|
+
L.push(" # it also adds these data sources and rewires references:");
|
|
145
179
|
for (const ds of dataSources) L.push(` # data.${ds.type}.${ds.name} (for ${ds.address})`);
|
|
146
|
-
} else {
|
|
180
|
+
} else if (!excised.length) {
|
|
147
181
|
L.push(" # no inbound edges — no survivor patch needed.");
|
|
148
182
|
}
|
|
149
183
|
L.push(" terraform plan # expect: in-place updates to the survivors only");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
3
|
import { resolveCarveSet, boundaryReport } from "./carve";
|
|
4
4
|
import type { Hcl2JsonTree } from "./types";
|
|
5
5
|
|
|
@@ -27,7 +27,7 @@ const workedExample: Hcl2JsonTree = {
|
|
|
27
27
|
|
|
28
28
|
describe("resolveCarveSet", () => {
|
|
29
29
|
test("selected node plus its folded sub-resources", () => {
|
|
30
|
-
const set = resolveCarveSet(
|
|
30
|
+
const set = resolveCarveSet(buildFixtureGraph(workedExample), "aws_s3_bucket.assets");
|
|
31
31
|
expect(set.map((m) => m.address).sort()).toEqual([
|
|
32
32
|
"aws_s3_bucket.assets",
|
|
33
33
|
"aws_s3_bucket_versioning.assets",
|
|
@@ -37,13 +37,13 @@ describe("resolveCarveSet", () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test("empty for an unknown address", () => {
|
|
40
|
-
expect(resolveCarveSet(
|
|
40
|
+
expect(resolveCarveSet(buildFixtureGraph(workedExample), "aws_s3_bucket.nope")).toEqual([]);
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
describe("boundaryReport", () => {
|
|
45
45
|
test("classifies the Lambda inbound edge; folds the versioning internal edge away", () => {
|
|
46
|
-
const report = boundaryReport(
|
|
46
|
+
const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
47
47
|
expect(report.target).toBe("aws_s3_bucket.assets");
|
|
48
48
|
expect(report.peelability).toBe(88);
|
|
49
49
|
expect(report.reversible).toBe(true);
|
|
@@ -56,6 +56,7 @@ describe("boundaryReport", () => {
|
|
|
56
56
|
survivor: "aws_lambda_function.api",
|
|
57
57
|
carved: "aws_s3_bucket.assets",
|
|
58
58
|
attrs: ["arn", "bucket"],
|
|
59
|
+
via: ["environment"],
|
|
59
60
|
bridge: "tf-data-source",
|
|
60
61
|
required: "immediately",
|
|
61
62
|
},
|
|
@@ -71,7 +72,7 @@ describe("boundaryReport", () => {
|
|
|
71
72
|
},
|
|
72
73
|
};
|
|
73
74
|
// Carve the queue: it depends on the topic (a survivor) → outbound/deferred.
|
|
74
|
-
const report = boundaryReport(
|
|
75
|
+
const report = boundaryReport(buildFixtureGraph(tree), "aws_sqs_queue.dlq")!;
|
|
75
76
|
expect(report.inbound).toEqual([]);
|
|
76
77
|
expect(report.outbound).toEqual([
|
|
77
78
|
{
|
|
@@ -79,6 +80,7 @@ describe("boundaryReport", () => {
|
|
|
79
80
|
survivor: "aws_sns_topic.alerts",
|
|
80
81
|
carved: "aws_sqs_queue.dlq",
|
|
81
82
|
attrs: ["arn"],
|
|
83
|
+
via: ["redrive"],
|
|
82
84
|
bridge: "deferred-input",
|
|
83
85
|
required: "at-apply",
|
|
84
86
|
},
|
|
@@ -87,11 +89,11 @@ describe("boundaryReport", () => {
|
|
|
87
89
|
|
|
88
90
|
test("unsupported type is flagged in diagnostics", () => {
|
|
89
91
|
const tree: Hcl2JsonTree = { resource: { random_pet: { n: [{ length: 2 }] } } };
|
|
90
|
-
const report = boundaryReport(
|
|
92
|
+
const report = boundaryReport(buildFixtureGraph(tree), "random_pet.n")!;
|
|
91
93
|
expect(report.diagnostics.join(" ")).toMatch(/no known native mapping/i);
|
|
92
94
|
});
|
|
93
95
|
|
|
94
96
|
test("null for an unknown target", () => {
|
|
95
|
-
expect(boundaryReport(
|
|
97
|
+
expect(boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
|
|
96
98
|
});
|
|
97
99
|
});
|
package/src/terraform/carve.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { exciseResourceBlocks, type ExciseTarget } from "./excise";
|
|
3
|
+
|
|
4
|
+
const bucket: ExciseTarget = { address: "aws_s3_bucket.assets", type: "aws_s3_bucket", name: "assets" };
|
|
5
|
+
const versioning: ExciseTarget = {
|
|
6
|
+
address: "aws_s3_bucket_versioning.assets",
|
|
7
|
+
type: "aws_s3_bucket_versioning",
|
|
8
|
+
name: "assets",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("exciseResourceBlocks", () => {
|
|
12
|
+
test("removes the target block, leaves the rest byte-for-byte", () => {
|
|
13
|
+
const tf = `# the bucket
|
|
14
|
+
resource "aws_s3_bucket" "assets" {
|
|
15
|
+
bucket = "myapp-assets-prod"
|
|
16
|
+
tags = { Team = "web" }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resource "aws_lambda_function" "api" {
|
|
20
|
+
function_name = "myapp-api"
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
const r = exciseResourceBlocks(tf, [bucket]);
|
|
24
|
+
expect(r.excised).toEqual(["aws_s3_bucket.assets"]);
|
|
25
|
+
expect(r.content).toBe(`# the bucket
|
|
26
|
+
resource "aws_lambda_function" "api" {
|
|
27
|
+
function_name = "myapp-api"
|
|
28
|
+
}
|
|
29
|
+
`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("removes several targets (a folded sub-resource travels with its parent)", () => {
|
|
33
|
+
const tf = `resource "aws_s3_bucket" "assets" {
|
|
34
|
+
bucket = "b"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
resource "aws_s3_bucket_versioning" "assets" {
|
|
38
|
+
bucket = aws_s3_bucket.assets.id
|
|
39
|
+
versioning_configuration {
|
|
40
|
+
status = "Enabled"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
resource "aws_sns_topic" "alerts" {
|
|
45
|
+
name = "a"
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
const r = exciseResourceBlocks(tf, [bucket, versioning]);
|
|
49
|
+
expect(r.excised).toEqual(["aws_s3_bucket.assets", "aws_s3_bucket_versioning.assets"]);
|
|
50
|
+
expect(r.content).toBe(`resource "aws_sns_topic" "alerts" {\n name = "a"\n}\n`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a same-name block of a different type stays", () => {
|
|
54
|
+
const tf = `resource "aws_s3_bucket" "assets" { bucket = "b" }
|
|
55
|
+
resource "aws_s3_bucket_versioning" "assets" { bucket = "b" }
|
|
56
|
+
`;
|
|
57
|
+
const r = exciseResourceBlocks(tf, [bucket]);
|
|
58
|
+
expect(r.excised).toEqual(["aws_s3_bucket.assets"]);
|
|
59
|
+
expect(r.content).toContain('resource "aws_s3_bucket_versioning" "assets"');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("braces inside strings, interpolations, comments, and heredocs do not fool the scanner", () => {
|
|
63
|
+
const tf = `resource "aws_s3_bucket" "assets" {
|
|
64
|
+
bucket = "b"
|
|
65
|
+
# a } in a comment
|
|
66
|
+
policy = jsonencode({ Statement = [{ Effect = "Allow" }] })
|
|
67
|
+
note = "literal } and \${var.m["k}"]} too"
|
|
68
|
+
user_data = <<EOF
|
|
69
|
+
if true; then echo "}"; fi
|
|
70
|
+
EOF
|
|
71
|
+
}
|
|
72
|
+
resource "aws_sns_topic" "alerts" { name = "a" }
|
|
73
|
+
`;
|
|
74
|
+
const r = exciseResourceBlocks(tf, [bucket]);
|
|
75
|
+
expect(r.excised).toEqual(["aws_s3_bucket.assets"]);
|
|
76
|
+
expect(r.content).toBe(`resource "aws_sns_topic" "alerts" { name = "a" }\n`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("no target present → the file comes back unchanged", () => {
|
|
80
|
+
const tf = `resource "aws_sns_topic" "alerts" { name = "a" }\n`;
|
|
81
|
+
const r = exciseResourceBlocks(tf, [bucket]);
|
|
82
|
+
expect(r.excised).toEqual([]);
|
|
83
|
+
expect(r.content).toBe(tf);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("an unbalanced block is left alone rather than corrupted", () => {
|
|
87
|
+
const tf = `resource "aws_s3_bucket" "assets" {\n bucket = "b"\n`;
|
|
88
|
+
const r = exciseResourceBlocks(tf, [bucket]);
|
|
89
|
+
expect(r.excised).toEqual([]);
|
|
90
|
+
expect(r.content).toBe(tf);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -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
|
+
}
|