@oscharko-dev/keiko-tools 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -0
- package/dist/browser/cdp-client.d.ts +36 -0
- package/dist/browser/cdp-client.d.ts.map +1 -0
- package/dist/browser/cdp-client.js +218 -0
- package/dist/browser/errors.d.ts +27 -0
- package/dist/browser/errors.d.ts.map +1 -0
- package/dist/browser/errors.js +61 -0
- package/dist/browser/session.d.ts +46 -0
- package/dist/browser/session.d.ts.map +1 -0
- package/dist/browser/session.js +759 -0
- package/dist/browser/types.d.ts +49 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/validators.d.ts +6 -0
- package/dist/browser/validators.d.ts.map +1 -0
- package/dist/browser/validators.js +97 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/exec.d.ts +45 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +372 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/patch-content.d.ts +11 -0
- package/dist/patch-content.d.ts.map +1 -0
- package/dist/patch-content.js +130 -0
- package/dist/patch-normalize.d.ts +2 -0
- package/dist/patch-normalize.d.ts.map +1 -0
- package/dist/patch-normalize.js +85 -0
- package/dist/patch-parse.d.ts +9 -0
- package/dist/patch-parse.d.ts.map +1 -0
- package/dist/patch-parse.js +201 -0
- package/dist/patch.d.ts +22 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +469 -0
- package/dist/registry.d.ts +35 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +240 -0
- package/dist/sandbox.d.ts +9 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +131 -0
- package/dist/schemas.d.ts +3 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +51 -0
- package/dist/terminal-policy.d.ts +10 -0
- package/dist/terminal-policy.d.ts.map +1 -0
- package/dist/terminal-policy.js +306 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/writer.d.ts +8 -0
- package/dist/writer.d.ts.map +1 -0
- package/dist/writer.js +20 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Public barrel for @oscharko-dev/keiko-tools — the safe tool-execution layer (ADR-0006 + ADR-
|
|
2
|
+
// 0017). Combines the root tool host surface (errors, sandbox, exec, patch, registry, schemas,
|
|
3
|
+
// writer, terminal-policy, types) with the browser CDP sub-surface (validators, cdp-client,
|
|
4
|
+
// errors, session, types). The browser surface lives behind a per-file shim at
|
|
5
|
+
// `src/tools/browser/index.ts` so cross-tree callers (src/ui/browser.ts, src/ui/deps.ts) keep
|
|
6
|
+
// their `../tools/browser/index.js` imports unchanged via that shim. No subpath export.
|
|
7
|
+
export { DEFAULT_COMMAND_RULES, DEFAULT_ENV_ALLOWLIST, DEFAULT_PATCH_LIMITS, DEFAULT_SANDBOX_POLICY, DEFAULT_TOOL_HOST_CONFIG, resolveToolHostConfig, } from "./types.js";
|
|
8
|
+
// ─── Tool error taxonomy (re-exported from keiko-security; package-self-contained) ──
|
|
9
|
+
export { CommandCancelledError, CommandDeniedError, CommandTimeoutError, OutputLimitError, PatchApplyDisabledError, PatchApplyError, PatchValidationError, TOOL_CODES, ToolArgumentError, ToolError, UnknownToolError, } from "./errors.js";
|
|
10
|
+
// ─── Sandbox decisions + env build + command allowlist ──────────────────────────────
|
|
11
|
+
export { buildSandboxEnv, collectSensitiveEnvValues, isCommandAllowed, } from "./sandbox.js";
|
|
12
|
+
// ─── Command execution boundary ─────────────────────────────────────────────────────
|
|
13
|
+
export { runCommand, } from "./exec.js";
|
|
14
|
+
// ─── Patch workflow ─────────────────────────────────────────────────────────────────
|
|
15
|
+
export { applyPatch, buildRestorePatch, invertPatch, renderDryRun, validatePatch, } from "./patch.js";
|
|
16
|
+
export { normalizeUnifiedDiffHunks } from "./patch-normalize.js";
|
|
17
|
+
export { parseUnifiedDiff, PatchParseError } from "./patch-parse.js";
|
|
18
|
+
export { computeFileContent } from "./patch-content.js";
|
|
19
|
+
// ─── Tool definitions (model-facing JSON-Schema table) ──────────────────────────────
|
|
20
|
+
export { TOOL_DEFINITIONS } from "./schemas.js";
|
|
21
|
+
// ─── Tool host implementation ───────────────────────────────────────────────────────
|
|
22
|
+
export { WorkspaceToolHost } from "./registry.js";
|
|
23
|
+
// ─── Terminal-policy: command-allowlist gate used by the terminal BFF ───────────────
|
|
24
|
+
// `terminal-policy.ts` re-exports the symbol surface src/ui/terminal.ts depends on. Surface
|
|
25
|
+
// every name it exports so the shim at src/tools/terminal-policy.ts can forward from here.
|
|
26
|
+
export * from "./terminal-policy.js";
|
|
27
|
+
// ─── Browser sub-surface (ADR-0017) ─────────────────────────────────────────────────
|
|
28
|
+
export { BROWSER_ERROR_CODES, BrowserToolError } from "./browser/errors.js";
|
|
29
|
+
export { isLoopbackHost, isLoopbackUrl, normalizeCdpPort, normalizeNavigateUrl, } from "./browser/validators.js";
|
|
30
|
+
export { CdpClient, PERMITTED_CDP_METHODS, } from "./browser/cdp-client.js";
|
|
31
|
+
export { createBrowserSessionManager, } from "./browser/session.js";
|
|
32
|
+
// ─── Package version ────────────────────────────────────────────────────────────────
|
|
33
|
+
export { KEIKO_TOOLS_VERSION } from "./version.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PatchFileChange } from "./types.js";
|
|
2
|
+
export interface HunkConflict {
|
|
3
|
+
readonly hunkIndex: number;
|
|
4
|
+
readonly reason: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ApplyOutcome {
|
|
7
|
+
readonly content: string | null;
|
|
8
|
+
readonly conflicts: readonly HunkConflict[];
|
|
9
|
+
}
|
|
10
|
+
export declare function computeFileContent(change: PatchFileChange, current: string | undefined, allowOverwrite?: boolean): ApplyOutcome;
|
|
11
|
+
//# sourceMappingURL=patch-content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patch-content.d.ts","sourceRoot":"","sources":["../src/patch-content.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAa,MAAM,YAAY,CAAC;AAE7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,SAAS,YAAY,EAAE,CAAC;CAC7C;AAkHD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,eAAe,EACvB,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,cAAc,UAAQ,GACrB,YAAY,CA0Bd"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// PURE hunk application against in-memory file content. Given the current file lines and a
|
|
2
|
+
// file's hunks, it verifies the pre-image (context and removed lines must match the current
|
|
3
|
+
// content at the hunk location) and produces the post-image. A mismatch yields a conflict
|
|
4
|
+
// rather than a silent corruption — the apply phase refuses to write when any conflict exists.
|
|
5
|
+
// Splits file content into lines WITHOUT a trailing empty element for a final newline, so line
|
|
6
|
+
// indexing matches unified-diff 1-based line numbers. An empty file is zero lines.
|
|
7
|
+
function toLines(content) {
|
|
8
|
+
if (content === "") {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const lines = content.split("\n");
|
|
12
|
+
if (lines.at(-1) === "") {
|
|
13
|
+
lines.pop();
|
|
14
|
+
}
|
|
15
|
+
return lines;
|
|
16
|
+
}
|
|
17
|
+
function joinLines(lines) {
|
|
18
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
19
|
+
}
|
|
20
|
+
// Applies a single hunk starting at `cursor` (0-based index into the original lines). Returns the
|
|
21
|
+
// produced output lines, the count of original lines consumed, and a conflict reason on mismatch.
|
|
22
|
+
function applyHunk(original, hunk, cursor) {
|
|
23
|
+
const out = [];
|
|
24
|
+
let pos = cursor;
|
|
25
|
+
for (const raw of hunk.lines) {
|
|
26
|
+
const marker = raw.charAt(0);
|
|
27
|
+
const text = raw.slice(1);
|
|
28
|
+
if (marker === "+") {
|
|
29
|
+
out.push(text);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// context (" ") and removal ("-") must both match the current line at pos.
|
|
33
|
+
if (original[pos] !== text) {
|
|
34
|
+
return {
|
|
35
|
+
outLines: [],
|
|
36
|
+
consumed: 0,
|
|
37
|
+
conflict: `context mismatch at original line ${String(pos + 1)}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (marker === " ") {
|
|
41
|
+
out.push(text);
|
|
42
|
+
}
|
|
43
|
+
pos += 1;
|
|
44
|
+
}
|
|
45
|
+
return { outLines: out, consumed: pos - cursor, conflict: undefined };
|
|
46
|
+
}
|
|
47
|
+
// Applies all hunks of a modify in order. Hunks are anchored by their stated oldStart (1-based);
|
|
48
|
+
// lines between hunks are copied verbatim. Returns the new content or the collected conflicts.
|
|
49
|
+
function applyModify(original, hunks) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const conflicts = [];
|
|
52
|
+
let cursor = 0;
|
|
53
|
+
hunks.forEach((hunk, index) => {
|
|
54
|
+
const anchor = Math.max(hunk.oldStart - 1, 0);
|
|
55
|
+
if (anchor < cursor) {
|
|
56
|
+
conflicts.push({ hunkIndex: index, reason: "overlapping or out-of-order hunk" });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Copy verbatim the unchanged lines between the previous cursor and this hunk's anchor.
|
|
60
|
+
out.push(...original.slice(cursor, Math.min(anchor, original.length)));
|
|
61
|
+
cursor = anchor;
|
|
62
|
+
const result = applyHunk(original, hunk, cursor);
|
|
63
|
+
if (result.conflict !== undefined) {
|
|
64
|
+
conflicts.push({ hunkIndex: index, reason: result.conflict });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
out.push(...result.outLines);
|
|
68
|
+
cursor += result.consumed;
|
|
69
|
+
});
|
|
70
|
+
// Copy any remaining original lines after the last applied hunk.
|
|
71
|
+
out.push(...original.slice(cursor));
|
|
72
|
+
return conflicts.length > 0
|
|
73
|
+
? { content: null, conflicts }
|
|
74
|
+
: { content: joinLines(out), conflicts: [] };
|
|
75
|
+
}
|
|
76
|
+
// Verifies a delete's pre-image against the current content (C2). A delete hunk lists the lines to
|
|
77
|
+
// remove (`-`) and surrounding context (` `); their concatenation must equal the current file, or
|
|
78
|
+
// the diff is stale/fabricated and we MUST NOT delete a mismatched file. A hunk-free delete (no
|
|
79
|
+
// pre-image to check) is accepted as-is. `+` lines are not expected in a delete and are ignored.
|
|
80
|
+
function verifyDeletePreImage(change, current) {
|
|
81
|
+
const preImage = [];
|
|
82
|
+
for (const hunk of change.hunks) {
|
|
83
|
+
for (const raw of hunk.lines) {
|
|
84
|
+
const marker = raw.charAt(0);
|
|
85
|
+
if (marker === " " || marker === "-") {
|
|
86
|
+
preImage.push(raw.slice(1));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (preImage.length === 0) {
|
|
91
|
+
return { content: null, conflicts: [] };
|
|
92
|
+
}
|
|
93
|
+
const matches = joinLines(preImage) === current || preImage.join("\n") === current;
|
|
94
|
+
return matches
|
|
95
|
+
? { content: null, conflicts: [] }
|
|
96
|
+
: {
|
|
97
|
+
content: null,
|
|
98
|
+
conflicts: [{ hunkIndex: 0, reason: "delete pre-image does not match current content" }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Computes the post-image for one file change against its current content (undefined = absent).
|
|
102
|
+
// `allowOverwrite` (default false) governs only the create-over-existing case: when false a create
|
|
103
|
+
// whose target already exists is a conflict (the default no-silent-overwrite guardrail, Issue #1204
|
|
104
|
+
// AC7/AC14); when true — set only after explicit user confirmation — the existing file is replaced with
|
|
105
|
+
// the created content.
|
|
106
|
+
export function computeFileContent(change, current, allowOverwrite = false) {
|
|
107
|
+
if (change.kind === "create") {
|
|
108
|
+
if (current !== undefined && !allowOverwrite) {
|
|
109
|
+
return {
|
|
110
|
+
content: null,
|
|
111
|
+
conflicts: [{ hunkIndex: 0, reason: "create target already exists" }],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const added = change.hunks.flatMap((h) => h.lines.filter((l) => l.startsWith("+")).map((l) => l.slice(1)));
|
|
115
|
+
return { content: joinLines(added), conflicts: [] };
|
|
116
|
+
}
|
|
117
|
+
if (change.kind === "delete") {
|
|
118
|
+
if (current === undefined) {
|
|
119
|
+
return {
|
|
120
|
+
content: null,
|
|
121
|
+
conflicts: [{ hunkIndex: 0, reason: "delete target does not exist" }],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return verifyDeletePreImage(change, current);
|
|
125
|
+
}
|
|
126
|
+
if (current === undefined) {
|
|
127
|
+
return { content: null, conflicts: [{ hunkIndex: 0, reason: "modify target does not exist" }] };
|
|
128
|
+
}
|
|
129
|
+
return applyModify(toLines(current), change.hunks);
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patch-normalize.d.ts","sourceRoot":"","sources":["../src/patch-normalize.ts"],"names":[],"mappings":"AAoFA,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAkB9D"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Best-effort normalization for common LLM unified-diff shorthand. This module rewrites hunk headers
|
|
2
|
+
// from the hunk body markers (" ", "+", "-") and repairs blank context lines that models often emit
|
|
3
|
+
// as an empty line instead of a single-space diff line. It does not invent paths; the normal
|
|
4
|
+
// validate/apply path still performs path containment, deny-list checks, and conflict checks.
|
|
5
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/;
|
|
6
|
+
function isBodyLine(line) {
|
|
7
|
+
const marker = line.charAt(0);
|
|
8
|
+
return marker === " " || marker === "+" || marker === "-";
|
|
9
|
+
}
|
|
10
|
+
function isFileHeaderPair(lines, index) {
|
|
11
|
+
return lines[index]?.startsWith("--- ") === true && lines[index + 1]?.startsWith("+++ ") === true;
|
|
12
|
+
}
|
|
13
|
+
function hunkEnd(lines, start) {
|
|
14
|
+
let index = start;
|
|
15
|
+
while (index < lines.length) {
|
|
16
|
+
const line = lines[index] ?? "";
|
|
17
|
+
if (line.startsWith("@@") || isFileHeaderPair(lines, index)) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
index += 1;
|
|
21
|
+
}
|
|
22
|
+
return index;
|
|
23
|
+
}
|
|
24
|
+
function countOldLines(lines) {
|
|
25
|
+
return lines.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
|
|
26
|
+
}
|
|
27
|
+
function countNewLines(lines) {
|
|
28
|
+
return lines.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
|
|
29
|
+
}
|
|
30
|
+
function parseStarts(line) {
|
|
31
|
+
const match = HUNK_HEADER.exec(line);
|
|
32
|
+
if (match === null) {
|
|
33
|
+
return line.trim() === "@@" ? { oldStart: 0, newStart: 1, suffix: "" } : undefined;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
oldStart: Number(match[1]),
|
|
37
|
+
newStart: Number(match[2]),
|
|
38
|
+
suffix: match[3] ?? "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function formatRange(start, count) {
|
|
42
|
+
return `${String(start)},${String(count)}`;
|
|
43
|
+
}
|
|
44
|
+
function normalizeHunkHeader(header, body) {
|
|
45
|
+
const starts = parseStarts(header);
|
|
46
|
+
if (starts === undefined) {
|
|
47
|
+
return header;
|
|
48
|
+
}
|
|
49
|
+
const { oldStart, newStart, suffix } = starts;
|
|
50
|
+
return `@@ -${formatRange(oldStart, countOldLines(body))} +${formatRange(newStart, countNewLines(body))} @@${suffix}`;
|
|
51
|
+
}
|
|
52
|
+
function normalizeBlankContextLines(lines) {
|
|
53
|
+
// O(n) forward+backward scan instead of per-blank slice().some() (which is O(n²)).
|
|
54
|
+
const n = lines.length;
|
|
55
|
+
// seenBefore[i] is true when at least one body line exists at index < i.
|
|
56
|
+
const seenBefore = new Uint8Array(n);
|
|
57
|
+
for (let i = 1; i < n; i += 1) {
|
|
58
|
+
seenBefore[i] = seenBefore[i - 1] === 1 || isBodyLine(lines[i - 1] ?? "") ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
// seenAfter[i] is true when at least one body line exists at index > i.
|
|
61
|
+
const seenAfter = new Uint8Array(n);
|
|
62
|
+
for (let i = n - 2; i >= 0; i -= 1) {
|
|
63
|
+
seenAfter[i] = seenAfter[i + 1] === 1 || isBodyLine(lines[i + 1] ?? "") ? 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
return lines.map((line, index) => line === "" && seenBefore[index] === 1 && seenAfter[index] === 1 ? " " : line);
|
|
66
|
+
}
|
|
67
|
+
export function normalizeUnifiedDiffHunks(diff) {
|
|
68
|
+
const lines = diff.split("\n");
|
|
69
|
+
const out = [];
|
|
70
|
+
let index = 0;
|
|
71
|
+
while (index < lines.length) {
|
|
72
|
+
const line = lines[index] ?? "";
|
|
73
|
+
if (!line.startsWith("@@")) {
|
|
74
|
+
out.push(line);
|
|
75
|
+
index += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const end = hunkEnd(lines, index + 1);
|
|
79
|
+
const normalizedLines = normalizeBlankContextLines(lines.slice(index + 1, end));
|
|
80
|
+
const body = normalizedLines.filter(isBodyLine);
|
|
81
|
+
out.push(normalizeHunkHeader(line, body), ...normalizedLines);
|
|
82
|
+
index = end;
|
|
83
|
+
}
|
|
84
|
+
return out.join("\n");
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PatchFileChange } from "./types.js";
|
|
2
|
+
export interface ParsedPatch {
|
|
3
|
+
readonly files: readonly PatchFileChange[];
|
|
4
|
+
}
|
|
5
|
+
export declare class PatchParseError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function parseUnifiedDiff(diff: string): ParsedPatch;
|
|
9
|
+
//# sourceMappingURL=patch-parse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patch-parse.d.ts","sourceRoot":"","sources":["../src/patch-parse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAmB,eAAe,EAAa,MAAM,YAAY,CAAC;AAK9E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,eAAe,EAAE,CAAC;CAC5C;AAED,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AA2ND,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAS1D"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// PURE, ReDoS-free unified-diff parser. Supported subset (documented in ADR-0006):
|
|
2
|
+
// - file headers `--- a/<path>` / `+++ b/<path>`, with `/dev/null` for create/delete;
|
|
3
|
+
// - hunk headers `@@ -l,s +l,s @@` (the count `,s` defaults to 1 when omitted);
|
|
4
|
+
// - body lines beginning with " " (context), "+" (add), "-" (remove), and "\" (no-newline).
|
|
5
|
+
// This is NOT git-apply: no rename detection, no binary patches, no fuzzy matching. The parser
|
|
6
|
+
// is linear (a single pass with bounded per-line regexes) so it cannot backtrack catastrophically.
|
|
7
|
+
// Bounded, anchored hunk-header regex. Each numeric group is `\d+` (linear, no nesting).
|
|
8
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
9
|
+
export class PatchParseError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "PatchParseError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function stripPrefix(raw) {
|
|
16
|
+
if (raw === "/dev/null") {
|
|
17
|
+
return raw;
|
|
18
|
+
}
|
|
19
|
+
// Drop a leading `a/` or `b/` git prefix; otherwise keep the path verbatim.
|
|
20
|
+
if (raw.startsWith("a/") || raw.startsWith("b/")) {
|
|
21
|
+
return raw.slice(2);
|
|
22
|
+
}
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
|
+
// The header path may carry a trailing tab + timestamp (`path\t2024-…`); keep only the path.
|
|
26
|
+
function headerPath(line, marker) {
|
|
27
|
+
const rest = line.slice(marker.length);
|
|
28
|
+
const tab = rest.indexOf("\t");
|
|
29
|
+
const raw = tab === -1 ? rest : rest.slice(0, tab);
|
|
30
|
+
return stripPrefix(raw.trim());
|
|
31
|
+
}
|
|
32
|
+
function classify(oldPath, newPath) {
|
|
33
|
+
if (oldPath === "/dev/null") {
|
|
34
|
+
return { kind: "create", path: newPath };
|
|
35
|
+
}
|
|
36
|
+
if (newPath === "/dev/null") {
|
|
37
|
+
return { kind: "delete", path: oldPath };
|
|
38
|
+
}
|
|
39
|
+
return { kind: "modify", path: newPath };
|
|
40
|
+
}
|
|
41
|
+
function toHunk(acc) {
|
|
42
|
+
return {
|
|
43
|
+
oldStart: acc.oldStart,
|
|
44
|
+
oldLines: acc.oldLines,
|
|
45
|
+
newStart: acc.newStart,
|
|
46
|
+
newLines: acc.newLines,
|
|
47
|
+
lines: acc.lines,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function parseHunkHeader(line) {
|
|
51
|
+
const match = HUNK_HEADER.exec(line);
|
|
52
|
+
if (match === null) {
|
|
53
|
+
throw new PatchParseError("malformed hunk header");
|
|
54
|
+
}
|
|
55
|
+
const oldLines = match[2] === undefined ? 1 : Number(match[2]);
|
|
56
|
+
const newLines = match[4] === undefined ? 1 : Number(match[4]);
|
|
57
|
+
return {
|
|
58
|
+
oldStart: Number(match[1]),
|
|
59
|
+
oldLines,
|
|
60
|
+
newStart: Number(match[3]),
|
|
61
|
+
newLines,
|
|
62
|
+
lines: [],
|
|
63
|
+
oldRemaining: oldLines,
|
|
64
|
+
newRemaining: newLines,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function newFileAccumulator() {
|
|
68
|
+
return {
|
|
69
|
+
oldPath: undefined,
|
|
70
|
+
newPath: undefined,
|
|
71
|
+
hunks: [],
|
|
72
|
+
current: undefined,
|
|
73
|
+
added: 0,
|
|
74
|
+
removed: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function finishHunk(file) {
|
|
78
|
+
if (file.current !== undefined) {
|
|
79
|
+
if (file.current.oldRemaining !== 0 || file.current.newRemaining !== 0) {
|
|
80
|
+
throw new PatchParseError("hunk body has fewer lines than declared");
|
|
81
|
+
}
|
|
82
|
+
file.hunks.push(toHunk(file.current));
|
|
83
|
+
file.current = undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function finishFile(files, file) {
|
|
87
|
+
finishHunk(file);
|
|
88
|
+
if (file.oldPath === undefined || file.newPath === undefined) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { kind, path } = classify(file.oldPath, file.newPath);
|
|
92
|
+
files.push({
|
|
93
|
+
path,
|
|
94
|
+
kind,
|
|
95
|
+
hunks: file.hunks,
|
|
96
|
+
addedLines: file.added,
|
|
97
|
+
removedLines: file.removed,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function countBody(file, line) {
|
|
101
|
+
if (line.startsWith("+")) {
|
|
102
|
+
file.added += 1;
|
|
103
|
+
}
|
|
104
|
+
else if (line.startsWith("-")) {
|
|
105
|
+
file.removed += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function startNewFile(state, oldPath) {
|
|
109
|
+
if (state.current !== undefined) {
|
|
110
|
+
finishFile(state.files, state.current);
|
|
111
|
+
}
|
|
112
|
+
const file = newFileAccumulator();
|
|
113
|
+
file.oldPath = oldPath;
|
|
114
|
+
state.current = file;
|
|
115
|
+
return file;
|
|
116
|
+
}
|
|
117
|
+
function isHunkBodyLine(line) {
|
|
118
|
+
const marker = line.charAt(0);
|
|
119
|
+
return marker === " " || marker === "+" || marker === "-";
|
|
120
|
+
}
|
|
121
|
+
// A hunk is still consuming body lines while either the old or the new line budget is positive.
|
|
122
|
+
function hunkActive(file) {
|
|
123
|
+
const hunk = file.current;
|
|
124
|
+
return hunk !== undefined && (hunk.oldRemaining > 0 || hunk.newRemaining > 0);
|
|
125
|
+
}
|
|
126
|
+
// Draws down the hunk's old/new line budget for a body line (context draws both, `-` old, `+` new).
|
|
127
|
+
function assertBudgetAvailable(hunk, line) {
|
|
128
|
+
const marker = line.charAt(0);
|
|
129
|
+
const oldNeeded = marker === " " || marker === "-" ? 1 : 0;
|
|
130
|
+
const newNeeded = marker === " " || marker === "+" ? 1 : 0;
|
|
131
|
+
if (hunk.oldRemaining < oldNeeded || hunk.newRemaining < newNeeded) {
|
|
132
|
+
throw new PatchParseError("hunk body has more lines than declared");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function consumeBudget(hunk, line) {
|
|
136
|
+
const marker = line.charAt(0);
|
|
137
|
+
if (marker === " ") {
|
|
138
|
+
hunk.oldRemaining -= 1;
|
|
139
|
+
hunk.newRemaining -= 1;
|
|
140
|
+
}
|
|
141
|
+
else if (marker === "-") {
|
|
142
|
+
hunk.oldRemaining -= 1;
|
|
143
|
+
}
|
|
144
|
+
else if (marker === "+") {
|
|
145
|
+
hunk.newRemaining -= 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function handleBodyLine(file, line) {
|
|
149
|
+
if (file.current === undefined) {
|
|
150
|
+
return; // lines outside a hunk (e.g. `diff --git`, `index …`) are ignored
|
|
151
|
+
}
|
|
152
|
+
if (line.startsWith("\\")) {
|
|
153
|
+
return; // "" marker
|
|
154
|
+
}
|
|
155
|
+
// Only genuine body lines (context/add/remove) belong to the hunk. An empty trailing line
|
|
156
|
+
// (the split artifact of a final newline) or any other token ends the hunk so it is not
|
|
157
|
+
// mistaken for a zero-length context line that would never match the file content.
|
|
158
|
+
if (!isHunkBodyLine(line)) {
|
|
159
|
+
finishHunk(file);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
assertBudgetAvailable(file.current, line);
|
|
163
|
+
file.current.lines.push(line);
|
|
164
|
+
countBody(file, line);
|
|
165
|
+
consumeBudget(file.current, line);
|
|
166
|
+
}
|
|
167
|
+
function handleLine(state, line) {
|
|
168
|
+
// While a hunk still has budget, ` `/`+`/`-` lines are BODY even if they render as `--- `/`+++ `
|
|
169
|
+
// (C6). Only once the hunk is consumed (or absent) can a `--- ` line open a new file.
|
|
170
|
+
if (state.current !== undefined && hunkActive(state.current) && isHunkBodyLine(line)) {
|
|
171
|
+
handleBodyLine(state.current, line);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (line.startsWith("--- ")) {
|
|
175
|
+
startNewFile(state, headerPath(line, "--- "));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (state.current === undefined) {
|
|
179
|
+
return; // skip preamble before the first file header
|
|
180
|
+
}
|
|
181
|
+
if (line.startsWith("+++ ")) {
|
|
182
|
+
state.current.newPath = headerPath(line, "+++ ");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (line.startsWith("@@")) {
|
|
186
|
+
finishHunk(state.current);
|
|
187
|
+
state.current.current = parseHunkHeader(line);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
handleBodyLine(state.current, line);
|
|
191
|
+
}
|
|
192
|
+
export function parseUnifiedDiff(diff) {
|
|
193
|
+
const state = { files: [], current: undefined };
|
|
194
|
+
for (const line of diff.split("\n")) {
|
|
195
|
+
handleLine(state, line);
|
|
196
|
+
}
|
|
197
|
+
if (state.current !== undefined) {
|
|
198
|
+
finishFile(state.files, state.current);
|
|
199
|
+
}
|
|
200
|
+
return { files: state.files };
|
|
201
|
+
}
|
package/dist/patch.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type WorkspaceFs, type WorkspaceInfo } from "@oscharko-dev/keiko-workspace";
|
|
2
|
+
import { type WorkspaceWriter } from "./writer.js";
|
|
3
|
+
import { type PatchApplyResult, type PatchLimits, type PatchValidation } from "./types.js";
|
|
4
|
+
export interface ValidateDeps {
|
|
5
|
+
readonly fs?: WorkspaceFs | undefined;
|
|
6
|
+
readonly limits?: PatchLimits | undefined;
|
|
7
|
+
readonly allowOverwrite?: boolean | undefined;
|
|
8
|
+
}
|
|
9
|
+
export declare function validatePatch(workspace: WorkspaceInfo, diff: string, deps?: ValidateDeps): PatchValidation;
|
|
10
|
+
export declare function renderDryRun(validation: PatchValidation): string;
|
|
11
|
+
export declare function invertPatch(diff: string): string;
|
|
12
|
+
export declare function buildRestorePatch(workspace: WorkspaceInfo, diff: string, deps?: ValidateDeps): string | undefined;
|
|
13
|
+
export interface ApplyDeps {
|
|
14
|
+
readonly applyEnabled: boolean;
|
|
15
|
+
readonly signal: AbortSignal;
|
|
16
|
+
readonly fs?: WorkspaceFs | undefined;
|
|
17
|
+
readonly writer?: WorkspaceWriter | undefined;
|
|
18
|
+
readonly limits?: PatchLimits | undefined;
|
|
19
|
+
readonly allowOverwrite?: boolean | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare function applyPatch(workspace: WorkspaceInfo, diff: string, deps: ApplyDeps): PatchApplyResult;
|
|
22
|
+
//# sourceMappingURL=patch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patch.d.ts","sourceRoot":"","sources":["../src/patch.ts"],"names":[],"mappings":"AAOA,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,aAAa,EACnB,MAAM,+BAA+B,CAAC;AAWvC,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAEL,KAAK,gBAAgB,EAKrB,KAAK,WAAW,EAEhB,KAAK,eAAe,EACrB,MAAM,YAAY,CAAC;AA2RpB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAG1C,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAC/C;AAwED,wBAAgB,aAAa,CAC3B,SAAS,EAAE,aAAa,EACxB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,YAAiB,GACtB,eAAe,CAejB;AAQD,wBAAgB,YAAY,CAAC,UAAU,EAAE,eAAe,GAAG,MAAM,CAYhE;AA8CD,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGhD;AAeD,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,aAAa,EACxB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,YAAiB,GACtB,MAAM,GAAG,SAAS,CAmBpB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,EAAE,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAG1C,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAC/C;AAgGD,wBAAgB,UAAU,CACxB,SAAS,EAAE,aAAa,EACxB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,SAAS,GACd,gBAAgB,CAyBlB"}
|