@intentius/chant 0.41.20 → 0.44.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/build.d.ts +15 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts +9 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts +7 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +7 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/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/plugins.d.ts +8 -0
- package/dist/cli/plugins.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/deploy-units.d.ts.map +1 -1
- package/dist/components/verbs/sbom.d.ts +1 -1
- package/dist/components/verbs/sbom.d.ts.map +1 -1
- package/dist/components/verbs/vuln-gate.d.ts +15 -4
- package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +18 -6
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +13 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -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/aws-resources.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/tier-map.d.ts +4 -3
- package/dist/terraform/tier-map.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/audit/rules-doc.ts +1 -1
- package/src/build.test.ts +95 -0
- package/src/build.ts +46 -1
- package/src/cli/commands/build.ts +9 -1
- 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 +16 -9
- 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/plugins.ts +17 -0
- 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/deploy-units.test.ts +13 -0
- package/src/components/deploy-units.ts +5 -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 +50 -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/aws-resources.test.ts +52 -2
- package/src/terraform/aws-resources.ts +95 -4
- 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 +73 -10
- package/src/terraform/graph.ts +124 -47
- 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/tier-map.ts +60 -8
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
3
|
import { scoreEstate, bandFor } from "./score";
|
|
4
4
|
import type { Hcl2JsonTree } from "./types";
|
|
5
5
|
|
|
@@ -28,7 +28,7 @@ describe("scoreEstate — #197 worked examples", () => {
|
|
|
28
28
|
},
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
|
-
const results = scoreEstate(
|
|
31
|
+
const results = scoreEstate(buildFixtureGraph(tree));
|
|
32
32
|
const map = byAddress(results);
|
|
33
33
|
|
|
34
34
|
// The folded sub-resource is not ranked on its own.
|
|
@@ -51,7 +51,7 @@ describe("scoreEstate — #197 worked examples", () => {
|
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
53
|
};
|
|
54
|
-
const results = scoreEstate(
|
|
54
|
+
const results = scoreEstate(buildFixtureGraph(tree));
|
|
55
55
|
const cdn = byAddress(results)["module.cdn"];
|
|
56
56
|
expect(cdn.score).toBe(61); // 100 - 12*2 - 15*(2-1)
|
|
57
57
|
expect(cdn.band).toBe("carvable w/ edits");
|
|
@@ -70,7 +70,7 @@ describe("scoreEstate — #197 worked examples", () => {
|
|
|
70
70
|
aws_subnet: consumers,
|
|
71
71
|
},
|
|
72
72
|
};
|
|
73
|
-
const vpc = byAddress(scoreEstate(
|
|
73
|
+
const vpc = byAddress(scoreEstate(buildFixtureGraph(tree)))["aws_vpc.main"];
|
|
74
74
|
expect(vpc.score).toBe(0);
|
|
75
75
|
expect(vpc.band).toBe("leave in Terraform");
|
|
76
76
|
expect(vpc.breakdown.inbound).toBe(40);
|
|
@@ -82,7 +82,7 @@ describe("scoreEstate — penalties", () => {
|
|
|
82
82
|
const tree: Hcl2JsonTree = {
|
|
83
83
|
resource: { random_pet: { name: [{ length: 2 }] } },
|
|
84
84
|
};
|
|
85
|
-
const r = scoreEstate(
|
|
85
|
+
const r = scoreEstate(buildFixtureGraph(tree))[0];
|
|
86
86
|
expect(r.score).toBe(0);
|
|
87
87
|
expect(r.breakdown.tier).toBeNull();
|
|
88
88
|
expect(r.mapsTo).toBeUndefined();
|
|
@@ -97,7 +97,7 @@ describe("scoreEstate — penalties", () => {
|
|
|
97
97
|
},
|
|
98
98
|
},
|
|
99
99
|
};
|
|
100
|
-
const map = byAddress(scoreEstate(
|
|
100
|
+
const map = byAddress(scoreEstate(buildFixtureGraph(tree)));
|
|
101
101
|
expect(map["aws_sqs_queue.plain"].score).toBe(100); // tier1, no edges, no dynamic
|
|
102
102
|
expect(map["aws_sqs_queue.fanned"].score).toBe(90); // -10 dynamic
|
|
103
103
|
});
|
|
@@ -110,7 +110,7 @@ describe("scoreEstate — penalties", () => {
|
|
|
110
110
|
aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
|
|
111
111
|
},
|
|
112
112
|
};
|
|
113
|
-
const map = byAddress(scoreEstate(
|
|
113
|
+
const map = byAddress(scoreEstate(buildFixtureGraph(tree)));
|
|
114
114
|
expect(map["aws_sqs_queue.dlq"].score).toBe(96); // 100 - 4*1 outbound
|
|
115
115
|
expect(map["aws_sns_topic.alerts"].score).toBe(88); // 100 - 12*1 inbound
|
|
116
116
|
});
|
|
@@ -123,7 +123,7 @@ describe("scoreEstate — penalties", () => {
|
|
|
123
123
|
aws_sns_topic: { t: [{ name: "t" }] },
|
|
124
124
|
},
|
|
125
125
|
};
|
|
126
|
-
const scores = scoreEstate(
|
|
126
|
+
const scores = scoreEstate(buildFixtureGraph(tree)).map((r) => r.score);
|
|
127
127
|
expect(scores).toEqual([...scores].sort((a, b) => b - a));
|
|
128
128
|
});
|
|
129
129
|
});
|
|
@@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { readStateInstanceCounts, applyStateCounts } from "./state";
|
|
6
|
-
import {
|
|
6
|
+
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
7
7
|
import { scoreEstate } from "./score";
|
|
8
8
|
import type { Hcl2JsonTree } from "./types";
|
|
9
9
|
|
|
@@ -44,7 +44,7 @@ describe("readStateInstanceCounts", () => {
|
|
|
44
44
|
|
|
45
45
|
describe("applyStateCounts", () => {
|
|
46
46
|
test("overlays counts; nodes absent from state keep 1", () => {
|
|
47
|
-
const graph =
|
|
47
|
+
const graph = buildFixtureGraph({
|
|
48
48
|
resource: {
|
|
49
49
|
aws_instance: { web: [{ count: 3, ami: "x" }] },
|
|
50
50
|
aws_sqs_queue: { orphan: [{ name: "q" }] },
|
|
@@ -61,12 +61,12 @@ describe("applyStateCounts", () => {
|
|
|
61
61
|
resource: { aws_sqs_queue: { fan: [{ count: 5, name: "q" }] } },
|
|
62
62
|
};
|
|
63
63
|
// Without state: instances=1, only the -10 dynamic penalty → 90.
|
|
64
|
-
const noState = scoreEstate(
|
|
64
|
+
const noState = scoreEstate(buildFixtureGraph(tree))[0];
|
|
65
65
|
expect(noState.score).toBe(90);
|
|
66
66
|
expect(noState.breakdown.instances).toBe(1);
|
|
67
67
|
|
|
68
68
|
// With state: 5 instances → -10 dynamic -3*(5-1)=-12 → 78, drops out of "clean leaf".
|
|
69
|
-
const withCounts =
|
|
69
|
+
const withCounts = buildFixtureGraph(tree);
|
|
70
70
|
applyStateCounts(withCounts, new Map([["aws_sqs_queue.fan", 5]]));
|
|
71
71
|
const stated = scoreEstate(withCounts)[0];
|
|
72
72
|
expect(stated.breakdown.instances).toBe(5);
|
|
@@ -20,12 +20,52 @@ export interface TierInfo {
|
|
|
20
20
|
mapsTo: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Kubernetes provider types with a native k8s-lexicon target. `kubernetes_manifest`
|
|
25
|
+
* IS the manifest (tier 1). The typed provider resources (`kubernetes_deployment`,
|
|
26
|
+
* ...) reshape their HCL schema back into a manifest, so they rank tier 2; the
|
|
27
|
+
* common `_v1` aliases share their base type's entry.
|
|
28
|
+
*/
|
|
29
|
+
const K8S_TIER_BASE: Record<string, TierInfo> = {
|
|
30
|
+
kubernetes_manifest: { tier: 1, mapsTo: "k8s:manifest" },
|
|
31
|
+
kubernetes_namespace: { tier: 2, mapsTo: "k8s:Namespace" },
|
|
32
|
+
kubernetes_config_map: { tier: 2, mapsTo: "k8s:ConfigMap" },
|
|
33
|
+
kubernetes_secret: { tier: 2, mapsTo: "k8s:Secret" },
|
|
34
|
+
kubernetes_service: { tier: 2, mapsTo: "k8s:Service" },
|
|
35
|
+
kubernetes_service_account: { tier: 2, mapsTo: "k8s:ServiceAccount" },
|
|
36
|
+
kubernetes_deployment: { tier: 2, mapsTo: "k8s:Deployment" },
|
|
37
|
+
kubernetes_stateful_set: { tier: 2, mapsTo: "k8s:StatefulSet" },
|
|
38
|
+
kubernetes_daemon_set: { tier: 2, mapsTo: "k8s:DaemonSet" },
|
|
39
|
+
kubernetes_job: { tier: 2, mapsTo: "k8s:Job" },
|
|
40
|
+
kubernetes_cron_job: { tier: 2, mapsTo: "k8s:CronJob" },
|
|
41
|
+
kubernetes_ingress: { tier: 2, mapsTo: "k8s:Ingress" },
|
|
42
|
+
kubernetes_network_policy: { tier: 2, mapsTo: "k8s:NetworkPolicy" },
|
|
43
|
+
kubernetes_persistent_volume_claim: { tier: 2, mapsTo: "k8s:PersistentVolumeClaim" },
|
|
44
|
+
kubernetes_role: { tier: 2, mapsTo: "k8s:Role" },
|
|
45
|
+
kubernetes_role_binding: { tier: 2, mapsTo: "k8s:RoleBinding" },
|
|
46
|
+
kubernetes_cluster_role: { tier: 2, mapsTo: "k8s:ClusterRole" },
|
|
47
|
+
kubernetes_cluster_role_binding: { tier: 2, mapsTo: "k8s:ClusterRoleBinding" },
|
|
48
|
+
kubernetes_resource_quota: { tier: 2, mapsTo: "k8s:ResourceQuota" },
|
|
49
|
+
kubernetes_limit_range: { tier: 2, mapsTo: "k8s:LimitRange" },
|
|
50
|
+
kubernetes_priority_class: { tier: 2, mapsTo: "k8s:PriorityClass" },
|
|
51
|
+
kubernetes_pod_disruption_budget: { tier: 2, mapsTo: "k8s:PodDisruptionBudget" },
|
|
52
|
+
kubernetes_horizontal_pod_autoscaler: { tier: 2, mapsTo: "k8s:HorizontalPodAutoscaler" },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const K8S_TIER: Record<string, TierInfo> = Object.fromEntries(
|
|
56
|
+
Object.entries(K8S_TIER_BASE).flatMap(([type, info]) =>
|
|
57
|
+
type === "kubernetes_manifest" ? [[type, info]] : [[type, info], [`${type}_v1`, info]],
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
// The one provider type whose current alias is _v2, not _v1.
|
|
61
|
+
K8S_TIER.kubernetes_horizontal_pod_autoscaler_v2 = K8S_TIER_BASE.kubernetes_horizontal_pod_autoscaler;
|
|
62
|
+
|
|
23
63
|
/** TF resource type → native tier. Absent = unsupported (score 0). */
|
|
24
64
|
export const TIER_MAP: Record<string, TierInfo> = {
|
|
25
65
|
// AWS: derived from the carve-out table so advise and emit stay in lockstep.
|
|
26
66
|
...Object.fromEntries(AWS_CARVE_TYPES.map((t) => [t.tfType, { tier: t.tier, mapsTo: t.nativeType }])),
|
|
27
|
-
// Kubernetes —
|
|
28
|
-
|
|
67
|
+
// Kubernetes — ranked by advise; emit support is a separate target.
|
|
68
|
+
...K8S_TIER,
|
|
29
69
|
};
|
|
30
70
|
|
|
31
71
|
/**
|
|
@@ -44,16 +84,28 @@ export const FOLDS_INTO: Record<string, string> = {
|
|
|
44
84
|
aws_s3_bucket_public_access_block: "aws_s3_bucket",
|
|
45
85
|
aws_s3_bucket_server_side_encryption_configuration: "aws_s3_bucket",
|
|
46
86
|
aws_s3_bucket_lifecycle_configuration: "aws_s3_bucket",
|
|
87
|
+
aws_s3_bucket_website_configuration: "aws_s3_bucket",
|
|
88
|
+
aws_s3_bucket_cors_configuration: "aws_s3_bucket",
|
|
89
|
+
aws_s3_bucket_logging: "aws_s3_bucket",
|
|
90
|
+
aws_s3_bucket_ownership_controls: "aws_s3_bucket",
|
|
91
|
+
aws_s3_bucket_notification: "aws_s3_bucket",
|
|
92
|
+
aws_s3_bucket_accelerate_configuration: "aws_s3_bucket",
|
|
93
|
+
aws_s3_bucket_request_payment_configuration: "aws_s3_bucket",
|
|
94
|
+
aws_s3_bucket_intelligent_tiering_configuration: "aws_s3_bucket",
|
|
95
|
+
aws_ecr_lifecycle_policy: "aws_ecr_repository",
|
|
96
|
+
aws_ecr_repository_policy: "aws_ecr_repository",
|
|
47
97
|
};
|
|
48
98
|
|
|
49
99
|
/**
|
|
50
|
-
* The HCL attribute carrying a resource's physical name, per type —
|
|
51
|
-
* from the
|
|
52
|
-
*
|
|
100
|
+
* The HCL attribute carrying a resource's physical name, per type — AWS entries
|
|
101
|
+
* derived from the carve-out table, non-AWS listed directly. A dotted entry is
|
|
102
|
+
* a path into nested blocks (`manifest.metadata.name`). Used for the graph's
|
|
103
|
+
* identity and the live-import hint. Absent → fall back to the TF logical name.
|
|
53
104
|
*/
|
|
54
|
-
export const IDENTITY_ATTR: Record<string, string> =
|
|
55
|
-
AWS_CARVE_TYPES.filter((t) => t.identityAttr).map((t) => [t.tfType, t.identityAttr!]),
|
|
56
|
-
|
|
105
|
+
export const IDENTITY_ATTR: Record<string, string> = {
|
|
106
|
+
...Object.fromEntries(AWS_CARVE_TYPES.filter((t) => t.identityAttr).map((t) => [t.tfType, t.identityAttr!])),
|
|
107
|
+
kubernetes_manifest: "manifest.metadata.name",
|
|
108
|
+
};
|
|
57
109
|
|
|
58
110
|
export function resolveTier(tfType: string): TierInfo | null {
|
|
59
111
|
return TIER_MAP[tfType] ?? null;
|
package/src/terraform/types.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface TfEdge {
|
|
|
48
48
|
to: string;
|
|
49
49
|
/** The attribute path(s) referenced, e.g. `["id", "arn"]`. */
|
|
50
50
|
attrs: string[];
|
|
51
|
+
/**
|
|
52
|
+
* The referring block's own top-level attribute(s) the reference sits in,
|
|
53
|
+
* e.g. `["vpc_id"]` for a subnet reading its VPC (#998). This is what a
|
|
54
|
+
* deferred outbound input is named after when emit turns it into a build
|
|
55
|
+
* parameter. Empty when the reference came from a non-object block shape.
|
|
56
|
+
*/
|
|
57
|
+
via: string[];
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
export interface TfGraph {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { newFileDiff, unifiedDiff } from "./unified-diff";
|
|
7
|
+
|
|
8
|
+
const OLD = `resource "aws_lambda_function" "api" {
|
|
9
|
+
function_name = "myapp-api"
|
|
10
|
+
environment {
|
|
11
|
+
variables = {
|
|
12
|
+
ASSETS_BUCKET = aws_s3_bucket.assets.bucket
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
const NEW = OLD.replace("aws_s3_bucket.assets.bucket", "data.aws_s3_bucket.assets.bucket");
|
|
18
|
+
|
|
19
|
+
describe("unifiedDiff", () => {
|
|
20
|
+
test("identical inputs produce no diff", () => {
|
|
21
|
+
expect(unifiedDiff("main.tf", OLD, OLD)).toBe("");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a one-line rewrite yields one hunk with context and git headers", () => {
|
|
25
|
+
const patch = unifiedDiff("main.tf", OLD, NEW);
|
|
26
|
+
const lines = patch.split("\n");
|
|
27
|
+
expect(lines[0]).toBe("diff --git a/main.tf b/main.tf");
|
|
28
|
+
expect(lines[1]).toBe("--- a/main.tf");
|
|
29
|
+
expect(lines[2]).toBe("+++ b/main.tf");
|
|
30
|
+
expect(lines[3]).toBe("@@ -2,7 +2,7 @@");
|
|
31
|
+
expect(patch).toContain("- ASSETS_BUCKET = aws_s3_bucket.assets.bucket");
|
|
32
|
+
expect(patch).toContain("+ ASSETS_BUCKET = data.aws_s3_bucket.assets.bucket");
|
|
33
|
+
// 3 context lines either side of the change.
|
|
34
|
+
expect(lines.filter((l) => l.startsWith(" "))).toHaveLength(6);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("distant changes split into separate hunks; close ones join", () => {
|
|
38
|
+
const hunkHeaders = (patch: string) => patch.split("\n").filter((l) => l.startsWith("@@"));
|
|
39
|
+
const oldText = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") + "\n";
|
|
40
|
+
const twoFar = oldText.replace("line 2", "LINE 2").replace("line 17", "LINE 17");
|
|
41
|
+
expect(hunkHeaders(unifiedDiff("f.txt", oldText, twoFar))).toHaveLength(2);
|
|
42
|
+
const twoNear = oldText.replace("line 8", "LINE 8").replace("line 10", "LINE 10");
|
|
43
|
+
expect(hunkHeaders(unifiedDiff("f.txt", oldText, twoNear))).toHaveLength(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("marks a missing trailing newline", () => {
|
|
47
|
+
const patch = unifiedDiff("f.txt", "a\nb\n", "a\nb2");
|
|
48
|
+
expect(patch).toContain("+b2\n\");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("newFileDiff renders /dev/null → b/<path>", () => {
|
|
52
|
+
const patch = newFileDiff("ds.tf", 'data "aws_s3_bucket" "assets" {\n bucket = "b"\n}\n');
|
|
53
|
+
expect(patch).toContain("new file mode 100644");
|
|
54
|
+
expect(patch).toContain("--- /dev/null");
|
|
55
|
+
expect(patch).toContain("+++ b/ds.tf");
|
|
56
|
+
expect(patch).toContain("@@ -0,0 +1,3 @@");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("git apply accepts the combined patch and reproduces the new content", () => {
|
|
60
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-udiff-"));
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(join(dir, "main.tf"), OLD);
|
|
63
|
+
const patch = unifiedDiff("main.tf", OLD, NEW) + newFileDiff("ds.tf", 'data "x" "y" {}\n');
|
|
64
|
+
writeFileSync(join(dir, "bridge.patch"), patch);
|
|
65
|
+
try {
|
|
66
|
+
execFileSync("git", ["apply", "bridge.patch"], { cwd: dir });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // no git on this machine
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
expect(readFileSync(join(dir, "main.tf"), "utf-8")).toBe(NEW);
|
|
72
|
+
expect(readFileSync(join(dir, "ds.tf"), "utf-8")).toBe('data "x" "y" {}\n');
|
|
73
|
+
} finally {
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal unified-diff generator for the carve bridge patch (#998). Emits
|
|
3
|
+
* git-applyable hunks (`git apply`, or plain `patch -p1`) so the whole survivor
|
|
4
|
+
* edit — data sources plus rewired references — lands in one reviewable file.
|
|
5
|
+
* LCS-based; bridge inputs are hand-sized `.tf` files, so O(n·m) is fine.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One file's diff, `a/<path>` → `b/<path>`, with git headers. Empty when unchanged. */
|
|
9
|
+
export function unifiedDiff(path: string, oldText: string, newText: string, context = 3): string {
|
|
10
|
+
if (oldText === newText) return "";
|
|
11
|
+
|
|
12
|
+
const oldLines = splitLines(oldText);
|
|
13
|
+
const newLines = splitLines(newText);
|
|
14
|
+
const ops = diffOps(oldLines.lines, newLines.lines);
|
|
15
|
+
const hunks = buildHunks(ops, context);
|
|
16
|
+
if (hunks.length === 0) return "";
|
|
17
|
+
|
|
18
|
+
const L: string[] = [];
|
|
19
|
+
L.push(`diff --git a/${path} b/${path}`);
|
|
20
|
+
L.push(`--- a/${path}`);
|
|
21
|
+
L.push(`+++ b/${path}`);
|
|
22
|
+
for (const hunk of hunks) {
|
|
23
|
+
L.push(`@@ -${hunkRange(hunk.oldStart, hunk.oldCount)} +${hunkRange(hunk.newStart, hunk.newCount)} @@`);
|
|
24
|
+
for (const op of hunk.ops) {
|
|
25
|
+
L.push(`${op.kind === "add" ? "+" : op.kind === "del" ? "-" : " "}${op.line}`);
|
|
26
|
+
if (op.kind !== "add" && op.oldIndex === oldLines.lines.length - 1 && !oldLines.trailingNewline) {
|
|
27
|
+
L.push("\");
|
|
28
|
+
}
|
|
29
|
+
if (op.kind !== "del" && op.newIndex === newLines.lines.length - 1 && !newLines.trailingNewline) {
|
|
30
|
+
L.push("\");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return L.join("\n") + "\n";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A new-file diff (`/dev/null` → `b/<path>`), git-applyable. */
|
|
38
|
+
export function newFileDiff(path: string, text: string): string {
|
|
39
|
+
const { lines, trailingNewline } = splitLines(text);
|
|
40
|
+
const L: string[] = [];
|
|
41
|
+
L.push(`diff --git a/${path} b/${path}`);
|
|
42
|
+
L.push("new file mode 100644");
|
|
43
|
+
L.push("--- /dev/null");
|
|
44
|
+
L.push(`+++ b/${path}`);
|
|
45
|
+
if (lines.length > 0) {
|
|
46
|
+
L.push(`@@ -0,0 +${hunkRange(1, lines.length)} @@`);
|
|
47
|
+
for (const line of lines) L.push(`+${line}`);
|
|
48
|
+
if (!trailingNewline) L.push("\");
|
|
49
|
+
}
|
|
50
|
+
return L.join("\n") + "\n";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hunkRange(start: number, count: number): string {
|
|
54
|
+
return count === 1 ? String(start) : `${count === 0 ? start - 1 : start},${count}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function splitLines(text: string): { lines: string[]; trailingNewline: boolean } {
|
|
58
|
+
if (text === "") return { lines: [], trailingNewline: true };
|
|
59
|
+
const trailingNewline = text.endsWith("\n");
|
|
60
|
+
const lines = text.split("\n");
|
|
61
|
+
if (trailingNewline) lines.pop();
|
|
62
|
+
return { lines, trailingNewline };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface DiffOp {
|
|
66
|
+
kind: "keep" | "del" | "add";
|
|
67
|
+
line: string;
|
|
68
|
+
/** Index in the old file (keep/del). */
|
|
69
|
+
oldIndex: number;
|
|
70
|
+
/** Index in the new file (keep/add). */
|
|
71
|
+
newIndex: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Line-level LCS diff as a flat op list. */
|
|
75
|
+
function diffOps(a: string[], b: string[]): DiffOp[] {
|
|
76
|
+
const n = a.length;
|
|
77
|
+
const m = b.length;
|
|
78
|
+
// lcs[i][j] = LCS length of a[i..] and b[j..]
|
|
79
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
80
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
81
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
82
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const ops: DiffOp[] = [];
|
|
86
|
+
let i = 0;
|
|
87
|
+
let j = 0;
|
|
88
|
+
while (i < n && j < m) {
|
|
89
|
+
if (a[i] === b[j]) {
|
|
90
|
+
ops.push({ kind: "keep", line: a[i], oldIndex: i, newIndex: j });
|
|
91
|
+
i++;
|
|
92
|
+
j++;
|
|
93
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
94
|
+
ops.push({ kind: "del", line: a[i], oldIndex: i, newIndex: j });
|
|
95
|
+
i++;
|
|
96
|
+
} else {
|
|
97
|
+
ops.push({ kind: "add", line: b[j], oldIndex: i, newIndex: j });
|
|
98
|
+
j++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (; i < n; i++) ops.push({ kind: "del", line: a[i], oldIndex: i, newIndex: j });
|
|
102
|
+
for (; j < m; j++) ops.push({ kind: "add", line: b[j], oldIndex: i, newIndex: j });
|
|
103
|
+
return ops;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface Hunk {
|
|
107
|
+
oldStart: number;
|
|
108
|
+
oldCount: number;
|
|
109
|
+
newStart: number;
|
|
110
|
+
newCount: number;
|
|
111
|
+
ops: DiffOp[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Group change ops into hunks with `context` lines of surrounding keeps. */
|
|
115
|
+
function buildHunks(ops: DiffOp[], context: number): Hunk[] {
|
|
116
|
+
const changed = ops.map((op) => op.kind !== "keep");
|
|
117
|
+
const include = new Array<boolean>(ops.length).fill(false);
|
|
118
|
+
for (let k = 0; k < ops.length; k++) {
|
|
119
|
+
if (!changed[k]) continue;
|
|
120
|
+
for (let c = Math.max(0, k - context); c <= Math.min(ops.length - 1, k + context); c++) {
|
|
121
|
+
include[c] = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hunks: Hunk[] = [];
|
|
126
|
+
let k = 0;
|
|
127
|
+
while (k < ops.length) {
|
|
128
|
+
if (!include[k]) {
|
|
129
|
+
k++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const start = k;
|
|
133
|
+
while (k < ops.length && include[k]) k++;
|
|
134
|
+
const slice = ops.slice(start, k);
|
|
135
|
+
const oldOps = slice.filter((op) => op.kind !== "add");
|
|
136
|
+
const newOps = slice.filter((op) => op.kind !== "del");
|
|
137
|
+
hunks.push({
|
|
138
|
+
oldStart: (oldOps[0]?.oldIndex ?? slice[0].oldIndex) + 1,
|
|
139
|
+
oldCount: oldOps.length,
|
|
140
|
+
newStart: (newOps[0]?.newIndex ?? slice[0].newIndex) + 1,
|
|
141
|
+
newCount: newOps.length,
|
|
142
|
+
ops: slice,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return hunks;
|
|
146
|
+
}
|