happyskills 0.18.1 → 0.20.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 +17 -0
- package/package.json +2 -1
- package/src/commands/publish.js +4 -1
- package/src/commands/pull.js +156 -20
- package/src/commands/status.js +12 -8
- package/src/commands/validate.js +4 -1
- package/src/merge/changelog_merge.js +117 -0
- package/src/merge/changelog_merge.test.js +92 -0
- package/src/merge/json_merge.js +152 -0
- package/src/merge/json_merge.test.js +148 -0
- package/src/merge/report.js +86 -1
- package/src/merge/report.test.js +181 -31
- package/src/merge/text_merge.js +58 -0
- package/src/merge/text_merge.test.js +100 -0
- package/src/validation/conflict_marker_rules.js +54 -0
- package/src/validation/conflict_marker_rules.test.js +90 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured three-way merge for skill.json files.
|
|
3
|
+
*
|
|
4
|
+
* Always produces valid JSON — no conflict markers.
|
|
5
|
+
* Conflicts are reported structurally so the CLI can display them
|
|
6
|
+
* and the user can resolve before publishing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const semver = require('semver')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} base - Base skill.json (from base_commit)
|
|
13
|
+
* @param {object} local - Local skill.json (from disk)
|
|
14
|
+
* @param {object} remote - Remote skill.json (from head_commit)
|
|
15
|
+
* @returns {{ merged: object, conflicts: Array<{ field: string, base_value: *, local_value: *, remote_value: *, suggestion: * }> }}
|
|
16
|
+
*/
|
|
17
|
+
const merge_skill_json = (base, local, remote) => {
|
|
18
|
+
const merged = {}
|
|
19
|
+
const conflicts = []
|
|
20
|
+
|
|
21
|
+
const all_keys = new Set([
|
|
22
|
+
...Object.keys(base || {}),
|
|
23
|
+
...Object.keys(local || {}),
|
|
24
|
+
...Object.keys(remote || {})
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
for (const key of all_keys) {
|
|
28
|
+
const b = base?.[key]
|
|
29
|
+
const l = local?.[key]
|
|
30
|
+
const r = remote?.[key]
|
|
31
|
+
|
|
32
|
+
if (key === 'version') {
|
|
33
|
+
merge_version(b, l, r, merged, conflicts)
|
|
34
|
+
} else if (key === 'dependencies') {
|
|
35
|
+
merge_dependencies(b, l, r, merged, conflicts)
|
|
36
|
+
} else {
|
|
37
|
+
merge_scalar(key, b, l, r, merged, conflicts)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { merged, conflicts }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const merge_version = (base_v, local_v, remote_v, merged, conflicts) => {
|
|
45
|
+
const local_changed = local_v !== base_v
|
|
46
|
+
const remote_changed = remote_v !== base_v
|
|
47
|
+
|
|
48
|
+
if (!local_changed && !remote_changed) {
|
|
49
|
+
merged.version = base_v
|
|
50
|
+
} else if (local_changed && !remote_changed) {
|
|
51
|
+
merged.version = local_v
|
|
52
|
+
} else if (!local_changed && remote_changed) {
|
|
53
|
+
merged.version = remote_v
|
|
54
|
+
} else if (local_v === remote_v) {
|
|
55
|
+
// Both changed to the same value — no conflict
|
|
56
|
+
merged.version = local_v
|
|
57
|
+
} else {
|
|
58
|
+
// Both changed differently — suggest next patch after the higher version
|
|
59
|
+
const higher = semver.gt(remote_v || '0.0.0', local_v || '0.0.0') ? remote_v : local_v
|
|
60
|
+
const suggestion = semver.inc(higher, 'patch')
|
|
61
|
+
merged.version = suggestion
|
|
62
|
+
conflicts.push({
|
|
63
|
+
field: 'version',
|
|
64
|
+
base_value: base_v,
|
|
65
|
+
local_value: local_v,
|
|
66
|
+
remote_value: remote_v,
|
|
67
|
+
suggestion
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const merge_dependencies = (base_deps, local_deps, remote_deps, merged, conflicts) => {
|
|
73
|
+
const b = base_deps || {}
|
|
74
|
+
const l = local_deps || {}
|
|
75
|
+
const r = remote_deps || {}
|
|
76
|
+
const all_dep_keys = new Set([...Object.keys(b), ...Object.keys(l), ...Object.keys(r)])
|
|
77
|
+
const result = {}
|
|
78
|
+
|
|
79
|
+
for (const dep of all_dep_keys) {
|
|
80
|
+
const bv = b[dep]
|
|
81
|
+
const lv = l[dep]
|
|
82
|
+
const rv = r[dep]
|
|
83
|
+
const local_changed = lv !== bv
|
|
84
|
+
const remote_changed = rv !== bv
|
|
85
|
+
|
|
86
|
+
if (!local_changed && !remote_changed) {
|
|
87
|
+
if (bv !== undefined) result[dep] = bv
|
|
88
|
+
} else if (local_changed && !remote_changed) {
|
|
89
|
+
if (lv !== undefined) result[dep] = lv // local added or modified
|
|
90
|
+
// lv undefined = local removed
|
|
91
|
+
} else if (!local_changed && remote_changed) {
|
|
92
|
+
if (rv !== undefined) result[dep] = rv // remote added or modified
|
|
93
|
+
// rv undefined = remote removed
|
|
94
|
+
} else {
|
|
95
|
+
// Both changed
|
|
96
|
+
if (lv === rv) {
|
|
97
|
+
// Same change — no conflict
|
|
98
|
+
if (lv !== undefined) result[dep] = lv
|
|
99
|
+
} else {
|
|
100
|
+
// Conflict — suggest remote constraint (published version wins)
|
|
101
|
+
const suggestion = rv !== undefined ? rv : lv
|
|
102
|
+
result[dep] = suggestion
|
|
103
|
+
conflicts.push({
|
|
104
|
+
field: `dependencies.${dep}`,
|
|
105
|
+
base_value: bv || null,
|
|
106
|
+
local_value: lv || null,
|
|
107
|
+
remote_value: rv || null,
|
|
108
|
+
suggestion
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Object.keys(result).length > 0) {
|
|
115
|
+
merged.dependencies = result
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const merge_scalar = (key, base_val, local_val, remote_val, merged, conflicts) => {
|
|
120
|
+
const b_json = JSON.stringify(base_val)
|
|
121
|
+
const l_json = JSON.stringify(local_val)
|
|
122
|
+
const r_json = JSON.stringify(remote_val)
|
|
123
|
+
|
|
124
|
+
const local_changed = l_json !== b_json
|
|
125
|
+
const remote_changed = r_json !== b_json
|
|
126
|
+
|
|
127
|
+
if (!local_changed && !remote_changed) {
|
|
128
|
+
if (base_val !== undefined) merged[key] = base_val
|
|
129
|
+
} else if (local_changed && !remote_changed) {
|
|
130
|
+
if (local_val !== undefined) merged[key] = local_val
|
|
131
|
+
} else if (!local_changed && remote_changed) {
|
|
132
|
+
if (remote_val !== undefined) merged[key] = remote_val
|
|
133
|
+
} else {
|
|
134
|
+
// Both changed
|
|
135
|
+
if (l_json === r_json) {
|
|
136
|
+
if (local_val !== undefined) merged[key] = local_val
|
|
137
|
+
} else {
|
|
138
|
+
// Conflict — suggest remote value
|
|
139
|
+
const suggestion = remote_val !== undefined ? remote_val : local_val
|
|
140
|
+
if (suggestion !== undefined) merged[key] = suggestion
|
|
141
|
+
conflicts.push({
|
|
142
|
+
field: key,
|
|
143
|
+
base_value: base_val !== undefined ? base_val : null,
|
|
144
|
+
local_value: local_val !== undefined ? local_val : null,
|
|
145
|
+
remote_value: remote_val !== undefined ? remote_val : null,
|
|
146
|
+
suggestion: suggestion !== undefined ? suggestion : null
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { merge_skill_json }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const { merge_skill_json } = require('./json_merge')
|
|
4
|
+
|
|
5
|
+
describe('merge_skill_json', () => {
|
|
6
|
+
it('returns base unchanged when no changes', () => {
|
|
7
|
+
const base = { name: 'acme/deploy', version: '1.0.0' }
|
|
8
|
+
const r = merge_skill_json(base, { ...base }, { ...base })
|
|
9
|
+
assert.deepStrictEqual(r.merged, base)
|
|
10
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('takes local-only scalar change', () => {
|
|
14
|
+
const base = { name: 'acme/deploy', version: '1.0.0', description: 'old' }
|
|
15
|
+
const local = { ...base, description: 'new local' }
|
|
16
|
+
const r = merge_skill_json(base, local, { ...base })
|
|
17
|
+
assert.strictEqual(r.merged.description, 'new local')
|
|
18
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('takes remote-only scalar change', () => {
|
|
22
|
+
const base = { name: 'acme/deploy', version: '1.0.0', description: 'old' }
|
|
23
|
+
const remote = { ...base, description: 'new remote' }
|
|
24
|
+
const r = merge_skill_json(base, { ...base }, remote)
|
|
25
|
+
assert.strictEqual(r.merged.description, 'new remote')
|
|
26
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reports conflict for both-changed scalar', () => {
|
|
30
|
+
const base = { name: 'acme/deploy', description: 'old' }
|
|
31
|
+
const local = { name: 'acme/deploy', description: 'local desc' }
|
|
32
|
+
const remote = { name: 'acme/deploy', description: 'remote desc' }
|
|
33
|
+
const r = merge_skill_json(base, local, remote)
|
|
34
|
+
assert.strictEqual(r.conflicts.length, 1)
|
|
35
|
+
assert.strictEqual(r.conflicts[0].field, 'description')
|
|
36
|
+
assert.strictEqual(r.conflicts[0].suggestion, 'remote desc')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reports version conflict with suggested next patch', () => {
|
|
40
|
+
const base = { version: '1.5.0' }
|
|
41
|
+
const local = { version: '1.6.0' }
|
|
42
|
+
const remote = { version: '1.8.0' }
|
|
43
|
+
const r = merge_skill_json(base, local, remote)
|
|
44
|
+
assert.strictEqual(r.conflicts.length, 1)
|
|
45
|
+
assert.strictEqual(r.conflicts[0].field, 'version')
|
|
46
|
+
assert.strictEqual(r.merged.version, '1.8.1')
|
|
47
|
+
assert.strictEqual(r.conflicts[0].suggestion, '1.8.1')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('takes local-only version change without conflict', () => {
|
|
51
|
+
const base = { version: '1.0.0' }
|
|
52
|
+
const local = { version: '1.1.0' }
|
|
53
|
+
const r = merge_skill_json(base, local, { ...base })
|
|
54
|
+
assert.strictEqual(r.merged.version, '1.1.0')
|
|
55
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('takes remote-only version change without conflict', () => {
|
|
59
|
+
const base = { version: '1.0.0' }
|
|
60
|
+
const remote = { version: '1.2.0' }
|
|
61
|
+
const r = merge_skill_json(base, { ...base }, remote)
|
|
62
|
+
assert.strictEqual(r.merged.version, '1.2.0')
|
|
63
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('merges dependencies — local-only add', () => {
|
|
67
|
+
const base = { dependencies: { 'acme/utils': '^2.0.0' } }
|
|
68
|
+
const local = { dependencies: { 'acme/utils': '^2.0.0', 'acme/logger': '^1.0.0' } }
|
|
69
|
+
const r = merge_skill_json(base, local, { ...base })
|
|
70
|
+
assert.strictEqual(r.merged.dependencies['acme/logger'], '^1.0.0')
|
|
71
|
+
assert.strictEqual(r.merged.dependencies['acme/utils'], '^2.0.0')
|
|
72
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('merges dependencies — remote-only add', () => {
|
|
76
|
+
const base = { dependencies: { 'acme/utils': '^2.0.0' } }
|
|
77
|
+
const remote = { dependencies: { 'acme/utils': '^2.0.0', 'acme/auth': '^1.0.0' } }
|
|
78
|
+
const r = merge_skill_json(base, { ...base }, remote)
|
|
79
|
+
assert.strictEqual(r.merged.dependencies['acme/auth'], '^1.0.0')
|
|
80
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('merges dependencies — both add different deps (clean)', () => {
|
|
84
|
+
const base = { dependencies: {} }
|
|
85
|
+
const local = { dependencies: { 'acme/logger': '^1.0.0' } }
|
|
86
|
+
const remote = { dependencies: { 'acme/auth': '^1.0.0' } }
|
|
87
|
+
const r = merge_skill_json(base, local, remote)
|
|
88
|
+
assert.strictEqual(r.merged.dependencies['acme/logger'], '^1.0.0')
|
|
89
|
+
assert.strictEqual(r.merged.dependencies['acme/auth'], '^1.0.0')
|
|
90
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('reports conflict for same dep with different constraints', () => {
|
|
94
|
+
const base = { dependencies: { 'acme/utils': '^2.0.0' } }
|
|
95
|
+
const local = { dependencies: { 'acme/utils': '^2.5.0' } }
|
|
96
|
+
const remote = { dependencies: { 'acme/utils': '^3.0.0' } }
|
|
97
|
+
const r = merge_skill_json(base, local, remote)
|
|
98
|
+
assert.strictEqual(r.conflicts.length, 1)
|
|
99
|
+
assert.strictEqual(r.conflicts[0].field, 'dependencies.acme/utils')
|
|
100
|
+
assert.strictEqual(r.conflicts[0].suggestion, '^3.0.0')
|
|
101
|
+
assert.strictEqual(r.merged.dependencies['acme/utils'], '^3.0.0')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('handles local dep removal', () => {
|
|
105
|
+
const base = { dependencies: { 'acme/utils': '^2.0.0', 'acme/logger': '^1.0.0' } }
|
|
106
|
+
const local = { dependencies: { 'acme/utils': '^2.0.0' } }
|
|
107
|
+
const r = merge_skill_json(base, local, { ...base })
|
|
108
|
+
assert.strictEqual(r.merged.dependencies['acme/logger'], undefined)
|
|
109
|
+
assert.strictEqual(r.merged.dependencies['acme/utils'], '^2.0.0')
|
|
110
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('handles null base (no previous skill.json)', () => {
|
|
114
|
+
const local = { name: 'acme/deploy', version: '1.0.0' }
|
|
115
|
+
const remote = { name: 'acme/deploy', version: '1.0.0' }
|
|
116
|
+
const r = merge_skill_json(null, local, remote)
|
|
117
|
+
assert.strictEqual(r.merged.name, 'acme/deploy')
|
|
118
|
+
assert.strictEqual(r.conflicts.length, 0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('always produces valid JSON (no conflict markers)', () => {
|
|
122
|
+
const base = { version: '1.0.0', name: 'a' }
|
|
123
|
+
const local = { version: '2.0.0', name: 'b' }
|
|
124
|
+
const remote = { version: '3.0.0', name: 'c' }
|
|
125
|
+
const r = merge_skill_json(base, local, remote)
|
|
126
|
+
const json_str = JSON.stringify(r.merged)
|
|
127
|
+
assert.doesNotThrow(() => JSON.parse(json_str))
|
|
128
|
+
assert.ok(!json_str.includes('<<<<<<<'))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('handles the full spec example correctly', () => {
|
|
132
|
+
const base = { version: '1.5.0', dependencies: { 'acme/utils': '^2.0.0' } }
|
|
133
|
+
const local = { version: '1.6.0', dependencies: { 'acme/utils': '^2.0.0', 'acme/logger': '^1.0.0' } }
|
|
134
|
+
const remote = { version: '1.8.0', dependencies: { 'acme/utils': '^3.0.0', 'acme/auth': '^1.0.0' } }
|
|
135
|
+
const r = merge_skill_json(base, local, remote)
|
|
136
|
+
// Version: both changed → suggestion = 1.8.1
|
|
137
|
+
assert.strictEqual(r.merged.version, '1.8.1')
|
|
138
|
+
// Utils: remote-only change → ^3.0.0
|
|
139
|
+
assert.strictEqual(r.merged.dependencies['acme/utils'], '^3.0.0')
|
|
140
|
+
// Logger: local-only add
|
|
141
|
+
assert.strictEqual(r.merged.dependencies['acme/logger'], '^1.0.0')
|
|
142
|
+
// Auth: remote-only add
|
|
143
|
+
assert.strictEqual(r.merged.dependencies['acme/auth'], '^1.0.0')
|
|
144
|
+
// Only version should be a conflict
|
|
145
|
+
const version_conflicts = r.conflicts.filter(c => c.field === 'version')
|
|
146
|
+
assert.strictEqual(version_conflicts.length, 1)
|
|
147
|
+
})
|
|
148
|
+
})
|
package/src/merge/report.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* The report is consumed by both human-readable CLI output and the AI agent
|
|
5
5
|
* semantic review layer (Layer 2). It must include enough data for the agent
|
|
6
6
|
* to reason about conflicts without additional API calls.
|
|
7
|
+
*
|
|
8
|
+
* When --full-report is used, file entries include inline content fields
|
|
9
|
+
* (base_content, local_content, remote_content, merged_content) so the
|
|
10
|
+
* consuming LLM can perform semantic review in a single pass.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
const CLASSIFICATIONS = [
|
|
@@ -13,6 +17,12 @@ const CLASSIFICATIONS = [
|
|
|
13
17
|
'unchanged'
|
|
14
18
|
]
|
|
15
19
|
|
|
20
|
+
const SEMANTIC_REVIEW_CLASSIFICATIONS = new Set([
|
|
21
|
+
'both_modified',
|
|
22
|
+
'remote_only_modified', 'local_only_modified',
|
|
23
|
+
'remote_only_added', 'local_only_added'
|
|
24
|
+
])
|
|
25
|
+
|
|
16
26
|
const build_report = (skill, base_version, remote_version, classified) => {
|
|
17
27
|
const files = []
|
|
18
28
|
let clean = 0
|
|
@@ -56,4 +66,79 @@ const build_report = (skill, base_version, remote_version, classified) => {
|
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Enrich a report file entry with inline content for --full-report mode.
|
|
71
|
+
* Only sets fields that are provided (non-undefined).
|
|
72
|
+
*
|
|
73
|
+
* @param {object} report_file - A file entry from report.files
|
|
74
|
+
* @param {{ base?: string, local?: string, remote?: string, merged?: string }} content
|
|
75
|
+
*/
|
|
76
|
+
const enrich_file_content = (report_file, content) => {
|
|
77
|
+
if (content.base !== undefined) report_file.base_content = content.base
|
|
78
|
+
if (content.local !== undefined) report_file.local_content = content.local
|
|
79
|
+
if (content.remote !== undefined) report_file.remote_content = content.remote
|
|
80
|
+
if (content.merged !== undefined) report_file.merged_content = content.merged
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build action-typed resolution steps from the merge report.
|
|
85
|
+
* Steps are deterministic — computed from classification data and merge results.
|
|
86
|
+
* The consuming LLM adds intelligence (semantic reasoning, natural language).
|
|
87
|
+
*
|
|
88
|
+
* @param {object} report - The merge report from build_report
|
|
89
|
+
* @param {Array} [json_conflicts] - Conflicts from skill.json merge
|
|
90
|
+
* @returns {Array} Ordered resolution steps
|
|
91
|
+
*/
|
|
92
|
+
const build_resolution_steps = (report, json_conflicts = []) => {
|
|
93
|
+
const steps = []
|
|
94
|
+
|
|
95
|
+
// 1. Conflict markers — must be resolved before publishing
|
|
96
|
+
const marker_files = report.files.filter(f => f.conflict_written)
|
|
97
|
+
if (marker_files.length > 0) {
|
|
98
|
+
steps.push({
|
|
99
|
+
action: 'resolve_conflict_markers',
|
|
100
|
+
files: marker_files.map(f => f.path),
|
|
101
|
+
description: 'Files with conflict markers that must be manually resolved'
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. JSON field suggestions — review auto-applied defaults
|
|
106
|
+
if (json_conflicts.length > 0) {
|
|
107
|
+
steps.push({
|
|
108
|
+
action: 'review_json_suggestions',
|
|
109
|
+
files: ['skill.json'],
|
|
110
|
+
suggestions: json_conflicts.map(c => ({
|
|
111
|
+
field: c.field,
|
|
112
|
+
value: c.suggestion,
|
|
113
|
+
reason: suggestion_reason(c.field)
|
|
114
|
+
}))
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 3. Semantic review — all files where concurrent changes may contradict
|
|
119
|
+
const semantic_files = report.files.filter(f => SEMANTIC_REVIEW_CLASSIFICATIONS.has(f.classification))
|
|
120
|
+
if (semantic_files.length > 0) {
|
|
121
|
+
steps.push({
|
|
122
|
+
action: 'semantic_review',
|
|
123
|
+
files: semantic_files.map(f => f.path),
|
|
124
|
+
description: 'Review these files for logical consistency — changes from both sides may introduce semantic contradictions even without text conflicts'
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 4. Verify — always present as final step
|
|
129
|
+
steps.push({
|
|
130
|
+
action: 'verify',
|
|
131
|
+
command: 'happyskills status',
|
|
132
|
+
description: 'Verify all conflicts are resolved before publishing'
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return steps
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const suggestion_reason = (field) => {
|
|
139
|
+
if (field === 'version') return 'Next patch after the higher version'
|
|
140
|
+
if (field.startsWith('dependencies.')) return 'Published version wins by default'
|
|
141
|
+
return 'Remote value used as default'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { build_report, enrich_file_content, build_resolution_steps, CLASSIFICATIONS }
|
package/src/merge/report.test.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
const { describe, it } = require('node:test')
|
|
2
2
|
const assert = require('node:assert/strict')
|
|
3
|
-
const { build_report, CLASSIFICATIONS } = require('./report')
|
|
3
|
+
const { build_report, enrich_file_content, build_resolution_steps, CLASSIFICATIONS } = require('./report')
|
|
4
|
+
|
|
5
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const make_classified = (overrides = {}) => ({
|
|
8
|
+
remote_only_modified: [], local_only_modified: [], both_modified: [],
|
|
9
|
+
remote_only_added: [], local_only_added: [],
|
|
10
|
+
remote_only_deleted: [], local_only_deleted: [],
|
|
11
|
+
unchanged: [],
|
|
12
|
+
...overrides
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// ─── build_report ─────────────────────────────────────────────────────────────
|
|
4
16
|
|
|
5
17
|
describe('build_report', () => {
|
|
6
18
|
it('produces v1 contract fields', () => {
|
|
7
|
-
const classified = {
|
|
19
|
+
const classified = make_classified({
|
|
8
20
|
remote_only_modified: [{ path: 'a.md', base_sha: '111', remote_sha: '222' }],
|
|
9
|
-
local_only_modified: [],
|
|
10
21
|
both_modified: [{ path: 'b.md', base_sha: '333', local_sha: '444', remote_sha: '555' }],
|
|
11
|
-
remote_only_added: [],
|
|
12
|
-
local_only_added: [],
|
|
13
|
-
remote_only_deleted: [],
|
|
14
|
-
local_only_deleted: [],
|
|
15
22
|
unchanged: [{ path: 'c.md', sha: '666' }]
|
|
16
|
-
}
|
|
23
|
+
})
|
|
17
24
|
const report = build_report('acme/deploy-aws', '1.15.0', '1.17.0', classified)
|
|
18
25
|
|
|
19
26
|
assert.strictEqual(report.skill, 'acme/deploy-aws')
|
|
@@ -24,16 +31,13 @@ describe('build_report', () => {
|
|
|
24
31
|
})
|
|
25
32
|
|
|
26
33
|
it('summary counts match file array', () => {
|
|
27
|
-
const classified = {
|
|
34
|
+
const classified = make_classified({
|
|
28
35
|
remote_only_modified: [{ path: 'a.md', base_sha: '1', remote_sha: '2' }],
|
|
29
36
|
local_only_modified: [{ path: 'b.md', base_sha: '3', local_sha: '4' }],
|
|
30
|
-
both_modified: [],
|
|
31
37
|
remote_only_added: [{ path: 'c.md', remote_sha: '5' }],
|
|
32
|
-
local_only_added: [],
|
|
33
38
|
remote_only_deleted: [{ path: 'd.md' }],
|
|
34
|
-
local_only_deleted: [],
|
|
35
39
|
unchanged: [{ path: 'e.md', sha: '6' }, { path: 'f.md', sha: '7' }]
|
|
36
|
-
}
|
|
40
|
+
})
|
|
37
41
|
const report = build_report('test/skill', '1.0.0', '2.0.0', classified)
|
|
38
42
|
|
|
39
43
|
assert.strictEqual(report.files.length, report.summary.auto_merged + report.summary.conflicted)
|
|
@@ -41,16 +45,10 @@ describe('build_report', () => {
|
|
|
41
45
|
})
|
|
42
46
|
|
|
43
47
|
it('classifications are from the defined vocabulary', () => {
|
|
44
|
-
const classified = {
|
|
48
|
+
const classified = make_classified({
|
|
45
49
|
remote_only_modified: [{ path: 'a.md', base_sha: '1', remote_sha: '2' }],
|
|
46
|
-
local_only_modified: [],
|
|
47
50
|
both_modified: [{ path: 'b.md', base_sha: '3', local_sha: '4', remote_sha: '5' }],
|
|
48
|
-
|
|
49
|
-
local_only_added: [],
|
|
50
|
-
remote_only_deleted: [],
|
|
51
|
-
local_only_deleted: [],
|
|
52
|
-
unchanged: []
|
|
53
|
-
}
|
|
51
|
+
})
|
|
54
52
|
const report = build_report('test/skill', '1.0.0', '2.0.0', classified)
|
|
55
53
|
|
|
56
54
|
for (const file of report.files) {
|
|
@@ -59,12 +57,9 @@ describe('build_report', () => {
|
|
|
59
57
|
})
|
|
60
58
|
|
|
61
59
|
it('handles null versions gracefully', () => {
|
|
62
|
-
const classified = {
|
|
63
|
-
remote_only_modified: [], local_only_modified: [], both_modified: [],
|
|
64
|
-
remote_only_added: [], local_only_added: [],
|
|
65
|
-
remote_only_deleted: [], local_only_deleted: [],
|
|
60
|
+
const classified = make_classified({
|
|
66
61
|
unchanged: [{ path: 'a.md', sha: '111' }]
|
|
67
|
-
}
|
|
62
|
+
})
|
|
68
63
|
const report = build_report('test/skill', null, null, classified)
|
|
69
64
|
assert.strictEqual(report.base_version, null)
|
|
70
65
|
assert.strictEqual(report.remote_version, null)
|
|
@@ -72,14 +67,169 @@ describe('build_report', () => {
|
|
|
72
67
|
})
|
|
73
68
|
|
|
74
69
|
it('unchanged files are not included in files array', () => {
|
|
75
|
-
const classified = {
|
|
76
|
-
remote_only_modified: [], local_only_modified: [], both_modified: [],
|
|
77
|
-
remote_only_added: [], local_only_added: [],
|
|
78
|
-
remote_only_deleted: [], local_only_deleted: [],
|
|
70
|
+
const classified = make_classified({
|
|
79
71
|
unchanged: [{ path: 'a.md', sha: '111' }, { path: 'b.md', sha: '222' }]
|
|
80
|
-
}
|
|
72
|
+
})
|
|
81
73
|
const report = build_report('test/skill', '1.0.0', '1.0.0', classified)
|
|
82
74
|
assert.strictEqual(report.files.length, 0)
|
|
83
75
|
assert.strictEqual(report.summary.clean, 2)
|
|
84
76
|
})
|
|
85
77
|
})
|
|
78
|
+
|
|
79
|
+
// ─── enrich_file_content ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('enrich_file_content', () => {
|
|
82
|
+
it('sets all provided content fields', () => {
|
|
83
|
+
const file = { path: 'SKILL.md', classification: 'both_modified' }
|
|
84
|
+
enrich_file_content(file, {
|
|
85
|
+
base: 'base text',
|
|
86
|
+
local: 'local text',
|
|
87
|
+
remote: 'remote text',
|
|
88
|
+
merged: 'merged text'
|
|
89
|
+
})
|
|
90
|
+
assert.strictEqual(file.base_content, 'base text')
|
|
91
|
+
assert.strictEqual(file.local_content, 'local text')
|
|
92
|
+
assert.strictEqual(file.remote_content, 'remote text')
|
|
93
|
+
assert.strictEqual(file.merged_content, 'merged text')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('only sets fields that are provided (non-undefined)', () => {
|
|
97
|
+
const file = { path: 'a.md', classification: 'remote_only_modified' }
|
|
98
|
+
enrich_file_content(file, { remote: 'remote text' })
|
|
99
|
+
assert.strictEqual(file.remote_content, 'remote text')
|
|
100
|
+
assert.strictEqual(file.base_content, undefined)
|
|
101
|
+
assert.strictEqual(file.local_content, undefined)
|
|
102
|
+
assert.strictEqual(file.merged_content, undefined)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not set fields for undefined values', () => {
|
|
106
|
+
const file = { path: 'a.md' }
|
|
107
|
+
enrich_file_content(file, { base: undefined, local: 'text' })
|
|
108
|
+
assert.ok(!('base_content' in file))
|
|
109
|
+
assert.strictEqual(file.local_content, 'text')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('handles empty string content', () => {
|
|
113
|
+
const file = { path: 'a.md' }
|
|
114
|
+
enrich_file_content(file, { base: '', remote: '' })
|
|
115
|
+
assert.strictEqual(file.base_content, '')
|
|
116
|
+
assert.strictEqual(file.remote_content, '')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ─── build_resolution_steps ───────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe('build_resolution_steps', () => {
|
|
123
|
+
it('always includes verify step as last step', () => {
|
|
124
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
125
|
+
unchanged: [{ path: 'a.md', sha: '111' }]
|
|
126
|
+
}))
|
|
127
|
+
const steps = build_resolution_steps(report)
|
|
128
|
+
assert.strictEqual(steps.length, 1)
|
|
129
|
+
assert.strictEqual(steps[0].action, 'verify')
|
|
130
|
+
assert.strictEqual(steps[0].command, 'happyskills status')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('includes resolve_conflict_markers for files with conflict_written', () => {
|
|
134
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
135
|
+
both_modified: [
|
|
136
|
+
{ path: 'SKILL.md', base_sha: '1', local_sha: '2', remote_sha: '3' },
|
|
137
|
+
{ path: 'ref.md', base_sha: '4', local_sha: '5', remote_sha: '6' }
|
|
138
|
+
]
|
|
139
|
+
}))
|
|
140
|
+
// Simulate pull.js marking conflict_written
|
|
141
|
+
report.files[0].conflict_written = true
|
|
142
|
+
|
|
143
|
+
const steps = build_resolution_steps(report)
|
|
144
|
+
const marker_step = steps.find(s => s.action === 'resolve_conflict_markers')
|
|
145
|
+
assert.ok(marker_step)
|
|
146
|
+
assert.deepStrictEqual(marker_step.files, ['SKILL.md'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('includes review_json_suggestions when json_conflicts exist', () => {
|
|
150
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
151
|
+
both_modified: [{ path: 'skill.json', base_sha: '1', local_sha: '2', remote_sha: '3' }]
|
|
152
|
+
}))
|
|
153
|
+
const json_conflicts = [
|
|
154
|
+
{ field: 'version', base_value: '1.0.0', local_value: '1.1.0', remote_value: '1.2.0', suggestion: '1.2.1' },
|
|
155
|
+
{ field: 'dependencies.acme/utils', base_value: '1.0.0', local_value: '2.0.0', remote_value: '3.0.0', suggestion: '3.0.0' }
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
const steps = build_resolution_steps(report, json_conflicts)
|
|
159
|
+
const json_step = steps.find(s => s.action === 'review_json_suggestions')
|
|
160
|
+
assert.ok(json_step)
|
|
161
|
+
assert.deepStrictEqual(json_step.files, ['skill.json'])
|
|
162
|
+
assert.strictEqual(json_step.suggestions.length, 2)
|
|
163
|
+
assert.strictEqual(json_step.suggestions[0].field, 'version')
|
|
164
|
+
assert.strictEqual(json_step.suggestions[0].value, '1.2.1')
|
|
165
|
+
assert.strictEqual(json_step.suggestions[0].reason, 'Next patch after the higher version')
|
|
166
|
+
assert.strictEqual(json_step.suggestions[1].reason, 'Published version wins by default')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('includes semantic_review for all modified/added classifications', () => {
|
|
170
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
171
|
+
both_modified: [{ path: 'SKILL.md', base_sha: '1', local_sha: '2', remote_sha: '3' }],
|
|
172
|
+
remote_only_modified: [{ path: 'refs/auth.md', base_sha: '4', remote_sha: '5' }],
|
|
173
|
+
local_only_modified: [{ path: 'refs/deploy.md', base_sha: '6', local_sha: '7' }],
|
|
174
|
+
remote_only_added: [{ path: 'refs/new-remote.md', remote_sha: '8' }],
|
|
175
|
+
local_only_added: [{ path: 'refs/new-local.md', local_sha: '9' }],
|
|
176
|
+
remote_only_deleted: [{ path: 'old.md', base_sha: '10' }],
|
|
177
|
+
local_only_deleted: [{ path: 'legacy.md', base_sha: '11' }]
|
|
178
|
+
}))
|
|
179
|
+
|
|
180
|
+
const steps = build_resolution_steps(report)
|
|
181
|
+
const semantic_step = steps.find(s => s.action === 'semantic_review')
|
|
182
|
+
assert.ok(semantic_step)
|
|
183
|
+
// Should include all modified/added but NOT deleted or unchanged
|
|
184
|
+
assert.deepStrictEqual(semantic_step.files, [
|
|
185
|
+
'refs/auth.md', 'refs/deploy.md', 'SKILL.md',
|
|
186
|
+
'refs/new-remote.md', 'refs/new-local.md'
|
|
187
|
+
])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('excludes semantic_review when only deletions exist', () => {
|
|
191
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
192
|
+
remote_only_deleted: [{ path: 'old.md', base_sha: '1' }]
|
|
193
|
+
}))
|
|
194
|
+
|
|
195
|
+
const steps = build_resolution_steps(report)
|
|
196
|
+
assert.ok(!steps.find(s => s.action === 'semantic_review'))
|
|
197
|
+
assert.strictEqual(steps.length, 1) // only verify
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('steps are ordered: markers → json → semantic → verify', () => {
|
|
201
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
202
|
+
both_modified: [{ path: 'SKILL.md', base_sha: '1', local_sha: '2', remote_sha: '3' }],
|
|
203
|
+
remote_only_modified: [{ path: 'auth.md', base_sha: '4', remote_sha: '5' }]
|
|
204
|
+
}))
|
|
205
|
+
report.files.find(f => f.path === 'SKILL.md').conflict_written = true
|
|
206
|
+
|
|
207
|
+
const json_conflicts = [{ field: 'version', suggestion: '2.0.1' }]
|
|
208
|
+
const steps = build_resolution_steps(report, json_conflicts)
|
|
209
|
+
|
|
210
|
+
const actions = steps.map(s => s.action)
|
|
211
|
+
assert.deepStrictEqual(actions, [
|
|
212
|
+
'resolve_conflict_markers',
|
|
213
|
+
'review_json_suggestions',
|
|
214
|
+
'semantic_review',
|
|
215
|
+
'verify'
|
|
216
|
+
])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('handles empty report gracefully', () => {
|
|
220
|
+
const report = build_report('test/skill', '1.0.0', '1.0.0', make_classified())
|
|
221
|
+
const steps = build_resolution_steps(report)
|
|
222
|
+
assert.strictEqual(steps.length, 1)
|
|
223
|
+
assert.strictEqual(steps[0].action, 'verify')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('suggestion reason for generic scalar field', () => {
|
|
227
|
+
const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
|
|
228
|
+
both_modified: [{ path: 'skill.json', base_sha: '1', local_sha: '2', remote_sha: '3' }]
|
|
229
|
+
}))
|
|
230
|
+
const json_conflicts = [{ field: 'description', suggestion: 'Remote desc' }]
|
|
231
|
+
const steps = build_resolution_steps(report, json_conflicts)
|
|
232
|
+
const json_step = steps.find(s => s.action === 'review_json_suggestions')
|
|
233
|
+
assert.strictEqual(json_step.suggestions[0].reason, 'Remote value used as default')
|
|
234
|
+
})
|
|
235
|
+
})
|