@neabyte/v4a-diff 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NeaByteLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ <div align='center'>
2
+
3
+ # V4A Diff
4
+
5
+ Context-anchored diff engine for LLM-powered file editing
6
+
7
+ [![Deno](https://img.shields.io/badge/deno-compatible-ffcb00?logo=deno&logoColor=000000)](https://deno.com) [![Node](https://img.shields.io/badge/node-%3E%3D24-6DA55F?logo=node.js&logoColor=white)](https://nodejs.org) [![Bun](https://img.shields.io/badge/bun-compatible-f9f1e1?logo=bun&logoColor=000000)](https://bun.sh) [![Browser](https://img.shields.io/badge/browser-compatible-4285F4?logo=googlechrome&logoColor=white)](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
8
+
9
+ [![Module type: Deno/ESM](https://img.shields.io/badge/module%20type-deno%2Fesm-brightgreen)](https://github.com/NeaByteLab/V4A-Diff) [![npm version](https://img.shields.io/npm/v/@neabyte/v4a-diff.svg)](https://www.npmjs.org/package/@neabyte/v4a-diff) [![JSR](https://jsr.io/badges/@neabyte/v4a-diff)](https://jsr.io/@neabyte/v4a-diff) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
+
11
+ <img src="./assets/preview.webp" alt="V4A Diff Preview" width="100%">
12
+
13
+ </div>
14
+
15
+ ## What is V4A?
16
+
17
+ V4A is a context-anchored diff format designed for LLM tool calling. Instead of line numbers, hunks use `@@` text anchors to locate edits - making diffs resilient to file changes between turns. This package parses V4A diffs, applies them to source text, and returns both the patched result and structured diff metadata with line numbers.
18
+
19
+ ## Features
20
+
21
+ - **Token efficient** - Returns only changed lines, not the entire file.
22
+ - **Instant rollback** - Original source included in result, no manual tracking.
23
+ - **Structured diff** - Line-by-line add/delete/equal metadata with line numbers.
24
+ - **Smart matching** - Fuzzy context resolution with whitespace and Unicode tolerance.
25
+
26
+ ## Installation
27
+
28
+ **Deno (JSR):**
29
+
30
+ ```bash
31
+ deno add jsr:@neabyte/v4a-diff
32
+ ```
33
+
34
+ **npm:**
35
+
36
+ ```bash
37
+ npm install @neabyte/v4a-diff
38
+ ```
39
+
40
+ **CDN (jsDelivr/esm.sh):**
41
+
42
+ ```html
43
+ <script type="module">
44
+ import V4A from 'https://cdn.jsdelivr.net/npm/@neabyte/v4a-diff/dist/index.mjs'
45
+ </script>
46
+ ```
47
+
48
+ Or via [esm.sh](https://esm.sh):
49
+
50
+ ```html
51
+ <script type="module">
52
+ import V4A from 'https://esm.sh/@neabyte/v4a-diff'
53
+ </script>
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```ts
59
+ import V4A from '@neabyte/v4a-diff'
60
+
61
+ // Update an existing file
62
+ const result = V4A.apply(
63
+ 'function add(a, b) {\n return a - b\n}',
64
+ '@@\n function add(a, b) {\n- return a - b\n+ return a + b\n }'
65
+ )
66
+
67
+ console.log(result.source)
68
+ // function add(a, b) {
69
+ // return a - b
70
+ // }
71
+
72
+ console.log(result.text)
73
+ // function add(a, b) {
74
+ // return a + b
75
+ // }
76
+
77
+ console.log(result.diff)
78
+ // [
79
+ // { type: 'equal', value: 'function add(a, b) {', oldLine: 1, newLine: 1 },
80
+ // { type: 'delete', value: ' return a - b', oldLine: 2, newLine: null },
81
+ // { type: 'add', value: ' return a + b', oldLine: null, newLine: 2 },
82
+ // { type: 'equal', value: '}', oldLine: 3, newLine: 3 }
83
+ // ]
84
+ ```
85
+
86
+ ### With `*** Begin Patch` envelope
87
+
88
+ ```ts
89
+ const result = V4A.apply(
90
+ 'const x = 1',
91
+ '*** Begin Patch\n*** Update File: /src/config.ts\n@@\n-const x = 1\n+const x = 2\n*** End Patch'
92
+ )
93
+ // result.text === 'const x = 2'
94
+ ```
95
+
96
+ ### Create a new file
97
+
98
+ ```ts
99
+ const result = V4A.apply(
100
+ '',
101
+ '+export const PORT = 8080\n+export const HOST = "localhost"',
102
+ 'create'
103
+ )
104
+ // result.text === 'export const PORT = 8080\nexport const HOST = "localhost"'
105
+ ```
106
+
107
+ ### Multi-hunk edits
108
+
109
+ ```ts
110
+ const result = V4A.apply(
111
+ 'function add(a, b) {\n return a - b\n}\nfunction sub(a, b) {\n return a + b\n}',
112
+ '@@ function add(\n function add(a, b) {\n- return a - b\n+ return a + b\n }\n@@ function sub(\n function sub(a, b) {\n- return a + b\n+ return a - b\n }'
113
+ )
114
+ ```
115
+
116
+ ## API
117
+
118
+ ### `V4A.apply(sourceText, diffText, mode?)`
119
+
120
+ | Parameter | Type | Description |
121
+ | ------------ | ----------------------- | -------------------------------------------------------------------- |
122
+ | `sourceText` | `string` | Original file content |
123
+ | `diffText` | `string` | V4A format diff string |
124
+ | `mode` | `'default' \| 'create'` | `default` updates existing text, `create` builds from `+` lines only |
125
+
126
+ **Returns:** `ApplyDiffResult`
127
+
128
+ ```ts
129
+ type ApplyDiffResult = {
130
+ text: string // Patched output text
131
+ diff: DiffLine[] // Structured line-by-line diff
132
+ source: string // Original source text for rollback
133
+ }
134
+
135
+ type DiffLine = {
136
+ type: 'add' | 'delete' | 'equal'
137
+ value: string // Line content
138
+ oldLine: number | null // Source line number (null for adds)
139
+ newLine: number | null // Result line number (null for deletes)
140
+ }
141
+ ```
142
+
143
+ ## V4A Diff Format
144
+
145
+ ```
146
+ *** Begin Patch
147
+ *** Update File: {path}
148
+ @@ {anchor_text}
149
+ context line (space prefix, exact copy from file)
150
+ -removed line (must match file exactly)
151
+ +added line
152
+ *** End Patch
153
+ ```
154
+
155
+ - `@@` followed by text anchors to a matching line in the source file.
156
+ - A bare `@@` alone means "start of file."
157
+ - Every line in a hunk must start with ``(space),`-`, or `+`.
158
+ - `*** Add File:` with `+` prefixed lines creates new files.
159
+
160
+ ## LLM Tool Schemas
161
+
162
+ Pre-built schemas for tool calling live in [`schema/`](schema/README.md):
163
+
164
+ - [`schema/openai.json`](schema/openai.json) - OpenAI function calling format
165
+ - [`schema/anthropic.json`](schema/anthropic.json) - Anthropic tool use format
166
+
167
+ ## Build
168
+
169
+ ```bash
170
+ npm run build
171
+ ```
172
+
173
+ ## Testing
174
+
175
+ ```bash
176
+ deno task check
177
+ ```
178
+
179
+ ```bash
180
+ deno task test
181
+ ```
182
+
183
+ ## Acknowledgements
184
+
185
+ This is a clean-room TypeScript reimplementation of the V4A context-anchored diff format, with additional features including fuzz matching, envelope stripping, and structured diff output.
186
+
187
+ Based on the V4A format specification and reference implementations by OpenAI:
188
+
189
+ - [Apply Patch Tool Documentation](https://developers.openai.com/api/docs/guides/tools-apply-patch)
190
+ - [Rust Reference (codex apply-patch)](https://github.com/openai/codex/tree/main/codex-rs/apply-patch)
191
+ - [TypeScript Reference (openai-agents-js)](https://github.com/openai/openai-agents-js/blob/main/packages/agents-core/src/utils/applyDiff.ts)
192
+
193
+ ## License
194
+
195
+ This project is licensed under the MIT license. See the [LICENSE](LICENSE) file for details.
package/dist/index.cjs ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";class f{static anchorStrategies=[{mapFn:e=>e,fuzzScore:0},{mapFn:e=>e.trim(),fuzzScore:1},{mapFn:this.normalizeUnicode.bind(this),fuzzScore:10}];static contextStrategies=[{mapFn:e=>e,fuzzScore:0},{mapFn:e=>e.trimEnd(),fuzzScore:1},{mapFn:e=>e.trim(),fuzzScore:100},{mapFn:this.normalizeUnicode.bind(this),fuzzScore:1e3}];static unicodeMap=new Map([[8208,"-"],[8209,"-"],[8210,"-"],[8211,"-"],[8212,"-"],[8213,"-"],[8722,"-"],[8216,"'"],[8217,"'"],[8218,"'"],[8219,"'"],[8220,'"'],[8221,'"'],[8222,'"'],[8223,'"'],[160," "],[8194," "],[8195," "],[8196," "],[8197," "],[8198," "],[8199," "],[8200," "],[8201," "],[8202," "],[8239," "],[8287," "],[12288," "]]);static findContext(e,t,n,r){if(r){const i=this.findContextCore(e,t,Math.max(0,e.length-t.length));if(i.matchedIndex!==-1)return i;const c=this.findContextCore(e,t,n);return{matchedIndex:c.matchedIndex,fuzzScore:c.fuzzScore+1e4}}return this.findContextCore(e,t,n)}static resolveAnchor(e,t,n,r){for(const i of this.anchorStrategies){const c=i.mapFn(e);let u=!1;for(let a=0;a<n;a+=1)if(i.mapFn(t[a])===c){u=!0;break}if(u)return n;const s=this.searchLines(t,e,n,i.mapFn);if(s!==-1)return r.fuzzScore+=i.fuzzScore,s}return n}static findContextCore(e,t,n){if(!t.length)return{matchedIndex:n,fuzzScore:0};for(const r of this.contextStrategies)for(let i=n;i<e.length;i+=1)if(this.sliceEquals(e,t,i,r.mapFn))return{matchedIndex:i,fuzzScore:r.fuzzScore};return{matchedIndex:-1,fuzzScore:0}}static normalizeUnicode(e){const t=e.trim(),n=[];for(let r=0;r<t.length;r+=1)n.push(this.unicodeMap.get(t.charCodeAt(r))??t[r]);return n.join("")}static searchLines(e,t,n,r){const i=r(t);for(let c=n;c<e.length;c+=1)if(r(e[c])===i)return c;return-1}static sliceEquals(e,t,n,r){if(n+t.length>e.length)return!1;for(let i=0;i<t.length;i+=1)if(r(e[n+i])!==r(t[i]))return!1;return!0}}class p{static endFile="*** End of File";static prefixToMode={"+":"add","-":"delete"," ":"keep"};static terminators=["*** End Patch","*** Update File:","*** Delete File:","*** Add File:"];static normalizeDiffLines(e){const t=e.split(/\r?\n/);return t.at(-1)===""&&t.pop(),t}static parseCreateDiff(e){const t=this.createState(e),n=[];for(;!this.isDone(t,!1);){const r=t.lines[t.currentIndex];if(t.currentIndex+=1,!r.startsWith("+"))throw new SyntaxError(`expected '+' prefix but got "${r}"`);n.push(r.slice(1))}return n.join(`
2
+ `)}static parseUpdateDiff(e,t){const n=this.createState(e),r=t.split(`
3
+ `),i=[];let c=0;for(;!this.isDone(n,!0);){const u=this.consumePrefix(n,"@@ "),s=!u&&n.lines[n.currentIndex]==="@@";if(s&&(n.currentIndex+=1),!u&&!s&&c!==0)throw new SyntaxError(`unexpected line "${n.lines[n.currentIndex]}"`);u?.trim()&&(c=f.resolveAnchor(u,r,c,n));const a=this.readSection(n.lines,n.currentIndex),l=f.findContext(r,a.contextLines,c,a.isEndOfFile);if(l.matchedIndex===-1){const d=a.isEndOfFile?"EOF context":"context";throw new SyntaxError(`unmatched ${d} at cursor ${c} "${a.contextLines.join(`
4
+ `)}"`)}n.fuzzScore+=l.fuzzScore;for(const d of a.diffChunks)i.push({...d,sourceIndex:d.sourceIndex+l.matchedIndex});c=l.matchedIndex+a.contextLines.length,n.currentIndex=a.endIndex}return{diffChunks:i,fuzzScore:n.fuzzScore}}static consumePrefix(e,t){return e.lines[e.currentIndex]?.startsWith(t)?(e.currentIndex+=1,e.lines[e.currentIndex-1].slice(t.length)):""}static createState(e){return{lines:[...e,this.terminators[0]],currentIndex:0,fuzzScore:0}}static isDone(e,t){return e.currentIndex>=e.lines.length||this.isTerminator(e.lines[e.currentIndex],t)}static isSectionBoundary(e){return e.startsWith("@@")||this.isTerminator(e,!0)}static isTerminator(e,t){return this.terminators.some(n=>e.startsWith(n))?!0:t&&e.startsWith(this.endFile)}static readSection(e,t){const n=[];let r=[],i=[];const c=[];let u="keep",s=t;for(;s<e.length;){const a=e[s];if(this.isSectionBoundary(a)||a==="***")break;if(a.startsWith("***"))throw new SyntaxError(`unexpected marker "${a}"`);s+=1;const l=u,d=a||" ",m=this.prefixToMode[d[0]];if(!m)throw new SyntaxError(`unexpected line prefix "${d}"`);u=m;const h=d.slice(1);u==="keep"&&l!==u&&(r.length||i.length)&&(c.push({sourceIndex:n.length-r.length,deletedLines:r,insertedLines:i}),r=[],i=[]),u==="delete"?(r.push(h),n.push(h)):u==="add"?i.push(h):n.push(h)}if((r.length||i.length)&&c.push({sourceIndex:n.length-r.length,deletedLines:r,insertedLines:i}),s<e.length&&e[s]===this.endFile)return{contextLines:n,diffChunks:c,endIndex:s+1,isEndOfFile:!0};if(s===t)throw new SyntaxError(`empty section at index ${s} "${e[s]}"`);return{contextLines:n,diffChunks:c,endIndex:s,isEndOfFile:!1}}}class o{static apply(e,t,n="default"){const r=this.stripLeadingEmpty(this.stripEnvelope(p.normalizeDiffLines(t)));if(n==="create"){const i=p.parseCreateDiff(r),c=i.split(`
5
+ `),u=[];for(let s=0;s<c.length;s+=1)u.push({type:"add",value:c[s],oldLine:null,newLine:s+1});return{text:i,diff:u,source:e}}return this.applyChunks(e,p.parseUpdateDiff(r,e).diffChunks)}static applyChunks(e,t){const n=e.split(`
6
+ `),r=[],i=[];let c=0,u=1;for(const s of t){if(s.sourceIndex>n.length)throw new RangeError(`chunk sourceIndex ${s.sourceIndex} exceeds input length ${n.length}`);if(c>s.sourceIndex)throw new RangeError(`overlapping chunk at ${s.sourceIndex} cursor already at ${c}`);for(let a=c;a<s.sourceIndex;a+=1)r.push(n[a]),i.push({type:"equal",value:n[a],oldLine:a+1,newLine:u}),u+=1;c=s.sourceIndex;for(const a of s.deletedLines)i.push({type:"delete",value:a,oldLine:c+1,newLine:null}),c+=1;for(const a of s.insertedLines)r.push(a),i.push({type:"add",value:a,oldLine:null,newLine:u}),u+=1}for(let s=c;s<n.length;s+=1)r.push(n[s]),i.push({type:"equal",value:n[s],oldLine:s+1,newLine:u}),u+=1;return{text:r.join(`
7
+ `),diff:i,source:e}}static stripEnvelope(e){return e.filter(t=>!(t==="*** Begin Patch"||t==="*** End Patch"||t.startsWith("*** Update File:")||t.startsWith("*** Add File:")||t.startsWith("--- a/")||t.startsWith("+++ b/")||t.startsWith("--- a\\")||t.startsWith("+++ b\\")||t==="\"))}static stripLeadingEmpty(e){let t=0;for(;t<e.length&&e[t]==="";)t+=1;return t>0?e.slice(t):e}}module.exports=o;
@@ -0,0 +1,140 @@
1
+ /** Diff application mode selector */
2
+ type ApplyDiffMode = 'default' | 'create';
3
+ /**
4
+ * Result from applying a diff.
5
+ * @description Contains patched text, structured diff, and source.
6
+ */
7
+ type ApplyDiffResult = {
8
+ /** Patched output text */
9
+ text: string;
10
+ /** Structured line-by-line diff entries */
11
+ diff: DiffLine[];
12
+ /** Original source text before patching */
13
+ source: string;
14
+ };
15
+ /**
16
+ * Fuzzy context match result.
17
+ * @description Holds matched line index and fuzz penalty.
18
+ */
19
+ type ContextMatch = {
20
+ /** Matched source line index */
21
+ matchedIndex: number;
22
+ /** Accumulated fuzz penalty score */
23
+ fuzzScore: number;
24
+ };
25
+ /**
26
+ * Single chunk of diff operations.
27
+ * @description Groups deleted and inserted lines at source position.
28
+ */
29
+ type DiffChunk = {
30
+ /** Starting index in source lines */
31
+ sourceIndex: number;
32
+ /** Lines removed from source */
33
+ deletedLines: string[];
34
+ /** Lines added to output */
35
+ insertedLines: string[];
36
+ };
37
+ /**
38
+ * Single line in structured diff.
39
+ * @description Represents one add, delete, or equal line entry.
40
+ */
41
+ type DiffLine = {
42
+ /** Operation type for this line */
43
+ type: 'add' | 'delete' | 'equal';
44
+ /** Line content without prefix */
45
+ value: string;
46
+ /** Source line number or null */
47
+ oldLine: number | null;
48
+ /** Result line number or null */
49
+ newLine: number | null;
50
+ };
51
+ /**
52
+ * Fuzzy matching strategy with penalty.
53
+ * @description Pairs a line transform with fuzz score.
54
+ */
55
+ type FuzzStrategy = {
56
+ /** Line transformation function */
57
+ mapFn: (line: string) => string;
58
+ /** Penalty score for this strategy */
59
+ fuzzScore: number;
60
+ };
61
+ /** Hunk line operation mode */
62
+ type HunkLineMode = 'keep' | 'add' | 'delete';
63
+ /**
64
+ * Parsed update diff result.
65
+ * @description Contains diff chunks and total fuzz score.
66
+ */
67
+ type ParsedUpdate = {
68
+ /** Ordered list of diff chunks */
69
+ diffChunks: DiffChunk[];
70
+ /** Total accumulated fuzz score */
71
+ fuzzScore: number;
72
+ };
73
+ /**
74
+ * Mutable parser state during processing.
75
+ * @description Tracks lines, cursor position, and fuzz score.
76
+ */
77
+ type ParserState = {
78
+ /** All diff lines being parsed */
79
+ lines: string[];
80
+ /** Current cursor position */
81
+ currentIndex: number;
82
+ /** Running fuzz penalty total */
83
+ fuzzScore: number;
84
+ };
85
+ /**
86
+ * Parsed section with context and chunks.
87
+ * @description Groups context lines, chunks, and boundary info.
88
+ */
89
+ type SectionResult = {
90
+ /** Context lines from source */
91
+ contextLines: string[];
92
+ /** Diff chunks in this section */
93
+ diffChunks: DiffChunk[];
94
+ /** Line index after section end */
95
+ endIndex: number;
96
+ /** True when section ends at EOF */
97
+ isEndOfFile: boolean;
98
+ };
99
+
100
+ /**
101
+ * V4A context-anchored diff applicator.
102
+ * @description Applies V4A patches and produces structured diff output.
103
+ */
104
+ declare class V4A {
105
+ /**
106
+ * Apply a V4A diff patch.
107
+ * @description Parses and applies diff to source text.
108
+ * @param sourceText - Original source file text
109
+ * @param diffText - V4A format diff string
110
+ * @param mode - Apply mode: default or create
111
+ * @returns Result with patched text and diff lines
112
+ */
113
+ static apply(sourceText: string, diffText: string, mode?: ApplyDiffMode): ApplyDiffResult;
114
+ /**
115
+ * Apply diff chunks to source text.
116
+ * @description Builds output text and structured diff from chunks.
117
+ * @param sourceText - Original source file text
118
+ * @param diffChunks - Parsed diff chunks to apply
119
+ * @returns Result with patched text and diff lines
120
+ * @throws RangeError on out-of-bounds or overlapping chunks
121
+ */
122
+ private static applyChunks;
123
+ /**
124
+ * Strip patch envelope markers from lines.
125
+ * @description Removes Begin/End Patch, file headers, and git markers.
126
+ * @param diffLines - Raw diff lines to filter
127
+ * @returns Lines without envelope markers
128
+ */
129
+ private static stripEnvelope;
130
+ /**
131
+ * Strip leading empty lines from diff.
132
+ * @description Skips empty lines at start of array.
133
+ * @param diffLines - Lines to trim
134
+ * @returns Lines without leading empties
135
+ */
136
+ private static stripLeadingEmpty;
137
+ }
138
+
139
+ export = V4A;
140
+ export type { ApplyDiffMode, ApplyDiffResult, ContextMatch, DiffChunk, DiffLine, FuzzStrategy, HunkLineMode, ParsedUpdate, ParserState, SectionResult };
@@ -0,0 +1,140 @@
1
+ /** Diff application mode selector */
2
+ type ApplyDiffMode = 'default' | 'create';
3
+ /**
4
+ * Result from applying a diff.
5
+ * @description Contains patched text, structured diff, and source.
6
+ */
7
+ type ApplyDiffResult = {
8
+ /** Patched output text */
9
+ text: string;
10
+ /** Structured line-by-line diff entries */
11
+ diff: DiffLine[];
12
+ /** Original source text before patching */
13
+ source: string;
14
+ };
15
+ /**
16
+ * Fuzzy context match result.
17
+ * @description Holds matched line index and fuzz penalty.
18
+ */
19
+ type ContextMatch = {
20
+ /** Matched source line index */
21
+ matchedIndex: number;
22
+ /** Accumulated fuzz penalty score */
23
+ fuzzScore: number;
24
+ };
25
+ /**
26
+ * Single chunk of diff operations.
27
+ * @description Groups deleted and inserted lines at source position.
28
+ */
29
+ type DiffChunk = {
30
+ /** Starting index in source lines */
31
+ sourceIndex: number;
32
+ /** Lines removed from source */
33
+ deletedLines: string[];
34
+ /** Lines added to output */
35
+ insertedLines: string[];
36
+ };
37
+ /**
38
+ * Single line in structured diff.
39
+ * @description Represents one add, delete, or equal line entry.
40
+ */
41
+ type DiffLine = {
42
+ /** Operation type for this line */
43
+ type: 'add' | 'delete' | 'equal';
44
+ /** Line content without prefix */
45
+ value: string;
46
+ /** Source line number or null */
47
+ oldLine: number | null;
48
+ /** Result line number or null */
49
+ newLine: number | null;
50
+ };
51
+ /**
52
+ * Fuzzy matching strategy with penalty.
53
+ * @description Pairs a line transform with fuzz score.
54
+ */
55
+ type FuzzStrategy = {
56
+ /** Line transformation function */
57
+ mapFn: (line: string) => string;
58
+ /** Penalty score for this strategy */
59
+ fuzzScore: number;
60
+ };
61
+ /** Hunk line operation mode */
62
+ type HunkLineMode = 'keep' | 'add' | 'delete';
63
+ /**
64
+ * Parsed update diff result.
65
+ * @description Contains diff chunks and total fuzz score.
66
+ */
67
+ type ParsedUpdate = {
68
+ /** Ordered list of diff chunks */
69
+ diffChunks: DiffChunk[];
70
+ /** Total accumulated fuzz score */
71
+ fuzzScore: number;
72
+ };
73
+ /**
74
+ * Mutable parser state during processing.
75
+ * @description Tracks lines, cursor position, and fuzz score.
76
+ */
77
+ type ParserState = {
78
+ /** All diff lines being parsed */
79
+ lines: string[];
80
+ /** Current cursor position */
81
+ currentIndex: number;
82
+ /** Running fuzz penalty total */
83
+ fuzzScore: number;
84
+ };
85
+ /**
86
+ * Parsed section with context and chunks.
87
+ * @description Groups context lines, chunks, and boundary info.
88
+ */
89
+ type SectionResult = {
90
+ /** Context lines from source */
91
+ contextLines: string[];
92
+ /** Diff chunks in this section */
93
+ diffChunks: DiffChunk[];
94
+ /** Line index after section end */
95
+ endIndex: number;
96
+ /** True when section ends at EOF */
97
+ isEndOfFile: boolean;
98
+ };
99
+
100
+ /**
101
+ * V4A context-anchored diff applicator.
102
+ * @description Applies V4A patches and produces structured diff output.
103
+ */
104
+ declare class V4A {
105
+ /**
106
+ * Apply a V4A diff patch.
107
+ * @description Parses and applies diff to source text.
108
+ * @param sourceText - Original source file text
109
+ * @param diffText - V4A format diff string
110
+ * @param mode - Apply mode: default or create
111
+ * @returns Result with patched text and diff lines
112
+ */
113
+ static apply(sourceText: string, diffText: string, mode?: ApplyDiffMode): ApplyDiffResult;
114
+ /**
115
+ * Apply diff chunks to source text.
116
+ * @description Builds output text and structured diff from chunks.
117
+ * @param sourceText - Original source file text
118
+ * @param diffChunks - Parsed diff chunks to apply
119
+ * @returns Result with patched text and diff lines
120
+ * @throws RangeError on out-of-bounds or overlapping chunks
121
+ */
122
+ private static applyChunks;
123
+ /**
124
+ * Strip patch envelope markers from lines.
125
+ * @description Removes Begin/End Patch, file headers, and git markers.
126
+ * @param diffLines - Raw diff lines to filter
127
+ * @returns Lines without envelope markers
128
+ */
129
+ private static stripEnvelope;
130
+ /**
131
+ * Strip leading empty lines from diff.
132
+ * @description Skips empty lines at start of array.
133
+ * @param diffLines - Lines to trim
134
+ * @returns Lines without leading empties
135
+ */
136
+ private static stripLeadingEmpty;
137
+ }
138
+
139
+ export { V4A as default };
140
+ export type { ApplyDiffMode, ApplyDiffResult, ContextMatch, DiffChunk, DiffLine, FuzzStrategy, HunkLineMode, ParsedUpdate, ParserState, SectionResult };
@@ -0,0 +1,140 @@
1
+ /** Diff application mode selector */
2
+ type ApplyDiffMode = 'default' | 'create';
3
+ /**
4
+ * Result from applying a diff.
5
+ * @description Contains patched text, structured diff, and source.
6
+ */
7
+ type ApplyDiffResult = {
8
+ /** Patched output text */
9
+ text: string;
10
+ /** Structured line-by-line diff entries */
11
+ diff: DiffLine[];
12
+ /** Original source text before patching */
13
+ source: string;
14
+ };
15
+ /**
16
+ * Fuzzy context match result.
17
+ * @description Holds matched line index and fuzz penalty.
18
+ */
19
+ type ContextMatch = {
20
+ /** Matched source line index */
21
+ matchedIndex: number;
22
+ /** Accumulated fuzz penalty score */
23
+ fuzzScore: number;
24
+ };
25
+ /**
26
+ * Single chunk of diff operations.
27
+ * @description Groups deleted and inserted lines at source position.
28
+ */
29
+ type DiffChunk = {
30
+ /** Starting index in source lines */
31
+ sourceIndex: number;
32
+ /** Lines removed from source */
33
+ deletedLines: string[];
34
+ /** Lines added to output */
35
+ insertedLines: string[];
36
+ };
37
+ /**
38
+ * Single line in structured diff.
39
+ * @description Represents one add, delete, or equal line entry.
40
+ */
41
+ type DiffLine = {
42
+ /** Operation type for this line */
43
+ type: 'add' | 'delete' | 'equal';
44
+ /** Line content without prefix */
45
+ value: string;
46
+ /** Source line number or null */
47
+ oldLine: number | null;
48
+ /** Result line number or null */
49
+ newLine: number | null;
50
+ };
51
+ /**
52
+ * Fuzzy matching strategy with penalty.
53
+ * @description Pairs a line transform with fuzz score.
54
+ */
55
+ type FuzzStrategy = {
56
+ /** Line transformation function */
57
+ mapFn: (line: string) => string;
58
+ /** Penalty score for this strategy */
59
+ fuzzScore: number;
60
+ };
61
+ /** Hunk line operation mode */
62
+ type HunkLineMode = 'keep' | 'add' | 'delete';
63
+ /**
64
+ * Parsed update diff result.
65
+ * @description Contains diff chunks and total fuzz score.
66
+ */
67
+ type ParsedUpdate = {
68
+ /** Ordered list of diff chunks */
69
+ diffChunks: DiffChunk[];
70
+ /** Total accumulated fuzz score */
71
+ fuzzScore: number;
72
+ };
73
+ /**
74
+ * Mutable parser state during processing.
75
+ * @description Tracks lines, cursor position, and fuzz score.
76
+ */
77
+ type ParserState = {
78
+ /** All diff lines being parsed */
79
+ lines: string[];
80
+ /** Current cursor position */
81
+ currentIndex: number;
82
+ /** Running fuzz penalty total */
83
+ fuzzScore: number;
84
+ };
85
+ /**
86
+ * Parsed section with context and chunks.
87
+ * @description Groups context lines, chunks, and boundary info.
88
+ */
89
+ type SectionResult = {
90
+ /** Context lines from source */
91
+ contextLines: string[];
92
+ /** Diff chunks in this section */
93
+ diffChunks: DiffChunk[];
94
+ /** Line index after section end */
95
+ endIndex: number;
96
+ /** True when section ends at EOF */
97
+ isEndOfFile: boolean;
98
+ };
99
+
100
+ /**
101
+ * V4A context-anchored diff applicator.
102
+ * @description Applies V4A patches and produces structured diff output.
103
+ */
104
+ declare class V4A {
105
+ /**
106
+ * Apply a V4A diff patch.
107
+ * @description Parses and applies diff to source text.
108
+ * @param sourceText - Original source file text
109
+ * @param diffText - V4A format diff string
110
+ * @param mode - Apply mode: default or create
111
+ * @returns Result with patched text and diff lines
112
+ */
113
+ static apply(sourceText: string, diffText: string, mode?: ApplyDiffMode): ApplyDiffResult;
114
+ /**
115
+ * Apply diff chunks to source text.
116
+ * @description Builds output text and structured diff from chunks.
117
+ * @param sourceText - Original source file text
118
+ * @param diffChunks - Parsed diff chunks to apply
119
+ * @returns Result with patched text and diff lines
120
+ * @throws RangeError on out-of-bounds or overlapping chunks
121
+ */
122
+ private static applyChunks;
123
+ /**
124
+ * Strip patch envelope markers from lines.
125
+ * @description Removes Begin/End Patch, file headers, and git markers.
126
+ * @param diffLines - Raw diff lines to filter
127
+ * @returns Lines without envelope markers
128
+ */
129
+ private static stripEnvelope;
130
+ /**
131
+ * Strip leading empty lines from diff.
132
+ * @description Skips empty lines at start of array.
133
+ * @param diffLines - Lines to trim
134
+ * @returns Lines without leading empties
135
+ */
136
+ private static stripLeadingEmpty;
137
+ }
138
+
139
+ export = V4A;
140
+ export type { ApplyDiffMode, ApplyDiffResult, ContextMatch, DiffChunk, DiffLine, FuzzStrategy, HunkLineMode, ParsedUpdate, ParserState, SectionResult };
package/dist/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ class x{static anchorStrategies=[{mapFn:e=>e,fuzzScore:0},{mapFn:e=>e.trim(),fuzzScore:1},{mapFn:this.normalizeUnicode.bind(this),fuzzScore:10}];static contextStrategies=[{mapFn:e=>e,fuzzScore:0},{mapFn:e=>e.trimEnd(),fuzzScore:1},{mapFn:e=>e.trim(),fuzzScore:100},{mapFn:this.normalizeUnicode.bind(this),fuzzScore:1e3}];static unicodeMap=new Map([[8208,"-"],[8209,"-"],[8210,"-"],[8211,"-"],[8212,"-"],[8213,"-"],[8722,"-"],[8216,"'"],[8217,"'"],[8218,"'"],[8219,"'"],[8220,'"'],[8221,'"'],[8222,'"'],[8223,'"'],[160," "],[8194," "],[8195," "],[8196," "],[8197," "],[8198," "],[8199," "],[8200," "],[8201," "],[8202," "],[8239," "],[8287," "],[12288," "]]);static findContext(e,t,n,r){if(r){const i=this.findContextCore(e,t,Math.max(0,e.length-t.length));if(i.matchedIndex!==-1)return i;const o=this.findContextCore(e,t,n);return{matchedIndex:o.matchedIndex,fuzzScore:o.fuzzScore+1e4}}return this.findContextCore(e,t,n)}static resolveAnchor(e,t,n,r){for(const i of this.anchorStrategies){const o=i.mapFn(e);let a=!1;for(let c=0;c<n;c+=1)if(i.mapFn(t[c])===o){a=!0;break}if(a)return n;const s=this.searchLines(t,e,n,i.mapFn);if(s!==-1)return r.fuzzScore+=i.fuzzScore,s}return n}static findContextCore(e,t,n){if(!t.length)return{matchedIndex:n,fuzzScore:0};for(const r of this.contextStrategies)for(let i=n;i<e.length;i+=1)if(this.sliceEquals(e,t,i,r.mapFn))return{matchedIndex:i,fuzzScore:r.fuzzScore};return{matchedIndex:-1,fuzzScore:0}}static normalizeUnicode(e){const t=e.trim(),n=[];for(let r=0;r<t.length;r+=1)n.push(this.unicodeMap.get(t.charCodeAt(r))??t[r]);return n.join("")}static searchLines(e,t,n,r){const i=r(t);for(let o=n;o<e.length;o+=1)if(r(e[o])===i)return o;return-1}static sliceEquals(e,t,n,r){if(n+t.length>e.length)return!1;for(let i=0;i<t.length;i+=1)if(r(e[n+i])!==r(t[i]))return!1;return!0}}class h{static endFile="*** End of File";static prefixToMode={"+":"add","-":"delete"," ":"keep"};static terminators=["*** End Patch","*** Update File:","*** Delete File:","*** Add File:"];static normalizeDiffLines(e){const t=e.split(/\r?\n/);return t.at(-1)===""&&t.pop(),t}static parseCreateDiff(e){const t=this.createState(e),n=[];for(;!this.isDone(t,!1);){const r=t.lines[t.currentIndex];if(t.currentIndex+=1,!r.startsWith("+"))throw new SyntaxError(`expected '+' prefix but got "${r}"`);n.push(r.slice(1))}return n.join(`
2
+ `)}static parseUpdateDiff(e,t){const n=this.createState(e),r=t.split(`
3
+ `),i=[];let o=0;for(;!this.isDone(n,!0);){const a=this.consumePrefix(n,"@@ "),s=!a&&n.lines[n.currentIndex]==="@@";if(s&&(n.currentIndex+=1),!a&&!s&&o!==0)throw new SyntaxError(`unexpected line "${n.lines[n.currentIndex]}"`);a?.trim()&&(o=x.resolveAnchor(a,r,o,n));const c=this.readSection(n.lines,n.currentIndex),d=x.findContext(r,c.contextLines,o,c.isEndOfFile);if(d.matchedIndex===-1){const u=c.isEndOfFile?"EOF context":"context";throw new SyntaxError(`unmatched ${u} at cursor ${o} "${c.contextLines.join(`
4
+ `)}"`)}n.fuzzScore+=d.fuzzScore;for(const u of c.diffChunks)i.push({...u,sourceIndex:u.sourceIndex+d.matchedIndex});o=d.matchedIndex+c.contextLines.length,n.currentIndex=c.endIndex}return{diffChunks:i,fuzzScore:n.fuzzScore}}static consumePrefix(e,t){return e.lines[e.currentIndex]?.startsWith(t)?(e.currentIndex+=1,e.lines[e.currentIndex-1].slice(t.length)):""}static createState(e){return{lines:[...e,this.terminators[0]],currentIndex:0,fuzzScore:0}}static isDone(e,t){return e.currentIndex>=e.lines.length||this.isTerminator(e.lines[e.currentIndex],t)}static isSectionBoundary(e){return e.startsWith("@@")||this.isTerminator(e,!0)}static isTerminator(e,t){return this.terminators.some(n=>e.startsWith(n))?!0:t&&e.startsWith(this.endFile)}static readSection(e,t){const n=[];let r=[],i=[];const o=[];let a="keep",s=t;for(;s<e.length;){const c=e[s];if(this.isSectionBoundary(c)||c==="***")break;if(c.startsWith("***"))throw new SyntaxError(`unexpected marker "${c}"`);s+=1;const d=a,u=c||" ",p=this.prefixToMode[u[0]];if(!p)throw new SyntaxError(`unexpected line prefix "${u}"`);a=p;const l=u.slice(1);a==="keep"&&d!==a&&(r.length||i.length)&&(o.push({sourceIndex:n.length-r.length,deletedLines:r,insertedLines:i}),r=[],i=[]),a==="delete"?(r.push(l),n.push(l)):a==="add"?i.push(l):n.push(l)}if((r.length||i.length)&&o.push({sourceIndex:n.length-r.length,deletedLines:r,insertedLines:i}),s<e.length&&e[s]===this.endFile)return{contextLines:n,diffChunks:o,endIndex:s+1,isEndOfFile:!0};if(s===t)throw new SyntaxError(`empty section at index ${s} "${e[s]}"`);return{contextLines:n,diffChunks:o,endIndex:s,isEndOfFile:!1}}}class m{static apply(e,t,n="default"){const r=this.stripLeadingEmpty(this.stripEnvelope(h.normalizeDiffLines(t)));if(n==="create"){const i=h.parseCreateDiff(r),o=i.split(`
5
+ `),a=[];for(let s=0;s<o.length;s+=1)a.push({type:"add",value:o[s],oldLine:null,newLine:s+1});return{text:i,diff:a,source:e}}return this.applyChunks(e,h.parseUpdateDiff(r,e).diffChunks)}static applyChunks(e,t){const n=e.split(`
6
+ `),r=[],i=[];let o=0,a=1;for(const s of t){if(s.sourceIndex>n.length)throw new RangeError(`chunk sourceIndex ${s.sourceIndex} exceeds input length ${n.length}`);if(o>s.sourceIndex)throw new RangeError(`overlapping chunk at ${s.sourceIndex} cursor already at ${o}`);for(let c=o;c<s.sourceIndex;c+=1)r.push(n[c]),i.push({type:"equal",value:n[c],oldLine:c+1,newLine:a}),a+=1;o=s.sourceIndex;for(const c of s.deletedLines)i.push({type:"delete",value:c,oldLine:o+1,newLine:null}),o+=1;for(const c of s.insertedLines)r.push(c),i.push({type:"add",value:c,oldLine:null,newLine:a}),a+=1}for(let s=o;s<n.length;s+=1)r.push(n[s]),i.push({type:"equal",value:n[s],oldLine:s+1,newLine:a}),a+=1;return{text:r.join(`
7
+ `),diff:i,source:e}}static stripEnvelope(e){return e.filter(t=>!(t==="*** Begin Patch"||t==="*** End Patch"||t.startsWith("*** Update File:")||t.startsWith("*** Add File:")||t.startsWith("--- a/")||t.startsWith("+++ b/")||t.startsWith("--- a\\")||t.startsWith("+++ b\\")||t==="\"))}static stripLeadingEmpty(e){let t=0;for(;t<e.length&&e[t]==="";)t+=1;return t>0?e.slice(t):e}}export{m as default};
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@neabyte/v4a-diff",
3
+ "version": "0.1.0",
4
+ "description": "Apply context-anchored file patches from LLM tool calls",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "require": "./dist/index.cjs",
12
+ "import": "./dist/index.mjs",
13
+ "types": "./dist/index.d.ts"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "unbuild --minify",
18
+ "clean": "rm -rf dist",
19
+ "prepublishOnly": "rm -rf dist && npm run build"
20
+ },
21
+ "keywords": [
22
+ "anthropic",
23
+ "code-editing",
24
+ "context-anchored-diff",
25
+ "deno",
26
+ "diff-engine",
27
+ "diff-parser",
28
+ "esm",
29
+ "fuzz-matching",
30
+ "jsr",
31
+ "llm-tools",
32
+ "npm-package",
33
+ "openai",
34
+ "patch-applier",
35
+ "structured-diff",
36
+ "text-patching",
37
+ "tool-calling",
38
+ "typescript",
39
+ "unicode-normalization",
40
+ "v4a-diff",
41
+ "zero-dependencies"
42
+ ],
43
+ "dependencies": {},
44
+ "devDependencies": {
45
+ "unbuild": "^3.6.1"
46
+ },
47
+ "author": {
48
+ "name": "NeaByteLab",
49
+ "email": "me@neabyte.com",
50
+ "url": "https://github.com/NeaByteLab"
51
+ },
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/NeaByteLab/V4A-Diff.git"
56
+ },
57
+ "bugs": {
58
+ "url": "https://github.com/NeaByteLab/V4A-Diff/issues"
59
+ },
60
+ "homepage": "https://github.com/NeaByteLab/V4A-Diff#readme",
61
+ "engines": {
62
+ "node": ">=24.0.0"
63
+ },
64
+ "files": [
65
+ "dist/**/*",
66
+ "README.md",
67
+ "LICENSE"
68
+ ],
69
+ "publishConfig": {
70
+ "access": "public"
71
+ }
72
+ }