@intentius/chant 0.44.5 → 0.44.6

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.
@@ -7,6 +7,8 @@
7
7
  * emit/boundary/apply phases stay in #197, gated on demand.
8
8
  */
9
9
  import { type Peelability } from "../../terraform/score.js";
10
+ import { type BoundaryEdge } from "../../terraform/carve.js";
11
+ import type { TfGraph } from "../../terraform/types.js";
10
12
  export interface CarveAdviseOptions {
11
13
  /** Terraform estate directory (from `--from`). */
12
14
  from?: string;
@@ -20,10 +22,65 @@ export interface CarveAdviseResult {
20
22
  error?: string;
21
23
  from?: string;
22
24
  results?: Peelability[];
25
+ /**
26
+ * The parsed dependency graph the scores came from. Kept so the JSON report
27
+ * can carry the boundary edge lists (#1636) rather than only their counts.
28
+ * Not part of the JSON payload — `carveJson` derives from it.
29
+ */
30
+ graph?: TfGraph;
23
31
  }
24
32
  export declare function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdviseResult>;
25
- /** The `--json` / `--report` payload. */
26
- export declare function carveJson(result: CarveAdviseResult): unknown;
33
+ /**
34
+ * The schema version of the `--json` / `--report` payload (#1636).
35
+ *
36
+ * The report is a cross-tool contract — behold renders it as a graph — so it
37
+ * says which shape it is. The promise attached to this number:
38
+ *
39
+ * - **Additive within a version.** New top-level fields, new per-resource
40
+ * fields, and new entries in an existing list may appear in any release. A
41
+ * reader must ignore keys it does not know.
42
+ * - **A removal, a rename, or a changed meaning bumps it.** So does narrowing
43
+ * a field's type (an optional becoming required is additive; the reverse is
44
+ * not).
45
+ * - A reader that does not know the version it is handed should refuse the
46
+ * report rather than half-read it.
47
+ */
48
+ export declare const CARVE_REPORT_VERSION = 1;
49
+ /** One ranked resource in the JSON report: its score, plus the boundary its carve would cut. */
50
+ export interface CarveJsonResource extends Peelability {
51
+ /**
52
+ * Every dependency edge carving this resource would cut (#1636), in chant's
53
+ * own `BoundaryEdge` shape — the same classification the emit/bridge path
54
+ * runs on. `inbound` edges need a Terraform `data`-source patch the moment
55
+ * the carve lands; `outbound` edges become deploy-time inputs, deferred
56
+ * until apply.
57
+ *
58
+ * The lists are the *carve set's* boundary, so a folded sub-resource never
59
+ * appears as an endpoint: it carves with its parent, and the parent's edges
60
+ * stand in for it. Edges internal to the carve set are not boundary work and
61
+ * are not listed.
62
+ *
63
+ * Present (possibly with two empty lists) whenever the graph was available;
64
+ * absent means this chant did not compute it — "none" and "not reported" are
65
+ * different claims. `breakdown.inbound`/`outbound` keep the counts.
66
+ */
67
+ boundary?: {
68
+ inbound: BoundaryEdge[];
69
+ outbound: BoundaryEdge[];
70
+ };
71
+ }
72
+ /** The `chant carve advise --json` / `--report` payload. Versioned; see {@link CARVE_REPORT_VERSION}. */
73
+ export interface CarveJsonReport {
74
+ version: number;
75
+ from?: string;
76
+ advisory: string;
77
+ count: number;
78
+ /** Band name -> how many resources landed in it. */
79
+ bands: Record<string, number>;
80
+ resources: CarveJsonResource[];
81
+ }
82
+ /** Build the `--json` / `--report` payload. */
83
+ export declare function carveJson(result: CarveAdviseResult): CarveJsonReport;
27
84
  /** Human-readable banded, ranked summary. */
28
85
  export declare function formatCarveReport(result: CarveAdviseResult): string;
29
86
  //# sourceMappingURL=carve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"carve.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/carve.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAe,KAAK,WAAW,EAAwB,MAAM,uBAAuB,CAAC;AAE5F,MAAM,WAAW,kBAAkB;IACjC,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAID,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAwBtF;AAED,yCAAyC;AACzC,wBAAgB,SAAS,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAY5D;AAED,6CAA6C;AAC7C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAyBnE"}
1
+ {"version":3,"file":"carve.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/carve.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAe,KAAK,WAAW,EAAwB,MAAM,uBAAuB,CAAC;AAC5F,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAErD,MAAM,WAAW,kBAAkB;IACjC,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAID,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA2BtF;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,gGAAgG;AAChG,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE,YAAY,EAAE,CAAC;QAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC;CAClE;AAED,yGAAyG;AACzG,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,SAAS,EAAE,iBAAiB,EAAE,CAAC;CAChC;AAED,+CAA+C;AAC/C,wBAAgB,SAAS,CAAC,MAAM,EAAE,iBAAiB,GAAG,eAAe,CAapE;AAUD,6CAA6C;AAC7C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAyBnE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.44.5",
3
+ "version": "0.44.6",
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",
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
- import { carveAdvise, carveJson, formatCarveReport } from "./carve";
5
+ import { carveAdvise, carveJson, formatCarveReport, CARVE_REPORT_VERSION } from "./carve";
6
6
  import { loadHcl2json } from "../../terraform/parse";
7
7
 
8
8
  let parserAvailable = false;
@@ -78,9 +78,25 @@ describe("carveAdvise", () => {
78
78
 
79
79
  // report file is valid JSON with the advisory banner + band counts
80
80
  const payload = JSON.parse(readFileSync(reportFile, "utf-8"));
81
+ expect(payload.version).toBe(CARVE_REPORT_VERSION);
81
82
  expect(payload.advisory).toContain("read-only");
82
83
  expect(payload.bands["clean leaf"]).toBeGreaterThanOrEqual(1);
83
84
  expect(payload.count).toBe(r.results!.length);
85
+
86
+ // The written report carries the edge lists, not just the counts (#1636).
87
+ const written = payload.resources.find((x: { address: string }) => x.address === "aws_s3_bucket.assets");
88
+ expect(written.boundary.inbound).toEqual([
89
+ {
90
+ direction: "inbound",
91
+ survivor: "aws_lambda_function.api",
92
+ carved: "aws_s3_bucket.assets",
93
+ attrs: ["bucket"],
94
+ via: ["environment"],
95
+ bridge: "tf-data-source",
96
+ required: "immediately",
97
+ },
98
+ ]);
99
+ expect(written.boundary.outbound).toEqual([]);
84
100
  });
85
101
  });
