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.
@@ -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
+ })
@@ -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
- // Cheapest possible drift probe: read the on-disk skill.json and compare its
7
- // version field to the lock entry's version. Catches the most common class of
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: 'version_mismatch', expected, actual }
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 [, dir_exists] = await file_exists(install_dir)
23
- if (!dir_exists) return { ok: false, reason: 'missing_dir', expected, actual: null }
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 [, manifest_exists] = await file_exists(manifest_path)
27
- if (!manifest_exists) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
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 !== expected) return { ok: false, reason: 'version_mismatch', expected, 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
- return { ok: true }
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 === 'version_mismatch') return `drift (lock ${verify_result.expected}, disk ${verify_result.actual})`
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
- module.exports = { verify_lock_disk_consistency, describe_drift }
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
+ }
@@ -5,7 +5,13 @@ const os = require('os')
5
5
  const path = require('path')
6
6
  const fs = require('fs')
7
7
 
8
- const { verify_lock_disk_consistency, describe_drift } = require('./verify')
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('detects version_mismatch when disk skill.json has a different version (the linwong bug)', async () => {
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-design')
34
- write_skill_json(install_dir, { name: 'happyskills-design', version: '0.3.0' })
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: 'version_mismatch', expected: '0.4.0', actual: '0.3.0' })
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 version_mismatch when disk skill.json has no version field', async () => {
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: 'version_mismatch', expected: '1.0.0', actual: null })
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 version_mismatch with both versions', () => {
285
+ it('describes regression with both versions', () => {
114
286
  assert.strictEqual(
115
- describe_drift({ ok: false, reason: 'version_mismatch', expected: '0.4.0', actual: '0.3.0' }),
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