@dex-ai/tools-extension 0.1.4
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/README.md +152 -0
- package/package.json +53 -0
- package/src/_cwd.test.ts +66 -0
- package/src/_cwd.ts +137 -0
- package/src/_diff.test.ts +57 -0
- package/src/_diff.ts +221 -0
- package/src/_errors.ts +20 -0
- package/src/_fake-provider.ts +102 -0
- package/src/_test-helpers.ts +30 -0
- package/src/bash.test.ts +105 -0
- package/src/bash.ts +203 -0
- package/src/edit-file.test.ts +175 -0
- package/src/edit-file.ts +207 -0
- package/src/index.ts +78 -0
- package/src/read-file.test.ts +87 -0
- package/src/read-file.ts +110 -0
- package/src/search.test.ts +109 -0
- package/src/search.ts +276 -0
- package/src/write-file.test.ts +77 -0
- package/src/write-file.ts +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# @dex-ai/tools
|
|
2
|
+
|
|
3
|
+
Core tool Extensions for [`@dex-ai/sdk`](https://github.com/klxdev/dex-ai-sdk). Each tool is its own Extension — compose the ones you want, skip the ones you don't.
|
|
4
|
+
|
|
5
|
+
| Extension | Tool | What it does | Sandboxed | Destructive |
|
|
6
|
+
|------------------------|-------------|-----------------------------------------------------------|-----------|-------------|
|
|
7
|
+
| `readFileExtension` | `read_file` | Read a file (optional line range). | yes (cwd) | no |
|
|
8
|
+
| `writeFileExtension` | `write_file`| Create or overwrite a file. | yes (cwd) | **yes** |
|
|
9
|
+
| `editFileExtension` | `edit_file` | Atomic multi-edit by exact string match. | yes (cwd) | **yes** |
|
|
10
|
+
| `searchExtension` | `search` | grep regex over contents, or glob over paths. | yes (cwd) | no |
|
|
11
|
+
| `bashExtension` | `bash` | Run `sh -c <command>` with timeout + captured output. | no | **yes** |
|
|
12
|
+
|
|
13
|
+
## Install + use
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { DexAgent } from '@dex-ai/runtime';
|
|
17
|
+
import { openai } from '@dex-ai/openai';
|
|
18
|
+
import {
|
|
19
|
+
readFileExtension, writeFileExtension, editFileExtension,
|
|
20
|
+
searchExtension, bashExtension,
|
|
21
|
+
allFsExtensions,
|
|
22
|
+
} from '@dex-ai/tools';
|
|
23
|
+
|
|
24
|
+
const agent = new DexAgent({
|
|
25
|
+
provider: openai({ modelId: 'gpt-4.1' }),
|
|
26
|
+
extensions: [
|
|
27
|
+
// pick individually...
|
|
28
|
+
readFileExtension({ cwd: process.cwd() }),
|
|
29
|
+
searchExtension({ cwd: process.cwd() }),
|
|
30
|
+
|
|
31
|
+
// ...or bulk register the full fs set:
|
|
32
|
+
...allFsExtensions({ cwd: process.cwd() }),
|
|
33
|
+
|
|
34
|
+
bashExtension({ cwd: process.cwd() }),
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Subpath imports are also available for bundle-size control:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { bashExtension } from '@dex-ai/tools/bash';
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Tool contracts
|
|
46
|
+
|
|
47
|
+
### `read_file`
|
|
48
|
+
```
|
|
49
|
+
params: { path: string, lineStart?: number, lineEnd?: number }
|
|
50
|
+
output: { type: 'text', value: string }
|
|
51
|
+
errors: path-outside-cwd, file-not-found, not-a-regular-file, too-large (>10MB)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `write_file`
|
|
55
|
+
```
|
|
56
|
+
params: { path: string, content: string }
|
|
57
|
+
output: { type: 'json', value: { bytesWritten: number, created: boolean } }
|
|
58
|
+
behavior: creates parent dirs if missing; overwrites existing files
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `edit_file`
|
|
62
|
+
```
|
|
63
|
+
params: {
|
|
64
|
+
path: string,
|
|
65
|
+
edits: Array<{ oldString: string, newString: string, replaceAll?: boolean }>,
|
|
66
|
+
}
|
|
67
|
+
output: { type: 'json', value: { appliedEdits: number } }
|
|
68
|
+
semantics: all-or-nothing. Each oldString must appear exactly once unless
|
|
69
|
+
replaceAll is true. Overlapping ranges fail. File must exist.
|
|
70
|
+
Use write_file to create new files.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### `search`
|
|
74
|
+
```
|
|
75
|
+
params: {
|
|
76
|
+
mode: 'grep' | 'find',
|
|
77
|
+
pattern: string, // grep: regex source; find: glob (*, **, ?)
|
|
78
|
+
path?: string, // defaults to cwd
|
|
79
|
+
maxResults?: number, // default 100
|
|
80
|
+
caseInsensitive?: boolean // grep-only
|
|
81
|
+
}
|
|
82
|
+
output: { type: 'json', value: { matches: [...], truncated: boolean } }
|
|
83
|
+
behavior: skips node_modules, .git, dist by default unless the caller scopes
|
|
84
|
+
`path` into them.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `bash`
|
|
88
|
+
```
|
|
89
|
+
params: { command: string, timeoutMs?: number } // default 120_000ms
|
|
90
|
+
output: { type: 'json', value: { stdout, stderr, exitCode, timedOut } }
|
|
91
|
+
behavior: runs via /bin/sh -c in cwd. Timeout kills the process and returns
|
|
92
|
+
timedOut: true rather than throwing.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Approval — it's not here
|
|
96
|
+
|
|
97
|
+
This package ships **zero approval logic**. The SDK treats approval as cross-cutting via the `onToolCall` hook; apps install their own approver extension. The typical shape:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import type { Extension, ToolCall, ToolResult } from '@dex-ai/sdk';
|
|
101
|
+
|
|
102
|
+
function approvalExtension(opts: {
|
|
103
|
+
gates: (call: ToolCall) => boolean; // returns true if this call needs approval
|
|
104
|
+
ask: (call: ToolCall) => Promise<boolean>; // async prompt; returns true to allow
|
|
105
|
+
}): Extension {
|
|
106
|
+
return {
|
|
107
|
+
name: 'approval',
|
|
108
|
+
async onToolCall(call): Promise<ToolResult | void> {
|
|
109
|
+
if (!opts.gates(call)) return; // no gate — run the tool
|
|
110
|
+
const ok = await opts.ask(call);
|
|
111
|
+
if (ok) return; // approved — pass through
|
|
112
|
+
return { // rejected — model sees this as a tool-result
|
|
113
|
+
toolCallId: call.toolCallId,
|
|
114
|
+
toolName: call.toolName,
|
|
115
|
+
output: { type: 'error-text', value: 'User rejected this tool call.' },
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// wire it up:
|
|
122
|
+
const agent = new DexAgent({
|
|
123
|
+
provider,
|
|
124
|
+
extensions: [
|
|
125
|
+
bashExtension({ cwd }),
|
|
126
|
+
writeFileExtension({ cwd }),
|
|
127
|
+
approvalExtension({
|
|
128
|
+
gates: (call) => ['bash', 'write_file', 'edit_file'].includes(call.toolName),
|
|
129
|
+
ask: (call) => ui.confirm(`Run ${call.toolName}: ${JSON.stringify(call.input)}?`),
|
|
130
|
+
}),
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Rejected tool calls come back to the model as a normal `tool-result` message containing `error-text`. The model decides what to do next (try a different tool, stop, ask the user).
|
|
136
|
+
|
|
137
|
+
## Sandbox details
|
|
138
|
+
|
|
139
|
+
The four fs tools resolve every path against `cwd` and reject anything outside. Specifically:
|
|
140
|
+
- `../` traversal is rejected (paths are normalized).
|
|
141
|
+
- Absolute paths outside `cwd` are rejected.
|
|
142
|
+
- Symlinks are followed via `fs.realpath` — a symlink inside `cwd` that points outside is rejected.
|
|
143
|
+
|
|
144
|
+
`bash` is **not** sandboxed at the filesystem level. It runs with `cwd` as its working directory but any approved shell command can read/write anywhere the process has permission to. Approval is the gate — use `onToolCall`.
|
|
145
|
+
|
|
146
|
+
## Testing
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
bun test
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
50 tests across 6 files as of v0.1 — every tool exercised end-to-end through a `DexAgent` + scripted fake Provider.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dex-ai/tools-extension",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Core tool Extensions for @dex-ai/sdk — file read/write/edit, search, bash. Each tool is its own Extension.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./read-file": {
|
|
12
|
+
"types": "./src/read-file.ts",
|
|
13
|
+
"default": "./src/read-file.ts"
|
|
14
|
+
},
|
|
15
|
+
"./write-file": {
|
|
16
|
+
"types": "./src/write-file.ts",
|
|
17
|
+
"default": "./src/write-file.ts"
|
|
18
|
+
},
|
|
19
|
+
"./edit-file": {
|
|
20
|
+
"types": "./src/edit-file.ts",
|
|
21
|
+
"default": "./src/edit-file.ts"
|
|
22
|
+
},
|
|
23
|
+
"./search": {
|
|
24
|
+
"types": "./src/search.ts",
|
|
25
|
+
"default": "./src/search.ts"
|
|
26
|
+
},
|
|
27
|
+
"./bash": {
|
|
28
|
+
"types": "./src/bash.ts",
|
|
29
|
+
"default": "./src/bash.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "bun test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@dex-ai/sdk": "^0.1.2"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"zod": "^3.23.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"zod": "^3.23.8"
|
|
47
|
+
},
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org/"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/_cwd.test.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { mkdir, writeFile, symlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tempWorkspace } from './_test-helpers';
|
|
5
|
+
import { resolveInCwd, PathOutsideCwdError } from './_cwd';
|
|
6
|
+
|
|
7
|
+
describe('resolveInCwd', () => {
|
|
8
|
+
let cwd: string;
|
|
9
|
+
let cleanup: () => Promise<void>;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
({ cwd, cleanup } = await tempWorkspace());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => cleanup());
|
|
16
|
+
|
|
17
|
+
test('relative path resolves under cwd', async () => {
|
|
18
|
+
await writeFile(join(cwd, 'a.txt'), 'hi');
|
|
19
|
+
const r = await resolveInCwd(cwd, 'a.txt');
|
|
20
|
+
expect(r.startsWith(cwd)).toBe(true);
|
|
21
|
+
expect(r.endsWith('a.txt')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('absolute path under cwd is accepted', async () => {
|
|
25
|
+
await writeFile(join(cwd, 'a.txt'), 'hi');
|
|
26
|
+
const r = await resolveInCwd(cwd, join(cwd, 'a.txt'));
|
|
27
|
+
expect(r.endsWith('a.txt')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('../ escape is rejected', async () => {
|
|
31
|
+
expect(resolveInCwd(cwd, '../escape.txt')).rejects.toBeInstanceOf(PathOutsideCwdError);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('absolute path outside cwd is rejected', async () => {
|
|
35
|
+
expect(resolveInCwd(cwd, '/etc/passwd')).rejects.toBeInstanceOf(PathOutsideCwdError);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('non-existent file inside cwd is fine (for write)', async () => {
|
|
39
|
+
const r = await resolveInCwd(cwd, 'new/file.txt');
|
|
40
|
+
expect(r.startsWith(cwd)).toBe(true);
|
|
41
|
+
expect(r.endsWith('new/file.txt')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('symlink inside cwd pointing OUTSIDE is rejected', async () => {
|
|
45
|
+
const outside = await tempWorkspace();
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(join(outside.cwd, 'secret'), 'nope');
|
|
48
|
+
await symlink(join(outside.cwd, 'secret'), join(cwd, 'bad-link'));
|
|
49
|
+
expect(resolveInCwd(cwd, 'bad-link')).rejects.toBeInstanceOf(PathOutsideCwdError);
|
|
50
|
+
} finally {
|
|
51
|
+
await outside.cleanup();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('symlink inside cwd pointing INSIDE is fine', async () => {
|
|
56
|
+
await mkdir(join(cwd, 'sub'));
|
|
57
|
+
await writeFile(join(cwd, 'sub', 'target'), 'ok');
|
|
58
|
+
await symlink(join(cwd, 'sub', 'target'), join(cwd, 'good-link'));
|
|
59
|
+
const r = await resolveInCwd(cwd, 'good-link');
|
|
60
|
+
expect(r.startsWith(cwd)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('mustExist=true rejects missing paths', async () => {
|
|
64
|
+
expect(resolveInCwd(cwd, 'missing.txt', { mustExist: true })).rejects.toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/_cwd.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cwd sandbox helper for fs tools.
|
|
3
|
+
*
|
|
4
|
+
* resolveInCwd takes an absolute or relative input path, resolves it
|
|
5
|
+
* (following `..` segments and symlinks via fs.realpath), and rejects
|
|
6
|
+
* anything that lands outside the sandbox root. Every fs-facing tool
|
|
7
|
+
* calls this before touching the filesystem.
|
|
8
|
+
*/
|
|
9
|
+
import { realpath } from "node:fs/promises";
|
|
10
|
+
import { resolve, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A CwdProvider is either a static path string or a function that returns
|
|
14
|
+
* the current working directory. Tools use this to support dynamic cwd
|
|
15
|
+
* (e.g. changed via an env extension's `cd` tool).
|
|
16
|
+
*/
|
|
17
|
+
export type CwdProvider = string | (() => string);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A RootsProvider returns the list of allowed sandbox root directories.
|
|
21
|
+
* Can be static or dynamic (grows when user adds directories).
|
|
22
|
+
*/
|
|
23
|
+
export type RootsProvider =
|
|
24
|
+
| ReadonlyArray<string>
|
|
25
|
+
| (() => ReadonlyArray<string>);
|
|
26
|
+
|
|
27
|
+
/** Resolve a CwdProvider to a concrete path. */
|
|
28
|
+
export function resolveCwd(provider: CwdProvider): string {
|
|
29
|
+
return typeof provider === "function" ? provider() : provider;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve a RootsProvider to a concrete array. */
|
|
33
|
+
export function resolveRoots(
|
|
34
|
+
provider: RootsProvider | undefined,
|
|
35
|
+
): ReadonlyArray<string> | undefined {
|
|
36
|
+
if (provider === undefined) return undefined;
|
|
37
|
+
return typeof provider === "function" ? provider() : provider;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PathOutsideCwdError extends Error {
|
|
41
|
+
override readonly name = "PathOutsideCwdError";
|
|
42
|
+
constructor(
|
|
43
|
+
public readonly input: string,
|
|
44
|
+
public readonly cwd: string,
|
|
45
|
+
public readonly resolved: string,
|
|
46
|
+
) {
|
|
47
|
+
super(
|
|
48
|
+
`Path outside working directory: ${input} (resolved to ${resolved}; cwd=${cwd})`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolveOptions {
|
|
54
|
+
/**
|
|
55
|
+
* If true, the target must already exist (realpath will throw ENOENT otherwise).
|
|
56
|
+
* If false, we resolve as far as possible and check the deepest existing ancestor
|
|
57
|
+
* — this supports write creating new paths inside cwd.
|
|
58
|
+
*/
|
|
59
|
+
mustExist?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Additional allowed root directories. When provided, a path is valid if it
|
|
62
|
+
* resides within cwd OR within any of these roots. Supports multi-root sandboxes.
|
|
63
|
+
*/
|
|
64
|
+
roots?: ReadonlyArray<string> | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveToRealpath(
|
|
68
|
+
cwdReal: string,
|
|
69
|
+
input: string,
|
|
70
|
+
): Promise<string> {
|
|
71
|
+
const joined = resolve(cwdReal, input);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return await realpath(joined);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as { code?: string }).code !== "ENOENT") throw err;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Path doesn't exist yet. Walk up to find the deepest existing ancestor,
|
|
80
|
+
// realpath that (to resolve symlinks), then reattach the missing suffix.
|
|
81
|
+
const parts = joined.split(sep);
|
|
82
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
83
|
+
const candidate = parts.slice(0, i).join(sep) || sep;
|
|
84
|
+
try {
|
|
85
|
+
const real = await realpath(candidate);
|
|
86
|
+
const tail = parts.slice(i).join(sep);
|
|
87
|
+
return tail === "" ? real : resolve(real, tail);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if ((err as { code?: string }).code !== "ENOENT") throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Nothing exists up the chain; return the joined path as-is.
|
|
93
|
+
return joined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve `input` against `cwd` and assert the result stays inside the sandbox.
|
|
98
|
+
* Returns the realpath (absolute, symlinks resolved) on success.
|
|
99
|
+
* Throws PathOutsideCwdError on escape.
|
|
100
|
+
*
|
|
101
|
+
* When `opts.roots` is provided, the path is valid if it resides within
|
|
102
|
+
* cwd OR any of the given roots.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveInCwd(
|
|
105
|
+
cwd: string,
|
|
106
|
+
input: string,
|
|
107
|
+
opts: ResolveOptions = {},
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
// Realpath cwd once so symlinks in cwd itself don't confuse the comparison.
|
|
110
|
+
const cwdReal = await realpath(cwd);
|
|
111
|
+
const target =
|
|
112
|
+
opts.mustExist === true
|
|
113
|
+
? await realpath(resolve(cwdReal, input))
|
|
114
|
+
: await resolveToRealpath(cwdReal, input);
|
|
115
|
+
|
|
116
|
+
// Check if target is inside cwd
|
|
117
|
+
if (target === cwdReal || target.startsWith(cwdReal + sep)) {
|
|
118
|
+
return target;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check additional roots if provided
|
|
122
|
+
if (opts.roots && opts.roots.length > 0) {
|
|
123
|
+
for (const root of opts.roots) {
|
|
124
|
+
let rootReal: string;
|
|
125
|
+
try {
|
|
126
|
+
rootReal = await realpath(root);
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (target === rootReal || target.startsWith(rootReal + sep)) {
|
|
131
|
+
return target;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new PathOutsideCwdError(input, cwdReal, target);
|
|
137
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { unifiedDiff } from './_diff';
|
|
3
|
+
|
|
4
|
+
describe('unifiedDiff', () => {
|
|
5
|
+
it('returns empty string for identical content', () => {
|
|
6
|
+
const content = 'line1\nline2\nline3\n';
|
|
7
|
+
expect(unifiedDiff(content, content, 'test.ts')).toBe('');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('shows single line change with context', () => {
|
|
11
|
+
const original = 'line1\nline2\nline3\nline4\nline5\n';
|
|
12
|
+
const modified = 'line1\nline2\nchanged\nline4\nline5\n';
|
|
13
|
+
const diff = unifiedDiff(original, modified, 'test.ts');
|
|
14
|
+
|
|
15
|
+
expect(diff).toContain('--- a/test.ts');
|
|
16
|
+
expect(diff).toContain('+++ b/test.ts');
|
|
17
|
+
expect(diff).toContain('-line3');
|
|
18
|
+
expect(diff).toContain('+changed');
|
|
19
|
+
// Context lines
|
|
20
|
+
expect(diff).toContain(' line2');
|
|
21
|
+
expect(diff).toContain(' line4');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('shows addition', () => {
|
|
25
|
+
const original = 'line1\nline2\n';
|
|
26
|
+
const modified = 'line1\nnew\nline2\n';
|
|
27
|
+
const diff = unifiedDiff(original, modified, 'test.ts');
|
|
28
|
+
|
|
29
|
+
expect(diff).toContain('+new');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('shows deletion', () => {
|
|
33
|
+
const original = 'line1\nline2\nline3\n';
|
|
34
|
+
const modified = 'line1\nline3\n';
|
|
35
|
+
const diff = unifiedDiff(original, modified, 'test.ts');
|
|
36
|
+
|
|
37
|
+
expect(diff).toContain('-line2');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('shows multiple hunks for distant changes', () => {
|
|
41
|
+
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
|
|
42
|
+
const original = lines.join('\n') + '\n';
|
|
43
|
+
const modLines = [...lines];
|
|
44
|
+
modLines[2] = 'changed3';
|
|
45
|
+
modLines[17] = 'changed18';
|
|
46
|
+
const modified = modLines.join('\n') + '\n';
|
|
47
|
+
const diff = unifiedDiff(original, modified, 'test.ts');
|
|
48
|
+
|
|
49
|
+
expect(diff).toContain('-line3');
|
|
50
|
+
expect(diff).toContain('+changed3');
|
|
51
|
+
expect(diff).toContain('-line18');
|
|
52
|
+
expect(diff).toContain('+changed18');
|
|
53
|
+
// Should have two @@ hunk headers
|
|
54
|
+
const hunkHeaders = diff.split('\n').filter(l => l.startsWith('@@'));
|
|
55
|
+
expect(hunkHeaders.length).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/_diff.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal unified-diff generator for showing file edits.
|
|
3
|
+
* Produces git-style unified diff output with context lines.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CONTEXT_LINES = 3;
|
|
7
|
+
|
|
8
|
+
interface Hunk {
|
|
9
|
+
oldStart: number;
|
|
10
|
+
oldCount: number;
|
|
11
|
+
newStart: number;
|
|
12
|
+
newCount: number;
|
|
13
|
+
lines: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Simple LCS-based diff producing edit operations.
|
|
18
|
+
* Returns an array of { type, line } entries.
|
|
19
|
+
*/
|
|
20
|
+
function diffLines(
|
|
21
|
+
a: string[],
|
|
22
|
+
b: string[],
|
|
23
|
+
): Array<{ type: "keep" | "del" | "add"; line: string }> {
|
|
24
|
+
const n = a.length;
|
|
25
|
+
const m = b.length;
|
|
26
|
+
|
|
27
|
+
// Optimize: for very large files, use a simpler approach
|
|
28
|
+
if (n * m > 1_000_000) {
|
|
29
|
+
return simpleDiff(a, b);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// LCS table
|
|
33
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
|
34
|
+
Array(m + 1).fill(0),
|
|
35
|
+
);
|
|
36
|
+
for (let i = 1; i <= n; i++) {
|
|
37
|
+
for (let j = 1; j <= m; j++) {
|
|
38
|
+
if (a[i - 1] === b[j - 1]) {
|
|
39
|
+
dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
|
40
|
+
} else {
|
|
41
|
+
dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Backtrack
|
|
47
|
+
const ops: Array<{ type: "keep" | "del" | "add"; line: string }> = [];
|
|
48
|
+
let i = n,
|
|
49
|
+
j = m;
|
|
50
|
+
while (i > 0 || j > 0) {
|
|
51
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
52
|
+
ops.push({ type: "keep", line: a[i - 1]! });
|
|
53
|
+
i--;
|
|
54
|
+
j--;
|
|
55
|
+
} else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
|
|
56
|
+
ops.push({ type: "add", line: b[j - 1]! });
|
|
57
|
+
j--;
|
|
58
|
+
} else {
|
|
59
|
+
ops.push({ type: "del", line: a[i - 1]! });
|
|
60
|
+
i--;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ops.reverse();
|
|
64
|
+
return ops;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fallback for large files: find changed regions by comparing line-by-line.
|
|
69
|
+
*/
|
|
70
|
+
function simpleDiff(
|
|
71
|
+
a: string[],
|
|
72
|
+
b: string[],
|
|
73
|
+
): Array<{ type: "keep" | "del" | "add"; line: string }> {
|
|
74
|
+
const ops: Array<{ type: "keep" | "del" | "add"; line: string }> = [];
|
|
75
|
+
|
|
76
|
+
// Find common prefix
|
|
77
|
+
let prefix = 0;
|
|
78
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) {
|
|
79
|
+
ops.push({ type: "keep", line: a[prefix]! });
|
|
80
|
+
prefix++;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Find common suffix
|
|
84
|
+
let suffixA = a.length - 1;
|
|
85
|
+
let suffixB = b.length - 1;
|
|
86
|
+
const suffixOps: Array<{ type: "keep" | "del" | "add"; line: string }> = [];
|
|
87
|
+
while (suffixA >= prefix && suffixB >= prefix && a[suffixA] === b[suffixB]) {
|
|
88
|
+
suffixOps.push({ type: "keep", line: a[suffixA]! });
|
|
89
|
+
suffixA--;
|
|
90
|
+
suffixB--;
|
|
91
|
+
}
|
|
92
|
+
suffixOps.reverse();
|
|
93
|
+
|
|
94
|
+
// Middle section: all deletions then all additions
|
|
95
|
+
for (let i = prefix; i <= suffixA; i++) {
|
|
96
|
+
ops.push({ type: "del", line: a[i]! });
|
|
97
|
+
}
|
|
98
|
+
for (let i = prefix; i <= suffixB; i++) {
|
|
99
|
+
ops.push({ type: "add", line: b[i]! });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ops.push(...suffixOps);
|
|
103
|
+
return ops;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Group diff ops into hunks with context lines.
|
|
108
|
+
*/
|
|
109
|
+
function buildHunks(
|
|
110
|
+
ops: Array<{ type: "keep" | "del" | "add"; line: string }>,
|
|
111
|
+
): Hunk[] {
|
|
112
|
+
const hunks: Hunk[] = [];
|
|
113
|
+
let currentHunk: Hunk | null = null;
|
|
114
|
+
let oldLine = 0;
|
|
115
|
+
let newLine = 0;
|
|
116
|
+
let trailingContext = 0;
|
|
117
|
+
|
|
118
|
+
for (let idx = 0; idx < ops.length; idx++) {
|
|
119
|
+
const op = ops[idx]!;
|
|
120
|
+
|
|
121
|
+
if (op.type === "keep") {
|
|
122
|
+
oldLine++;
|
|
123
|
+
newLine++;
|
|
124
|
+
|
|
125
|
+
if (currentHunk) {
|
|
126
|
+
trailingContext++;
|
|
127
|
+
currentHunk.lines.push(` ${op.line}`);
|
|
128
|
+
currentHunk.oldCount++;
|
|
129
|
+
currentHunk.newCount++;
|
|
130
|
+
|
|
131
|
+
// If we've accumulated enough trailing context and there's no more changes nearby
|
|
132
|
+
if (trailingContext >= CONTEXT_LINES) {
|
|
133
|
+
// Check if next change is within context distance
|
|
134
|
+
let nextChange = -1;
|
|
135
|
+
for (
|
|
136
|
+
let k = idx + 1;
|
|
137
|
+
k < ops.length && k <= idx + CONTEXT_LINES;
|
|
138
|
+
k++
|
|
139
|
+
) {
|
|
140
|
+
if (ops[k]!.type !== "keep") {
|
|
141
|
+
nextChange = k;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (nextChange === -1) {
|
|
146
|
+
// Close hunk
|
|
147
|
+
hunks.push(currentHunk);
|
|
148
|
+
currentHunk = null;
|
|
149
|
+
trailingContext = 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Change line — start or extend hunk
|
|
155
|
+
if (!currentHunk) {
|
|
156
|
+
// Start new hunk with leading context
|
|
157
|
+
const contextStart = Math.max(0, idx - CONTEXT_LINES);
|
|
158
|
+
currentHunk = {
|
|
159
|
+
oldStart: oldLine - (idx - contextStart) + 1,
|
|
160
|
+
oldCount: 0,
|
|
161
|
+
newStart: newLine - (idx - contextStart) + 1,
|
|
162
|
+
newCount: 0,
|
|
163
|
+
lines: [],
|
|
164
|
+
};
|
|
165
|
+
// Add leading context
|
|
166
|
+
for (let k = contextStart; k < idx; k++) {
|
|
167
|
+
currentHunk.lines.push(` ${ops[k]!.line}`);
|
|
168
|
+
currentHunk.oldCount++;
|
|
169
|
+
currentHunk.newCount++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
trailingContext = 0;
|
|
173
|
+
|
|
174
|
+
if (op.type === "del") {
|
|
175
|
+
currentHunk.lines.push(`-${op.line}`);
|
|
176
|
+
currentHunk.oldCount++;
|
|
177
|
+
oldLine++;
|
|
178
|
+
} else {
|
|
179
|
+
currentHunk.lines.push(`+${op.line}`);
|
|
180
|
+
currentHunk.newCount++;
|
|
181
|
+
newLine++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (currentHunk) {
|
|
187
|
+
hunks.push(currentHunk);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return hunks;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Produce a unified diff string from two file contents.
|
|
195
|
+
*/
|
|
196
|
+
export function unifiedDiff(
|
|
197
|
+
original: string,
|
|
198
|
+
modified: string,
|
|
199
|
+
path: string,
|
|
200
|
+
): string {
|
|
201
|
+
const aLines = original.split("\n");
|
|
202
|
+
const bLines = modified.split("\n");
|
|
203
|
+
|
|
204
|
+
const ops = diffLines(aLines, bLines);
|
|
205
|
+
const hunks = buildHunks(ops);
|
|
206
|
+
|
|
207
|
+
if (hunks.length === 0) return "";
|
|
208
|
+
|
|
209
|
+
const out: string[] = [];
|
|
210
|
+
out.push(`--- a/${path}`);
|
|
211
|
+
out.push(`+++ b/${path}`);
|
|
212
|
+
|
|
213
|
+
for (const hunk of hunks) {
|
|
214
|
+
out.push(
|
|
215
|
+
`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`,
|
|
216
|
+
);
|
|
217
|
+
out.push(...hunk.lines);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return out.join("\n");
|
|
221
|
+
}
|