@intentius/chant 0.41.20 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/cli/commands/carve-apply.d.ts +9 -0
  2. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  3. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  4. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  5. package/dist/cli/commands/carve-emit.d.ts +7 -0
  6. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  7. package/dist/cli/commands/init.d.ts +2 -0
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  10. package/dist/cli/handlers/components.d.ts.map +1 -1
  11. package/dist/cli/handlers/explain.d.ts +11 -0
  12. package/dist/cli/handlers/explain.d.ts.map +1 -0
  13. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +2 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  19. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  20. package/dist/codegen/okf-lexicon.d.ts +36 -0
  21. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  22. package/dist/codegen/package.d.ts +2 -1
  23. package/dist/codegen/package.d.ts.map +1 -1
  24. package/dist/components/verbs/sbom.d.ts +1 -1
  25. package/dist/components/verbs/sbom.d.ts.map +1 -1
  26. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  27. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  28. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  29. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  30. package/dist/config.d.ts +12 -0
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/deep-observation.d.ts +13 -0
  33. package/dist/deep-observation.d.ts.map +1 -1
  34. package/dist/lexicon.d.ts +3 -0
  35. package/dist/lexicon.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts.map +1 -1
  37. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  38. package/dist/okf.d.ts +57 -0
  39. package/dist/okf.d.ts.map +1 -0
  40. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  41. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  42. package/dist/terraform/adopt-state.d.ts +33 -1
  43. package/dist/terraform/adopt-state.d.ts.map +1 -1
  44. package/dist/terraform/bridge.d.ts +7 -1
  45. package/dist/terraform/bridge.d.ts.map +1 -1
  46. package/dist/terraform/carve.d.ts +12 -0
  47. package/dist/terraform/carve.d.ts.map +1 -1
  48. package/dist/terraform/excise.d.ts +31 -0
  49. package/dist/terraform/excise.d.ts.map +1 -0
  50. package/dist/terraform/graduate.d.ts +12 -0
  51. package/dist/terraform/graduate.d.ts.map +1 -1
  52. package/dist/terraform/graph.d.ts +35 -5
  53. package/dist/terraform/graph.d.ts.map +1 -1
  54. package/dist/terraform/manifest.d.ts +89 -0
  55. package/dist/terraform/manifest.d.ts.map +1 -0
  56. package/dist/terraform/parse.d.ts +9 -4
  57. package/dist/terraform/parse.d.ts.map +1 -1
  58. package/dist/terraform/types.d.ts +7 -0
  59. package/dist/terraform/types.d.ts.map +1 -1
  60. package/dist/terraform/unified-diff.d.ts +11 -0
  61. package/dist/terraform/unified-diff.d.ts.map +1 -0
  62. package/package.json +1 -1
  63. package/src/__snapshots__/okf.test.ts.snap +381 -0
  64. package/src/cli/commands/carve-apply.test.ts +94 -2
  65. package/src/cli/commands/carve-apply.ts +62 -11
  66. package/src/cli/commands/carve-bridge.test.ts +82 -4
  67. package/src/cli/commands/carve-bridge.ts +79 -11
  68. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  69. package/src/cli/commands/carve-emit.ts +184 -8
  70. package/src/cli/commands/init.ts +1 -1
  71. package/src/cli/handlers/carve-apply.ts +1 -0
  72. package/src/cli/handlers/components.ts +4 -2
  73. package/src/cli/handlers/explain.test.ts +93 -0
  74. package/src/cli/handlers/explain.ts +60 -0
  75. package/src/cli/handlers/lifecycle.ts +9 -7
  76. package/src/cli/main.ts +21 -5
  77. package/src/cli/mcp/server.test.ts +17 -0
  78. package/src/cli/mcp/tools/explain.ts +15 -3
  79. package/src/cli/registry.ts +2 -0
  80. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  81. package/src/codegen/docs-rule-scanning.ts +35 -14
  82. package/src/codegen/okf-lexicon.test.ts +248 -0
  83. package/src/codegen/okf-lexicon.ts +303 -0
  84. package/src/codegen/package.ts +29 -4
  85. package/src/components/config-defaults.test.ts +30 -0
  86. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  87. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  88. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  89. package/src/components/verbs/sbom.ts +1 -1
  90. package/src/components/verbs/vuln-gate.test.ts +144 -1
  91. package/src/components/verbs/vuln-gate.ts +61 -10
  92. package/src/components/verbs/vuln-scan.test.ts +94 -0
  93. package/src/components/verbs/vuln-scan.ts +44 -9
  94. package/src/config.test.ts +21 -0
  95. package/src/config.ts +18 -0
  96. package/src/deep-observation.test.ts +19 -0
  97. package/src/deep-observation.ts +32 -13
  98. package/src/lexicon.ts +3 -0
  99. package/src/lifecycle/observe.test.ts +18 -0
  100. package/src/lifecycle/observe.ts +10 -3
  101. package/src/lifecycle/snapshot.ts +5 -4
  102. package/src/okf.test.ts +169 -0
  103. package/src/okf.ts +308 -0
  104. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  105. package/src/terraform/adopt-state.test.ts +34 -1
  106. package/src/terraform/adopt-state.ts +69 -2
  107. package/src/terraform/bridge.test.ts +38 -14
  108. package/src/terraform/bridge.ts +48 -14
  109. package/src/terraform/carve.test.ts +9 -7
  110. package/src/terraform/carve.ts +0 -0
  111. package/src/terraform/excise.test.ts +92 -0
  112. package/src/terraform/excise.ts +139 -0
  113. package/src/terraform/graduate.test.ts +73 -6
  114. package/src/terraform/graduate.ts +49 -0
  115. package/src/terraform/graph.test.ts +56 -10
  116. package/src/terraform/graph.ts +112 -45
  117. package/src/terraform/manifest.test.ts +105 -0
  118. package/src/terraform/manifest.ts +162 -0
  119. package/src/terraform/parse.test.ts +39 -1
  120. package/src/terraform/parse.ts +36 -9
  121. package/src/terraform/score.test.ts +8 -8
  122. package/src/terraform/state.test.ts +4 -4
  123. package/src/terraform/types.ts +7 -0
  124. package/src/terraform/unified-diff.test.ts +77 -0
  125. package/src/terraform/unified-diff.ts +146 -0