86
102
 
@@ -96,9 +112,34 @@ describe("carveAdvise", () => {
96
112
  });
97
113
  });
98
114
 
99
- test("carveJson carries the read-only advisory banner", () => {
100
- const payload = carveJson({ ok: true, from: "x", results: [] }) as { advisory: string; count: number };
115
+ test("carveJson carries the read-only advisory banner and the schema version", () => {
116
+ const payload = carveJson({ ok: true, from: "x", results: [] });
101
117
  expect(payload.advisory).toContain("read-only");
102
118
  expect(payload.count).toBe(0);
119
+ expect(payload.version).toBe(1);
120
+ });
121
+
122
+ test("carveJson omits boundary entirely with no graph — 'none' and 'not reported' differ", () => {
123
+ const payload = carveJson({
124
+ ok: true,
125
+ from: "x",
126
+ results: [
127
+ {
128
+ address: "aws_vpc.main",
129
+ kind: "resource",
130
+ score: 100,
131
+ band: "clean leaf",
132
+ breakdown: {
133
+ inbound: 0,
134
+ outbound: 0,
135
+ tier: 1,
136
+ hasDynamic: false,
137
+ instances: 1,
138
+ penalties: { inbound: 0, outbound: 0, tier: 0, dynamic: 0, instances: 0 },
139
+ },
140
+ },
141
+ ],
142
+ });
143
+ expect(payload.resources[0]).not.toHaveProperty("boundary");
103
144
  });
104
145
  });
