@intentius/chant 0.18.36 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/carve-apply.d.ts +30 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -0
- package/dist/cli/commands/carve-bridge.d.ts +32 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -0
- package/dist/cli/commands/carve-emit.d.ts +58 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -0
- package/dist/cli/commands/carve.d.ts +29 -0
- package/dist/cli/commands/carve.d.ts.map +1 -0
- package/dist/cli/handlers/carve-apply.d.ts +9 -0
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -0
- package/dist/cli/handlers/carve-bridge.d.ts +10 -0
- package/dist/cli/handlers/carve-bridge.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts +11 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -0
- package/dist/cli/handlers/carve.d.ts +12 -0
- package/dist/cli/handlers/carve.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +32 -0
- package/dist/terraform/adopt-state.d.ts.map +1 -0
- package/dist/terraform/aws-resources.d.ts +49 -0
- package/dist/terraform/aws-resources.d.ts.map +1 -0
- package/dist/terraform/bridge.d.ts +65 -0
- package/dist/terraform/bridge.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +66 -0
- package/dist/terraform/carve.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +43 -0
- package/dist/terraform/graduate.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts +24 -0
- package/dist/terraform/graph.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +33 -0
- package/dist/terraform/parse.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +50 -0
- package/dist/terraform/score.d.ts.map +1 -0
- package/dist/terraform/state.d.ts +42 -0
- package/dist/terraform/state.d.ts.map +1 -0
- package/dist/terraform/tier-map.d.ts +38 -0
- package/dist/terraform/tier-map.d.ts.map +1 -0
- package/dist/terraform/types.d.ts +66 -0
- package/dist/terraform/types.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/cli/commands/carve-apply.test.ts +82 -0
- package/src/cli/commands/carve-apply.ts +106 -0
- package/src/cli/commands/carve-bridge.test.ts +97 -0
- package/src/cli/commands/carve-bridge.ts +134 -0
- package/src/cli/commands/carve-emit-state.test.ts +114 -0
- package/src/cli/commands/carve-emit.test.ts +158 -0
- package/src/cli/commands/carve-emit.ts +181 -0
- package/src/cli/commands/carve.test.ts +104 -0
- package/src/cli/commands/carve.ts +123 -0
- package/src/cli/handlers/carve-apply.ts +34 -0
- package/src/cli/handlers/carve-bridge.ts +33 -0
- package/src/cli/handlers/carve-emit.ts +51 -0
- package/src/cli/handlers/carve.ts +44 -0
- package/src/cli/main.ts +48 -0
- package/src/cli/registry.ts +12 -0
- package/src/terraform/__fixtures__/advise.test.ts +51 -0
- package/src/terraform/__fixtures__/carve-arc.test.ts +77 -0
- package/src/terraform/__fixtures__/sample-estate/main.tf +58 -0
- package/src/terraform/adopt-state.test.ts +64 -0
- package/src/terraform/adopt-state.ts +79 -0
- package/src/terraform/aws-resources.test.ts +74 -0
- package/src/terraform/aws-resources.ts +154 -0
- package/src/terraform/bridge.test.ts +118 -0
- package/src/terraform/bridge.ts +164 -0
- package/src/terraform/carve.test.ts +97 -0
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/graduate.test.ts +49 -0
- package/src/terraform/graduate.ts +78 -0
- package/src/terraform/graph.test.ts +137 -0
- package/src/terraform/graph.ts +176 -0
- package/src/terraform/parse.test.ts +56 -0
- package/src/terraform/parse.ts +87 -0
- package/src/terraform/score.test.ts +140 -0
- package/src/terraform/score.ts +148 -0
- package/src/terraform/state.test.ts +76 -0
- package/src/terraform/state.ts +87 -0
- package/src/terraform/tier-map.ts +60 -0
- package/src/terraform/types.ts +69 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peelability scoring for the carve-out advisor (#214 T3).
|
|
3
|
+
*
|
|
4
|
+
* The model, verbatim from #197:
|
|
5
|
+
*
|
|
6
|
+
* score = 100
|
|
7
|
+
* - 12 * inbound # survivors that depend on this → each a TF data-source patch
|
|
8
|
+
* - 4 * outbound # this depends on survivors → a deferred deploy-time input
|
|
9
|
+
* - 15 * (tier - 1) # native-spec map: tier1=0, tier2=-15, tier3=-30
|
|
10
|
+
* - 10 * has_dynamic # count / for_each / data present
|
|
11
|
+
* - 3 * (instances - 1) # state-expanded instance count
|
|
12
|
+
* clamp 0..100 (unsupported provider/type → 0)
|
|
13
|
+
*
|
|
14
|
+
* Sub-resources that inline into a parent (see `FOLDS_INTO`) are folded into the
|
|
15
|
+
* parent's carve set: they are removed from the ranking and their edge to the
|
|
16
|
+
* parent is not counted as inbound — inlining them is free, not boundary work.
|
|
17
|
+
*/
|
|
18
|
+
import type { TfGraph } from "./types.js";
|
|
19
|
+
export type PeelabilityBand = "clean leaf" | "carvable w/ edits" | "leave in Terraform";
|
|
20
|
+
export interface PeelabilityBreakdown {
|
|
21
|
+
inbound: number;
|
|
22
|
+
outbound: number;
|
|
23
|
+
tier: 1 | 2 | 3 | null;
|
|
24
|
+
hasDynamic: boolean;
|
|
25
|
+
instances: number;
|
|
26
|
+
/** Signed penalty contributions, for report transparency. */
|
|
27
|
+
penalties: {
|
|
28
|
+
inbound: number;
|
|
29
|
+
outbound: number;
|
|
30
|
+
tier: number;
|
|
31
|
+
dynamic: number;
|
|
32
|
+
instances: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface Peelability {
|
|
36
|
+
address: string;
|
|
37
|
+
kind: "resource" | "module";
|
|
38
|
+
score: number;
|
|
39
|
+
band: PeelabilityBand;
|
|
40
|
+
/** Native spec type a carve would target; undefined for modules/unsupported. */
|
|
41
|
+
mapsTo?: string;
|
|
42
|
+
breakdown: PeelabilityBreakdown;
|
|
43
|
+
}
|
|
44
|
+
export declare function bandFor(score: number): PeelabilityBand;
|
|
45
|
+
/**
|
|
46
|
+
* Score every carvable node in the estate, ranked most-peelable first. Folded
|
|
47
|
+
* sub-resources are omitted (they carve with their parent, not on their own).
|
|
48
|
+
*/
|
|
49
|
+
export declare function scoreEstate(graph: TfGraph): Peelability[];
|
|
50
|
+
//# sourceMappingURL=score.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"score.d.ts","sourceRoot":"","sources":["../../src/terraform/score.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAU,MAAM,SAAS,CAAC;AAE/C,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,mBAAmB,GAAG,oBAAoB,CAAC;AAExF,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,EAAE;QACT,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,eAAe,CAAC;IACtB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,oBAAoB,CAAC;CACjC;AAID,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CAItD;AAiFD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,EAAE,CAMzD"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in `.tfstate` reader for the carve-out advisor (#214 T2).
|
|
3
|
+
*
|
|
4
|
+
* A `.tf`-only parse cannot know how many instances a `count`/`for_each` block
|
|
5
|
+
* expands to — it reports 1 and flags the node dynamic. State is where those
|
|
6
|
+
* are unrolled: each managed resource carries an `instances` array with one
|
|
7
|
+
* entry per expansion. Feeding state in makes the `-3*(instances-1)` penalty
|
|
8
|
+
* accurate, so a 200-instance fan-out ranks as the boundary work it really is.
|
|
9
|
+
*
|
|
10
|
+
* This is deliberately separate and opt-in (`--state <path>`): a pure-`.tf`
|
|
11
|
+
* parse is a plain file read, while reading state pulls in a state backend's
|
|
12
|
+
* output. JSON only — no HCL, no wasm.
|
|
13
|
+
*/
|
|
14
|
+
import type { TfGraph } from "./types.js";
|
|
15
|
+
/** A resource's resolved attributes, read from state for adoption. */
|
|
16
|
+
export interface StateResource {
|
|
17
|
+
type: string;
|
|
18
|
+
name: string;
|
|
19
|
+
/** The first instance's resolved attributes (id, arn, tags, ...). */
|
|
20
|
+
attributes: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read one root-module managed resource's resolved attributes from state, by
|
|
24
|
+
* Terraform address (`aws_s3_bucket.assets`). This is the true source of a
|
|
25
|
+
* Terraform-managed resource's live shape — unlike a CloudFormation export, it
|
|
26
|
+
* exists whether or not the resource was ever in a CFN stack. Returns null if
|
|
27
|
+
* the address is absent, is a data source, or is module-nested.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readStateResource(statePath: string, address: string): StateResource | null;
|
|
30
|
+
/**
|
|
31
|
+
* Map each root-module managed resource address to its state-expanded instance
|
|
32
|
+
* count. Resources nested in a `module` are skipped: the `.tf` graph models a
|
|
33
|
+
* module as a single node, so there is no per-resource node to attach the count
|
|
34
|
+
* to (a documented v1 limitation — module internals are not descended into).
|
|
35
|
+
*/
|
|
36
|
+
export declare function readStateInstanceCounts(statePath: string): Map<string, number>;
|
|
37
|
+
/**
|
|
38
|
+
* Overlay state instance counts onto a graph in place. Nodes absent from state
|
|
39
|
+
* keep their `.tf`-derived count of 1. Returns the same graph for chaining.
|
|
40
|
+
*/
|
|
41
|
+
export declare function applyStateCounts(graph: TfGraph, counts: Map<string, number>): TfGraph;
|
|
42
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/terraform/state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAevC,sEAAsE;AACtE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAU1F;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAW9E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAMrF"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terraform-type → native-spec tier map for the carve-out advisor (#214 T3).
|
|
3
|
+
*
|
|
4
|
+
* The AWS entries are derived from the single AWS carve-out table
|
|
5
|
+
* (`aws-resources.ts`), so the advisor ranks exactly the AWS types `carve emit`
|
|
6
|
+
* can produce — no advise↔emit cliff. Non-AWS entries (e.g. `kubernetes_manifest`)
|
|
7
|
+
* are listed here directly; emit for those is a separate target.
|
|
8
|
+
*
|
|
9
|
+
* tier 1 — a clean 1:1 native resource
|
|
10
|
+
* tier 2 — maps, but with reshaping
|
|
11
|
+
* tier 3 — a hard/partial map
|
|
12
|
+
* null — no known native mapping (unsupported provider/type) → score 0
|
|
13
|
+
*/
|
|
14
|
+
export interface TierInfo {
|
|
15
|
+
tier: 1 | 2 | 3;
|
|
16
|
+
/** The native spec type a carve would target, for the report. */
|
|
17
|
+
mapsTo: string;
|
|
18
|
+
}
|
|
19
|
+
/** TF resource type → native tier. Absent = unsupported (score 0). */
|
|
20
|
+
export declare const TIER_MAP: Record<string, TierInfo>;
|
|
21
|
+
/**
|
|
22
|
+
* TF "sub-resource" types that inline into a parent resource rather than
|
|
23
|
+
* standing alone (TF splits config the native spec keeps in one resource).
|
|
24
|
+
* A sub-resource that shares its parent's name is folded into the parent's
|
|
25
|
+
* carve set: it is not ranked on its own, and its edge to the parent does not
|
|
26
|
+
* count as inbound boundary work — inlining it is free.
|
|
27
|
+
*
|
|
28
|
+
* Maps sub-resource TF type → parent TF type.
|
|
29
|
+
*/
|
|
30
|
+
export declare const FOLDS_INTO: Record<string, string>;
|
|
31
|
+
/**
|
|
32
|
+
* The HCL attribute carrying a resource's physical name, per type — derived
|
|
33
|
+
* from the AWS carve-out table. Used for the graph's identity and the
|
|
34
|
+
* live-import hint. Absent → fall back to the TF logical name.
|
|
35
|
+
*/
|
|
36
|
+
export declare const IDENTITY_ATTR: Record<string, string>;
|
|
37
|
+
export declare function resolveTier(tfType: string): TierInfo | null;
|
|
38
|
+
//# sourceMappingURL=tier-map.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tier-map.d.ts","sourceRoot":"","sources":["../../src/terraform/tier-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAChB,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,sEAAsE;AACtE,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAK7C,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAO7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAEhD,CAAC;AAEF,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAE3D"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the read-only Terraform carve-out advisor (#214).
|
|
3
|
+
*
|
|
4
|
+
* The advisor never emits, patches, or mutates state. It parses a Terraform
|
|
5
|
+
* estate into a dependency graph and scores each resource/module by
|
|
6
|
+
* "peelability" — how cleanly it could later be carved into native chant
|
|
7
|
+
* source (the emit/boundary/apply phases stay in #197, gated on demand).
|
|
8
|
+
*
|
|
9
|
+
* The graph layer here is intentionally pure: it consumes the JSON shape
|
|
10
|
+
* `@cdktf/hcl2json` produces (see `parse.ts`), so the graph and scoring can
|
|
11
|
+
* be tested without loading the wasm parser.
|
|
12
|
+
*/
|
|
13
|
+
/** A node in the Terraform dependency graph — one resource or module block. */
|
|
14
|
+
export interface TfNode {
|
|
15
|
+
/** Terraform address, e.g. `aws_s3_bucket.assets` or `module.cdn`. */
|
|
16
|
+
address: string;
|
|
17
|
+
kind: "resource" | "module";
|
|
18
|
+
/** Resource type (`aws_s3_bucket`); undefined for modules. */
|
|
19
|
+
type?: string;
|
|
20
|
+
/** Local name (`assets`) or module name (`cdn`). */
|
|
21
|
+
name: string;
|
|
22
|
+
/**
|
|
23
|
+
* State-expanded instance count. 1 unless `count`/`for_each` is present and
|
|
24
|
+
* a `.tfstate` was read (see `state.ts`, #214 T2). Without state, a block
|
|
25
|
+
* with `count`/`for_each` reports 1 and sets `hasDynamic`.
|
|
26
|
+
*/
|
|
27
|
+
instances: number;
|
|
28
|
+
/** `count`, `for_each`, a `data` source, or an unresolved interpolation is present. */
|
|
29
|
+
hasDynamic: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The resource's physical name, when it is a plain literal in the HCL (e.g.
|
|
32
|
+
* `bucket = "myapp-assets-prod"`). Used to build the live-import selector on
|
|
33
|
+
* emit. Undefined when the identity attribute is absent or interpolated.
|
|
34
|
+
*/
|
|
35
|
+
identity?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A directed dependency edge: `from` references `to`.
|
|
39
|
+
*
|
|
40
|
+
* For a candidate carve target T:
|
|
41
|
+
* - inbound = edges where `to === T` (surviving resources depend on T → each is a data-source patch)
|
|
42
|
+
* - outbound = edges where `from === T` (T depends on survivors → a deferred deploy-time input)
|
|
43
|
+
*/
|
|
44
|
+
export interface TfEdge {
|
|
45
|
+
from: string;
|
|
46
|
+
to: string;
|
|
47
|
+
/** The attribute path(s) referenced, e.g. `["id", "arn"]`. */
|
|
48
|
+
attrs: string[];
|
|
49
|
+
}
|
|
50
|
+
export interface TfGraph {
|
|
51
|
+
nodes: TfNode[];
|
|
52
|
+
edges: TfEdge[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The subset of `@cdktf/hcl2json`'s `parse()` output the advisor reads. A
|
|
56
|
+
* merged tree across every `.tf` file in the estate. Values are left as the
|
|
57
|
+
* raw hcl2json encoding (interpolations survive as `"${...}"` strings), which
|
|
58
|
+
* is what edge extraction scans.
|
|
59
|
+
*/
|
|
60
|
+
export interface Hcl2JsonTree {
|
|
61
|
+
resource?: Record<string, Record<string, unknown[]>>;
|
|
62
|
+
module?: Record<string, unknown[]>;
|
|
63
|
+
data?: Record<string, Record<string, unknown[]>>;
|
|
64
|
+
[k: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/terraform/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,MAAM;IACrB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC5B,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,8DAA8D;IAC9D,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IACjD,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"zod": "^4.3.6"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
+
"@cdktf/hcl2json": "^0.21.0",
|
|
86
87
|
"ajv": "^8.20.0"
|
|
87
88
|
}
|
|
88
89
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveApply, formatCarveApply } from "./carve-apply";
|
|
6
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
|
|
8
|
+
let parserAvailable = false;
|
|
9
|
+
try {
|
|
10
|
+
await loadHcl2json();
|
|
11
|
+
parserAvailable = true;
|
|
12
|
+
} catch {
|
|
13
|
+
parserAvailable = false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ESTATE = `
|
|
17
|
+
resource "aws_s3_bucket" "assets" { bucket = "myapp-assets-prod" }
|
|
18
|
+
resource "aws_lambda_function" "api" {
|
|
19
|
+
environment { variables = { B = aws_s3_bucket.assets.bucket } }
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-apply-"));
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
27
|
+
return await fn(dir);
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("carveApply", () => {
|
|
34
|
+
test("requires --from and --select", async () => {
|
|
35
|
+
expect((await carveApply({})).error).toContain("--from");
|
|
36
|
+
expect((await carveApply({ from: "/x" })).error).toContain("--select");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("plans graduation with ownership marker; writes nothing by default", async () => {
|
|
40
|
+
if (!parserAvailable) return;
|
|
41
|
+
await withEstate(async (dir) => {
|
|
42
|
+
const res = await carveApply({ from: dir, select: "aws_s3_bucket.assets", env: "prod", stack: "assets" });
|
|
43
|
+
expect(res.ok).toBe(true);
|
|
44
|
+
expect(res.written).toBeUndefined();
|
|
45
|
+
expect(res.plan!.ownershipTags["chant:managed-by"]).toBe("chant");
|
|
46
|
+
expect(res.plan!.marker).toEqual({ stack: "assets", env: "prod" });
|
|
47
|
+
|
|
48
|
+
const text = formatCarveApply(res);
|
|
49
|
+
expect(text).toContain("observe → apply");
|
|
50
|
+
expect(text).toContain("no cloud call");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("--write emits the graduation doc", async () => {
|
|
55
|
+
if (!parserAvailable) return;
|
|
56
|
+
await withEstate(async (dir) => {
|
|
57
|
+
const out = join(dir, "carveout");
|
|
58
|
+
const res = await carveApply({
|
|
59
|
+
from: dir,
|
|
60
|
+
select: "aws_s3_bucket.assets",
|
|
61
|
+
env: "prod",
|
|
62
|
+
output: out,
|
|
63
|
+
write: true,
|
|
64
|
+
});
|
|
65
|
+
expect(res.ok).toBe(true);
|
|
66
|
+
expect(res.written).toBe(join(out, "aws_s3_bucket-assets-graduation.md"));
|
|
67
|
+
expect(existsSync(res.written!)).toBe(true);
|
|
68
|
+
const doc = readFileSync(res.written!, "utf-8");
|
|
69
|
+
expect(doc).toContain("# Apply graduation: aws_s3_bucket.assets");
|
|
70
|
+
expect(doc).toContain("chant:managed-by = chant");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("errors on an unknown target", async () => {
|
|
75
|
+
if (!parserAvailable) return;
|
|
76
|
+
await withEstate(async (dir) => {
|
|
77
|
+
const res = await carveApply({ from: dir, select: "aws_s3_bucket.nope", env: "prod" });
|
|
78
|
+
expect(res.ok).toBe(false);
|
|
79
|
+
expect(res.error).toContain("not found");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant carve apply` — apply graduation for the strangler-fig carve (#197).
|
|
3
|
+
*
|
|
4
|
+
* The dial-turn from observe to apply: resolve the ownership marker that makes
|
|
5
|
+
* the carved resource chant-owned and finalize the ordered apply runbook.
|
|
6
|
+
*
|
|
7
|
+
* BYOL-honest: it does not call the cloud. It computes the marker + graduation
|
|
8
|
+
* plan and (with `--write`) emits a graduation doc. The apply is whatever
|
|
9
|
+
* lifecycle you brought.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
|
+
import { boundaryReport } from "../../terraform/carve";
|
|
16
|
+
import { graduationPlan, type GraduationPlan } from "../../terraform/graduate";
|
|
17
|
+
|
|
18
|
+
export interface CarveApplyOptions {
|
|
19
|
+
from?: string;
|
|
20
|
+
select?: string;
|
|
21
|
+
env?: string;
|
|
22
|
+
stack?: string;
|
|
23
|
+
statePath?: string;
|
|
24
|
+
output?: string;
|
|
25
|
+
/** Write the graduation doc to the output dir. */
|
|
26
|
+
write?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CarveApplyResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
plan?: GraduationPlan;
|
|
33
|
+
written?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyResult> {
|
|
37
|
+
if (!opts.from) return { ok: false, error: "chant carve apply requires --from <terraform-dir>" };
|
|
38
|
+
if (!opts.select) return { ok: false, error: "chant carve apply requires --select <tf-address>" };
|
|
39
|
+
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
40
|
+
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let plan: GraduationPlan;
|
|
44
|
+
try {
|
|
45
|
+
const graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
|
|
46
|
+
const report = boundaryReport(graph, opts.select);
|
|
47
|
+
if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
|
|
48
|
+
plan = graduationPlan(report, { stack: opts.stack, env: opts.env });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
51
|
+
return { ok: false, error: `Failed to plan graduation: ${err instanceof Error ? err.message : String(err)}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let written: string | undefined;
|
|
55
|
+
if (opts.write) {
|
|
56
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
57
|
+
mkdirSync(outDir, { recursive: true });
|
|
58
|
+
const slug = opts.select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
59
|
+
written = join(outDir, `${slug}-graduation.md`);
|
|
60
|
+
writeFileSync(written, renderGraduationDoc(plan) + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { ok: true, plan, written };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderGraduationDoc(plan: GraduationPlan): string {
|
|
67
|
+
const L: string[] = [];
|
|
68
|
+
L.push(`# Apply graduation: ${plan.target}`);
|
|
69
|
+
L.push("");
|
|
70
|
+
L.push(`Ownership marker: stack=${plan.marker.stack}${plan.marker.env ? `, env=${plan.marker.env}` : ""}`);
|
|
71
|
+
L.push("");
|
|
72
|
+
L.push("## Ownership tags (stamped on apply so chant owns the resource)");
|
|
73
|
+
for (const [k, v] of Object.entries(plan.ownershipTags)) L.push(`- ${k} = ${v}`);
|
|
74
|
+
L.push("");
|
|
75
|
+
L.push("## Steps");
|
|
76
|
+
for (const s of plan.steps) L.push(s);
|
|
77
|
+
if (plan.warnings.length) {
|
|
78
|
+
L.push("");
|
|
79
|
+
L.push("## Before you apply");
|
|
80
|
+
for (const w of plan.warnings) L.push(`- ${w}`);
|
|
81
|
+
}
|
|
82
|
+
return L.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function formatCarveApply(result: CarveApplyResult): string {
|
|
86
|
+
if (!result.ok || !result.plan) return result.error ?? "carve apply failed";
|
|
87
|
+
const p = result.plan;
|
|
88
|
+
const L: string[] = [];
|
|
89
|
+
L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply).`);
|
|
90
|
+
L.push(` Ownership marker: stack=${p.marker.stack}${p.marker.env ? `, env=${p.marker.env}` : ""}`);
|
|
91
|
+
L.push(" Ownership tags (stamped on apply):");
|
|
92
|
+
for (const [k, v] of Object.entries(p.ownershipTags)) L.push(` ${k} = ${v}`);
|
|
93
|
+
L.push("");
|
|
94
|
+
for (const s of p.steps) L.push(` ${s}`);
|
|
95
|
+
if (p.warnings.length) {
|
|
96
|
+
L.push("");
|
|
97
|
+
for (const w of p.warnings) L.push(` ! ${w}`);
|
|
98
|
+
}
|
|
99
|
+
L.push("");
|
|
100
|
+
if (result.written) {
|
|
101
|
+
L.push(`Wrote graduation doc: ${result.written}`);
|
|
102
|
+
} else {
|
|
103
|
+
L.push("Plan only — no cloud call, nothing written. The apply is your lifecycle (BYOL). Add --write to save this as a doc.");
|
|
104
|
+
}
|
|
105
|
+
return L.join("\n");
|
|
106
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveBridge, formatCarveBridge } from "./carve-bridge";
|
|
6
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
|
|
8
|
+
let parserAvailable = false;
|
|
9
|
+
try {
|
|
10
|
+
await loadHcl2json();
|
|
11
|
+
parserAvailable = true;
|
|
12
|
+
} catch {
|
|
13
|
+
parserAvailable = false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BUCKET_TF = `resource "aws_s3_bucket" "assets" {
|
|
17
|
+
bucket = "myapp-assets-prod"
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
const API_TF = `resource "aws_lambda_function" "api" {
|
|
21
|
+
function_name = "myapp-api"
|
|
22
|
+
environment {
|
|
23
|
+
variables = {
|
|
24
|
+
ASSETS_BUCKET = aws_s3_bucket.assets.bucket
|
|
25
|
+
ASSETS_ARN = aws_s3_bucket.assets.arn
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-bridge-"));
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(join(dir, "bucket.tf"), BUCKET_TF);
|
|
35
|
+
writeFileSync(join(dir, "api.tf"), API_TF);
|
|
36
|
+
return await fn(dir);
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("carveBridge", () => {
|
|
43
|
+
test("requires --from and --select", async () => {
|
|
44
|
+
expect((await carveBridge({})).error).toContain("--from");
|
|
45
|
+
expect((await carveBridge({ from: "/x" })).error).toContain("--select");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("dry-run: writes runbook + data sources + proposed survivor, touches no .tf in place", async () => {
|
|
49
|
+
if (!parserAvailable) return;
|
|
50
|
+
await withEstate(async (dir) => {
|
|
51
|
+
const out = join(dir, "carveout");
|
|
52
|
+
const res = await carveBridge({ from: dir, select: "aws_s3_bucket.assets", output: out });
|
|
53
|
+
expect(res.ok).toBe(true);
|
|
54
|
+
expect(res.appliedInPlace).toBeFalsy();
|
|
55
|
+
|
|
56
|
+
// Runbook + data sources + proposed api.tf were written to the output dir.
|
|
57
|
+
expect(existsSync(join(out, "aws_s3_bucket-assets-runbook.md"))).toBe(true);
|
|
58
|
+
const ds = readFileSync(join(out, "aws_s3_bucket-assets-datasources.tf"), "utf-8");
|
|
59
|
+
expect(ds).toContain('data "aws_s3_bucket" "assets"');
|
|
60
|
+
const proposed = readFileSync(join(out, "api.tf"), "utf-8");
|
|
61
|
+
expect(proposed).toContain("data.aws_s3_bucket.assets.bucket");
|
|
62
|
+
|
|
63
|
+
// The ORIGINAL api.tf in the estate is untouched.
|
|
64
|
+
expect(readFileSync(join(dir, "api.tf"), "utf-8")).toBe(API_TF);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("--apply-rewrites edits the survivor .tf in place", async () => {
|
|
69
|
+
if (!parserAvailable) return;
|
|
70
|
+
await withEstate(async (dir) => {
|
|
71
|
+
const res = await carveBridge({
|
|
72
|
+
from: dir,
|
|
73
|
+
select: "aws_s3_bucket.assets",
|
|
74
|
+
output: join(dir, "carveout"),
|
|
75
|
+
applyRewrites: true,
|
|
76
|
+
});
|
|
77
|
+
expect(res.ok).toBe(true);
|
|
78
|
+
expect(res.appliedInPlace).toBe(true);
|
|
79
|
+
|
|
80
|
+
// api.tf rewritten in place; bucket.tf (the carved declaration) untouched.
|
|
81
|
+
const api = readFileSync(join(dir, "api.tf"), "utf-8");
|
|
82
|
+
expect(api).toContain("data.aws_s3_bucket.assets.bucket");
|
|
83
|
+
expect(readFileSync(join(dir, "bucket.tf"), "utf-8")).toBe(BUCKET_TF);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("formatCarveBridge summarizes data sources, rewires, and safety", async () => {
|
|
88
|
+
if (!parserAvailable) return;
|
|
89
|
+
await withEstate(async (dir) => {
|
|
90
|
+
const res = await carveBridge({ from: dir, select: "aws_s3_bucket.assets", output: join(dir, "carveout") });
|
|
91
|
+
const text = formatCarveBridge(res);
|
|
92
|
+
expect(text).toContain("data.aws_s3_bucket.assets");
|
|
93
|
+
expect(text).toContain("Nothing in your Terraform changed");
|
|
94
|
+
expect(text).toContain("runbook");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant carve bridge` — the boundary-bridging step of the strangler-fig carve
|
|
3
|
+
* (#197). Generates the edits to the surviving Terraform (data sources +
|
|
4
|
+
* rewired references), records the deferred deploy-time inputs, and writes a
|
|
5
|
+
* reversible handoff runbook.
|
|
6
|
+
*
|
|
7
|
+
* Safe by default: writes proposed files to an output directory for review.
|
|
8
|
+
* `--apply-rewrites` writes the rewritten survivor `.tf` back in place — an
|
|
9
|
+
* opt-in mutation of your Terraform, never the default.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, statSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
import { join, basename } from "path";
|
|
14
|
+
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
|
+
import { boundaryReport } from "../../terraform/carve";
|
|
16
|
+
import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
|
|
17
|
+
import { IDENTITY_ATTR } from "../../terraform/tier-map";
|
|
18
|
+
|
|
19
|
+
export interface CarveBridgeOptions {
|
|
20
|
+
from?: string;
|
|
21
|
+
select?: string;
|
|
22
|
+
statePath?: string;
|
|
23
|
+
/** Output directory for proposed files (default `<from>/carveout`). */
|
|
24
|
+
output?: string;
|
|
25
|
+
/** Write rewritten survivor `.tf` back in place instead of to the output dir. */
|
|
26
|
+
applyRewrites?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CarveBridgeResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
plan?: BridgePlan;
|
|
33
|
+
/** Absolute paths written. */
|
|
34
|
+
written?: string[];
|
|
35
|
+
/** True if survivor files were edited in place. */
|
|
36
|
+
appliedInPlace?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listTfFiles(dir: string): string[] {
|
|
40
|
+
return readdirSync(dir)
|
|
41
|
+
.filter((f) => f.endsWith(".tf"))
|
|
42
|
+
.map((f) => join(dir, f))
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridgeResult> {
|
|
47
|
+
if (!opts.from) return { ok: false, error: "chant carve bridge requires --from <terraform-dir>" };
|
|
48
|
+
if (!opts.select) return { ok: false, error: "chant carve bridge requires --select <tf-address>" };
|
|
49
|
+
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
50
|
+
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let plan: BridgePlan;
|
|
54
|
+
try {
|
|
55
|
+
const graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
|
|
56
|
+
const report = boundaryReport(graph, opts.select);
|
|
57
|
+
if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
|
|
58
|
+
|
|
59
|
+
// Physical identities for the carved resources, for the data sources.
|
|
60
|
+
const identities = new Map<string, CarvedIdentity>();
|
|
61
|
+
for (const node of graph.nodes) {
|
|
62
|
+
if (node.type && node.identity) {
|
|
63
|
+
identities.set(node.address, { attr: IDENTITY_ATTR[node.type], value: node.identity });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const files = listTfFiles(opts.from).map((path) => ({ path, content: readFileSync(path, "utf-8") }));
|
|
68
|
+
plan = generateBridge(report, files, identities);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
71
|
+
return { ok: false, error: `Failed to build the bridge: ${err instanceof Error ? err.message : String(err)}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const slug = opts.select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
75
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
76
|
+
mkdirSync(outDir, { recursive: true });
|
|
77
|
+
const written: string[] = [];
|
|
78
|
+
|
|
79
|
+
// Runbook.
|
|
80
|
+
const runbookPath = join(outDir, `${slug}-runbook.md`);
|
|
81
|
+
writeFileSync(runbookPath, plan.runbook + "\n");
|
|
82
|
+
written.push(runbookPath);
|
|
83
|
+
|
|
84
|
+
// Data sources for the survivors to read.
|
|
85
|
+
if (plan.dataSources.length) {
|
|
86
|
+
const dsPath = join(outDir, `${slug}-datasources.tf`);
|
|
87
|
+
writeFileSync(dsPath, plan.dataSources.map((d) => d.hcl).join("\n\n") + "\n");
|
|
88
|
+
written.push(dsPath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Rewritten survivors: in place (opt-in) or proposed into the output dir.
|
|
92
|
+
const changed = plan.rewrites.filter((r) => r.changed);
|
|
93
|
+
for (const r of changed) {
|
|
94
|
+
if (opts.applyRewrites) {
|
|
95
|
+
writeFileSync(r.path, r.rewritten);
|
|
96
|
+
written.push(r.path);
|
|
97
|
+
} else {
|
|
98
|
+
const proposed = join(outDir, basename(r.path));
|
|
99
|
+
writeFileSync(proposed, r.rewritten);
|
|
100
|
+
written.push(proposed);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ok: true, plan, written, appliedInPlace: opts.applyRewrites && changed.length > 0 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatCarveBridge(result: CarveBridgeResult): string {
|
|
108
|
+
if (!result.ok || !result.plan) return result.error ?? "carve bridge failed";
|
|
109
|
+
const p = result.plan;
|
|
110
|
+
const L: string[] = [];
|
|
111
|
+
L.push(`Boundary bridge for ${p.target}:`);
|
|
112
|
+
if (p.dataSources.length) {
|
|
113
|
+
L.push(` ${p.dataSources.length} data source(s) for survivors to read:`);
|
|
114
|
+
for (const d of p.dataSources) L.push(` data.${d.type}.${d.name} (was ${d.address})`);
|
|
115
|
+
} else {
|
|
116
|
+
L.push(" no inbound edges — no survivor patch needed.");
|
|
117
|
+
}
|
|
118
|
+
const changed = p.rewrites.filter((r) => r.changed);
|
|
119
|
+
if (changed.length) {
|
|
120
|
+
L.push(` ${changed.length} survivor file(s) rewired: ${changed.map((r) => basename(r.path)).join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
if (p.deferredInputs.length) {
|
|
123
|
+
L.push(` ${p.deferredInputs.length} deferred deploy-time input(s) (wired at apply):`);
|
|
124
|
+
for (const d of p.deferredInputs) L.push(` - ${d.note}`);
|
|
125
|
+
}
|
|
126
|
+
L.push("");
|
|
127
|
+
if (result.appliedInPlace) {
|
|
128
|
+
L.push("Rewritten survivor Terraform in place. Review with `git diff`, then `terraform plan`.");
|
|
129
|
+
} else {
|
|
130
|
+
L.push(`Wrote proposals to ${result.written?.[0] ? result.written[0].replace(/[^/]+$/, "") : "the output dir"} — review, then apply. Nothing in your Terraform changed.`);
|
|
131
|
+
}
|
|
132
|
+
L.push("See the runbook for the reversible, observe-first handoff steps.");
|
|
133
|
+
return L.join("\n");
|
|
134
|
+
}
|