happyskills 0.36.0 → 0.38.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 +19 -0
- package/package.json +1 -1
- package/src/api/repos.js +1 -2
- package/src/commands/diff.js +159 -9
- package/src/commands/search.js +13 -9
- package/src/integration/cli.test.js +3 -2
- package/src/utils/text_diff.js +247 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.38.0] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **BREAKING:** Make `--limit` required for the `search` command — there is no default. Running `happyskills search ...` without `--limit` now returns a usage error (exit code 2). The previous hidden default of 10 caused AI-agent callers to miss results without realizing a limit was being applied. Affects both smart-search and `--exact` keyword modes. Update help text and all examples to show `--limit` explicitly.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Fix `search --workspace <slug>` (in browse mode with no query, and in `--exact` keyword mode) silently excluding private skills the caller has access to. The CLI was omitting the auth token when only `--workspace` was specified without `--mine` or `--personal`, causing the API to fall back to public-only results. The auth token is now always attached when one is available, regardless of the search mode.
|
|
17
|
+
|
|
18
|
+
## [0.37.0] - 2026-04-25
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Add unified content diffs to `diff` command — fetches base and local/remote file content for changed files, computes line-by-line unified diffs using the Myers O(ND) algorithm, and includes them in both CLI and JSON output
|
|
22
|
+
- Add `--no-content` flag to `diff` command to skip content diffs and show only the file list (previous behavior)
|
|
23
|
+
- Add `text_diff.js` utility module — lightweight Myers diff with unified diff formatting, no external dependencies
|
|
24
|
+
- Add colored CLI diff output (red/green/cyan) matching standard unified diff conventions
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Change `diff` JSON output to include `diff` field on each changed file entry with the unified diff text. For `both_modified` files, `local_diff` and `remote_diff` fields are provided instead
|
|
28
|
+
|
|
10
29
|
## [0.36.0] - 2026-04-17
|
|
11
30
|
|
|
12
31
|
### Added
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -10,8 +10,7 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
|
|
|
10
10
|
if (options.workspace) params.set('workspace', options.workspace)
|
|
11
11
|
if (options.tags) params.set('tags', options.tags)
|
|
12
12
|
if (options.type) params.set('type', options.type)
|
|
13
|
-
const
|
|
14
|
-
const [errors, data] = await client.get(`/repos/search?${params}`, { auth: needs_auth || false })
|
|
13
|
+
const [errors, data] = await client.get(`/repos/search?${params}`)
|
|
15
14
|
if (errors) throw errors[errors.length - 1]
|
|
16
15
|
return data
|
|
17
16
|
})
|
package/src/commands/diff.js
CHANGED
|
@@ -2,19 +2,20 @@ const fs = require('fs')
|
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
4
4
|
const repos_api = require('../api/repos')
|
|
5
|
-
const { detect_status } = require('../merge/detector')
|
|
6
5
|
const { classify_changes } = require('../merge/comparator')
|
|
7
6
|
const { build_report } = require('../merge/report')
|
|
8
7
|
const { hash_blob } = require('../utils/git_hash')
|
|
8
|
+
const { unified_diff } = require('../utils/text_diff')
|
|
9
9
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
10
10
|
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
11
11
|
const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
|
|
12
|
+
const { red, green, cyan, bold } = require('../ui/colors')
|
|
12
13
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
13
14
|
const { EXIT_CODES } = require('../constants')
|
|
14
15
|
|
|
15
16
|
const HELP_TEXT = `Usage: happyskills diff <owner/skill> [options]
|
|
16
17
|
|
|
17
|
-
Show file-level differences for a skill.
|
|
18
|
+
Show file-level differences for a skill with unified diffs.
|
|
18
19
|
|
|
19
20
|
Arguments:
|
|
20
21
|
owner/skill Skill to diff (required)
|
|
@@ -22,6 +23,7 @@ Arguments:
|
|
|
22
23
|
Options:
|
|
23
24
|
--remote Show base vs remote changes (default: local vs base)
|
|
24
25
|
--full Show three-way diff (base vs local vs remote)
|
|
26
|
+
--no-content Show only file list without content diffs
|
|
25
27
|
-g, --global Diff globally installed skill
|
|
26
28
|
--json Output as JSON
|
|
27
29
|
|
|
@@ -30,7 +32,8 @@ Aliases: d
|
|
|
30
32
|
Examples:
|
|
31
33
|
happyskills diff acme/deploy-aws
|
|
32
34
|
happyskills diff acme/deploy-aws --remote
|
|
33
|
-
happyskills diff acme/deploy-aws --full
|
|
35
|
+
happyskills diff acme/deploy-aws --full
|
|
36
|
+
happyskills diff acme/deploy-aws --no-content`
|
|
34
37
|
|
|
35
38
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
36
39
|
|
|
@@ -64,6 +67,98 @@ const STATUS_LABELS = {
|
|
|
64
67
|
local_only_deleted: 'D (local)'
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
const DIFF_CLASSIFICATIONS = new Set([
|
|
71
|
+
'local_only_modified', 'local_only_added', 'local_only_deleted',
|
|
72
|
+
'remote_only_modified', 'remote_only_added', 'remote_only_deleted',
|
|
73
|
+
'both_modified'
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
// ─── Content diff enrichment ─────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Enrich report file entries with unified diffs for changed files.
|
|
80
|
+
*
|
|
81
|
+
* Memory strategy:
|
|
82
|
+
* - Only decode/read content for files whose SHAs actually differ
|
|
83
|
+
* - Local file reads run concurrently via Promise.all
|
|
84
|
+
* - All content maps are cleared after diffs are computed
|
|
85
|
+
*
|
|
86
|
+
* @param {object} report - The merge report with file entries
|
|
87
|
+
* @param {Array} base_clone_files - Raw clone response files (with base64 content)
|
|
88
|
+
* @param {string|null} skill_dir - Local skill directory (null if local content not needed)
|
|
89
|
+
* @param {Array|null} remote_clone_files - Raw remote clone files (null if not needed)
|
|
90
|
+
*/
|
|
91
|
+
const enrich_with_diffs = (report, base_clone_files, skill_dir, remote_clone_files) => catch_errors('Failed to compute content diffs', async () => {
|
|
92
|
+
const changed = report.files.filter(f => DIFF_CLASSIFICATIONS.has(f.classification))
|
|
93
|
+
if (changed.length === 0) return
|
|
94
|
+
|
|
95
|
+
const changed_paths = new Set(changed.map(f => f.path))
|
|
96
|
+
|
|
97
|
+
// Build base content map — only decode changed paths from the clone response
|
|
98
|
+
const base_map = new Map()
|
|
99
|
+
for (const f of (base_clone_files || [])) {
|
|
100
|
+
if (changed_paths.has(f.path) && f.content) {
|
|
101
|
+
base_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build remote content map — same selective decode
|
|
106
|
+
const remote_map = new Map()
|
|
107
|
+
for (const f of (remote_clone_files || [])) {
|
|
108
|
+
if (changed_paths.has(f.path) && f.content) {
|
|
109
|
+
remote_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Read local content concurrently — only files that exist locally
|
|
114
|
+
const local_map = new Map()
|
|
115
|
+
if (skill_dir) {
|
|
116
|
+
const needs_local = changed.filter(f => f.local_sha)
|
|
117
|
+
const results = await Promise.all(needs_local.map(async f => {
|
|
118
|
+
try {
|
|
119
|
+
const content = await fs.promises.readFile(path.join(skill_dir, f.path), 'utf-8')
|
|
120
|
+
return { path: f.path, content }
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}))
|
|
125
|
+
for (const r of results) {
|
|
126
|
+
if (r) local_map.set(r.path, r.content)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compute unified diffs — CPU-only, no I/O
|
|
131
|
+
for (const f of changed) {
|
|
132
|
+
const base = base_map.get(f.path) ?? ''
|
|
133
|
+
const local = local_map.get(f.path) ?? ''
|
|
134
|
+
const remote = remote_map.get(f.path) ?? ''
|
|
135
|
+
|
|
136
|
+
switch (f.classification) {
|
|
137
|
+
case 'local_only_modified':
|
|
138
|
+
case 'local_only_added':
|
|
139
|
+
case 'local_only_deleted':
|
|
140
|
+
f.diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
|
|
141
|
+
break
|
|
142
|
+
case 'remote_only_modified':
|
|
143
|
+
case 'remote_only_added':
|
|
144
|
+
case 'remote_only_deleted':
|
|
145
|
+
f.diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
|
|
146
|
+
break
|
|
147
|
+
case 'both_modified':
|
|
148
|
+
f.local_diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
|
|
149
|
+
f.remote_diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Flush content maps to release memory
|
|
155
|
+
base_map.clear()
|
|
156
|
+
remote_map.clear()
|
|
157
|
+
local_map.clear()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── CLI output ──────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
67
162
|
const print_file_table = (classified) => {
|
|
68
163
|
const lines = []
|
|
69
164
|
for (const [category, label] of Object.entries(STATUS_LABELS)) {
|
|
@@ -84,6 +179,44 @@ const print_file_table = (classified) => {
|
|
|
84
179
|
}
|
|
85
180
|
}
|
|
86
181
|
|
|
182
|
+
const print_colored_diff = (diff_text) => {
|
|
183
|
+
if (!diff_text) return
|
|
184
|
+
for (const line of diff_text.split('\n')) {
|
|
185
|
+
if (line.startsWith('---') || line.startsWith('+++')) {
|
|
186
|
+
console.log(bold(line))
|
|
187
|
+
} else if (line.startsWith('@@')) {
|
|
188
|
+
console.log(cyan(line))
|
|
189
|
+
} else if (line.startsWith('+')) {
|
|
190
|
+
console.log(green(line))
|
|
191
|
+
} else if (line.startsWith('-')) {
|
|
192
|
+
console.log(red(line))
|
|
193
|
+
} else {
|
|
194
|
+
console.log(line)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const print_report_diffs = (report) => {
|
|
200
|
+
const has_diffs = report.files.some(f => f.diff || f.local_diff || f.remote_diff)
|
|
201
|
+
if (!has_diffs) return
|
|
202
|
+
|
|
203
|
+
console.log('')
|
|
204
|
+
for (const f of report.files) {
|
|
205
|
+
if (f.diff) {
|
|
206
|
+
print_colored_diff(f.diff)
|
|
207
|
+
console.log('')
|
|
208
|
+
}
|
|
209
|
+
if (f.local_diff) {
|
|
210
|
+
print_colored_diff(f.local_diff)
|
|
211
|
+
console.log('')
|
|
212
|
+
}
|
|
213
|
+
if (f.remote_diff) {
|
|
214
|
+
print_colored_diff(f.remote_diff)
|
|
215
|
+
console.log('')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
87
220
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
88
221
|
|
|
89
222
|
const run = (args) => catch_errors('Diff failed', async () => {
|
|
@@ -99,6 +232,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
99
232
|
|
|
100
233
|
const is_global = args.flags.global || false
|
|
101
234
|
const mode = args.flags.full ? 'full' : args.flags.remote ? 'remote' : 'local'
|
|
235
|
+
const skip_content = args.flags['no-content'] || false
|
|
102
236
|
const project_root = find_project_root()
|
|
103
237
|
|
|
104
238
|
// Read lock
|
|
@@ -114,29 +248,33 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
114
248
|
const base_dir = skills_dir(is_global, project_root)
|
|
115
249
|
const skill_dir = skill_install_dir(base_dir, repo)
|
|
116
250
|
|
|
117
|
-
// Always need base files
|
|
251
|
+
// Always need base files — keep full clone response for content diffs
|
|
118
252
|
const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
|
|
119
253
|
if (base_err) throw e('Failed to fetch base files', base_err)
|
|
120
254
|
const base_files = (base_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
121
255
|
|
|
122
256
|
if (mode === 'local') {
|
|
123
|
-
// Local vs base — use classify_changes with base as the "remote" side
|
|
124
257
|
const [local_err, local_files] = await build_local_entries(skill_dir)
|
|
125
258
|
if (local_err) throw e('Failed to read local files', local_err)
|
|
126
259
|
|
|
127
260
|
const classified = classify_changes(base_files, local_files, base_files)
|
|
261
|
+
const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
|
|
262
|
+
|
|
263
|
+
if (!skip_content) {
|
|
264
|
+
const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, null)
|
|
265
|
+
if (enrich_err) throw e('Content diff failed', enrich_err)
|
|
266
|
+
}
|
|
128
267
|
|
|
129
268
|
if (args.flags.json) {
|
|
130
|
-
const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
|
|
131
269
|
print_json({ data: { mode, report } })
|
|
132
270
|
} else {
|
|
133
271
|
print_file_table(classified)
|
|
272
|
+
print_report_diffs(report)
|
|
134
273
|
}
|
|
135
274
|
return
|
|
136
275
|
}
|
|
137
276
|
|
|
138
277
|
if (mode === 'remote') {
|
|
139
|
-
// Base vs remote — get head commit via compare, clone at that commit
|
|
140
278
|
const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
|
|
141
279
|
if (cmp_err) throw e('Compare failed', cmp_err)
|
|
142
280
|
|
|
@@ -145,17 +283,23 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
145
283
|
const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
146
284
|
|
|
147
285
|
const classified = classify_changes(base_files, base_files, remote_files)
|
|
286
|
+
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
287
|
+
|
|
288
|
+
if (!skip_content) {
|
|
289
|
+
const [enrich_err] = await enrich_with_diffs(report, base_clone.files, null, remote_clone.files)
|
|
290
|
+
if (enrich_err) throw e('Content diff failed', enrich_err)
|
|
291
|
+
}
|
|
148
292
|
|
|
149
293
|
if (args.flags.json) {
|
|
150
|
-
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
151
294
|
print_json({ data: { mode, report } })
|
|
152
295
|
} else {
|
|
153
296
|
print_file_table(classified)
|
|
297
|
+
print_report_diffs(report)
|
|
154
298
|
}
|
|
155
299
|
return
|
|
156
300
|
}
|
|
157
301
|
|
|
158
|
-
// Full three-way diff
|
|
302
|
+
// Full three-way diff
|
|
159
303
|
const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
|
|
160
304
|
if (cmp_err) throw e('Compare failed', cmp_err)
|
|
161
305
|
|
|
@@ -169,10 +313,16 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
169
313
|
const classified = classify_changes(base_files, local_files, remote_files)
|
|
170
314
|
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
171
315
|
|
|
316
|
+
if (!skip_content) {
|
|
317
|
+
const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, remote_clone.files)
|
|
318
|
+
if (enrich_err) throw e('Content diff failed', enrich_err)
|
|
319
|
+
}
|
|
320
|
+
|
|
172
321
|
if (args.flags.json) {
|
|
173
322
|
print_json({ data: { mode, report } })
|
|
174
323
|
} else {
|
|
175
324
|
print_file_table(classified)
|
|
325
|
+
print_report_diffs(report)
|
|
176
326
|
if (report.summary.conflicted > 0) {
|
|
177
327
|
console.error('')
|
|
178
328
|
print_warn(`${report.summary.conflicted} file(s) modified on both sides`)
|
package/src/commands/search.js
CHANGED
|
@@ -23,21 +23,21 @@ Options:
|
|
|
23
23
|
--tags <tags> Filter by tags (comma-separated)
|
|
24
24
|
--type <type> Filter by type (skill, kit)
|
|
25
25
|
--exact Use keyword-only matching instead of smart search
|
|
26
|
-
--limit <n> Max results (
|
|
26
|
+
--limit <n> Max results (required, 1-50)
|
|
27
27
|
--min-quality <n> Minimum quality score 0-100
|
|
28
28
|
--json Output as JSON
|
|
29
29
|
|
|
30
30
|
Aliases: s
|
|
31
31
|
|
|
32
32
|
Examples:
|
|
33
|
-
happyskills search deploy
|
|
34
|
-
happyskills search "REST API with Node.js"
|
|
35
|
-
happyskills search --mine
|
|
36
|
-
happyskills search deploy --workspace acme
|
|
37
|
-
happyskills search --type kit
|
|
38
|
-
happyskills s --personal --json
|
|
33
|
+
happyskills search deploy --limit 10
|
|
34
|
+
happyskills search "REST API with Node.js" --limit 10
|
|
35
|
+
happyskills search --mine --limit 20
|
|
36
|
+
happyskills search deploy --workspace acme --limit 50
|
|
37
|
+
happyskills search --type kit --limit 10
|
|
38
|
+
happyskills s --personal --json --limit 20
|
|
39
39
|
happyskills search "deploy to AWS" --limit 5
|
|
40
|
-
happyskills search deploy --exact`
|
|
40
|
+
happyskills search deploy --exact --limit 10`
|
|
41
41
|
|
|
42
42
|
const QUALITY_TIERS = [
|
|
43
43
|
{ min: 80, label: 'High quality', color: cyan },
|
|
@@ -95,7 +95,7 @@ const format_smart_result = (item, index) => {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
|
|
98
|
-
const limit =
|
|
98
|
+
const limit = parseInt(args.flags.limit)
|
|
99
99
|
const capped_limit = Math.min(Math.max(limit, 1), 50)
|
|
100
100
|
const min_quality = args.flags['min-quality'] != null ? parseInt(args.flags['min-quality']) : null
|
|
101
101
|
|
|
@@ -230,6 +230,10 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
230
230
|
throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
if (!args.flags.limit) {
|
|
234
|
+
throw new UsageError('--limit is required. Specify the number of results you want (e.g. --limit 10).')
|
|
235
|
+
}
|
|
236
|
+
|
|
233
237
|
if (type && !VALID_SKILL_TYPES.includes(type)) {
|
|
234
238
|
throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
|
|
235
239
|
}
|
|
@@ -276,8 +276,9 @@ describe('CLI — --json: stdout is always valid JSON', () => {
|
|
|
276
276
|
})
|
|
277
277
|
|
|
278
278
|
it('network error produces JSON with code NETWORK_ERROR', () => {
|
|
279
|
-
// search requires a network call; localhost:0 always fails
|
|
280
|
-
|
|
279
|
+
// search requires a network call; localhost:0 always fails.
|
|
280
|
+
// --limit is required, so it must be passed to reach the network call.
|
|
281
|
+
const { stdout, code } = run(['search', 'anything', '--json', '--limit', '10'])
|
|
281
282
|
assert.strictEqual(code, 4)
|
|
282
283
|
const out = parse_json_output(stdout, 'search --json network failure')
|
|
283
284
|
assert.strictEqual(out.error.code, 'NETWORK_ERROR')
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight unified diff — no external dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Myers diff algorithm for line-level comparison, then formats
|
|
5
|
+
* the result as a standard unified diff with configurable context lines.
|
|
6
|
+
*
|
|
7
|
+
* Designed for memory efficiency:
|
|
8
|
+
* - No intermediate copies of full file content
|
|
9
|
+
* - Hunks are built incrementally
|
|
10
|
+
* - Input strings are split once, not retained
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ─── Myers diff ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the shortest edit script between two line arrays using Myers' O(ND) algorithm.
|
|
17
|
+
* Returns an array of { type: 'equal'|'delete'|'insert', old_idx?, new_idx? } operations.
|
|
18
|
+
*
|
|
19
|
+
* For files with small edit distance D, this runs in O((M+N)*D) time and O(D²) space
|
|
20
|
+
* for the trace. Skill files are typically < 1000 lines with small diffs, so this is
|
|
21
|
+
* well within acceptable bounds.
|
|
22
|
+
*/
|
|
23
|
+
const myers_edit_script = (old_lines, new_lines) => {
|
|
24
|
+
const N = old_lines.length
|
|
25
|
+
const M = new_lines.length
|
|
26
|
+
|
|
27
|
+
if (N === 0 && M === 0) return []
|
|
28
|
+
if (N === 0) return new_lines.map((_, j) => ({ type: 'insert', new_idx: j }))
|
|
29
|
+
if (M === 0) return old_lines.map((_, i) => ({ type: 'delete', old_idx: i }))
|
|
30
|
+
|
|
31
|
+
const MAX = N + M
|
|
32
|
+
const V = new Map([[1, 0]])
|
|
33
|
+
const trace = []
|
|
34
|
+
|
|
35
|
+
for (let d = 0; d <= MAX; d++) {
|
|
36
|
+
// Snapshot V for backtracking
|
|
37
|
+
trace.push(new Map(V))
|
|
38
|
+
|
|
39
|
+
for (let k = -d; k <= d; k += 2) {
|
|
40
|
+
let x
|
|
41
|
+
if (k === -d || (k !== d && (V.get(k - 1) ?? -1) < (V.get(k + 1) ?? -1))) {
|
|
42
|
+
x = V.get(k + 1) ?? 0 // move down (insert)
|
|
43
|
+
} else {
|
|
44
|
+
x = (V.get(k - 1) ?? 0) + 1 // move right (delete)
|
|
45
|
+
}
|
|
46
|
+
let y = x - k
|
|
47
|
+
|
|
48
|
+
// Follow diagonal (equal lines)
|
|
49
|
+
while (x < N && y < M && old_lines[x] === new_lines[y]) {
|
|
50
|
+
x++
|
|
51
|
+
y++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
V.set(k, x)
|
|
55
|
+
|
|
56
|
+
if (x >= N && y >= M) {
|
|
57
|
+
return backtrack(trace, old_lines, new_lines, d)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Should never reach here
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Backtrack through the trace to reconstruct the edit script.
|
|
68
|
+
*/
|
|
69
|
+
const backtrack = (trace, old_lines, new_lines, d) => {
|
|
70
|
+
const ops = []
|
|
71
|
+
let x = old_lines.length
|
|
72
|
+
let y = new_lines.length
|
|
73
|
+
|
|
74
|
+
for (let step = d; step > 0; step--) {
|
|
75
|
+
// trace[step] = V snapshot taken at the START of iteration d=step,
|
|
76
|
+
// which is V AFTER d=step-1 completed — i.e., the previous step's result
|
|
77
|
+
const v_prev = trace[step]
|
|
78
|
+
const k = x - y
|
|
79
|
+
|
|
80
|
+
let prev_k
|
|
81
|
+
if (k === -step || (k !== step && (v_prev.get(k - 1) ?? -1) < (v_prev.get(k + 1) ?? -1))) {
|
|
82
|
+
prev_k = k + 1 // came from above (insert)
|
|
83
|
+
} else {
|
|
84
|
+
prev_k = k - 1 // came from left (delete)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const prev_x = v_prev.get(prev_k) ?? 0
|
|
88
|
+
const prev_y = prev_x - prev_k
|
|
89
|
+
|
|
90
|
+
// Diagonal moves (equal lines) — collect in reverse
|
|
91
|
+
while (x > prev_x + (prev_k < k ? 1 : 0) && y > prev_y + (prev_k > k ? 1 : 0)) {
|
|
92
|
+
x--
|
|
93
|
+
y--
|
|
94
|
+
ops.push({ type: 'equal', old_idx: x, new_idx: y })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The actual edit
|
|
98
|
+
if (prev_k < k) {
|
|
99
|
+
// Moved right → delete
|
|
100
|
+
x--
|
|
101
|
+
ops.push({ type: 'delete', old_idx: x })
|
|
102
|
+
} else {
|
|
103
|
+
// Moved down → insert
|
|
104
|
+
y--
|
|
105
|
+
ops.push({ type: 'insert', new_idx: y })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Remaining diagonal at the start
|
|
110
|
+
while (x > 0 && y > 0) {
|
|
111
|
+
x--
|
|
112
|
+
y--
|
|
113
|
+
ops.push({ type: 'equal', old_idx: x, new_idx: y })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ops.reverse()
|
|
117
|
+
return ops
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Hunk building ───────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Group edit operations into unified diff hunks with context lines.
|
|
124
|
+
*/
|
|
125
|
+
const build_hunks = (ops, old_lines, new_lines, context) => {
|
|
126
|
+
// Find ranges of changes (non-equal ops)
|
|
127
|
+
const change_indices = []
|
|
128
|
+
for (let i = 0; i < ops.length; i++) {
|
|
129
|
+
if (ops[i].type !== 'equal') change_indices.push(i)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (change_indices.length === 0) return []
|
|
133
|
+
|
|
134
|
+
// Group changes that are within (2 * context) lines of each other
|
|
135
|
+
const groups = []
|
|
136
|
+
let group_start = change_indices[0]
|
|
137
|
+
let group_end = change_indices[0]
|
|
138
|
+
|
|
139
|
+
for (let i = 1; i < change_indices.length; i++) {
|
|
140
|
+
const gap = change_indices[i] - change_indices[i - 1]
|
|
141
|
+
if (gap <= context * 2 + 1) {
|
|
142
|
+
group_end = change_indices[i]
|
|
143
|
+
} else {
|
|
144
|
+
groups.push([group_start, group_end])
|
|
145
|
+
group_start = change_indices[i]
|
|
146
|
+
group_end = change_indices[i]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
groups.push([group_start, group_end])
|
|
150
|
+
|
|
151
|
+
// Build hunks from groups
|
|
152
|
+
const hunks = []
|
|
153
|
+
for (const [start, end] of groups) {
|
|
154
|
+
const hunk_start = Math.max(0, start - context)
|
|
155
|
+
const hunk_end = Math.min(ops.length - 1, end + context)
|
|
156
|
+
|
|
157
|
+
let old_start = null
|
|
158
|
+
let new_start = null
|
|
159
|
+
let old_count = 0
|
|
160
|
+
let new_count = 0
|
|
161
|
+
const lines = []
|
|
162
|
+
|
|
163
|
+
for (let i = hunk_start; i <= hunk_end; i++) {
|
|
164
|
+
const op = ops[i]
|
|
165
|
+
if (op.type === 'equal') {
|
|
166
|
+
if (old_start === null) old_start = op.old_idx
|
|
167
|
+
if (new_start === null) new_start = op.new_idx
|
|
168
|
+
lines.push(` ${old_lines[op.old_idx]}`)
|
|
169
|
+
old_count++
|
|
170
|
+
new_count++
|
|
171
|
+
} else if (op.type === 'delete') {
|
|
172
|
+
if (old_start === null) old_start = op.old_idx
|
|
173
|
+
if (new_start === null) {
|
|
174
|
+
// Find the new_idx from the nearest equal op before this
|
|
175
|
+
new_start = find_new_start(ops, i)
|
|
176
|
+
}
|
|
177
|
+
lines.push(`-${old_lines[op.old_idx]}`)
|
|
178
|
+
old_count++
|
|
179
|
+
} else if (op.type === 'insert') {
|
|
180
|
+
if (new_start === null) new_start = op.new_idx
|
|
181
|
+
if (old_start === null) {
|
|
182
|
+
old_start = find_old_start(ops, i)
|
|
183
|
+
}
|
|
184
|
+
lines.push(`+${new_lines[op.new_idx]}`)
|
|
185
|
+
new_count++
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
hunks.push({
|
|
190
|
+
old_start: (old_start ?? 0) + 1, // 1-based
|
|
191
|
+
old_count,
|
|
192
|
+
new_start: (new_start ?? 0) + 1, // 1-based
|
|
193
|
+
new_count,
|
|
194
|
+
lines
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return hunks
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const find_new_start = (ops, from) => {
|
|
202
|
+
for (let i = from - 1; i >= 0; i--) {
|
|
203
|
+
if (ops[i].new_idx !== undefined) return ops[i].new_idx + 1
|
|
204
|
+
}
|
|
205
|
+
return 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const find_old_start = (ops, from) => {
|
|
209
|
+
for (let i = from - 1; i >= 0; i--) {
|
|
210
|
+
if (ops[i].old_idx !== undefined) return ops[i].old_idx + 1
|
|
211
|
+
}
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compute a unified diff between two text strings.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} old_text - Original text
|
|
221
|
+
* @param {string} new_text - Modified text
|
|
222
|
+
* @param {string} old_label - Label for original (e.g., "base/SKILL.md")
|
|
223
|
+
* @param {string} new_label - Label for modified (e.g., "local/SKILL.md")
|
|
224
|
+
* @param {number} [context=3] - Number of context lines around each change
|
|
225
|
+
* @returns {string} Unified diff string, empty if no differences
|
|
226
|
+
*/
|
|
227
|
+
const unified_diff = (old_text, new_text, old_label, new_label, context = 3) => {
|
|
228
|
+
if (old_text === new_text) return ''
|
|
229
|
+
|
|
230
|
+
const old_lines = old_text.split('\n')
|
|
231
|
+
const new_lines = new_text.split('\n')
|
|
232
|
+
|
|
233
|
+
const ops = myers_edit_script(old_lines, new_lines)
|
|
234
|
+
const hunks = build_hunks(ops, old_lines, new_lines, context)
|
|
235
|
+
|
|
236
|
+
if (hunks.length === 0) return ''
|
|
237
|
+
|
|
238
|
+
const header = `--- ${old_label}\n+++ ${new_label}`
|
|
239
|
+
const hunk_strings = hunks.map(h => {
|
|
240
|
+
const range = `@@ -${h.old_start},${h.old_count} +${h.new_start},${h.new_count} @@`
|
|
241
|
+
return `${range}\n${h.lines.join('\n')}`
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return `${header}\n${hunk_strings.join('\n')}`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { unified_diff }
|