@@ -10,6 +10,8 @@
10
10
  import { existsSync, statSync, writeFileSync } from "fs";
11
11
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
12
12
  import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
13
+ import { boundaryReport, type BoundaryEdge } from "../../terraform/carve";
14
+ import type { TfGraph } from "../../terraform/types";
13
15
 
14
16
  export interface CarveAdviseOptions {
15
17
  /** Terraform estate directory (from `--from`). */
@@ -25,6 +27,12 @@ export interface CarveAdviseResult {
25
27
  error?: string;
26
28
  from?: string;
27
29
  results?: Peelability[];
30
+ /**
31
+ * The parsed dependency graph the scores came from. Kept so the JSON report
32
+ * can carry the boundary edge lists (#1636) rather than only their counts.
33
+ * Not part of the JSON payload — `carveJson` derives from it.
34
+ */
35
+ graph?: TfGraph;
28
36
  }
29
37
 
30
38
  const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
@@ -40,36 +48,97 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
40
48
  return { ok: false, error: `State file not found: ${opts.statePath}` };
41
49
  }
42
50
 
51
+ let graph: TfGraph;
43
52
  let results: Peelability[];
44
53
  try {
45
- results = scoreEstate(await parseTerraformDir(opts.from, { statePath: opts.statePath }));
54
+ graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
55
+ results = scoreEstate(graph);
46
56
  } catch (err) {
47
57
  if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
48
58
  return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
49
59
  }
50
60
 
61
+ const result: CarveAdviseResult = { ok: true, from: opts.from, results, graph };
51
62
  if (opts.reportFile) {
52
- writeFileSync(opts.reportFile, JSON.stringify(carveJson({ ok: true, from: opts.from, results }), null, 2));
63
+ writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
53
64
  }
54
65
 
55
- return { ok: true, from: opts.from, results };
66
+ return result;
56
67
  }
57
68
 
58
- /** The `--json` / `--report` payload. */
59
- export function carveJson(result: CarveAdviseResult): unknown {
69
+ /**
70
+ * The schema version of the `--json` / `--report` payload (#1636).
71
+ *
72
+ * The report is a cross-tool contract — behold renders it as a graph — so it
73
+ * says which shape it is. The promise attached to this number:
74
+ *
75
+ * - **Additive within a version.** New top-level fields, new per-resource
76
+ * fields, and new entries in an existing list may appear in any release. A
77
+ * reader must ignore keys it does not know.
78
+ * - **A removal, a rename, or a changed meaning bumps it.** So does narrowing
79
+ * a field's type (an optional becoming required is additive; the reverse is
80
+ * not).
81
+ * - A reader that does not know the version it is handed should refuse the
82
+ * report rather than half-read it.
83
+ */
84
+ export const CARVE_REPORT_VERSION = 1;
85
+
86
+ /** One ranked resource in the JSON report: its score, plus the boundary its carve would cut. */
87
+ export interface CarveJsonResource extends Peelability {
88
+ /**
89
+ * Every dependency edge carving this resource would cut (#1636), in chant's
90
+ * own `BoundaryEdge` shape — the same classification the emit/bridge path
91
+ * runs on. `inbound` edges need a Terraform `data`-source patch the moment
92
+ * the carve lands; `outbound` edges become deploy-time inputs, deferred
93
+ * until apply.
94
+ *
95
+ * The lists are the *carve set's* boundary, so a folded sub-resource never
96
+ * appears as an endpoint: it carves with its parent, and the parent's edges
97
+ * stand in for it. Edges internal to the carve set are not boundary work and
98
+ * are not listed.
99
+ *
100
+ * Present (possibly with two empty lists) whenever the graph was available;
101
+ * absent means this chant did not compute it — "none" and "not reported" are
102
+ * different claims. `breakdown.inbound`/`outbound` keep the counts.
103
+ */
104
+ boundary?: { inbound: BoundaryEdge[]; outbound: BoundaryEdge[] };
105
+ }
106
+
107
+ /** The `chant carve advise --json` / `--report` payload. Versioned; see {@link CARVE_REPORT_VERSION}. */
108
+ export interface CarveJsonReport {
109
+ version: number;
110
+ from?: string;
111
+ advisory: string;
112
+ count: number;
113
+ /** Band name -> how many resources landed in it. */
114
+ bands: Record<string, number>;
115
+ resources: CarveJsonResource[];
116
+ }
117
+
118
+ /** Build the `--json` / `--report` payload. */
119
+ export function carveJson(result: CarveAdviseResult): CarveJsonReport {
60
120
  const results = result.results ?? [];
61
121
  const counts = Object.fromEntries(
62
122
  BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
63
123
  );
64
124
  return {
125
+ version: CARVE_REPORT_VERSION,
65
126
  from: result.from,
66
127
  advisory: "read-only — emits nothing, patches nothing, touches no live resource",
67
128
  count: results.length,
68
129
  bands: counts,
69
- resources: results,
130
+ resources: results.map((r) => withBoundary(r, result.graph)),
70
131
  };
71
132
  }
72
133
 
134
+ /** A scored resource plus its carve boundary, when the graph is at hand (#1636). */
135
+ function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
136
+ if (!graph) return r;
137
+ const report = boundaryReport(graph, r.address);
138
+ if (!report) return r;
139
+ return { ...r, boundary: { inbound: report.inbound, outbound: report.outbound } };
140
+ }
141
+
73
142
  /** Human-readable banded, ranked summary. */
