@lythos/cold-pool 0.16.0 → 0.16.1

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/README.md CHANGED
@@ -15,10 +15,10 @@ deck / curator / arena consume it instead of running `git clone` themselves.
15
15
 
16
16
  ```bash
17
17
  # Scan cold pool for unreferenced repos
18
- bunx @lythos/cold-pool@0.16.0 prune [--yes] [--dry-run]
18
+ bunx @lythos/cold-pool@0.16.1 prune [--yes] [--dry-run]
19
19
 
20
20
  # Validate lock file against cold pool
21
- bunx @lythos/cold-pool@0.16.0 validate [--lock ./skill-deck.lock]
21
+ bunx @lythos/cold-pool@0.16.1 validate [--lock ./skill-deck.lock]
22
22
  ```
23
23
 
24
24
  ## Architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/cold-pool",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
44
44
  "dependencies": {
45
- "@lythos/infra": "^0.16.0",
45
+ "@lythos/infra": "^0.16.1",
46
46
  "simple-git": "^3.36.0"
47
47
  },
48
48
  "engines": {
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -9,12 +9,12 @@ function dir(relPath: string): DirEntry { return { relPath, isDirectory: true }
9
9
  function file(relPath: string): DirEntry { return { relPath, isDirectory: false } }
10
10
 
11
11
  describe('ColdPool — constructor', () => {
12
- test('default path when none given', () => {
12
+ it('default path when none given', () => {
13
13
  const pool = new ColdPool()
14
14
  expect(pool.path).toBe(DEFAULT_COLD_POOL_PATH)
15
15
  })
16
16
 
17
- test('custom path is stored verbatim', () => {
17
+ it('custom path is stored verbatim', () => {
18
18
  const pool = new ColdPool('/tmp/custom-pool')
19
19
  expect(pool.path).toBe('/tmp/custom-pool')
20
20
  })
@@ -23,18 +23,18 @@ describe('ColdPool — constructor', () => {
23
23
  describe('ColdPool.resolveDir — pure path computation', () => {
24
24
  const pool = new ColdPool('/cold')
25
25
 
26
- test('host/owner/repo', () => {
26
+ it('host/owner/repo', () => {
27
27
  const loc = parseLocator('github.com/anthropics/skills')!
28
28
  expect(pool.resolveDir(loc)).toBe('/cold/github.com/anthropics/skills')
29
29
  })
30
30
 
31
- test('host/owner/repo/skill — skill subpath does NOT extend the dir', () => {
31
+ it('host/owner/repo/skill — skill subpath does NOT extend the dir', () => {
32
32
  // resolveDir returns repo dir, not skill dir. Skill subpath is for findSkillDir later.
33
33
  const loc = parseLocator('github.com/anthropics/skills/skills/pdf')!
34
34
  expect(pool.resolveDir(loc)).toBe('/cold/github.com/anthropics/skills')
35
35
  })
36
36
 
37
- test('localhost form — uniform <pool>/<host>/<owner>/<repo>, no special-case', () => {
37
+ it('localhost form — uniform <pool>/<host>/<owner>/<repo>, no special-case', () => {
38
38
  const loc = parseLocator('localhost/me/my-skill')!
39
39
  expect(pool.resolveDir(loc)).toBe('/cold/localhost/me/my-skill')
40
40
  })
@@ -56,22 +56,22 @@ describe('ColdPool — fs-backed read accessors', () => {
56
56
 
57
57
  const pool = new ColdPool(root)
58
58
 
59
- test('has() returns true for existing repo', () => {
59
+ it('has() returns true for existing repo', () => {
60
60
  const loc = parseLocator('github.com/owner/repo-a')!
61
61
  expect(pool.has(loc)).toBe(true)
62
62
  })
63
63
 
64
- test('has() returns false for missing repo', () => {
64
+ it('has() returns false for missing repo', () => {
65
65
  const loc = parseLocator('github.com/owner/missing')!
66
66
  expect(pool.has(loc)).toBe(false)
67
67
  })
68
68
 
69
- test('has() works for localhost form (uniform <host>/<owner>/<repo>)', () => {
69
+ it('has() works for localhost form (uniform <host>/<owner>/<repo>)', () => {
70
70
  const loc = parseLocator('localhost/me/skill-x')!
71
71
  expect(pool.has(loc)).toBe(true)
72
72
  })
73
73
 
74
- test('list() enumerates uniform host/owner/repo across all hosts, skips hidden', () => {
74
+ it('list() enumerates uniform host/owner/repo across all hosts, skips hidden', () => {
75
75
  const entries = pool.list().sort()
76
76
  expect(entries).toEqual([
77
77
  join(root, 'github.com/owner/repo-a'),
@@ -80,7 +80,7 @@ describe('ColdPool — fs-backed read accessors', () => {
80
80
  ].sort())
81
81
  })
82
82
 
83
- test('list() includes legacy drift entries (depth-2 SKILL.md, missing repo level) for cleanup awareness', () => {
83
+ it('list() includes legacy drift entries (depth-2 SKILL.md, missing repo level) for cleanup awareness', () => {
84
84
  const driftRoot = mkdtempSync(join(tmpdir(), 'cold-pool-drift-'))
85
85
  mkdirSync(join(driftRoot, 'github.com/o/r'), { recursive: true })
86
86
  mkdirSync(join(driftRoot, 'localhost/me/canonical'), { recursive: true })
@@ -99,7 +99,7 @@ describe('ColdPool — fs-backed read accessors', () => {
99
99
  expect(entries).toContain(join(driftRoot, 'localhost/legacy-name'))
100
100
  })
101
101
 
102
- test('list() returns [] when path does not exist', () => {
102
+ it('list() returns [] when path does not exist', () => {
103
103
  const empty = new ColdPool('/no/such/path')
104
104
  expect(empty.list()).toEqual([])
105
105
  })
@@ -108,7 +108,7 @@ describe('ColdPool — fs-backed read accessors', () => {
108
108
  describe('buildListPlan — pure classification (no IO)', () => {
109
109
  const root = '/pool'
110
110
 
111
- test('canonical 3-segment: host/owner/repo', () => {
111
+ it('canonical 3-segment: host/owner/repo', () => {
112
112
  const entries: DirEntry[] = [
113
113
  dir('github.com'),
114
114
  dir('github.com/owner'),
@@ -121,7 +121,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
121
121
  ])
122
122
  })
123
123
 
124
- test('skips hidden dirs', () => {
124
+ it('skips hidden dirs', () => {
125
125
  const entries: DirEntry[] = [
126
126
  dir('.git'),
127
127
  dir('github.com'),
@@ -136,7 +136,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
136
136
  ])
137
137
  })
138
138
 
139
- test('legacy depth-1: host/SKILL.md (missing owner+repo)', () => {
139
+ it('legacy depth-1: host/SKILL.md (missing owner+repo)', () => {
140
140
  const entries: DirEntry[] = [
141
141
  dir('legacy-skill'),
142
142
  file('legacy-skill/SKILL.md'),
@@ -147,7 +147,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
147
147
  ])
148
148
  })
149
149
 
150
- test('legacy depth-2: localhost/name/SKILL.md (missing repo)', () => {
150
+ it('legacy depth-2: localhost/name/SKILL.md (missing repo)', () => {
151
151
  const entries: DirEntry[] = [
152
152
  dir('localhost'),
153
153
  dir('localhost/legacy-name'),
@@ -159,7 +159,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
159
159
  ])
160
160
  })
161
161
 
162
- test('mixed canonical + legacy in same pool', () => {
162
+ it('mixed canonical + legacy in same pool', () => {
163
163
  const entries: DirEntry[] = [
164
164
  dir('github.com'),
165
165
  dir('github.com/owner'),
@@ -175,7 +175,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
175
175
  expect(plan.entries).toContainEqual({ path: '/pool/localhost/old-skill', kind: 'legacy-depth2' })
176
176
  })
177
177
 
178
- test('multiple repos under same owner', () => {
178
+ it('multiple repos under same owner', () => {
179
179
  const entries: DirEntry[] = [
180
180
  dir('github.com'),
181
181
  dir('github.com/owner'),
@@ -191,7 +191,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
191
191
  expect(plan.entries.every(e => e.kind === 'canonical')).toBe(true)
192
192
  })
193
193
 
194
- test('multiple hosts', () => {
194
+ it('multiple hosts', () => {
195
195
  const entries: DirEntry[] = [
196
196
  dir('github.com'),
197
197
  dir('github.com/a'),
@@ -210,11 +210,11 @@ describe('buildListPlan — pure classification (no IO)', () => {
210
210
  ])
211
211
  })
212
212
 
213
- test('returns empty when root has no entries', () => {
213
+ it('returns empty when root has no entries', () => {
214
214
  expect(buildListPlan(root, []).entries).toEqual([])
215
215
  })
216
216
 
217
- test('returns empty for hidden-only entries', () => {
217
+ it('returns empty for hidden-only entries', () => {
218
218
  const entries: DirEntry[] = [dir('.git'), dir('.hidden/.nested')]
219
219
  expect(buildListPlan(root, entries).entries).toEqual([])
220
220
  })
@@ -224,7 +224,7 @@ describe('ColdPool.metadata — MetadataDB integration', () => {
224
224
  const root = mkdtempSync(join(tmpdir(), 'cold-pool-meta-test-'))
225
225
  const pool = new ColdPool(root)
226
226
 
227
- test('metadata is auto-created on first access (lazy-open)', () => {
227
+ it('metadata is auto-created on first access (lazy-open)', () => {
228
228
  expect(pool.metadata).toBeDefined()
229
229
  // Lazy-open: DB file is NOT created until first method call.
230
230
  expect(existsSync(join(root, '.cold-pool-meta.db'))).toBe(false)
@@ -233,7 +233,7 @@ describe('ColdPool.metadata — MetadataDB integration', () => {
233
233
  expect(existsSync(join(root, '.cold-pool-meta.db'))).toBe(true)
234
234
  })
235
235
 
236
- test('metadata records survive round-trip', () => {
236
+ it('metadata records survive round-trip', () => {
237
237
  pool.metadata.recordRepoRef('github.com', 'lythos-labs', 'lythoskill', '9645fdb')
238
238
  expect(pool.metadata.getRepoRef('github.com', 'lythos-labs', 'lythoskill')).toBe('9645fdb')
239
239
  })
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { mkdtempSync, mkdirSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -8,7 +8,7 @@ import { buildFetchPlan, executeFetchPlan } from './fetch-plan'
8
8
  import type { FetchIO } from './types'
9
9
 
10
10
  describe('buildFetchPlan', () => {
11
- test('builds targetDir + cloneUrl from locator', () => {
11
+ it('builds targetDir + cloneUrl from locator', () => {
12
12
  const pool = new ColdPool('/cold')
13
13
  const loc = parseLocator('github.com/anthropics/skills/skills/pdf')!
14
14
  const plan = buildFetchPlan(pool, loc)
@@ -17,7 +17,7 @@ describe('buildFetchPlan', () => {
17
17
  expect(plan.alreadyExists).toBe(false)
18
18
  })
19
19
 
20
- test('alreadyExists reflects fs presence', () => {
20
+ it('alreadyExists reflects fs presence', () => {
21
21
  const root = mkdtempSync(join(tmpdir(), 'fetch-plan-test-'))
22
22
  mkdirSync(join(root, 'github.com/owner/repo'), { recursive: true })
23
23
  const pool = new ColdPool(root)
@@ -26,7 +26,7 @@ describe('buildFetchPlan', () => {
26
26
  expect(plan.alreadyExists).toBe(true)
27
27
  })
28
28
 
29
- test('localhost locator gets empty cloneUrl + uniform <host>/<owner>/<repo> path', () => {
29
+ it('localhost locator gets empty cloneUrl + uniform <host>/<owner>/<repo> path', () => {
30
30
  const pool = new ColdPool('/cold')
31
31
  const loc = parseLocator('localhost/me/my-skill')!
32
32
  const plan = buildFetchPlan(pool, loc)
@@ -34,7 +34,7 @@ describe('buildFetchPlan', () => {
34
34
  expect(plan.targetDir).toBe('/cold/localhost/me/my-skill')
35
35
  })
36
36
 
37
- test('passes ref through', () => {
37
+ it('passes ref through', () => {
38
38
  const pool = new ColdPool('/cold')
39
39
  const loc = parseLocator('github.com/o/r')!
40
40
  const plan = buildFetchPlan(pool, loc, { ref: 'v1.2.3' })
@@ -43,7 +43,7 @@ describe('buildFetchPlan', () => {
43
43
  })
44
44
 
45
45
  describe('executeFetchPlan', () => {
46
- test('alreadyExists path → already-present, no clone called', () => {
46
+ it('alreadyExists path → already-present, no clone called', () => {
47
47
  const pool = new ColdPool('/cold')
48
48
  const loc = parseLocator('github.com/o/r')!
49
49
  const plan = { ...buildFetchPlan(pool, loc), alreadyExists: true }
@@ -57,7 +57,7 @@ describe('executeFetchPlan', () => {
57
57
  expect(cloneCalls).toBe(0)
58
58
  })
59
59
 
60
- test('clones when target absent', () => {
60
+ it('clones when target absent', () => {
61
61
  const pool = new ColdPool('/cold')
62
62
  const loc = parseLocator('github.com/o/r')!
63
63
  const plan = buildFetchPlan(pool, loc)
@@ -73,7 +73,7 @@ describe('executeFetchPlan', () => {
73
73
  expect(cloneArgs[0].dir).toBe('/cold/github.com/o/r')
74
74
  })
75
75
 
76
- test('clone error → failed result with message', () => {
76
+ it('clone error → failed result with message', () => {
77
77
  const pool = new ColdPool('/cold')
78
78
  const loc = parseLocator('github.com/o/r')!
79
79
  const plan = buildFetchPlan(pool, loc)
@@ -86,7 +86,7 @@ describe('executeFetchPlan', () => {
86
86
  expect(result.message).toContain('boom')
87
87
  })
88
88
 
89
- test('localhost locator refuses to fetch', () => {
89
+ it('localhost locator refuses to fetch', () => {
90
90
  const pool = new ColdPool('/cold')
91
91
  const loc = parseLocator('localhost/me/x')!
92
92
  const plan = buildFetchPlan(pool, loc)
@@ -101,7 +101,7 @@ describe('executeFetchPlan', () => {
101
101
  expect(cloneCalls).toBe(0)
102
102
  })
103
103
 
104
- test('log IO is invoked', () => {
104
+ it('log IO is invoked', () => {
105
105
  const pool = new ColdPool('/cold')
106
106
  const loc = parseLocator('github.com/o/r')!
107
107
  const plan = buildFetchPlan(pool, loc)
@@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { simpleGit } from 'simple-git'
6
- import { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
6
+ import { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd, parseTreeHash, computeSha256, buildSkillPath } from './git-hash.js'
7
7
 
8
8
  let repoDir: string
9
9
  let canGit = false
@@ -91,6 +91,63 @@ describe('getSkillBlobHash', () => {
91
91
  })
92
92
  })
93
93
 
94
+ // ── Pure functions (L0 — no git, no fs) ─────────────────────────
95
+
96
+ describe('parseTreeHash', () => {
97
+ it('extracts hash from ls-tree output', () => {
98
+ // Real git ls-tree output format: <mode> <type> <hash>\t<path>
99
+ const line = '100644 blob abc123def456789012345678901234567890abcd\tSKILL.md'
100
+ expect(parseTreeHash(line)).toBe('abc123def456789012345678901234567890abcd')
101
+ })
102
+
103
+ it('extracts tree hash from tree entry', () => {
104
+ const line = '040000 tree def456abc789012345678901234567890abcd123\tpdf'
105
+ expect(parseTreeHash(line)).toBe('def456abc789012345678901234567890abcd123')
106
+ })
107
+
108
+ it('handles output with leading/trailing whitespace', () => {
109
+ const line = ' 100644 blob aaa111bbb222ccc333ddd444eee555fff666777888\tfile.md '
110
+ expect(parseTreeHash(line)).toBe('aaa111bbb222ccc333ddd444eee555fff666777888')
111
+ })
112
+
113
+ it('throws on empty output', () => {
114
+ expect(() => parseTreeHash('')).toThrow('Empty ls-tree output')
115
+ expect(() => parseTreeHash(' ')).toThrow('Empty ls-tree output')
116
+ })
117
+
118
+ it('throws on unparseable output', () => {
119
+ expect(() => parseTreeHash('garbage')).toThrow('Could not parse tree hash')
120
+ })
121
+ })
122
+
123
+ describe('computeSha256', () => {
124
+ it('computes deterministic hash', () => {
125
+ expect(computeSha256('hello')).toBe(computeSha256('hello'))
126
+ })
127
+
128
+ it('produces 64-char hex string', () => {
129
+ const hash = computeSha256('test content')
130
+ expect(hash.length).toBe(64)
131
+ expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true)
132
+ })
133
+
134
+ it('different content → different hash', () => {
135
+ expect(computeSha256('a')).not.toBe(computeSha256('b'))
136
+ })
137
+ })
138
+
139
+ describe('buildSkillPath', () => {
140
+ it('appends SKILL.md to subpath', () => {
141
+ expect(buildSkillPath('skills/pdf')).toBe('skills/pdf/SKILL.md')
142
+ })
143
+
144
+ it('returns just SKILL.md for empty subpath', () => {
145
+ expect(buildSkillPath('')).toBe('SKILL.md')
146
+ })
147
+ })
148
+
149
+ // ── IO functions (L1 — real git in temp repo) ───────────────────
150
+
94
151
  describe('getSkillTreeHash', () => {
95
152
  it('returns tree hash for subdirectory', async () => {
96
153
  if (!canGit) return
package/src/git-hash.ts CHANGED
@@ -2,6 +2,29 @@ import { simpleGit, type SimpleGit } from 'simple-git'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { readFileSync } from 'node:fs'
4
4
 
5
+ // ── Pure functions (L0-testable, no git, no fs) ─────────────────
6
+
7
+ /** Parse `git ls-tree` output line. Takes the third whitespace-separated field (the hash). */
8
+ export function parseTreeHash(lsTreeOutput: string): string {
9
+ const trimmed = lsTreeOutput.trim()
10
+ if (!trimmed) throw new Error('Empty ls-tree output')
11
+ const hash = trimmed.split(/\s+/)[2]
12
+ if (!hash) throw new Error(`Could not parse tree hash from: ${trimmed}`)
13
+ return hash
14
+ }
15
+
16
+ /** Compute SHA-256 of raw content. Content-addressable, provider-agnostic. */
17
+ export function computeSha256(content: string): string {
18
+ return createHash('sha256').update(content, 'utf-8').digest('hex')
19
+ }
20
+
21
+ /** Build the skill path for git blob/tree operations. */
22
+ export function buildSkillPath(skillSubpath: string): string {
23
+ return skillSubpath ? `${skillSubpath}/SKILL.md` : 'SKILL.md'
24
+ }
25
+
26
+ // ── IO wrappers (thin — call git/fs, delegate to pure functions) ─
27
+
5
28
  function git(repoDir: string): SimpleGit {
6
29
  return simpleGit(repoDir)
7
30
  }
@@ -11,17 +34,14 @@ export async function getRepoHeadRef(repoDir: string): Promise<string> {
11
34
  }
12
35
 
13
36
  export async function getSkillBlobHash(repoDir: string, skillSubpath: string): Promise<string> {
14
- const path = skillSubpath ? `${skillSubpath}/SKILL.md` : 'SKILL.md'
15
- return (await git(repoDir).raw(['hash-object', path])).trim()
37
+ return (await git(repoDir).raw(['hash-object', buildSkillPath(skillSubpath)])).trim()
16
38
  }
17
39
 
18
40
  export function hashSkillMd(skillMdPath: string): string {
19
- return createHash('sha256').update(readFileSync(skillMdPath, 'utf-8')).digest('hex')
41
+ return computeSha256(readFileSync(skillMdPath, 'utf-8'))
20
42
  }
21
43
 
22
44
  export async function getSkillTreeHash(repoDir: string, skillSubpath: string): Promise<string> {
23
45
  const output = (await git(repoDir).raw(['ls-tree', 'HEAD', skillSubpath || '.'])).trim()
24
- const hash = output.split(/\s+/)[2]
25
- if (!hash) throw new Error(`Could not parse tree hash from: ${output}`)
26
- return hash
46
+ return parseTreeHash(output)
27
47
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
1
+ import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
@@ -26,23 +26,23 @@ describe('detectGitRoot', () => {
26
26
  const orphanDir = join(root, 'pool/orphan')
27
27
  mkdirSync(orphanDir, { recursive: true })
28
28
 
29
- test('finds .git at the same dir', () => {
29
+ it('finds .git at the same dir', () => {
30
30
  const r = detectGitRoot(repoDir)
31
31
  expect(r.gitRoot).toBe(resolve(repoDir))
32
32
  })
33
33
 
34
- test('walks up to find .git', () => {
34
+ it('walks up to find .git', () => {
35
35
  const r = detectGitRoot(subDir)
36
36
  expect(r.gitRoot).toBe(resolve(repoDir))
37
37
  })
38
38
 
39
- test('returns not-found when no .git in walk', () => {
39
+ it('returns not-found when no .git in walk', () => {
40
40
  const r = detectGitRoot(orphanDir)
41
41
  expect(r.gitRoot).toBeNull()
42
42
  expect(r.reason).toBe('not-found')
43
43
  })
44
44
 
45
- test('respects coldPool boundary — outside-cold-pool when crossing out', () => {
45
+ it('respects coldPool boundary — outside-cold-pool when crossing out', () => {
46
46
  const isolated = mkdtempSync(join(tmpdir(), 'git-root-isolated-'))
47
47
  const inner = join(isolated, 'inner')
48
48
  const coldPool = join(isolated, 'pool')
@@ -53,7 +53,7 @@ describe('detectGitRoot', () => {
53
53
  expect(r.reason).toBe('outside-cold-pool')
54
54
  })
55
55
 
56
- test('coldPool given, walking up within cold pool finds .git', () => {
56
+ it('coldPool given, walking up within cold pool finds .git', () => {
57
57
  const r = detectGitRoot(subDir, join(root, 'pool'))
58
58
  expect(r.gitRoot).toBe(resolve(repoDir))
59
59
  })
@@ -70,7 +70,7 @@ describe('gitClone — SOCKS proxy args', () => {
70
70
  delete process.env.LYTHOS_SOCKS_PROXY
71
71
  })
72
72
 
73
- test('without LYTHOS_SOCKS_PROXY: no proxy flags in git args', () => {
73
+ it('without LYTHOS_SOCKS_PROXY: no proxy flags in git args', () => {
74
74
  const { mockExec, calls } = createMockExec()
75
75
  gitClone('https://github.com/example/repo.git', '/tmp/repo', undefined, mockExec)
76
76
  const call = calls.find((c) => c.args.includes('clone'))
@@ -79,7 +79,7 @@ describe('gitClone — SOCKS proxy args', () => {
79
79
  expect(call!.args).not.toContain('http.proxy')
80
80
  })
81
81
 
82
- test('with LYTHOS_SOCKS_PROXY: injects -c http.proxy and -c https.proxy', () => {
82
+ it('with LYTHOS_SOCKS_PROXY: injects -c http.proxy and -c https.proxy', () => {
83
83
  process.env.LYTHOS_SOCKS_PROXY = 'proxy.example.com:1080'
84
84
  const { mockExec, calls } = createMockExec()
85
85
  gitClone('https://github.com/example/repo.git', '/tmp/repo', undefined, mockExec)
@@ -90,7 +90,7 @@ describe('gitClone — SOCKS proxy args', () => {
90
90
  expect(call!.args).toContain('https.proxy=socks5://proxy.example.com:1080')
91
91
  })
92
92
 
93
- test('with LYTHOS_SOCKS_PROXY already including socks5://: does not double-prefix', () => {
93
+ it('with LYTHOS_SOCKS_PROXY already including socks5://: does not double-prefix', () => {
94
94
  process.env.LYTHOS_SOCKS_PROXY = 'socks5://proxy.example.com:1080'
95
95
  const { mockExec, calls } = createMockExec()
96
96
  gitClone('https://github.com/example/repo.git', '/tmp/repo', undefined, mockExec)
@@ -99,7 +99,7 @@ describe('gitClone — SOCKS proxy args', () => {
99
99
  expect(call!.args).not.toContain('http.proxy=socks5://socks5://proxy.example.com:1080')
100
100
  })
101
101
 
102
- test('ref checkout also receives proxy flags', () => {
102
+ it('ref checkout also receives proxy flags', () => {
103
103
  process.env.LYTHOS_SOCKS_PROXY = 'proxy.example.com:1080'
104
104
  const { mockExec, calls } = createMockExec()
105
105
  gitClone('https://github.com/example/repo.git', '/tmp/repo', { ref: 'v1.0.0' }, mockExec)
@@ -118,7 +118,7 @@ describe('gitPull — SOCKS proxy args', () => {
118
118
  delete process.env.LYTHOS_SOCKS_PROXY
119
119
  })
120
120
 
121
- test('with LYTHOS_SOCKS_PROXY: injects proxy flags', () => {
121
+ it('with LYTHOS_SOCKS_PROXY: injects proxy flags', () => {
122
122
  process.env.LYTHOS_SOCKS_PROXY = 'proxy.example.com:1080'
123
123
  const { mockExec, calls } = createMockExec()
124
124
  gitPull('/tmp/repo', 30000, mockExec)
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { fetchRepoTree, type FetchFn, type TreeEntry } from './github-tree'
3
3
 
4
4
  function mockFetch(impl: (url: string, init?: RequestInit) => {
@@ -18,7 +18,7 @@ function mockFetch(impl: (url: string, init?: RequestInit) => {
18
18
  }
19
19
 
20
20
  describe('fetchRepoTree', () => {
21
- test('200 → ok with entries', async () => {
21
+ it('200 → ok with entries', async () => {
22
22
  const entries: TreeEntry[] = [
23
23
  { path: 'README.md', type: 'blob', sha: 'a' },
24
24
  { path: 'skills/pdf/SKILL.md', type: 'blob', sha: 'b' },
@@ -34,7 +34,7 @@ describe('fetchRepoTree', () => {
34
34
  expect(res.truncated).toBe(false)
35
35
  })
36
36
 
37
- test('200 with truncated', async () => {
37
+ it('200 with truncated', async () => {
38
38
  const fetch = mockFetch(() => ({
39
39
  status: 200,
40
40
  body: { tree: [], truncated: true },
@@ -43,14 +43,14 @@ describe('fetchRepoTree', () => {
43
43
  expect(res.truncated).toBe(true)
44
44
  })
45
45
 
46
- test('404 → not-found', async () => {
46
+ it('404 → not-found', async () => {
47
47
  const fetch = mockFetch(() => ({ status: 404 }))
48
48
  const res = await fetchRepoTree('github.com', 'nope', 'nope', 'HEAD', fetch)
49
49
  expect(res.status).toBe('not-found')
50
50
  expect(res.httpStatus).toBe(404)
51
51
  })
52
52
 
53
- test('403 with X-RateLimit-Remaining: 0 → rate-limited', async () => {
53
+ it('403 with X-RateLimit-Remaining: 0 → rate-limited', async () => {
54
54
  const fetch = mockFetch(() => ({
55
55
  status: 403,
56
56
  headers: { 'X-RateLimit-Remaining': '0' },
@@ -59,26 +59,26 @@ describe('fetchRepoTree', () => {
59
59
  expect(res.status).toBe('rate-limited')
60
60
  })
61
61
 
62
- test('403 without rate-limit headers → private', async () => {
62
+ it('403 without rate-limit headers → private', async () => {
63
63
  const fetch = mockFetch(() => ({ status: 403 }))
64
64
  const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
65
65
  expect(res.status).toBe('private')
66
66
  })
67
67
 
68
- test('network error → network-error', async () => {
68
+ it('network error → network-error', async () => {
69
69
  const fetch = mockFetch(() => ({ status: 0, error: new Error('ENOTFOUND') }))
70
70
  const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
71
71
  expect(res.status).toBe('network-error')
72
72
  expect(res.message).toContain('ENOTFOUND')
73
73
  })
74
74
 
75
- test('non-github host → unsupported-host', async () => {
75
+ it('non-github host → unsupported-host', async () => {
76
76
  const fetch = mockFetch(() => ({ status: 200 })) // would be ok if fetched
77
77
  const res = await fetchRepoTree('gitlab.com', 'o', 'r', 'HEAD', fetch)
78
78
  expect(res.status).toBe('unsupported-host')
79
79
  })
80
80
 
81
- test('default ref is HEAD', async () => {
81
+ it('default ref is HEAD', async () => {
82
82
  let capturedUrl = ''
83
83
  const fetch = mockFetch((url) => {
84
84
  capturedUrl = url
@@ -88,7 +88,7 @@ describe('fetchRepoTree', () => {
88
88
  expect(capturedUrl).toContain('/trees/HEAD?recursive=1')
89
89
  })
90
90
 
91
- test('explicit ref is honored', async () => {
91
+ it('explicit ref is honored', async () => {
92
92
  let capturedUrl = ''
93
93
  const fetch = mockFetch((url) => {
94
94
  capturedUrl = url
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
 
18
18
  export { getMirror, rewriteUrl, mirrorUrls } from './mirror.js'
19
19
  export { parseLocator, formatLocator } from './parse-locator.js'
20
+ export type { Locator } from './types.js'
20
21
  export type { DirEntry, ListPlan, ListPlanEntry } from './cold-pool.js'
21
22
  export { ColdPool, DEFAULT_COLD_POOL_PATH, buildListPlan } from './cold-pool.js'
22
23
 
@@ -37,6 +38,7 @@ export type { GitCloneOptions, GitPullResult, GitRootResult } from './git-io.js'
37
38
  export { gitClone, gitPull, detectGitRoot } from './git-io.js'
38
39
 
39
40
  export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
41
+ export type { FetchIO, FetchPlan, FetchResult } from './types.js'
40
42
 
41
43
  export { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
42
44
 
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { inferSkillPath } from './infer-skill-path'
3
3
  import type { TreeEntry } from './github-tree'
4
4
 
@@ -6,13 +6,13 @@ const blob = (path: string): TreeEntry => ({ path, type: 'blob', sha: 'x' })
6
6
  const tree = (path: string): TreeEntry => ({ path, type: 'tree', sha: 'x' })
7
7
 
8
8
  describe('inferSkillPath', () => {
9
- test('repo-root SKILL.md → "" candidate', () => {
9
+ it('repo-root SKILL.md → "" candidate', () => {
10
10
  const r = inferSkillPath([blob('SKILL.md'), blob('README.md')])
11
11
  expect(r.candidates).toEqual([''])
12
12
  expect(r.exactMatch).toBeNull()
13
13
  })
14
14
 
15
- test('monorepo with multiple skills', () => {
15
+ it('monorepo with multiple skills', () => {
16
16
  const r = inferSkillPath([
17
17
  blob('skills/pdf/SKILL.md'),
18
18
  blob('skills/pdf/script.py'),
@@ -22,7 +22,7 @@ describe('inferSkillPath', () => {
22
22
  expect(r.candidates.sort()).toEqual(['skills/excel', 'skills/pdf'])
23
23
  })
24
24
 
25
- test('flat repo (root-level skill dirs)', () => {
25
+ it('flat repo (root-level skill dirs)', () => {
26
26
  const r = inferSkillPath([
27
27
  blob('skill-creator/SKILL.md'),
28
28
  blob('competitors-analysis/SKILL.md'),
@@ -31,7 +31,7 @@ describe('inferSkillPath', () => {
31
31
  expect(r.candidates.sort()).toEqual(['competitors-analysis', 'skill-creator'])
32
32
  })
33
33
 
34
- test('nested monorepo (skills/category/skill)', () => {
34
+ it('nested monorepo (skills/category/skill)', () => {
35
35
  const r = inferSkillPath([
36
36
  blob('skills/engineering/tdd/SKILL.md'),
37
37
  blob('skills/engineering/diagnose/SKILL.md'),
@@ -39,7 +39,7 @@ describe('inferSkillPath', () => {
39
39
  expect(r.candidates.sort()).toEqual(['skills/engineering/diagnose', 'skills/engineering/tdd'])
40
40
  })
41
41
 
42
- test('exactMatch reflects expectedSubpath when present', () => {
42
+ it('exactMatch reflects expectedSubpath when present', () => {
43
43
  const r = inferSkillPath(
44
44
  [blob('skills/pdf/SKILL.md'), blob('skills/excel/SKILL.md')],
45
45
  'skills/pdf',
@@ -47,7 +47,7 @@ describe('inferSkillPath', () => {
47
47
  expect(r.exactMatch).toBe('skills/pdf')
48
48
  })
49
49
 
50
- test('exactMatch is null when expectedSubpath is wrong', () => {
50
+ it('exactMatch is null when expectedSubpath is wrong', () => {
51
51
  const r = inferSkillPath(
52
52
  [blob('skills/pdf/SKILL.md')],
53
53
  'pdf', // missing the skills/ prefix
@@ -56,7 +56,7 @@ describe('inferSkillPath', () => {
56
56
  expect(r.candidates).toEqual(['skills/pdf'])
57
57
  })
58
58
 
59
- test('tree-type entries are ignored (only blobs counted)', () => {
59
+ it('tree-type entries are ignored (only blobs counted)', () => {
60
60
  const r = inferSkillPath([
61
61
  tree('SKILL.md'), // not a real file (tree, not blob)
62
62
  blob('skills/x/SKILL.md'),
@@ -64,7 +64,7 @@ describe('inferSkillPath', () => {
64
64
  expect(r.candidates).toEqual(['skills/x'])
65
65
  })
66
66
 
67
- test('empty entries → empty candidates', () => {
67
+ it('empty entries → empty candidates', () => {
68
68
  const r = inferSkillPath([])
69
69
  expect(r.candidates).toEqual([])
70
70
  expect(r.exactMatch).toBeNull()
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
2
  import { probeConnectivity } from './mirror.js'
3
3
 
4
4
  describe('probeConnectivity', () => {
@@ -51,7 +51,7 @@ describe('probeConnectivity', () => {
51
51
  }
52
52
 
53
53
  // ── Tracer Bullet ──
54
- test('direct reachable → returns direct path', async () => {
54
+ it('direct reachable → returns direct path', async () => {
55
55
  mockFetch(
56
56
  new Map([
57
57
  ['https://example.com/skill', new Response(null, { status: 200 })],
@@ -68,7 +68,7 @@ describe('probeConnectivity', () => {
68
68
  })
69
69
 
70
70
  // ── Vertical Slice 2 ──
71
- test('direct fails, user mirror ok → returns mirror path', async () => {
71
+ it('direct fails, user mirror ok → returns mirror path', async () => {
72
72
  process.env.LYTHOS_GH_MIRROR = 'https://my-mirror.example.com'
73
73
  mockFetch(
74
74
  new Map([
@@ -87,7 +87,7 @@ describe('probeConnectivity', () => {
87
87
  })
88
88
 
89
89
  // ── Vertical Slice 3 ──
90
- test('all paths fail → returns undefined', async () => {
90
+ it('all paths fail → returns undefined', async () => {
91
91
  mockFetch(new Map())
92
92
 
93
93
  const result = await probeConnectivity('https://example.com/skill')
@@ -96,7 +96,7 @@ describe('probeConnectivity', () => {
96
96
  })
97
97
 
98
98
  // ── Vertical Slice 4 ──
99
- test('404 is treated as reachable (server alive)', async () => {
99
+ it('404 is treated as reachable (server alive)', async () => {
100
100
  mockFetch(
101
101
  new Map([
102
102
  ['https://example.com/skill', new Response(null, { status: 404 })],
@@ -109,7 +109,7 @@ describe('probeConnectivity', () => {
109
109
  })
110
110
 
111
111
  // ── Vertical Slice 5: Racing behavior ──
112
- test('probes race concurrently, not sequentially', async () => {
112
+ it('probes race concurrently, not sequentially', async () => {
113
113
  process.env.LYTHOS_GH_MIRROR = 'https://my-mirror.example.com'
114
114
  const start = performance.now()
115
115
 
@@ -131,7 +131,7 @@ describe('probeConnectivity', () => {
131
131
  })
132
132
 
133
133
  // ── Vertical Slice 6: No user mirror set → only probes direct ──
134
- test('without user mirror, only probes direct', async () => {
134
+ it('without user mirror, only probes direct', async () => {
135
135
  mockFetch(
136
136
  new Map([
137
137
  ['https://example.com/skill', new Response(null, { status: 200 })],
@@ -146,7 +146,7 @@ describe('probeConnectivity', () => {
146
146
  })
147
147
 
148
148
  // ── Vertical Slice 7: Timeout honored ──
149
- test('timeout aborts slow probes', async () => {
149
+ it('timeout aborts slow probes', async () => {
150
150
  globalThis.fetch = async (_input, init?) => {
151
151
  return new Promise((_, reject) => {
152
152
  const timer = setTimeout(
@@ -169,7 +169,7 @@ describe('probeConnectivity', () => {
169
169
  })
170
170
 
171
171
  // ── Vertical Slice 8: SOCKS proxy routing ──
172
- test('SOCKS proxy set, curl succeeds → returns direct path', async () => {
172
+ it('SOCKS proxy set, curl succeeds → returns direct path', async () => {
173
173
  process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
174
174
  const execCalls: Array<{ file: string; args: string[] }> = []
175
175
 
@@ -193,7 +193,7 @@ describe('probeConnectivity', () => {
193
193
  })
194
194
 
195
195
  // ── Vertical Slice 9: SOCKS proxy with socks5:// prefix ──
196
- test('SOCKS proxy already has socks5:// prefix → does not double-prefix', async () => {
196
+ it('SOCKS proxy already has socks5:// prefix → does not double-prefix', async () => {
197
197
  process.env.LYTHOS_SOCKS_PROXY = 'socks5://proxy.example.com:1080'
198
198
  const execCalls: Array<{ file: string; args: string[] }> = []
199
199
 
@@ -210,7 +210,7 @@ describe('probeConnectivity', () => {
210
210
  })
211
211
 
212
212
  // ── Vertical Slice 10: SOCKS proxy fails → no automatic unproxied fallback ──
213
- test('SOCKS proxy fails → no automatic unproxied fallback', async () => {
213
+ it('SOCKS proxy fails → no automatic unproxied fallback', async () => {
214
214
  process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
215
215
 
216
216
  const result = await probeConnectivity('https://example.com/skill', 3000, {
@@ -228,7 +228,7 @@ describe('probeConnectivity', () => {
228
228
  })
229
229
 
230
230
  // ── Vertical Slice 11: SOCKS proxy only affects direct, mirror still works ──
231
- test('SOCKS proxy fails but mirror succeeds', async () => {
231
+ it('SOCKS proxy fails but mirror succeeds', async () => {
232
232
  process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
233
233
  process.env.LYTHOS_GH_MIRROR = 'https://my-mirror.example.com'
234
234
  const execCalls: Array<{ file: string; args: string[] }> = []
@@ -253,7 +253,7 @@ describe('probeConnectivity', () => {
253
253
  })
254
254
 
255
255
  // ── Backward compat: legacy env var name still works ──
256
- test('LYTHOSKILL_GH_MIRROR (legacy) still works with deprecation warning', async () => {
256
+ it('LYTHOSKILL_GH_MIRROR (legacy) still works with deprecation warning', async () => {
257
257
  // Ensure new name is not set
258
258
  delete process.env.LYTHOS_GH_MIRROR
259
259
  process.env.LYTHOSKILL_GH_MIRROR = 'https://legacy-mirror.example.com'
@@ -1,8 +1,8 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { formatLocator, parseLocator } from './parse-locator'
3
3
 
4
4
  describe('parseLocator — accepted forms', () => {
5
- test('host.tld/owner/repo/skill (monorepo)', () => {
5
+ it('host.tld/owner/repo/skill (monorepo)', () => {
6
6
  const loc = parseLocator('github.com/anthropics/skills/skills/pdf')
7
7
  expect(loc).toEqual({
8
8
  raw: 'github.com/anthropics/skills/skills/pdf',
@@ -15,19 +15,19 @@ describe('parseLocator — accepted forms', () => {
15
15
  })
16
16
  })
17
17
 
18
- test('host.tld/owner/repo/skill (nested skill subpath)', () => {
18
+ it('host.tld/owner/repo/skill (nested skill subpath)', () => {
19
19
  const loc = parseLocator('github.com/mattpocock/skills/skills/engineering/tdd')
20
20
  expect(loc?.skill).toBe('skills/engineering/tdd')
21
21
  expect(loc?.repo).toBe('skills')
22
22
  })
23
23
 
24
- test('host.tld/owner/repo/skill (flat repo, single skill segment)', () => {
24
+ it('host.tld/owner/repo/skill (flat repo, single skill segment)', () => {
25
25
  const loc = parseLocator('github.com/daymade/claude-code-skills/skill-creator')
26
26
  expect(loc?.skill).toBe('skill-creator')
27
27
  expect(loc?.repo).toBe('claude-code-skills')
28
28
  })
29
29
 
30
- test('host.tld/owner/repo (standalone — skill = null)', () => {
30
+ it('host.tld/owner/repo (standalone — skill = null)', () => {
31
31
  const loc = parseLocator('github.com/SpillwaveSolutions/design-doc-mermaid')
32
32
  expect(loc).toEqual({
33
33
  raw: 'github.com/SpillwaveSolutions/design-doc-mermaid',
@@ -40,12 +40,12 @@ describe('parseLocator — accepted forms', () => {
40
40
  })
41
41
  })
42
42
 
43
- test('host.tld/owner/repo/skill (arbitrary subdir name)', () => {
43
+ it('host.tld/owner/repo/skill (arbitrary subdir name)', () => {
44
44
  const loc = parseLocator('github.com/Cocoon-AI/architecture-diagram-generator/architecture-diagram')
45
45
  expect(loc?.skill).toBe('architecture-diagram')
46
46
  })
47
47
 
48
- test('localhost/<owner>/<repo> (canonical local skill, same shape as remote)', () => {
48
+ it('localhost/<owner>/<repo> (canonical local skill, same shape as remote)', () => {
49
49
  const loc = parseLocator('localhost/me/my-skill')
50
50
  expect(loc).toEqual({
51
51
  raw: 'localhost/me/my-skill',
@@ -58,42 +58,42 @@ describe('parseLocator — accepted forms', () => {
58
58
  })
59
59
  })
60
60
 
61
- test('non-github host accepted', () => {
61
+ it('non-github host accepted', () => {
62
62
  const loc = parseLocator('gitlab.com/owner/repo/skill-x')
63
63
  expect(loc?.host).toBe('gitlab.com')
64
64
  expect(loc?.skill).toBe('skill-x')
65
65
  })
66
66
 
67
- test('input is trimmed', () => {
67
+ it('input is trimmed', () => {
68
68
  const loc = parseLocator(' github.com/owner/repo ')
69
69
  expect(loc?.repo).toBe('repo')
70
70
  })
71
71
  })
72
72
 
73
73
  describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)', () => {
74
- test('empty string', () => {
74
+ it('empty string', () => {
75
75
  expect(parseLocator('')).toBeNull()
76
76
  expect(parseLocator(' ')).toBeNull()
77
77
  })
78
78
 
79
- test('single segment', () => {
79
+ it('single segment', () => {
80
80
  expect(parseLocator('my-skill')).toBeNull()
81
81
  })
82
82
 
83
- test('bare owner/repo (no host) is rejected — must be FQ', () => {
83
+ it('bare owner/repo (no host) is rejected — must be FQ', () => {
84
84
  expect(parseLocator('daymade/claude-code-skills')).toBeNull()
85
85
  expect(parseLocator('owner/repo/skill')).toBeNull()
86
86
  })
87
87
 
88
- test('host without dot is treated as bare', () => {
88
+ it('host without dot is treated as bare', () => {
89
89
  expect(parseLocator('foo/bar/baz')).toBeNull()
90
90
  })
91
91
 
92
- test('host.tld/owner without repo segment', () => {
92
+ it('host.tld/owner without repo segment', () => {
93
93
  expect(parseLocator('github.com/owner')).toBeNull()
94
94
  })
95
95
 
96
- test('localhost with multi-segment path (skill subpath beyond owner/repo)', () => {
96
+ it('localhost with multi-segment path (skill subpath beyond owner/repo)', () => {
97
97
  const loc = parseLocator('localhost/me/skills/my-skill')
98
98
  expect(loc).toEqual({
99
99
  raw: 'localhost/me/skills/my-skill',
@@ -106,17 +106,17 @@ describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)',
106
106
  })
107
107
  })
108
108
 
109
- test('localhost alone (no owner/repo) is rejected', () => {
109
+ it('localhost alone (no owner/repo) is rejected', () => {
110
110
  expect(parseLocator('localhost')).toBeNull()
111
111
  })
112
112
 
113
- test('localhost/<name> (2 segments) is rejected — use localhost/me/<skill> for quick local form', () => {
113
+ it('localhost/<name> (2 segments) is rejected — use localhost/me/<skill> for quick local form', () => {
114
114
  expect(parseLocator('localhost/my-skill')).toBeNull()
115
115
  })
116
116
  })
117
117
 
118
118
  describe('formatLocator — round-trips', () => {
119
- test.each([
119
+ it.each([
120
120
  'github.com/anthropics/skills/skills/pdf',
121
121
  'github.com/SpillwaveSolutions/design-doc-mermaid',
122
122
  'github.com/mattpocock/skills/skills/engineering/tdd',
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
2
  import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { tmpdir } from 'node:os'
@@ -48,20 +48,20 @@ describe('buildPrunePlan — plan-mode (IO via ColdPool + test filesystem)', ()
48
48
  rmSync(root, { recursive: true, force: true })
49
49
  })
50
50
 
51
- test('identifies unreferenced repos as prune candidates', () => {
51
+ it('identifies unreferenced repos as prune candidates', () => {
52
52
  const plan = buildPrunePlan(root)
53
53
  expect(plan.candidates.length).toBe(1)
54
54
  expect(plan.candidates[0].repoRel).toBe('github.com/org/repo-b')
55
55
  })
56
56
 
57
- test('does NOT flag actively referenced repos', () => {
57
+ it('does NOT flag actively referenced repos', () => {
58
58
  const plan = buildPrunePlan(root)
59
59
  const rels = plan.candidates.map(c => c.repoRel)
60
60
  expect(rels).not.toContain('github.com/org/repo-a')
61
61
  expect(rels).not.toContain('github.com/org/repo-c')
62
62
  })
63
63
 
64
- test('empty cold pool → empty plan', () => {
64
+ it('empty cold pool → empty plan', () => {
65
65
  const emptyDir = mkdtempSync(join(tmpdir(), 'prune-empty-'))
66
66
  const plan = buildPrunePlan(emptyDir)
67
67
  expect(plan.candidates).toEqual([])
@@ -69,7 +69,7 @@ describe('buildPrunePlan — plan-mode (IO via ColdPool + test filesystem)', ()
69
69
  rmSync(emptyDir, { recursive: true, force: true })
70
70
  })
71
71
 
72
- test('plan includes coldPoolPath and totalSize', () => {
72
+ it('plan includes coldPoolPath and totalSize', () => {
73
73
  const plan = buildPrunePlan(root)
74
74
  expect(plan.coldPoolPath).toBe(root)
75
75
  expect(plan.totalSize).toBeGreaterThan(0)
@@ -77,7 +77,7 @@ describe('buildPrunePlan — plan-mode (IO via ColdPool + test filesystem)', ()
77
77
  })
78
78
 
79
79
  describe('executePrunePlan — plan-mode (IO injected)', () => {
80
- test('calls delete for each candidate', () => {
80
+ it('calls delete for each candidate', () => {
81
81
  const deleted: string[] = []
82
82
  const plan = {
83
83
  coldPoolPath: '/pool',
@@ -97,7 +97,7 @@ describe('executePrunePlan — plan-mode (IO injected)', () => {
97
97
  expect(results[1].deleted).toBe(true)
98
98
  })
99
99
 
100
- test('logs count and total size', () => {
100
+ it('logs count and total size', () => {
101
101
  const logs: string[] = []
102
102
  executePrunePlan({
103
103
  coldPoolPath: '/pool',
@@ -112,7 +112,7 @@ describe('executePrunePlan — plan-mode (IO injected)', () => {
112
112
  expect(joined).toContain('500')
113
113
  })
114
114
 
115
- test('skips delete when candidates array empty', () => {
115
+ it('skips delete when candidates array empty', () => {
116
116
  let called = false
117
117
  const results = executePrunePlan(
118
118
  { coldPoolPath: '/pool', candidates: [], totalSize: 0 },
@@ -1,4 +1,4 @@
1
- import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
1
+ import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -34,7 +34,7 @@ describe('buildReconcilePlan — pure classification', () => {
34
34
  rmSync(root, { recursive: true, force: true })
35
35
  })
36
36
 
37
- test('empty desired + empty cold pool → all empty', () => {
37
+ it('empty desired + empty cold pool → all empty', () => {
38
38
  const desired: ReconcileDesiredState = { deckPath: '/project/skill-deck.toml', skills: [] }
39
39
  const plan = buildReconcilePlan(pool, desired)
40
40
  expect(plan.missing).toEqual([])
@@ -42,7 +42,7 @@ describe('buildReconcilePlan — pure classification', () => {
42
42
  expect(plan.extra).toEqual([])
43
43
  })
44
44
 
45
- test('desired skill missing from cold pool → reported as missing', () => {
45
+ it('desired skill missing from cold pool → reported as missing', () => {
46
46
  const desired: ReconcileDesiredState = {
47
47
  deckPath: '/project/skill-deck.toml',
48
48
  skills: [{ locator: 'github.com/lythos-labs/tdd', alias: 'tdd' }],
@@ -57,7 +57,7 @@ describe('buildReconcilePlan — pure classification', () => {
57
57
  expect(plan.extra).toEqual([])
58
58
  })
59
59
 
60
- test('desired skill exists in cold pool with metadata → reported as behind', () => {
60
+ it('desired skill exists in cold pool with metadata → reported as behind', () => {
61
61
  seedRepo(root, 'github.com', 'owner', 'repo-a')
62
62
  pool.metadata.recordRepoRef('github.com', 'owner', 'repo-a', 'abc123def')
63
63
 
@@ -73,7 +73,7 @@ describe('buildReconcilePlan — pure classification', () => {
73
73
  expect(plan.extra).toEqual([])
74
74
  })
75
75
 
76
- test('desired skill exists but no metadata → not flagged (manual clone)', () => {
76
+ it('desired skill exists but no metadata → not flagged (manual clone)', () => {
77
77
  seedRepo(root, 'github.com', 'owner', 'repo-b')
78
78
 
79
79
  const desired: ReconcileDesiredState = {
@@ -86,7 +86,7 @@ describe('buildReconcilePlan — pure classification', () => {
86
86
  expect(plan.extra).toEqual([])
87
87
  })
88
88
 
89
- test('monorepo skill path → extracts correct repo root', () => {
89
+ it('monorepo skill path → extracts correct repo root', () => {
90
90
  seedRepo(root, 'github.com', 'owner', 'monorepo')
91
91
  pool.metadata.recordRepoRef('github.com', 'owner', 'monorepo', 'def456')
92
92
 
@@ -100,7 +100,7 @@ describe('buildReconcilePlan — pure classification', () => {
100
100
  expect(plan.behind[0].repo).toBe('monorepo')
101
101
  })
102
102
 
103
- test('extra repo in cold pool not in desired → reported as extra', () => {
103
+ it('extra repo in cold pool not in desired → reported as extra', () => {
104
104
  seedRepo(root, 'github.com', 'someone', 'orphan-repo')
105
105
 
106
106
  const desired: ReconcileDesiredState = {
@@ -113,7 +113,7 @@ describe('buildReconcilePlan — pure classification', () => {
113
113
  expect(plan.extra[0].repo).toBe('orphan-repo')
114
114
  })
115
115
 
116
- test('multiple skills in same repo → single repo entry', () => {
116
+ it('multiple skills in same repo → single repo entry', () => {
117
117
  seedRepo(root, 'github.com', 'owner', 'shared-repo')
118
118
  pool.metadata.recordRepoRef('github.com', 'owner', 'shared-repo', 'ghi789')
119
119
 
@@ -130,7 +130,7 @@ describe('buildReconcilePlan — pure classification', () => {
130
130
  expect(plan.behind[0].aliases).toEqual(['alpha', 'beta'])
131
131
  })
132
132
 
133
- test('localhost skill in desired, exists in pool', () => {
133
+ it('localhost skill in desired, exists in pool', () => {
134
134
  seedRepo(root, 'localhost', 'me', 'local-skill')
135
135
 
136
136
  const desired: ReconcileDesiredState = {
@@ -144,7 +144,7 @@ describe('buildReconcilePlan — pure classification', () => {
144
144
  expect(plan.extra).toEqual([])
145
145
  })
146
146
 
147
- test('unrecognized locator format → reported as missing', () => {
147
+ it('unrecognized locator format → reported as missing', () => {
148
148
  const desired: ReconcileDesiredState = {
149
149
  deckPath: '/project/skill-deck.toml',
150
150
  skills: [{ locator: 'bare-name', alias: 'bare' }],
@@ -154,7 +154,7 @@ describe('buildReconcilePlan — pure classification', () => {
154
154
  expect(plan.missing[0].reason).toContain('Unrecognized')
155
155
  })
156
156
 
157
- test('mixed: missing + behind + extra all populated', () => {
157
+ it('mixed: missing + behind + extra all populated', () => {
158
158
  // Seed two repos that exist
159
159
  seedRepo(root, 'github.com', 'owner', 'exists-behind')
160
160
  pool.metadata.recordRepoRef('github.com', 'owner', 'exists-behind', 'abc')
@@ -185,12 +185,12 @@ describe('buildReconcilePlan — pure classification', () => {
185
185
  })
186
186
 
187
187
  describe('executeReconcilePlan — plan-first reporting', () => {
188
- test('empty plan → all zero count', () => {
188
+ it('empty plan → all zero count', () => {
189
189
  const results = executeReconcilePlan({ missing: [], behind: [], extra: [] })
190
190
  expect(results).toEqual([])
191
191
  })
192
192
 
193
- test('missing entry → failed status', () => {
193
+ it('missing entry → failed status', () => {
194
194
  const plan = {
195
195
  missing: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'Not found', aliases: ['r'] }],
196
196
  behind: [],
@@ -201,7 +201,7 @@ describe('executeReconcilePlan — plan-first reporting', () => {
201
201
  expect(results[0].status).toBe('failed')
202
202
  })
203
203
 
204
- test('behind entry → skipped status', () => {
204
+ it('behind entry → skipped status', () => {
205
205
  const plan = {
206
206
  missing: [],
207
207
  behind: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'HEAD mismatch', aliases: ['r'] }],
@@ -211,7 +211,7 @@ describe('executeReconcilePlan — plan-first reporting', () => {
211
211
  expect(results[0].status).toBe('skipped')
212
212
  })
213
213
 
214
- test('extra entry → extra-reported status', () => {
214
+ it('extra entry → extra-reported status', () => {
215
215
  const plan = {
216
216
  missing: [],
217
217
  behind: [],
@@ -221,7 +221,7 @@ describe('executeReconcilePlan — plan-first reporting', () => {
221
221
  expect(results[0].status).toBe('extra-reported')
222
222
  })
223
223
 
224
- test('io.log captures output', () => {
224
+ it('io.log captures output', () => {
225
225
  const lines: string[] = []
226
226
  const plan = {
227
227
  missing: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/p/github.com/o/r', reason: 'X', aliases: ['r'] }],
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { buildValidationPlan, executeValidationPlan, type ValidationCheck, type ValidationIO } from './validate-plan'
3
3
  import type { FetchFn, TreeEntry } from './github-tree'
4
4
 
@@ -18,25 +18,25 @@ const treeFor = (paths: string[]): TreeEntry[] =>
18
18
  const ioWith = (fetch: FetchFn): ValidationIO => ({ fetch })
19
19
 
20
20
  describe('buildValidationPlan', () => {
21
- test('parses input and stores defaults', () => {
21
+ it('parses input and stores defaults', () => {
22
22
  const plan = buildValidationPlan('github.com/owner/repo')
23
23
  expect(plan.locator?.repo).toBe('repo')
24
24
  expect(plan.checks).toEqual(['syntax', 'remote', 'path'])
25
25
  })
26
26
 
27
- test('parse failure → locator null', () => {
27
+ it('parse failure → locator null', () => {
28
28
  const plan = buildValidationPlan('bad-name')
29
29
  expect(plan.locator).toBeNull()
30
30
  })
31
31
 
32
- test('honors custom checks', () => {
32
+ it('honors custom checks', () => {
33
33
  const plan = buildValidationPlan('localhost/me/x', { checks: ['syntax'] })
34
34
  expect(plan.checks).toEqual(['syntax'])
35
35
  })
36
36
  })
37
37
 
38
38
  describe('executeValidationPlan — syntax phase', () => {
39
- test('parse failure → invalid + suggested fix', async () => {
39
+ it('parse failure → invalid + suggested fix', async () => {
40
40
  const plan = buildValidationPlan('bad-name')
41
41
  const report = await executeValidationPlan(plan)
42
42
  expect(report.status).toBe('invalid')
@@ -45,7 +45,7 @@ describe('executeValidationPlan — syntax phase', () => {
45
45
  expect(report.suggestedFixes.length).toBe(1)
46
46
  })
47
47
 
48
- test('localhost short-circuits — no remote fetch', async () => {
48
+ it('localhost short-circuits — no remote fetch', async () => {
49
49
  const plan = buildValidationPlan('localhost/me/my-skill')
50
50
  let fetched = false
51
51
  const io = ioWith(async () => {
@@ -59,7 +59,7 @@ describe('executeValidationPlan — syntax phase', () => {
59
59
  })
60
60
 
61
61
  describe('executeValidationPlan — remote phase', () => {
62
- test('404 → invalid (repo-existence)', async () => {
62
+ it('404 → invalid (repo-existence)', async () => {
63
63
  const plan = buildValidationPlan('github.com/nope/nope')
64
64
  const report = await executeValidationPlan(plan, ioWith(mockFetch(() => ({ status: 404 }))))
65
65
  expect(report.status).toBe('invalid')
@@ -67,7 +67,7 @@ describe('executeValidationPlan — remote phase', () => {
67
67
  expect(report.findings.repoExists).toBe(false)
68
68
  })
69
69
 
70
- test('rate-limit → ambiguous', async () => {
70
+ it('rate-limit → ambiguous', async () => {
71
71
  const plan = buildValidationPlan('github.com/owner/repo')
72
72
  const report = await executeValidationPlan(
73
73
  plan,
@@ -77,7 +77,7 @@ describe('executeValidationPlan — remote phase', () => {
77
77
  expect(report.phase).toBe('repo-existence')
78
78
  })
79
79
 
80
- test('private repo → ambiguous with hint', async () => {
80
+ it('private repo → ambiguous with hint', async () => {
81
81
  const plan = buildValidationPlan('github.com/owner/repo')
82
82
  const report = await executeValidationPlan(plan, ioWith(mockFetch(() => ({ status: 403 }))))
83
83
  expect(report.status).toBe('ambiguous')
@@ -86,7 +86,7 @@ describe('executeValidationPlan — remote phase', () => {
86
86
  })
87
87
 
88
88
  describe('executeValidationPlan — path phase', () => {
89
- test('standalone (skill=null) with SKILL.md at root → valid', async () => {
89
+ it('standalone (skill=null) with SKILL.md at root → valid', async () => {
90
90
  const plan = buildValidationPlan('github.com/owner/standalone')
91
91
  const fetch = mockFetch(() => ({
92
92
  status: 200,
@@ -98,7 +98,7 @@ describe('executeValidationPlan — path phase', () => {
98
98
  expect(report.findings.skillMdFound).toBe(true)
99
99
  })
100
100
 
101
- test('standalone but skills exist in subdirs → invalid + qualified-locator suggestions', async () => {
101
+ it('standalone but skills exist in subdirs → invalid + qualified-locator suggestions', async () => {
102
102
  const plan = buildValidationPlan('github.com/owner/repo')
103
103
  const fetch = mockFetch(() => ({
104
104
  status: 200,
@@ -113,7 +113,7 @@ describe('executeValidationPlan — path phase', () => {
113
113
  ])
114
114
  })
115
115
 
116
- test('skill subpath matches → valid', async () => {
116
+ it('skill subpath matches → valid', async () => {
117
117
  const plan = buildValidationPlan('github.com/anthropics/skills/skills/pdf')
118
118
  const fetch = mockFetch(() => ({
119
119
  status: 200,
@@ -123,7 +123,7 @@ describe('executeValidationPlan — path phase', () => {
123
123
  expect(report.status).toBe('valid')
124
124
  })
125
125
 
126
- test('skill subpath wrong → invalid + corrected suggestion', async () => {
126
+ it('skill subpath wrong → invalid + corrected suggestion', async () => {
127
127
  const plan = buildValidationPlan('github.com/anthropics/skills/pdf') // missing skills/ prefix
128
128
  const fetch = mockFetch(() => ({
129
129
  status: 200,
@@ -135,7 +135,7 @@ describe('executeValidationPlan — path phase', () => {
135
135
  expect(report.suggestedFixes.some((f) => f.newLocator === 'github.com/anthropics/skills/skills/pdf')).toBe(true)
136
136
  })
137
137
 
138
- test('repo with no SKILL.md anywhere → invalid + web-search hint', async () => {
138
+ it('repo with no SKILL.md anywhere → invalid + web-search hint', async () => {
139
139
  const plan = buildValidationPlan('github.com/owner/random-repo')
140
140
  const fetch = mockFetch(() => ({
141
141
  status: 200,
@@ -148,7 +148,7 @@ describe('executeValidationPlan — path phase', () => {
148
148
  })
149
149
 
150
150
  describe('executeValidationPlan — scoped checks', () => {
151
- test('checks: ["syntax"] — no remote fetch even for non-localhost', async () => {
151
+ it('checks: ["syntax"] — no remote fetch even for non-localhost', async () => {
152
152
  const plan = buildValidationPlan('github.com/o/r', { checks: ['syntax'] })
153
153
  let fetched = false
154
154
  const io = ioWith(async () => {