@lythos/cold-pool 0.16.0 → 0.17.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/README.md +2 -2
- package/package.json +2 -2
- package/src/cold-pool.test.ts +23 -23
- package/src/fetch-plan.test.ts +10 -10
- package/src/git-hash.test.ts +58 -1
- package/src/git-hash.ts +26 -6
- package/src/git-io.test.ts +11 -11
- package/src/github-tree.test.ts +10 -10
- package/src/index.ts +2 -0
- package/src/infer-skill-path.test.ts +9 -9
- package/src/mirror.test.ts +13 -13
- package/src/parse-locator.test.ts +18 -18
- package/src/prune-plan.test.ts +8 -8
- package/src/reconcile-plan.test.ts +16 -16
- package/src/validate-plan.test.ts +15 -15
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.
|
|
18
|
+
bunx @lythos/cold-pool@0.17.0 prune [--yes] [--dry-run]
|
|
19
19
|
|
|
20
20
|
# Validate lock file against cold pool
|
|
21
|
-
bunx @lythos/cold-pool@0.
|
|
21
|
+
bunx @lythos/cold-pool@0.17.0 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.
|
|
3
|
+
"version": "0.17.0",
|
|
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.
|
|
45
|
+
"@lythos/infra": "^0.17.0",
|
|
46
46
|
"simple-git": "^3.36.0"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
package/src/cold-pool.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
+
it('returns empty when root has no entries', () => {
|
|
214
214
|
expect(buildListPlan(root, []).entries).toEqual([])
|
|
215
215
|
})
|
|
216
216
|
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/fetch-plan.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/git-hash.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
25
|
-
if (!hash) throw new Error(`Could not parse tree hash from: ${output}`)
|
|
26
|
-
return hash
|
|
46
|
+
return parseTreeHash(output)
|
|
27
47
|
}
|
package/src/git-io.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/github-tree.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
it('empty entries → empty candidates', () => {
|
|
68
68
|
const r = inferSkillPath([])
|
|
69
69
|
expect(r.candidates).toEqual([])
|
|
70
70
|
expect(r.exactMatch).toBeNull()
|
package/src/mirror.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
it('empty string', () => {
|
|
75
75
|
expect(parseLocator('')).toBeNull()
|
|
76
76
|
expect(parseLocator(' ')).toBeNull()
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
it('single segment', () => {
|
|
80
80
|
expect(parseLocator('my-skill')).toBeNull()
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
|
|
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
|
-
|
|
88
|
+
it('host without dot is treated as bare', () => {
|
|
89
89
|
expect(parseLocator('foo/bar/baz')).toBeNull()
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
it('host.tld/owner without repo segment', () => {
|
|
93
93
|
expect(parseLocator('github.com/owner')).toBeNull()
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
-
|
|
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
|
-
|
|
109
|
+
it('localhost alone (no owner/repo) is rejected', () => {
|
|
110
110
|
expect(parseLocator('localhost')).toBeNull()
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
-
|
|
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
|
-
|
|
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',
|
package/src/prune-plan.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
it('parse failure → locator null', () => {
|
|
28
28
|
const plan = buildValidationPlan('bad-name')
|
|
29
29
|
expect(plan.locator).toBeNull()
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|