@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
|
-
/**
|
|
26
|
-
|
|
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;
|
|
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
|
@@ -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: [] })
|
|
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
|
-
|
|
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(
|
|
63
|
+
writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
return
|
|
66
|
+
return result;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
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 {
|
|
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 ?? [])]);
|