happyskills 0.48.0 → 0.49.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 +48 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills release` — § 8.2.
|
|
4
|
+
*
|
|
5
|
+
* Focus: the failure-mode envelopes (drift, missing_version, missing_changelog
|
|
6
|
+
* entry) and the ahead-recognition path through --dry-run. The full happy-path
|
|
7
|
+
* publish requires a real registry and is covered by the existing publish
|
|
8
|
+
* pipeline tests; here we verify orchestration, snapshot capture/restore, and
|
|
9
|
+
* the structured next_step envelopes.
|
|
10
|
+
*/
|
|
11
|
+
const { describe, it } = require('node:test')
|
|
12
|
+
const assert = require('node:assert/strict')
|
|
13
|
+
const { spawnSync } = require('child_process')
|
|
14
|
+
const fs = require('fs')
|
|
15
|
+
const os = require('os')
|
|
16
|
+
const path = require('path')
|
|
17
|
+
|
|
18
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
19
|
+
const NODE = process.execPath
|
|
20
|
+
|
|
21
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
|
|
22
|
+
const run = (args, opts) => {
|
|
23
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
24
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', ...(opts?.env || {}) },
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
timeout: 15000,
|
|
27
|
+
cwd: opts?.cwd
|
|
28
|
+
})
|
|
29
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
30
|
+
}
|
|
31
|
+
const parse_json = (stdout, label) => {
|
|
32
|
+
try { return JSON.parse(stdout) }
|
|
33
|
+
catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const scaffold = ({ full, short, lock_version, disk_version, changelog }) => {
|
|
37
|
+
const root = make_tmp()
|
|
38
|
+
const skill_dir = path.join(root, '.agents', 'skills', short)
|
|
39
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
42
|
+
`---\nname: ${short}\ndescription: release test skill for integration coverage\n---\n# ${short}\n\nbody\n`
|
|
43
|
+
)
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(skill_dir, 'skill.json'),
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
name: short,
|
|
48
|
+
version: disk_version,
|
|
49
|
+
type: 'skill',
|
|
50
|
+
description: 'release test skill for integration coverage',
|
|
51
|
+
keywords: ['testing']
|
|
52
|
+
}, null, '\t')
|
|
53
|
+
)
|
|
54
|
+
if (changelog) {
|
|
55
|
+
fs.writeFileSync(path.join(skill_dir, 'CHANGELOG.md'), changelog)
|
|
56
|
+
}
|
|
57
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
58
|
+
lockVersion: 2,
|
|
59
|
+
generatedAt: new Date().toISOString(),
|
|
60
|
+
skills: {
|
|
61
|
+
[full]: {
|
|
62
|
+
version: lock_version,
|
|
63
|
+
type: 'skill',
|
|
64
|
+
ref: `refs/tags/v${lock_version}`,
|
|
65
|
+
commit: 'lockcommit',
|
|
66
|
+
integrity: 'sha256-baseline',
|
|
67
|
+
base_commit: 'lockcommit',
|
|
68
|
+
base_integrity: 'sha256-baseline',
|
|
69
|
+
requested_by: ['__root__'],
|
|
70
|
+
dependencies: {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}, null, '\t'))
|
|
74
|
+
return {
|
|
75
|
+
root,
|
|
76
|
+
skill_dir,
|
|
77
|
+
read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
|
|
78
|
+
read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
|
|
79
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('release — orchestration envelopes', () => {
|
|
84
|
+
it('blocks on regression drift and routes to reconcile', () => {
|
|
85
|
+
const ctx = scaffold({
|
|
86
|
+
full: 'acme/regressed', short: 'regressed',
|
|
87
|
+
lock_version: '1.0.0', disk_version: '0.9.0'
|
|
88
|
+
})
|
|
89
|
+
try {
|
|
90
|
+
const { code, stdout } = run(['release', 'regressed', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
91
|
+
assert.notStrictEqual(code, 0)
|
|
92
|
+
const env = parse_json(stdout, 'release regressed')
|
|
93
|
+
assert.strictEqual(env.error.code, 'DRIFT_DETECTED')
|
|
94
|
+
assert.strictEqual(env.next_step.action, 'reconcile_first')
|
|
95
|
+
assert.match(env.next_step.context.reconcile_command, /reconcile/)
|
|
96
|
+
} finally { ctx.cleanup() }
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('recognizes ahead via --dry-run and uses the disk version (no revert)', () => {
|
|
100
|
+
const ctx = scaffold({
|
|
101
|
+
full: 'acme/ahead-dry', short: 'ahead-dry',
|
|
102
|
+
lock_version: '0.3.2', disk_version: '0.3.3',
|
|
103
|
+
changelog: '# Changelog\n\n## [0.3.3]\n- added new thing\n'
|
|
104
|
+
})
|
|
105
|
+
try {
|
|
106
|
+
const { code, stdout } = run(['release', 'ahead-dry', '--workspace', 'acme', '--dry-run', '--json'], { cwd: ctx.root })
|
|
107
|
+
assert.strictEqual(code, 0, 'dry-run on ahead must succeed')
|
|
108
|
+
const env = parse_json(stdout, 'release ahead dry-run')
|
|
109
|
+
assert.strictEqual(env.data.dry_run, true)
|
|
110
|
+
assert.strictEqual(env.data.target_version, '0.3.3')
|
|
111
|
+
assert.strictEqual(env.data.ahead_recognized, true)
|
|
112
|
+
assert.strictEqual(env.data.bump_applied, false, 'ahead must NOT trigger a re-bump')
|
|
113
|
+
// Critical: skill.json must NOT have been mutated by release.
|
|
114
|
+
assert.strictEqual(ctx.read_manifest().version, '0.3.3')
|
|
115
|
+
} finally { ctx.cleanup() }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('blocks with MISSING_VERSION on clean + --no-bump', () => {
|
|
119
|
+
const ctx = scaffold({
|
|
120
|
+
full: 'acme/clean', short: 'clean',
|
|
121
|
+
lock_version: '1.0.0', disk_version: '1.0.0'
|
|
122
|
+
})
|
|
123
|
+
try {
|
|
124
|
+
const { code, stdout } = run(['release', 'clean', '--workspace', 'acme', '--no-bump', '--json'], { cwd: ctx.root })
|
|
125
|
+
assert.notStrictEqual(code, 0)
|
|
126
|
+
const env = parse_json(stdout, 'release clean --no-bump')
|
|
127
|
+
assert.strictEqual(env.error.code, 'MISSING_VERSION')
|
|
128
|
+
} finally { ctx.cleanup() }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('blocks with MISSING_CHANGELOG_ENTRY when bump succeeds but CHANGELOG lacks the new version', () => {
|
|
132
|
+
const ctx = scaffold({
|
|
133
|
+
full: 'acme/no-cl', short: 'no-cl',
|
|
134
|
+
lock_version: '1.0.0', disk_version: '1.0.0',
|
|
135
|
+
changelog: '# Changelog\n\n## [1.0.0]\n- initial\n'
|
|
136
|
+
})
|
|
137
|
+
try {
|
|
138
|
+
const { code, stdout } = run(['release', 'no-cl', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
139
|
+
assert.notStrictEqual(code, 0)
|
|
140
|
+
const env = parse_json(stdout, 'release no-cl')
|
|
141
|
+
assert.strictEqual(env.error.code, 'MISSING_CHANGELOG_ENTRY')
|
|
142
|
+
assert.strictEqual(env.next_step.action, 'provide_changelog')
|
|
143
|
+
} finally { ctx.cleanup() }
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('emits bump_disagreement when --bump value contradicts an already-ahead disk', () => {
|
|
147
|
+
const ctx = scaffold({
|
|
148
|
+
full: 'acme/disagree', short: 'disagree',
|
|
149
|
+
lock_version: '0.1.0', disk_version: '0.5.0'
|
|
150
|
+
})
|
|
151
|
+
try {
|
|
152
|
+
const { code, stdout } = run(['release', 'disagree', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
153
|
+
assert.notStrictEqual(code, 0)
|
|
154
|
+
const env = parse_json(stdout, 'release disagree')
|
|
155
|
+
assert.strictEqual(env.error.code, 'BUMP_DISAGREEMENT')
|
|
156
|
+
assert.strictEqual(env.next_step.action, 'resolve_bump_disagreement')
|
|
157
|
+
assert.strictEqual(env.next_step.context.disk_version, '0.5.0')
|
|
158
|
+
} finally { ctx.cleanup() }
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('§2 scenario re-test: ahead state ends in a successful dry-run with NO revert and NO drift error', () => {
|
|
162
|
+
// This is the canonical regression test for the original failure.
|
|
163
|
+
// Setup mirrors §2 exactly: lock 0.3.2, disk 0.3.3 (hand-edited bump),
|
|
164
|
+
// CHANGELOG already has the 0.3.3 entry. The full publish would need
|
|
165
|
+
// a real registry — but the orchestration must reach the publish
|
|
166
|
+
// step cleanly, not block on a misclassified drift error.
|
|
167
|
+
const ctx = scaffold({
|
|
168
|
+
full: 'happyskillsai/happyskills-help', short: 'happyskills-help',
|
|
169
|
+
lock_version: '0.3.2', disk_version: '0.3.3',
|
|
170
|
+
changelog: '# Changelog\n\n## [0.3.3] - 2026-05-23\n\n### Added\n- directive vs invitation register routing\n'
|
|
171
|
+
})
|
|
172
|
+
try {
|
|
173
|
+
const { code, stdout } = run(['release', 'happyskills-help', '--workspace', 'happyskillsai', '--dry-run', '--json'], { cwd: ctx.root })
|
|
174
|
+
assert.strictEqual(code, 0, '§2 scenario must NOT block — ahead is normal authoring')
|
|
175
|
+
const env = parse_json(stdout, '§2 scenario re-test')
|
|
176
|
+
assert.strictEqual(env.data.dry_run, true)
|
|
177
|
+
assert.strictEqual(env.data.target_version, '0.3.3', 'must publish the disk version')
|
|
178
|
+
assert.strictEqual(env.data.ahead_recognized, true)
|
|
179
|
+
// Snapshot was created and (because dry-run) restored — disk must be unchanged.
|
|
180
|
+
assert.strictEqual(ctx.read_manifest().version, '0.3.3')
|
|
181
|
+
} finally { ctx.cleanup() }
|
|
182
|
+
})
|
|
183
|
+
})
|
package/src/lock/verify.js
CHANGED
|
@@ -1,48 +1,183 @@
|
|
|
1
1
|
const path = require('path')
|
|
2
2
|
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
-
const { read_json, file_exists } = require('../utils/fs')
|
|
3
|
+
const { read_json, read_file, file_exists } = require('../utils/fs')
|
|
4
|
+
const { gt, lt, valid } = require('../utils/semver')
|
|
4
5
|
const { SKILL_JSON } = require('../constants')
|
|
5
6
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// lock/disk drift (interrupted install, manual file edit, partial update)
|
|
9
|
-
// without paying for a full directory hash.
|
|
7
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// Lock/disk classification — § 10.5
|
|
10
9
|
//
|
|
10
|
+
// The lock represents the registry view; skill.json represents authoring intent.
|
|
11
|
+
// disk > lock during authoring is normal (`ahead`), NOT drift. We narrow the
|
|
12
|
+
// drift signal to genuine inconsistency only: regression (disk < lock), missing
|
|
13
|
+
// files, or unparseable skill.json.
|
|
14
|
+
//
|
|
15
|
+
// Functions:
|
|
16
|
+
// - verify_lock_disk_consistency: returns { ok } + (when ok:false) drift detail
|
|
17
|
+
// - detect_ahead_state: returns { ahead, lock_version, disk_version, ... }
|
|
18
|
+
// - classify_lock_disk: combines both into a single state value
|
|
19
|
+
// - describe_drift: plain-English description for human output
|
|
20
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
11
22
|
// Result shapes:
|
|
12
|
-
// { ok: true }
|
|
23
|
+
// { ok: true } — clean OR ahead (no drift)
|
|
13
24
|
// { ok: false, reason: 'missing_dir', expected, actual: null }
|
|
14
25
|
// { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
15
|
-
// { ok: false, reason: '
|
|
26
|
+
// { ok: false, reason: 'regression', expected, actual } — disk < lock
|
|
27
|
+
//
|
|
28
|
+
// Note: `regression` REPLACES the previous `version_mismatch` reason for the
|
|
29
|
+
// disk-less-than-lock direction. The disk-greater-than-lock direction is no
|
|
30
|
+
// longer reported as drift — it's the ahead state (see detect_ahead_state).
|
|
16
31
|
const verify_lock_disk_consistency = (lock_entry, install_dir) => catch_errors('Failed to verify lock/disk consistency', async () => {
|
|
17
32
|
const expected = lock_entry?.version || null
|
|
18
33
|
|
|
19
34
|
// Nothing in the lock to check against — caller has nothing to verify.
|
|
20
35
|
if (!expected) return { ok: true }
|
|
21
36
|
|
|
22
|
-
const [,
|
|
23
|
-
if (!
|
|
37
|
+
const [, dir_present] = await file_exists(install_dir)
|
|
38
|
+
if (!dir_present) return { ok: false, reason: 'missing_dir', expected, actual: null }
|
|
24
39
|
|
|
25
40
|
const manifest_path = path.join(install_dir, SKILL_JSON)
|
|
26
|
-
const [,
|
|
27
|
-
if (!
|
|
41
|
+
const [, manifest_present] = await file_exists(manifest_path)
|
|
42
|
+
if (!manifest_present) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
28
43
|
|
|
29
44
|
const [read_err, manifest] = await read_json(manifest_path)
|
|
30
45
|
if (read_err) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
31
46
|
|
|
32
47
|
const actual = manifest?.version || null
|
|
33
|
-
if (actual
|
|
48
|
+
if (actual === expected) return { ok: true }
|
|
49
|
+
|
|
50
|
+
// Direction-aware classification. If either side is not valid semver we
|
|
51
|
+
// fall back to a regression label — the safe choice that surfaces the
|
|
52
|
+
// inconsistency rather than waving it through.
|
|
53
|
+
if (actual && valid(actual) && valid(expected)) {
|
|
54
|
+
if (gt(actual, expected)) {
|
|
55
|
+
// Disk > lock: this is `ahead`, not drift. Report ok.
|
|
56
|
+
return { ok: true }
|
|
57
|
+
}
|
|
58
|
+
if (lt(actual, expected)) {
|
|
59
|
+
return { ok: false, reason: 'regression', expected, actual }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// actual === null (no version field) OR invalid semver on either side.
|
|
64
|
+
// Treat missing/invalid version as a corrupted skill.json — closer to
|
|
65
|
+
// missing_skill_json than to regression.
|
|
66
|
+
if (!actual) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
67
|
+
return { ok: false, reason: 'regression', expected, actual }
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Parse the first ## [<version>] heading from CHANGELOG.md.
|
|
71
|
+
const parse_changelog_top_version = (content) => {
|
|
72
|
+
if (!content) return null
|
|
73
|
+
const match = content.match(/^##\s+\[\s*([^\]]+?)\s*\]/m)
|
|
74
|
+
if (!match) return null
|
|
75
|
+
const candidate = match[1].trim()
|
|
76
|
+
return valid(candidate) ? candidate : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// detect_ahead_state — returns { ahead: bool, lock_version, disk_version,
|
|
80
|
+
// has_changelog_entry, changelog_version }. Pure local read; no registry call.
|
|
81
|
+
//
|
|
82
|
+
// Pre-requisite: the caller already knows verify_lock_disk_consistency.ok === true
|
|
83
|
+
// (i.e. no genuine drift). detect_ahead_state then asks: is disk > lock?
|
|
84
|
+
const detect_ahead_state = (lock_entry, install_dir) => catch_errors('Failed to detect ahead state', async () => {
|
|
85
|
+
const lock_version = lock_entry?.version || null
|
|
86
|
+
if (!lock_version) return { ahead: false, lock_version: null, disk_version: null }
|
|
87
|
+
|
|
88
|
+
const manifest_path = path.join(install_dir, SKILL_JSON)
|
|
89
|
+
const [, manifest_present] = await file_exists(manifest_path)
|
|
90
|
+
if (!manifest_present) return { ahead: false, lock_version, disk_version: null }
|
|
91
|
+
|
|
92
|
+
const [, manifest] = await read_json(manifest_path)
|
|
93
|
+
const disk_version = manifest?.version || null
|
|
94
|
+
if (!disk_version) return { ahead: false, lock_version, disk_version: null }
|
|
95
|
+
|
|
96
|
+
if (!valid(disk_version) || !valid(lock_version)) {
|
|
97
|
+
return { ahead: false, lock_version, disk_version }
|
|
98
|
+
}
|
|
34
99
|
|
|
35
|
-
|
|
100
|
+
if (!gt(disk_version, lock_version)) {
|
|
101
|
+
return { ahead: false, lock_version, disk_version }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// disk > lock → ahead. Read CHANGELOG for the hint fields.
|
|
105
|
+
const changelog_path = path.join(install_dir, 'CHANGELOG.md')
|
|
106
|
+
const [, cl_present] = await file_exists(changelog_path)
|
|
107
|
+
let changelog_version = null
|
|
108
|
+
if (cl_present) {
|
|
109
|
+
const [, cl_content] = await read_file(changelog_path)
|
|
110
|
+
if (cl_content) changelog_version = parse_changelog_top_version(cl_content)
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ahead: true,
|
|
114
|
+
lock_version,
|
|
115
|
+
disk_version,
|
|
116
|
+
has_changelog_entry: changelog_version === disk_version,
|
|
117
|
+
changelog_version
|
|
118
|
+
}
|
|
36
119
|
})
|
|
37
120
|
|
|
38
121
|
// Plain-language description of a drift result, suitable for the principal-facing
|
|
39
|
-
// table cell (status column). Returns null when ok.
|
|
122
|
+
// table cell (status column). Returns null when ok or ahead.
|
|
40
123
|
const describe_drift = (verify_result) => {
|
|
41
124
|
if (!verify_result || verify_result.ok) return null
|
|
42
125
|
if (verify_result.reason === 'missing_dir') return `drift (lock ${verify_result.expected}, skill not on disk)`
|
|
43
126
|
if (verify_result.reason === 'missing_skill_json') return `drift (lock ${verify_result.expected}, no skill.json on disk)`
|
|
44
|
-
if (verify_result.reason === '
|
|
127
|
+
if (verify_result.reason === 'regression') return `drift (regression: lock ${verify_result.expected}, disk ${verify_result.actual})`
|
|
45
128
|
return 'drift'
|
|
46
129
|
}
|
|
47
130
|
|
|
48
|
-
|
|
131
|
+
// Single classifier producing the seven canonical lock/disk states. Callers
|
|
132
|
+
// that also want registry context (outdated / diverged / conflicts) layer
|
|
133
|
+
// those on top — this function answers the local-only question.
|
|
134
|
+
//
|
|
135
|
+
// Returns one of:
|
|
136
|
+
// { status: 'clean' }
|
|
137
|
+
// { status: 'modified', current_integrity?, modified_files? } // when local_modified is true (callers compute it via detect_status)
|
|
138
|
+
// { status: 'ahead', ahead: { lock_version, disk_version, has_changelog_entry, changelog_version } }
|
|
139
|
+
// { status: 'drift', drift: { reason, lock_version, disk_version } }
|
|
140
|
+
//
|
|
141
|
+
// `options.local_modified` lets callers supply the result of detect_status
|
|
142
|
+
// without re-running it; if omitted, the function returns the lock/disk view
|
|
143
|
+
// only (clean / ahead / drift).
|
|
144
|
+
const classify_lock_disk = (lock_entry, install_dir, options = {}) => catch_errors('Failed to classify lock/disk state', async () => {
|
|
145
|
+
const [verify_err, verify] = await verify_lock_disk_consistency(lock_entry, install_dir)
|
|
146
|
+
if (verify_err) throw verify_err[0] || verify_err
|
|
147
|
+
if (!verify.ok) {
|
|
148
|
+
return {
|
|
149
|
+
status: 'drift',
|
|
150
|
+
drift: {
|
|
151
|
+
reason: verify.reason,
|
|
152
|
+
lock_version: verify.expected,
|
|
153
|
+
disk_version: verify.actual
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const [, ahead] = await detect_ahead_state(lock_entry, install_dir)
|
|
159
|
+
if (ahead && ahead.ahead) {
|
|
160
|
+
return {
|
|
161
|
+
status: 'ahead',
|
|
162
|
+
ahead: {
|
|
163
|
+
lock_version: ahead.lock_version,
|
|
164
|
+
disk_version: ahead.disk_version,
|
|
165
|
+
has_changelog_entry: ahead.has_changelog_entry || false,
|
|
166
|
+
changelog_version: ahead.changelog_version || null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.local_modified) {
|
|
172
|
+
return { status: 'modified' }
|
|
173
|
+
}
|
|
174
|
+
return { status: 'clean' }
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
verify_lock_disk_consistency,
|
|
179
|
+
detect_ahead_state,
|
|
180
|
+
describe_drift,
|
|
181
|
+
classify_lock_disk,
|
|
182
|
+
parse_changelog_top_version
|
|
183
|
+
}
|
package/src/lock/verify.test.js
CHANGED
|
@@ -5,7 +5,13 @@ const os = require('os')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const fs = require('fs')
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
verify_lock_disk_consistency,
|
|
10
|
+
detect_ahead_state,
|
|
11
|
+
describe_drift,
|
|
12
|
+
classify_lock_disk,
|
|
13
|
+
parse_changelog_top_version
|
|
14
|
+
} = require('./verify')
|
|
9
15
|
|
|
10
16
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
|
|
11
17
|
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
@@ -15,6 +21,11 @@ const write_skill_json = (dir, manifest) => {
|
|
|
15
21
|
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
const write_changelog = (dir, content) => {
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
26
|
+
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), content)
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
describe('verify_lock_disk_consistency', () => {
|
|
19
30
|
it('returns ok when lock version matches disk version', async () => {
|
|
20
31
|
const dir = make_tmp()
|
|
@@ -27,14 +38,25 @@ describe('verify_lock_disk_consistency', () => {
|
|
|
27
38
|
} finally { cleanup(dir) }
|
|
28
39
|
})
|
|
29
40
|
|
|
30
|
-
it('
|
|
41
|
+
it('returns ok when disk is AHEAD of lock (the §2 authoring-ahead state)', async () => {
|
|
31
42
|
const dir = make_tmp()
|
|
32
43
|
try {
|
|
33
|
-
const install_dir = path.join(dir, 'happyskills-
|
|
34
|
-
write_skill_json(install_dir, { name: 'happyskills-
|
|
44
|
+
const install_dir = path.join(dir, 'happyskills-help')
|
|
45
|
+
write_skill_json(install_dir, { name: 'happyskills-help', version: '0.3.3' })
|
|
46
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '0.3.2' }, install_dir)
|
|
47
|
+
assert.strictEqual(err, null)
|
|
48
|
+
assert.deepEqual(result, { ok: true }, 'disk > lock is ahead, not drift')
|
|
49
|
+
} finally { cleanup(dir) }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reports regression when disk is BEHIND lock (the genuine drift case)', async () => {
|
|
53
|
+
const dir = make_tmp()
|
|
54
|
+
try {
|
|
55
|
+
const install_dir = path.join(dir, 'regressed')
|
|
56
|
+
write_skill_json(install_dir, { name: 'regressed', version: '0.3.0' })
|
|
35
57
|
const [err, result] = await verify_lock_disk_consistency({ version: '0.4.0' }, install_dir)
|
|
36
58
|
assert.strictEqual(err, null)
|
|
37
|
-
assert.deepEqual(result, { ok: false, reason: '
|
|
59
|
+
assert.deepEqual(result, { ok: false, reason: 'regression', expected: '0.4.0', actual: '0.3.0' })
|
|
38
60
|
} finally { cleanup(dir) }
|
|
39
61
|
})
|
|
40
62
|
|
|
@@ -72,14 +94,14 @@ describe('verify_lock_disk_consistency', () => {
|
|
|
72
94
|
} finally { cleanup(dir) }
|
|
73
95
|
})
|
|
74
96
|
|
|
75
|
-
it('detects
|
|
97
|
+
it('detects missing_skill_json when disk skill.json has no version field', async () => {
|
|
76
98
|
const dir = make_tmp()
|
|
77
99
|
try {
|
|
78
100
|
const install_dir = path.join(dir, 'no-version')
|
|
79
101
|
write_skill_json(install_dir, { name: 'no-version' })
|
|
80
102
|
const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
|
|
81
103
|
assert.strictEqual(err, null)
|
|
82
|
-
assert.deepEqual(result, { ok: false, reason: '
|
|
104
|
+
assert.deepEqual(result, { ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null })
|
|
83
105
|
} finally { cleanup(dir) }
|
|
84
106
|
})
|
|
85
107
|
|
|
@@ -104,16 +126,166 @@ describe('verify_lock_disk_consistency', () => {
|
|
|
104
126
|
})
|
|
105
127
|
})
|
|
106
128
|
|
|
129
|
+
describe('detect_ahead_state', () => {
|
|
130
|
+
it('returns ahead:true when disk > lock', async () => {
|
|
131
|
+
const dir = make_tmp()
|
|
132
|
+
try {
|
|
133
|
+
const install_dir = path.join(dir, 'ahead')
|
|
134
|
+
write_skill_json(install_dir, { name: 'ahead', version: '0.3.3' })
|
|
135
|
+
const [err, result] = await detect_ahead_state({ version: '0.3.2' }, install_dir)
|
|
136
|
+
assert.strictEqual(err, null)
|
|
137
|
+
assert.strictEqual(result.ahead, true)
|
|
138
|
+
assert.strictEqual(result.lock_version, '0.3.2')
|
|
139
|
+
assert.strictEqual(result.disk_version, '0.3.3')
|
|
140
|
+
assert.strictEqual(result.has_changelog_entry, false)
|
|
141
|
+
assert.strictEqual(result.changelog_version, null)
|
|
142
|
+
} finally { cleanup(dir) }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns ahead:false when versions match', async () => {
|
|
146
|
+
const dir = make_tmp()
|
|
147
|
+
try {
|
|
148
|
+
const install_dir = path.join(dir, 'match')
|
|
149
|
+
write_skill_json(install_dir, { name: 'match', version: '1.0.0' })
|
|
150
|
+
const [err, result] = await detect_ahead_state({ version: '1.0.0' }, install_dir)
|
|
151
|
+
assert.strictEqual(err, null)
|
|
152
|
+
assert.strictEqual(result.ahead, false)
|
|
153
|
+
} finally { cleanup(dir) }
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns ahead:false when disk < lock (regression)', async () => {
|
|
157
|
+
const dir = make_tmp()
|
|
158
|
+
try {
|
|
159
|
+
const install_dir = path.join(dir, 'regress')
|
|
160
|
+
write_skill_json(install_dir, { name: 'regress', version: '0.2.0' })
|
|
161
|
+
const [err, result] = await detect_ahead_state({ version: '0.3.0' }, install_dir)
|
|
162
|
+
assert.strictEqual(err, null)
|
|
163
|
+
assert.strictEqual(result.ahead, false)
|
|
164
|
+
} finally { cleanup(dir) }
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('reports has_changelog_entry:true when CHANGELOG top heading matches disk version', async () => {
|
|
168
|
+
const dir = make_tmp()
|
|
169
|
+
try {
|
|
170
|
+
const install_dir = path.join(dir, 'with-cl')
|
|
171
|
+
write_skill_json(install_dir, { name: 'with-cl', version: '0.3.3' })
|
|
172
|
+
write_changelog(install_dir, '# Changelog\n\n## [0.3.3] - 2026-05-23\n\n### Added\n- new feature\n')
|
|
173
|
+
const [err, result] = await detect_ahead_state({ version: '0.3.2' }, install_dir)
|
|
174
|
+
assert.strictEqual(err, null)
|
|
175
|
+
assert.strictEqual(result.ahead, true)
|
|
176
|
+
assert.strictEqual(result.has_changelog_entry, true)
|
|
177
|
+
assert.strictEqual(result.changelog_version, '0.3.3')
|
|
178
|
+
} finally { cleanup(dir) }
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('reports has_changelog_entry:false when CHANGELOG names a different version', async () => {
|
|
182
|
+
const dir = make_tmp()
|
|
183
|
+
try {
|
|
184
|
+
const install_dir = path.join(dir, 'cl-mismatch')
|
|
185
|
+
write_skill_json(install_dir, { name: 'cl-mismatch', version: '0.3.3' })
|
|
186
|
+
write_changelog(install_dir, '# Changelog\n\n## [0.4.0]\n- different\n')
|
|
187
|
+
const [err, result] = await detect_ahead_state({ version: '0.3.2' }, install_dir)
|
|
188
|
+
assert.strictEqual(err, null)
|
|
189
|
+
assert.strictEqual(result.ahead, true)
|
|
190
|
+
assert.strictEqual(result.has_changelog_entry, false)
|
|
191
|
+
assert.strictEqual(result.changelog_version, '0.4.0')
|
|
192
|
+
} finally { cleanup(dir) }
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('parse_changelog_top_version', () => {
|
|
197
|
+
it('returns the version from the first ## [...] heading', () => {
|
|
198
|
+
assert.strictEqual(parse_changelog_top_version('# Changelog\n\n## [1.2.3]\n- note\n'), '1.2.3')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('handles surrounding whitespace inside brackets', () => {
|
|
202
|
+
assert.strictEqual(parse_changelog_top_version('## [ 1.2.3 ] - 2026-05-23\n'), '1.2.3')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('returns null when no heading is found', () => {
|
|
206
|
+
assert.strictEqual(parse_changelog_top_version('# Changelog\n\nNo entries yet.\n'), null)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('returns null when the value is not valid semver', () => {
|
|
210
|
+
assert.strictEqual(parse_changelog_top_version('## [Unreleased]\n'), null)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('returns null for null/empty content', () => {
|
|
214
|
+
assert.strictEqual(parse_changelog_top_version(null), null)
|
|
215
|
+
assert.strictEqual(parse_changelog_top_version(''), null)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('classify_lock_disk', () => {
|
|
220
|
+
it('returns status:clean when lock and disk agree', async () => {
|
|
221
|
+
const dir = make_tmp()
|
|
222
|
+
try {
|
|
223
|
+
const install_dir = path.join(dir, 'clean')
|
|
224
|
+
write_skill_json(install_dir, { name: 'clean', version: '1.0.0' })
|
|
225
|
+
const [err, result] = await classify_lock_disk({ version: '1.0.0' }, install_dir)
|
|
226
|
+
assert.strictEqual(err, null)
|
|
227
|
+
assert.deepEqual(result, { status: 'clean' })
|
|
228
|
+
} finally { cleanup(dir) }
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('returns status:ahead when disk > lock', async () => {
|
|
232
|
+
const dir = make_tmp()
|
|
233
|
+
try {
|
|
234
|
+
const install_dir = path.join(dir, 'ahead')
|
|
235
|
+
write_skill_json(install_dir, { name: 'ahead', version: '0.3.3' })
|
|
236
|
+
const [err, result] = await classify_lock_disk({ version: '0.3.2' }, install_dir)
|
|
237
|
+
assert.strictEqual(err, null)
|
|
238
|
+
assert.strictEqual(result.status, 'ahead')
|
|
239
|
+
assert.strictEqual(result.ahead.lock_version, '0.3.2')
|
|
240
|
+
assert.strictEqual(result.ahead.disk_version, '0.3.3')
|
|
241
|
+
} finally { cleanup(dir) }
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('returns status:drift with reason:regression when disk < lock', async () => {
|
|
245
|
+
const dir = make_tmp()
|
|
246
|
+
try {
|
|
247
|
+
const install_dir = path.join(dir, 'regression')
|
|
248
|
+
write_skill_json(install_dir, { name: 'regression', version: '0.2.0' })
|
|
249
|
+
const [err, result] = await classify_lock_disk({ version: '0.3.0' }, install_dir)
|
|
250
|
+
assert.strictEqual(err, null)
|
|
251
|
+
assert.strictEqual(result.status, 'drift')
|
|
252
|
+
assert.deepEqual(result.drift, { reason: 'regression', lock_version: '0.3.0', disk_version: '0.2.0' })
|
|
253
|
+
} finally { cleanup(dir) }
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('returns status:drift with reason:missing_dir', async () => {
|
|
257
|
+
const dir = make_tmp()
|
|
258
|
+
try {
|
|
259
|
+
const install_dir = path.join(dir, 'ghost')
|
|
260
|
+
const [err, result] = await classify_lock_disk({ version: '1.0.0' }, install_dir)
|
|
261
|
+
assert.strictEqual(err, null)
|
|
262
|
+
assert.strictEqual(result.status, 'drift')
|
|
263
|
+
assert.strictEqual(result.drift.reason, 'missing_dir')
|
|
264
|
+
} finally { cleanup(dir) }
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('returns status:modified when caller supplies local_modified=true and lock/disk agree', async () => {
|
|
268
|
+
const dir = make_tmp()
|
|
269
|
+
try {
|
|
270
|
+
const install_dir = path.join(dir, 'mod')
|
|
271
|
+
write_skill_json(install_dir, { name: 'mod', version: '1.0.0' })
|
|
272
|
+
const [err, result] = await classify_lock_disk({ version: '1.0.0' }, install_dir, { local_modified: true })
|
|
273
|
+
assert.strictEqual(err, null)
|
|
274
|
+
assert.deepEqual(result, { status: 'modified' })
|
|
275
|
+
} finally { cleanup(dir) }
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
107
279
|
describe('describe_drift', () => {
|
|
108
280
|
it('returns null for ok results', () => {
|
|
109
281
|
assert.strictEqual(describe_drift({ ok: true }), null)
|
|
110
282
|
assert.strictEqual(describe_drift(null), null)
|
|
111
283
|
})
|
|
112
284
|
|
|
113
|
-
it('describes
|
|
285
|
+
it('describes regression with both versions', () => {
|
|
114
286
|
assert.strictEqual(
|
|
115
|
-
describe_drift({ ok: false, reason: '
|
|
116
|
-
'drift (lock 0.4.0, disk 0.3.0)'
|
|
287
|
+
describe_drift({ ok: false, reason: 'regression', expected: '0.4.0', actual: '0.3.0' }),
|
|
288
|
+
'drift (regression: lock 0.4.0, disk 0.3.0)'
|
|
117
289
|
)
|
|
118
290
|
})
|
|
119
291
|
|