74
143
  export function formatCarveReport(result: CarveAdviseResult): string {
75
144
  const results = result.results ?? [];
@@ -1,6 +1,12 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { join } from "path";
3
- import { carveAdvise, formatCarveReport } from "../../cli/commands/carve";
3
+ import {
4
+ carveAdvise,
5
+ carveJson,
6
+ formatCarveReport,
7
+ CARVE_REPORT_VERSION,
8
+ type CarveJsonResource,
9
+ } from "../../cli/commands/carve";
4
10
  import { loadHcl2json } from "../parse";
5
11
 
6
12
  /**
@@ -49,3 +55,108 @@ describe("carve advise against the sample estate", () => {
49
55
  expect(text).not.toMatch(/state rm|apply|destroy/i);
50
56
  });
51
57
  });
58
+
59
+ /**
60
+ * The JSON payload as a cross-tool contract (#1636): a schema version, and the
61
+ * boundary edge LISTS beside the counts. behold's carve lens renders these —
62
+ * the counts alone can't be paired back into edges (this estate has 4 inbound
63
+ * and 4 outbound, which admits several matchings), so the lists are the thing
64
+ * that makes a predicted diff drawable.
65
+ */
66
+ describe("carve advise --json against the sample estate", () => {
67
+ test("is a versioned report whose resources carry their boundary edges", async () => {
68
+ if (!parserAvailable) return;
69
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
70
+
71
+ expect(report.version).toBe(CARVE_REPORT_VERSION);
72
+ expect(report.count).toBe(8);
73
+ expect(report.resources).toHaveLength(8);
74
+
75
+ const byAddr = Object.fromEntries(report.resources.map((x) => [x.address, x]));
76
+
77
+ // The VPC's three subnets: each one a data-source patch to surviving TF.
78
+ expect(byAddr["aws_vpc.main"].boundary!.inbound).toEqual([
79
+ { direction: "inbound", survivor: "aws_subnet.a", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
80
+ { direction: "inbound", survivor: "aws_subnet.b", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
81
+ { direction: "inbound", survivor: "aws_subnet.c", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
82
+ ]);
83
+ expect(byAddr["aws_vpc.main"].boundary!.outbound).toEqual([]);
84
+
85
+ // The same cut, seen from the other end: the subnet's carve defers a value.
86
+ expect(byAddr["aws_subnet.a"].boundary!.outbound).toEqual([
87
+ { direction: "outbound", survivor: "aws_vpc.main", carved: "aws_subnet.a", attrs: ["id"], via: ["vpc_id"], bridge: "deferred-input", required: "at-apply" },
88
+ ]);
89
+
90
+ // A clean leaf nothing touches reports two empty lists, not a missing key:
91
+ // "none" is a claim the advisor is willing to make.
92
+ expect(byAddr["aws_cloudwatch_log_group.api"].boundary).toEqual({ inbound: [], outbound: [] });
93
+
94
+ // The Lambda reads two of the bucket's attributes — one edge, both attrs.
95
+ expect(byAddr["aws_lambda_function.api"].boundary!.outbound).toEqual([
96
+ { direction: "outbound", survivor: "aws_s3_bucket.assets", carved: "aws_lambda_function.api", attrs: ["arn", "bucket"], via: ["environment"], bridge: "deferred-input", required: "at-apply" },
97
+ ]);
98
+ });
99
+
100
+ test("every edge endpoint is a real Terraform address in the estate", async () => {
101
+ if (!parserAvailable) return;
102
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
103
+ const ranked = new Set(report.resources.map((r) => r.address));
104
+
105
+ for (const edge of allEdges(report.resources)) {
106
+ // The carved side is always ranked — it is the resource reporting it.
107
+ expect(ranked.has(edge.carved)).toBe(true);
108
+ // The survivor side is a real address too. Every survivor in this estate
109
+ // is itself carvable, so it is ranked; in general a survivor may be an
110
+ // unranked address (a folded sub-resource's parent is never one, but an
111
+ // unsupported type is), which is why this checks the estate's own set.
112
+ expect(ranked.has(edge.survivor)).toBe(true);
113
+ expect(edge.survivor).not.toBe(edge.carved);
114
+ }
115
+ });
116
+
117
+ test("a folded sub-resource is never an edge endpoint — it carves with its parent", async () => {
118
+ if (!parserAvailable) return;
119
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
120
+ const endpoints = allEdges(report.resources).flatMap((e) => [e.survivor, e.carved]);
121
+
122
+ // `aws_s3_bucket_versioning.assets` references its bucket, but that edge is
123
+ // internal to the bucket's carve set: not boundary work, and not an endpoint.
124
+ expect(endpoints).not.toContain("aws_s3_bucket_versioning.assets");
125
+ expect(report.resources.map((r) => r.address)).not.toContain("aws_s3_bucket_versioning.assets");
126
+ });
127
+
128
+ test("the counts and the edge lists tell the same story", async () => {
129
+ if (!parserAvailable) return;
130
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
131
+
132
+ // `breakdown.inbound`/`outbound` are what the score was computed from and
133
+ // stay for backward compatibility. They must agree with the lists, or the
134
+ // arithmetic a reader prints beside the drawn edges is a lie.
135
+ for (const r of report.resources) {
136
+ expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound]);
137
+ expect([r.address, r.boundary!.outbound.length]).toEqual([r.address, r.breakdown.outbound]);
138
+ }
139
+
140
+ const inbound = report.resources.reduce((s, r) => s + r.breakdown.inbound, 0);
141
+ const outbound = report.resources.reduce((s, r) => s + r.breakdown.outbound, 0);
142
+ expect([inbound, outbound]).toEqual([4, 4]);
143
+
144
+ // Each cut is reported from both ends — inbound from the depended-on side,
145
+ // outbound from the depending side — so the two totals are the same set of
146
+ // edges seen twice. Keyed in dependency direction, that is 4 distinct cuts.
147
+ const cuts = new Set(
148
+ allEdges(report.resources).map((e) =>
149
+ e.direction === "inbound" ? `${e.survivor} -> ${e.carved}` : `${e.carved} -> ${e.survivor}`,
150
+ ),
151
+ );
152
+ expect([...cuts].sort()).toEqual([
153
+ "aws_lambda_function.api -> aws_s3_bucket.assets",
154
+ "aws_subnet.a -> aws_vpc.main",
155
+ "aws_subnet.b -> aws_vpc.main",
156
+ "aws_subnet.c -> aws_vpc.main",
157
+ ]);
158
+ });
159
+ });
160
+
161
+ const allEdges = (resources: CarveJsonResource[]) =>
162
+ resources.flatMap((r) => [...(r.boundary?.inbound ?? []), ...(r.boundary?.outbound ?? [])]);