dsh-patch-edit-plus 0.1.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/CHANGELOG.md +16 -0
- package/CHANGELOG.zh.md +16 -0
- package/LICENSE +21 -0
- package/README.ja.md +124 -0
- package/README.ko.md +124 -0
- package/README.md +124 -0
- package/README.zh.md +123 -0
- package/assets/.gitkeep +0 -0
- package/cordis.patch.yml +6 -0
- package/dsh.plugin.json +21 -0
- package/lib/apply/engine.js +160 -0
- package/lib/apply/plan.js +79 -0
- package/lib/apply/seek.js +47 -0
- package/lib/config.js +45 -0
- package/lib/detect.js +29 -0
- package/lib/errors.js +125 -0
- package/lib/fsops.js +51 -0
- package/lib/index.js +39 -0
- package/lib/parse/codex.js +189 -0
- package/lib/parse/types.js +9 -0
- package/lib/parse/unified.js +243 -0
- package/lib/register.js +60 -0
- package/lib/shellops.js +111 -0
- package/lib/tool.js +208 -0
- package/lib/types/apply/engine.d.ts +39 -0
- package/lib/types/apply/plan.d.ts +31 -0
- package/lib/types/apply/seek.d.ts +21 -0
- package/lib/types/config.d.ts +66 -0
- package/lib/types/detect.d.ts +15 -0
- package/lib/types/errors.d.ts +79 -0
- package/lib/types/fsops.d.ts +28 -0
- package/lib/types/index.d.ts +23 -0
- package/lib/types/parse/codex.d.ts +28 -0
- package/lib/types/parse/types.d.ts +58 -0
- package/lib/types/parse/unified.d.ts +17 -0
- package/lib/types/register.d.ts +23 -0
- package/lib/types/shellops.d.ts +41 -0
- package/lib/types/tool.d.ts +46 -0
- package/package.json +103 -0
- package/screenshots.json +3 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-phase atomic application engine.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 (verify) reads every target and computes the full in-memory result;
|
|
5
|
+
* ANY failure aborts the whole patch with zero bytes written. Phase 2 (apply)
|
|
6
|
+
* only writes what verify already validated.
|
|
7
|
+
* @module dsh-patch-edit-plus/apply/engine
|
|
8
|
+
*/
|
|
9
|
+
import { PatchError, addExistsError, mismatchMessage, missingFileError } from '../errors.js';
|
|
10
|
+
import { readFile, statFile, writeFile } from '../fsops.js';
|
|
11
|
+
import { moveFile, removeFile, assertShellReady } from '../shellops.js';
|
|
12
|
+
import { consumedLines, findSequence } from './seek.js';
|
|
13
|
+
import { needsShell } from './plan.js';
|
|
14
|
+
/**
|
|
15
|
+
* Verify the whole patch: read every file, locate every hunk, compute every
|
|
16
|
+
* result. Throws on the first failure — nothing has been written.
|
|
17
|
+
*/
|
|
18
|
+
export async function verifyPatch(ctx, exec, plan, cfg) {
|
|
19
|
+
if (plan.entries.some(entry => needsShell(entry.op))) {
|
|
20
|
+
assertShellReady(ctx, cfg);
|
|
21
|
+
}
|
|
22
|
+
const changes = [];
|
|
23
|
+
for (const entry of plan.entries) {
|
|
24
|
+
const { op } = entry;
|
|
25
|
+
if (op.kind === 'add') {
|
|
26
|
+
const existing = await statFile(ctx, exec, entry.target);
|
|
27
|
+
if (existing !== undefined)
|
|
28
|
+
throw addExistsError(op.path);
|
|
29
|
+
changes.push({ entry, before: null, after: joinContent(op.lines, op.trailingNewline, false) });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (op.kind === 'delete') {
|
|
33
|
+
const existing = await statFile(ctx, exec, entry.target);
|
|
34
|
+
if (existing === undefined)
|
|
35
|
+
throw missingFileError(op.path, 'delete');
|
|
36
|
+
const before = await readFile(ctx, exec, entry.target);
|
|
37
|
+
changes.push({ entry, before, after: null });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// update
|
|
41
|
+
const before = await readFile(ctx, exec, entry.target);
|
|
42
|
+
const after = op.hunks.length === 0 ? before : applyHunksToContent(op.path, before, op.hunks);
|
|
43
|
+
changes.push({ entry, before, after });
|
|
44
|
+
}
|
|
45
|
+
return changes;
|
|
46
|
+
}
|
|
47
|
+
/** Apply phase: writes only — verify already settled every decision. */
|
|
48
|
+
export async function applyPatch(ctx, exec, changes, cfg, sandboxPolicy) {
|
|
49
|
+
for (const change of changes) {
|
|
50
|
+
const { op } = change.entry;
|
|
51
|
+
if (op.kind === 'delete') {
|
|
52
|
+
await removeFile(ctx, exec, cfg, change.entry.target, sandboxPolicy);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (op.kind === 'add') {
|
|
56
|
+
await writeFile(ctx, exec, change.entry.target, change.after ?? '', sandboxPolicy);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (change.after !== null && change.after !== change.before) {
|
|
60
|
+
await writeFile(ctx, exec, change.entry.target, change.after, sandboxPolicy);
|
|
61
|
+
}
|
|
62
|
+
if (change.entry.moveToTarget !== undefined && op.kind === 'update') {
|
|
63
|
+
await moveFile(ctx, exec, cfg, change.entry.target, change.entry.moveToTarget, sandboxPolicy);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Pure-move entries (update with moveTo and no hunks) skip the write. */
|
|
68
|
+
export function isPureMove(change) {
|
|
69
|
+
return change.entry.op.kind === 'update'
|
|
70
|
+
&& change.entry.op.hunks.length === 0
|
|
71
|
+
&& change.entry.moveToTarget !== undefined;
|
|
72
|
+
}
|
|
73
|
+
function joinContent(lines, trailingNewline, crlf) {
|
|
74
|
+
const text = lines.join('\n') + (trailingNewline && lines.length > 0 ? '\n' : '');
|
|
75
|
+
return crlf ? text.replaceAll('\n', '\r\n') : text;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Apply hunks to raw file content with newline fidelity: CRLF files are
|
|
79
|
+
* normalized for matching and written back in their original style; the
|
|
80
|
+
* trailing-newline state of the file tail is preserved exactly.
|
|
81
|
+
*/
|
|
82
|
+
export function applyHunksToContent(path, before, hunks) {
|
|
83
|
+
const crlf = before.includes('\r\n');
|
|
84
|
+
const beforeEndsWithNewline = before.endsWith('\n');
|
|
85
|
+
const workLines = splitLines(before);
|
|
86
|
+
const output = [];
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
let lastMatchEnd = -1;
|
|
89
|
+
for (let h = 0; h < hunks.length; h += 1) {
|
|
90
|
+
const hunk = hunks[h];
|
|
91
|
+
const match = locate(workLines, hunk, cursor, path, h + 1);
|
|
92
|
+
output.push(...workLines.slice(cursor, match));
|
|
93
|
+
let pos = match;
|
|
94
|
+
for (const line of hunk.lines) {
|
|
95
|
+
if (line.kind === 'context') {
|
|
96
|
+
output.push(workLines[pos] ?? '');
|
|
97
|
+
pos += 1;
|
|
98
|
+
}
|
|
99
|
+
else if (line.kind === 'delete') {
|
|
100
|
+
pos += 1;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
output.push(line.text);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
cursor = pos;
|
|
107
|
+
lastMatchEnd = pos;
|
|
108
|
+
}
|
|
109
|
+
output.push(...workLines.slice(cursor));
|
|
110
|
+
const reachesEnd = lastMatchEnd === workLines.length;
|
|
111
|
+
const afterEndsWithNewline = resolveTrailingNewline(hunks.at(-1), reachesEnd, beforeEndsWithNewline);
|
|
112
|
+
return joinContent(output, afterEndsWithNewline, crlf);
|
|
113
|
+
}
|
|
114
|
+
/** Split raw content into LF lines, dropping the trailing-newline artifact. */
|
|
115
|
+
function splitLines(content) {
|
|
116
|
+
const withoutTrailing = content.endsWith('\n') ? content.slice(0, -1) : content;
|
|
117
|
+
if (withoutTrailing === '')
|
|
118
|
+
return [];
|
|
119
|
+
return withoutTrailing.split('\n').map(line => line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
120
|
+
}
|
|
121
|
+
function resolveTrailingNewline(lastHunk, reachesEnd, beforeEndsWithNewline) {
|
|
122
|
+
if (lastHunk === undefined || !reachesEnd)
|
|
123
|
+
return beforeEndsWithNewline;
|
|
124
|
+
const tail = newSideTail(lastHunk);
|
|
125
|
+
if (tail === 'context')
|
|
126
|
+
return beforeEndsWithNewline;
|
|
127
|
+
// Tail is an inserted line: newline-terminated unless the patch explicitly
|
|
128
|
+
// marked the new side as lacking the final newline.
|
|
129
|
+
return lastHunk.newNoNewline !== true;
|
|
130
|
+
}
|
|
131
|
+
function newSideTail(hunk) {
|
|
132
|
+
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
|
|
133
|
+
const kind = hunk.lines[i].kind;
|
|
134
|
+
if (kind === 'context' || kind === 'add')
|
|
135
|
+
return kind;
|
|
136
|
+
}
|
|
137
|
+
return 'context';
|
|
138
|
+
}
|
|
139
|
+
/** Locate one hunk with the header-line hint, falling back to the plain cursor. */
|
|
140
|
+
function locate(fileLines, hunk, cursor, path, hunkIndex) {
|
|
141
|
+
const options = { endOfFile: hunk.endOfFile };
|
|
142
|
+
if (hunk.oldStart !== undefined && hunk.oldStart > 0) {
|
|
143
|
+
const hinted = Math.max(cursor, hunk.oldStart - 1);
|
|
144
|
+
const atHint = findSequence(fileLines, hunk.lines, hinted, options);
|
|
145
|
+
if (atHint !== null)
|
|
146
|
+
return atHint;
|
|
147
|
+
}
|
|
148
|
+
const found = findSequence(fileLines, hunk.lines, cursor, options);
|
|
149
|
+
if (found !== null)
|
|
150
|
+
return found;
|
|
151
|
+
throw new PatchError('VALIDATION', mismatchMessage({
|
|
152
|
+
path,
|
|
153
|
+
hunkIndex,
|
|
154
|
+
searchFromLine: cursor + 1,
|
|
155
|
+
expected: hunk.lines.filter((line) => line.kind !== 'add').map(line => line.text),
|
|
156
|
+
actual: fileLines.slice(cursor, cursor + 10),
|
|
157
|
+
endOfFile: hunk.endOfFile,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
export { consumedLines };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation planning: path resolution, workspace containment, symlink policy
|
|
3
|
+
* and duplicate detection. Pure resolution work — no file content is read
|
|
4
|
+
* here (the engine's verify stage does that).
|
|
5
|
+
* @module dsh-patch-edit-plus/apply/plan
|
|
6
|
+
*/
|
|
7
|
+
import { duplicatePathError, limitError, missingParentError, pathEscapeError, symlinkRejectedError, } from '../errors.js';
|
|
8
|
+
/**
|
|
9
|
+
* Plan a parsed patch against the filesystem backend.
|
|
10
|
+
* @throws {@link PatchError} on limits, path escapes, symlinks, missing parents or duplicate targets.
|
|
11
|
+
*/
|
|
12
|
+
export async function buildPlan(ctx, exec, parsed, cfg) {
|
|
13
|
+
if (parsed.ops.length > cfg.maxFiles)
|
|
14
|
+
throw limitError('file section count', parsed.ops.length, cfg.maxFiles);
|
|
15
|
+
const fs = ctx.fs;
|
|
16
|
+
const cwd = exec.agent?.session.header.cwd;
|
|
17
|
+
const resolveOpts = { cwd, signal: exec.signal };
|
|
18
|
+
// Containment root: the session workspace, when the call carries one.
|
|
19
|
+
const rootTarget = cwd === undefined ? undefined : await fs.resolve(cwd, resolveOpts);
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const entries = [];
|
|
22
|
+
for (const op of parsed.ops) {
|
|
23
|
+
const target = await resolveChecked(fs, op.path, resolveOpts, rootTarget, cfg, seen);
|
|
24
|
+
let moveToTarget;
|
|
25
|
+
const moveTo = op.kind === 'update' ? op.moveTo : undefined;
|
|
26
|
+
if (moveTo !== undefined)
|
|
27
|
+
moveToTarget = await resolveChecked(fs, moveTo, resolveOpts, rootTarget, cfg, seen);
|
|
28
|
+
if (op.kind === 'add')
|
|
29
|
+
await checkParent(fs, op.path, resolveOpts);
|
|
30
|
+
entries.push({ op, target, moveToTarget });
|
|
31
|
+
}
|
|
32
|
+
return { format: parsed.format, entries };
|
|
33
|
+
}
|
|
34
|
+
/** Resolve one path with containment + symlink policy and duplicate tracking. */
|
|
35
|
+
async function resolveChecked(fs, path, resolveOpts, rootTarget, cfg, seen) {
|
|
36
|
+
const target = await fs.resolve(path, resolveOpts);
|
|
37
|
+
if (rootTarget !== undefined && !fs.contains(rootTarget, target))
|
|
38
|
+
throw pathEscapeError(path);
|
|
39
|
+
if (!cfg.followSymlinks)
|
|
40
|
+
await rejectSymlinks(fs, path, resolveOpts);
|
|
41
|
+
const previous = seen.get(target.targetKey);
|
|
42
|
+
if (previous !== undefined)
|
|
43
|
+
throw duplicatePathError(path);
|
|
44
|
+
seen.set(target.targetKey, path);
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
/** Reject when any path component (or the leaf) is a symbolic link. */
|
|
48
|
+
async function rejectSymlinks(fs, path, resolveOpts) {
|
|
49
|
+
const segments = splitPath(path);
|
|
50
|
+
let prefix = '';
|
|
51
|
+
for (const segment of segments) {
|
|
52
|
+
prefix = prefix === '' ? segment : `${prefix}/${segment}`;
|
|
53
|
+
const info = await fs.lstat(prefix, { cwd: resolveOpts.cwd }, resolveOpts.signal);
|
|
54
|
+
if (info !== undefined && info.type === 'symlink')
|
|
55
|
+
throw symlinkRejectedError(path, prefix);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function splitPath(path) {
|
|
59
|
+
const normalized = path.replaceAll('\\', '/');
|
|
60
|
+
const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
|
|
61
|
+
// Trailing slash or root-relative paths have no meaningful segment chain.
|
|
62
|
+
return segments.length === 0 ? [] : segments;
|
|
63
|
+
}
|
|
64
|
+
/** The Add parent directory must exist (the native write tool does not mkdir either). */
|
|
65
|
+
async function checkParent(fs, path, resolveOpts) {
|
|
66
|
+
const segments = splitPath(path);
|
|
67
|
+
if (segments.length <= 1)
|
|
68
|
+
return; // parent is the workspace root itself
|
|
69
|
+
const parent = segments.slice(0, -1).join('/');
|
|
70
|
+
const info = await fs.lstat(parent, { cwd: resolveOpts.cwd }, resolveOpts.signal);
|
|
71
|
+
if (info === undefined)
|
|
72
|
+
throw missingParentError(path, parent);
|
|
73
|
+
if (info.type !== 'directory')
|
|
74
|
+
throw missingParentError(path, parent);
|
|
75
|
+
}
|
|
76
|
+
/** True when the operation needs the shell backend (delete or move). */
|
|
77
|
+
export function needsShell(op) {
|
|
78
|
+
return op.kind === 'delete' || (op.kind === 'update' && op.moveTo !== undefined);
|
|
79
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-line location with three tolerance levels.
|
|
3
|
+
*
|
|
4
|
+
* Levels, tried in order over the whole search window (exact matches always
|
|
5
|
+
* win over trimmed ones): exact equality → `trimEnd` → full `trim`. Only the
|
|
6
|
+
* context/remove lines participate; add lines are insertions and never
|
|
7
|
+
* constrain the position.
|
|
8
|
+
* @module dsh-patch-edit-plus/apply/seek
|
|
9
|
+
*/
|
|
10
|
+
/** Locate `expected` inside `fileLines` starting at `from`. Returns the match index or `null`. */
|
|
11
|
+
export function findSequence(fileLines, expected, from, opts = {}) {
|
|
12
|
+
const sequence = expected.filter(line => line.kind !== 'add').map(line => line.text);
|
|
13
|
+
if (sequence.length === 0)
|
|
14
|
+
return from <= fileLines.length ? from : null;
|
|
15
|
+
const lastStart = fileLines.length - sequence.length;
|
|
16
|
+
if (lastStart < 0)
|
|
17
|
+
return null;
|
|
18
|
+
const start = opts.endOfFile === true ? Math.max(from, lastStart) : from;
|
|
19
|
+
if (start > lastStart)
|
|
20
|
+
return null;
|
|
21
|
+
const comparators = [
|
|
22
|
+
(a, b) => a === b,
|
|
23
|
+
(a, b) => a.trimEnd() === b.trimEnd(),
|
|
24
|
+
(a, b) => a.trim() === b.trim(),
|
|
25
|
+
];
|
|
26
|
+
for (const equal of comparators) {
|
|
27
|
+
for (let i = start; i <= lastStart; i += 1) {
|
|
28
|
+
if (matchesAt(fileLines, sequence, i, equal))
|
|
29
|
+
return i;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function matchesAt(fileLines, sequence, at, equal) {
|
|
35
|
+
for (let j = 0; j < sequence.length; j += 1) {
|
|
36
|
+
if (!equal(fileLines[at + j], sequence[j]))
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Number of old-side lines a hunk consumes at its match position
|
|
43
|
+
* (context + delete lines).
|
|
44
|
+
*/
|
|
45
|
+
export function consumedLines(hunk) {
|
|
46
|
+
return hunk.lines.filter(line => line.kind !== 'add').length;
|
|
47
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration: Schemastery schema + resolved defaults.
|
|
3
|
+
*
|
|
4
|
+
* Every knob the tool honors lives here; the tool body only ever reads the
|
|
5
|
+
* fully-resolved `ResolvedConfig` produced by `resolveConfig`.
|
|
6
|
+
* @module dsh-patch-edit-plus/config
|
|
7
|
+
*/
|
|
8
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
|
+
/** Schemastery schema for Loader defaults and generated configuration docs. */
|
|
10
|
+
export const Config = z.object({
|
|
11
|
+
toolName: z.string().default('apply_patch'),
|
|
12
|
+
conflictPolicy: z.union(['rename', 'skip', 'fail']).default('rename'),
|
|
13
|
+
renameSuffix: z.string().default('_1'),
|
|
14
|
+
allowUnifiedDiff: z.boolean().default(true),
|
|
15
|
+
allowCodexPatch: z.boolean().default(false),
|
|
16
|
+
deleteBackend: z.union(['shell', 'none']).default('shell'),
|
|
17
|
+
shellDialect: z.union(['auto', 'posix', 'pwsh']).default('auto'),
|
|
18
|
+
deleteCommand: z.string(),
|
|
19
|
+
moveCommand: z.string(),
|
|
20
|
+
dryRunByDefault: z.boolean().default(false),
|
|
21
|
+
followSymlinks: z.boolean().default(false),
|
|
22
|
+
maxFiles: z.number().step(1).min(1).max(500).default(50),
|
|
23
|
+
maxPatchBytes: z.number().step(1).min(1024).default(512 * 1024),
|
|
24
|
+
maxDiffBytes: z.number().step(1).min(256).default(16 * 1024),
|
|
25
|
+
});
|
|
26
|
+
/** Fill user config with defaults; `undefined`/null entries fall back too. */
|
|
27
|
+
export function resolveConfig(config) {
|
|
28
|
+
const c = config ?? {};
|
|
29
|
+
return {
|
|
30
|
+
toolName: typeof c.toolName === 'string' && c.toolName.trim() !== '' ? c.toolName : 'apply_patch',
|
|
31
|
+
conflictPolicy: c.conflictPolicy ?? 'rename',
|
|
32
|
+
renameSuffix: typeof c.renameSuffix === 'string' ? c.renameSuffix : '_1',
|
|
33
|
+
allowUnifiedDiff: c.allowUnifiedDiff ?? true,
|
|
34
|
+
allowCodexPatch: c.allowCodexPatch ?? false,
|
|
35
|
+
deleteBackend: c.deleteBackend ?? 'shell',
|
|
36
|
+
shellDialect: c.shellDialect ?? 'auto',
|
|
37
|
+
deleteCommand: typeof c.deleteCommand === 'string' && c.deleteCommand !== '' ? c.deleteCommand : undefined,
|
|
38
|
+
moveCommand: typeof c.moveCommand === 'string' && c.moveCommand !== '' ? c.moveCommand : undefined,
|
|
39
|
+
dryRunByDefault: c.dryRunByDefault ?? false,
|
|
40
|
+
followSymlinks: c.followSymlinks ?? false,
|
|
41
|
+
maxFiles: c.maxFiles ?? 50,
|
|
42
|
+
maxPatchBytes: c.maxPatchBytes ?? 512 * 1024,
|
|
43
|
+
maxDiffBytes: c.maxDiffBytes ?? 16 * 1024,
|
|
44
|
+
};
|
|
45
|
+
}
|
package/lib/detect.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch format detection.
|
|
3
|
+
*
|
|
4
|
+
* Runs BEFORE any parser so a recognized-but-disabled style can produce an
|
|
5
|
+
* actionable hint instead of a generic parse error (Codex syntax is a strong
|
|
6
|
+
* prior for GPT-family models; a silent failure traps them in retry loops).
|
|
7
|
+
* @module dsh-patch-edit-plus/detect
|
|
8
|
+
*/
|
|
9
|
+
import { unrecognizedPatchError } from './errors.js';
|
|
10
|
+
const HUNK_HINT = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/m;
|
|
11
|
+
/**
|
|
12
|
+
* Classify patch text. Order matters: the `*** Begin Patch` sentinel wins
|
|
13
|
+
* first, then explicit unified-diff markers, then Codex section headers.
|
|
14
|
+
* @throws {@link PatchError} code `PARSE` (with both syntax examples) when the text matches neither supported syntax.
|
|
15
|
+
*/
|
|
16
|
+
export function detectFormat(patch) {
|
|
17
|
+
const normalized = patch.replaceAll('\r\n', '\n').trimStart();
|
|
18
|
+
if (normalized.startsWith('*** Begin Patch'))
|
|
19
|
+
return 'codex';
|
|
20
|
+
if (normalized.startsWith('diff --git '))
|
|
21
|
+
return 'unified';
|
|
22
|
+
if (normalized.startsWith('--- '))
|
|
23
|
+
return 'unified';
|
|
24
|
+
if (HUNK_HINT.test(normalized))
|
|
25
|
+
return 'unified';
|
|
26
|
+
if (normalized.includes('*** Add File:') || normalized.includes('*** Update File:') || normalized.includes('*** Delete File:'))
|
|
27
|
+
return 'codex';
|
|
28
|
+
throw unrecognizedPatchError();
|
|
29
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error taxonomy and model-facing message builders.
|
|
3
|
+
*
|
|
4
|
+
* Every failure surfaces as a `PatchError` with a stable machine code so
|
|
5
|
+
* retry/permission/UI layers can branch without parsing prose. Messages are
|
|
6
|
+
* written for the model to self-correct: they carry the concrete file, hunk,
|
|
7
|
+
* position and excerpt needed to fix the patch (no stacks, no internal paths).
|
|
8
|
+
* @module dsh-patch-edit-plus/errors
|
|
9
|
+
*/
|
|
10
|
+
/** Typed patch failure. */
|
|
11
|
+
export class PatchError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'PatchError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Syntax examples embedded in "cannot recognize" errors so the model can retry in a valid dialect. */
|
|
20
|
+
export const SYNTAX_EXAMPLES = [
|
|
21
|
+
'Supported patch styles:',
|
|
22
|
+
'1. git/unified diff:',
|
|
23
|
+
'--- a/file.txt',
|
|
24
|
+
'+++ b/file.txt',
|
|
25
|
+
'@@ -1,3 +1,3 @@',
|
|
26
|
+
' context',
|
|
27
|
+
'-old line',
|
|
28
|
+
'+new line',
|
|
29
|
+
'2. Codex apply_patch syntax:',
|
|
30
|
+
'*** Begin Patch',
|
|
31
|
+
'*** Update File: file.txt',
|
|
32
|
+
'@@',
|
|
33
|
+
' context',
|
|
34
|
+
'-old line',
|
|
35
|
+
'+new line',
|
|
36
|
+
'*** End Patch',
|
|
37
|
+
].join('\n');
|
|
38
|
+
/** PARSE error for input that matches neither supported syntax. */
|
|
39
|
+
export function unrecognizedPatchError() {
|
|
40
|
+
return new PatchError('PARSE', `Cannot recognize the patch text as either supported syntax.\n${SYNTAX_EXAMPLES}`);
|
|
41
|
+
}
|
|
42
|
+
/** PARSE error with position information for malformed input. */
|
|
43
|
+
export function parseError(lineNo, why) {
|
|
44
|
+
return new PatchError('PARSE', `Invalid patch at line ${lineNo}: ${why}`);
|
|
45
|
+
}
|
|
46
|
+
/** Structured error when a recognized Codex patch arrives while the style is disabled (actionable, not a parse failure). */
|
|
47
|
+
export function codexDisabledError() {
|
|
48
|
+
return new PatchError('UNSUPPORTED', 'The patch was recognized as Codex `apply_patch` syntax, but that style is currently disabled. '
|
|
49
|
+
+ 'Retry with a git/unified diff patch instead, or enable the Codex style by setting '
|
|
50
|
+
+ '`allowCodexPatch: true` in the dsh-patch-edit-plus plugin configuration.');
|
|
51
|
+
}
|
|
52
|
+
/** Structured error when a unified diff arrives while that style is disabled. */
|
|
53
|
+
export function unifiedDisabledError() {
|
|
54
|
+
return new PatchError('UNSUPPORTED', 'The patch was recognized as a git/unified diff, but that style is currently disabled. '
|
|
55
|
+
+ 'Enable it by setting `allowUnifiedDiff: true` in the dsh-patch-edit-plus plugin configuration.');
|
|
56
|
+
}
|
|
57
|
+
/** CONFLICT error for an Add onto an existing path (Codex semantics). */
|
|
58
|
+
export function addExistsError(path) {
|
|
59
|
+
return new PatchError('CONFLICT', `Add File targets "${path}" but the file already exists. Use an Update hunk to modify it, or Delete it first.`);
|
|
60
|
+
}
|
|
61
|
+
/** CONFLICT error for update/delete of a missing file. */
|
|
62
|
+
export function missingFileError(path, action) {
|
|
63
|
+
return new PatchError('CONFLICT', `Cannot ${action} "${path}": the file does not exist.`);
|
|
64
|
+
}
|
|
65
|
+
/** CONFLICT error for a Move onto an existing path. */
|
|
66
|
+
export function moveTargetExistsError(path) {
|
|
67
|
+
return new PatchError('CONFLICT', `Cannot move to "${path}": the target already exists.`);
|
|
68
|
+
}
|
|
69
|
+
/** CONFLICT error when one resolved file is hit by more than one patch operation. */
|
|
70
|
+
export function duplicatePathError(path) {
|
|
71
|
+
return new PatchError('CONFLICT', `The patch touches "${path}" in more than one file section. Split it into separate apply_patch calls with one section per file.`);
|
|
72
|
+
}
|
|
73
|
+
/** VALIDATION error for paths escaping the workspace. */
|
|
74
|
+
export function pathEscapeError(path) {
|
|
75
|
+
return new PatchError('VALIDATION', `Path "${path}" escapes the session workspace. Patch paths must stay inside the workspace.`);
|
|
76
|
+
}
|
|
77
|
+
/** VALIDATION error when a symlink is on the patch path (followSymlinks: false). */
|
|
78
|
+
export function symlinkRejectedError(path, at) {
|
|
79
|
+
return new PatchError('VALIDATION', `Path "${path}" traverses the symbolic link "${at}". Symbolic links are rejected by the default followSymlinks: false policy.`);
|
|
80
|
+
}
|
|
81
|
+
/** VALIDATION error when the Add parent directory does not exist (matches the native write tool's behavior). */
|
|
82
|
+
export function missingParentError(path, parent) {
|
|
83
|
+
return new PatchError('VALIDATION', `Cannot add "${path}": parent directory "${parent}" does not exist. Create the directory first (for example with the bash tool).`);
|
|
84
|
+
}
|
|
85
|
+
/** LIMIT error for exceeding a configured cap. */
|
|
86
|
+
export function limitError(what, actual, limit) {
|
|
87
|
+
return new PatchError('LIMIT', `Patch rejected: ${what} is ${actual}, exceeding the configured limit of ${limit}. Split the patch into smaller apply_patch calls.`);
|
|
88
|
+
}
|
|
89
|
+
/** UNSUPPORTED error when delete/move is disabled or no shell capability exists. */
|
|
90
|
+
export function unsupportedShellError(why) {
|
|
91
|
+
return new PatchError('UNSUPPORTED', `${why} Apply the change with a bash command instead (for example \`rm\` or \`mv\`), or adjust the dsh-patch-edit-plus deleteBackend configuration.`);
|
|
92
|
+
}
|
|
93
|
+
const MAX_PREVIEW_LINES = 12;
|
|
94
|
+
const MAX_ACTUAL_LINES = 10;
|
|
95
|
+
/** Render one line with visible whitespace so exact-match failures are diagnosable. */
|
|
96
|
+
function visible(line) {
|
|
97
|
+
return line.replaceAll(' ', '[space]').replaceAll('\t', '[tab]') || '[empty]';
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Four-element mismatch diagnostic: file + hunk index, search origin, expected
|
|
101
|
+
* preview, actual excerpt — plus a targeted hint when the expected lines look
|
|
102
|
+
* like they still carry patch markers (the classic duplicated-prefix mistake).
|
|
103
|
+
*/
|
|
104
|
+
export function mismatchMessage(input) {
|
|
105
|
+
const expected = input.expected.slice(0, MAX_PREVIEW_LINES).map(visible);
|
|
106
|
+
const actual = input.actual.slice(0, MAX_ACTUAL_LINES).map(visible);
|
|
107
|
+
const parts = [
|
|
108
|
+
`Hunk ${input.hunkIndex} of "${input.path}" does not apply: the context does not match the file.`,
|
|
109
|
+
`Search started at line ${input.searchFromLine}${input.endOfFile ? ' (pinned to end of file)' : ''}.`,
|
|
110
|
+
`Expected lines:\n${expected.join('\n')}`,
|
|
111
|
+
`File around the search origin:\n${actual.length > 0 ? actual.join('\n') : '[end of file]'}`,
|
|
112
|
+
];
|
|
113
|
+
const markerHint = input.expected.find(line => /^[+\-@@]/.test(line));
|
|
114
|
+
if (markerHint !== undefined) {
|
|
115
|
+
parts.push(`Hint: an expected line starts with a patch marker ("${visible(markerHint).slice(0, 40)}"). `
|
|
116
|
+
+ 'Inside an Update hunk only the context/removed lines must match the file exactly as it exists — '
|
|
117
|
+
+ 'do not include "+"/"-"/"@@" prefixes from an outer diff in the hunk body.');
|
|
118
|
+
}
|
|
119
|
+
return parts.join('\n');
|
|
120
|
+
}
|
|
121
|
+
/** IO error wrapping a backend failure. */
|
|
122
|
+
export function ioError(what, cause) {
|
|
123
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
124
|
+
return new PatchError('IO', `${what}: ${detail}`);
|
|
125
|
+
}
|
package/lib/fsops.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem seam: reads plus the official write-intent dance.
|
|
3
|
+
*
|
|
4
|
+
* DSH providers NEVER dispatch `fs/*` events — the tool layer is the only
|
|
5
|
+
* initiator — so a bare `ctx.fs.writeText` would silently bypass the
|
|
6
|
+
* read-before-write gate. Every write here therefore replicates the official
|
|
7
|
+
* `write` tool verbatim:
|
|
8
|
+
* 1. `ctx.waterfall('fs/write-intent', target, exec, () => undefined)`
|
|
9
|
+
* 2. `ctx.fs.writeText(target, content, intent, exec.signal, sandboxPolicy)`
|
|
10
|
+
* 3. `ctx.emit('fs/observed', target, { kind: 'present', version }, exec)`
|
|
11
|
+
* With no policy plugin mounted the waterfall returns `undefined`, which is
|
|
12
|
+
* itself the correct "unconditional write" probe.
|
|
13
|
+
* @module dsh-patch-edit-plus/fsops
|
|
14
|
+
*/
|
|
15
|
+
import { ioError } from './errors.js';
|
|
16
|
+
/**
|
|
17
|
+
* Write one file through the full intent dance. Must stay byte-for-byte the
|
|
18
|
+
* official sequence — the verification script asserts it.
|
|
19
|
+
* @throws {@link PatchError} code `IO` when the backend rejects the write.
|
|
20
|
+
*/
|
|
21
|
+
export async function writeFile(ctx, exec, target, content, sandboxPolicy) {
|
|
22
|
+
try {
|
|
23
|
+
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined);
|
|
24
|
+
const outcome = await ctx.fs.writeText(target, content, intent, exec.signal, sandboxPolicy);
|
|
25
|
+
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec);
|
|
26
|
+
return outcome;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof Error && error.name === 'PatchError')
|
|
30
|
+
throw error;
|
|
31
|
+
throw ioError(`failed to write "${target.displayPath}"`, error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Read one file's text (whole file). */
|
|
35
|
+
export async function readFile(ctx, exec, target) {
|
|
36
|
+
try {
|
|
37
|
+
return await ctx.fs.readText(target, exec.signal);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw ioError(`failed to read "${target.displayPath}"`, error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Stat one resolved target; `undefined` when absent. */
|
|
44
|
+
export async function statFile(ctx, exec, target) {
|
|
45
|
+
try {
|
|
46
|
+
return await ctx.fs.stat(target, exec.signal);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw ioError(`failed to stat "${target.displayPath}"`, error);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-patch-edit-plus — patch-style file editing for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* One `apply_patch` tool accepting git/unified diff (default) and Codex
|
|
5
|
+
* `apply_patch` syntax (opt-in). All-or-nothing application through the
|
|
6
|
+
* official fs write-intent dance; delete/move through the sandbox-aware
|
|
7
|
+
* shell. Compatible with DSH 0.1.2-rc.1 through 0.1.5-rc.2: the tool
|
|
8
|
+
* authoring contract is byte-identical across those versions, so a single
|
|
9
|
+
* code path serves both; only settings registration needs the dual-API
|
|
10
|
+
* fallback (`installSection` first, `register` second).
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-patch-edit-plus
|
|
13
|
+
*/
|
|
14
|
+
import { Config, resolveConfig } from './config.js';
|
|
15
|
+
import { registerApplyPatchTool } from './register.js';
|
|
16
|
+
/** Cordis plugin name used by Loader diagnostics. */
|
|
17
|
+
export const name = 'dsh-patch-edit-plus';
|
|
18
|
+
/** Settings namespace (a plain string literal: valid on every target version). */
|
|
19
|
+
export const SETTINGS_NAMESPACE = 'patch_edit_plus';
|
|
20
|
+
export { Config };
|
|
21
|
+
/** Register the tool and the settings namespace. Every registration is scoped to this plugin. */
|
|
22
|
+
export function apply(ctx, config) {
|
|
23
|
+
const cfg = resolveConfig(config);
|
|
24
|
+
ctx.effect(() => {
|
|
25
|
+
const disposer = registerApplyPatchTool(ctx, cfg);
|
|
26
|
+
return disposer ?? (() => { });
|
|
27
|
+
});
|
|
28
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
29
|
+
const settings = settingsCtx.settings
|
|
30
|
+
?? settingsCtx;
|
|
31
|
+
const base = config ?? {};
|
|
32
|
+
if (typeof settings?.installSection === 'function') {
|
|
33
|
+
settings.installSection(ctx, SETTINGS_NAMESPACE, Config, base, { setSource: () => { }, onChange: () => { } });
|
|
34
|
+
}
|
|
35
|
+
else if (typeof settings?.register === 'function') {
|
|
36
|
+
settings.register(SETTINGS_NAMESPACE, Config, { base });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|