@@ -10,12 +10,16 @@
10
10
 
11
11
  import { readdirSync, readFileSync } from "fs";
12
12
  import { join } from "path";
13
- import { buildGraph } from "./graph";
13
+ import { buildGraph, collectExpressions, type ExpressionRefs } from "./graph";
14
14
  import { readStateInstanceCounts, applyStateCounts } from "./state";
15
15
  import type { Hcl2JsonTree, TfGraph } from "./types";
16
16
 
17
- /** Minimal shape of the parser export we depend on. */
18
- type Hcl2JsonParse = (filename: string, hcl: string) => Promise<Hcl2JsonTree>;
17
+ /** Minimal shape of the parser exports we depend on. */
18
+ export interface Hcl2Json {
19
+ parse: (filename: string, hcl: string) => Promise<Hcl2JsonTree>;
20
+ /** Expression-AST reference extraction (#998) — one traversal accessor per reference. */
21
+ getReferencesInExpression: (filename: string, expression: string) => Promise<Array<{ value: string }>>;
22
+ }
19
23
 
20
24
  export class Hcl2JsonNotInstalled extends Error {
21
25
  constructor(cause: unknown) {
@@ -32,15 +36,38 @@ export class Hcl2JsonNotInstalled extends Error {
32
36
  * Lazy-load the optional HCL parser. Throws `Hcl2JsonNotInstalled` with an
33
37
  * install hint when the package is missing, rather than a raw MODULE_NOT_FOUND.
34
38
  */
35
- export async function loadHcl2json(): Promise<Hcl2JsonParse> {
39
+ export async function loadHcl2json(): Promise<Hcl2Json> {
36
40
  try {
37
- const mod = (await import("@cdktf/hcl2json")) as { parse: Hcl2JsonParse };
38
- return mod.parse;
41
+ return (await import("@cdktf/hcl2json")) as Hcl2Json;
39
42
  } catch (err) {
40
43
  throw new Hcl2JsonNotInstalled(err);
41
44
  }
42
45
  }
43
46
 
47
+ /**
48
+ * Resolve every interpolated string in the merged tree through the parser's
49
+ * expression AST (`getReferencesInExpression`), producing the accessor map
50
+ * `buildGraph` classifies. The AST — not a regex over the expression body —
51
+ * decides what is a reference, so a quoted address used as a map key or an
52
+ * escaped `$${...}` literal yields none. An expression the AST cannot parse in
53
+ * isolation resolves to no references (fewer edges, never a phantom one).
54
+ */
55
+ async function resolveExpressionRefs(hcl2json: Hcl2Json, tree: Hcl2JsonTree): Promise<ExpressionRefs> {
56
+ const refs = new Map<string, string[]>();
57
+ for (const expr of collectExpressions(tree)) {
58
+ try {
59
+ const found = await hcl2json.getReferencesInExpression("expression.tf", expr);
60
+ refs.set(
61
+ expr,
62
+ found.map((r) => r.value),
63
+ );
64
+ } catch {
65
+ refs.set(expr, []);
66
+ }
67
+ }
68
+ return refs;
69
+ }
70
+
44
71
  /** Deep-merge hcl2json trees across files (resource/module/data namespaces). */
45
72
  function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
46
73
  for (const section of ["resource", "data"] as const) {
@@ -74,14 +101,14 @@ export interface ParseTerraformOptions {
74
101
  * instance counts become accurate (see `state.ts`).
75
102
  */
76
103
  export async function parseTerraformDir(dir: string, opts: ParseTerraformOptions = {}): Promise<TfGraph> {
77
- const parse = await loadHcl2json();
104
+ const hcl2json = await loadHcl2json();
78
105
  const files = listTfFiles(dir);
79
106
  const merged: Hcl2JsonTree = {};
80
107
  for (const file of files) {
81
- const tree = await parse(file, readFileSync(file, "utf-8"));
108
+ const tree = await hcl2json.parse(file, readFileSync(file, "utf-8"));
82
109
  mergeTrees(merged, tree);
83
110
  }
84
- const graph = buildGraph(merged);
111
+ const graph = buildGraph(merged, await resolveExpressionRefs(hcl2json, merged));
85
112
  if (opts.statePath) applyStateCounts(graph, readStateInstanceCounts(opts.statePath));
86
113
  return graph;
87
114
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { buildGraph } from "./graph";
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(buildGraph(tree));
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(buildGraph(tree));
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(buildGraph(tree)))["aws_vpc.main"];
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(buildGraph(tree))[0];
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(buildGraph(tree)));
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(buildGraph(tree)));
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(buildGraph(tree)).map((r) => r.score);
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 { buildGraph } from "./graph";
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 = buildGraph({
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(buildGraph(tree))[0];
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 = buildGraph(tree);
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);
@@ -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
+ }