@lythos/cold-pool 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { buildValidationPlan, executeValidationPlan, type ValidationCheck, type ValidationIO } from './validate-plan'
3
+ import type { FetchFn, TreeEntry } from './github-tree'
4
+
5
+ function mockFetch(impl: (url: string) => { status: number; body?: unknown; headers?: Record<string, string> }): FetchFn {
6
+ return async (url) => {
7
+ const out = impl(url)
8
+ return new Response(out.body !== undefined ? JSON.stringify(out.body) : null, {
9
+ status: out.status,
10
+ headers: out.headers,
11
+ })
12
+ }
13
+ }
14
+
15
+ const treeFor = (paths: string[]): TreeEntry[] =>
16
+ paths.map((p) => ({ path: p, type: 'blob' as const, sha: 'x' }))
17
+
18
+ const ioWith = (fetch: FetchFn): ValidationIO => ({ fetch })
19
+
20
+ describe('buildValidationPlan', () => {
21
+ test('parses input and stores defaults', () => {
22
+ const plan = buildValidationPlan('github.com/owner/repo')
23
+ expect(plan.locator?.repo).toBe('repo')
24
+ expect(plan.checks).toEqual(['syntax', 'remote', 'path'])
25
+ })
26
+
27
+ test('parse failure → locator null', () => {
28
+ const plan = buildValidationPlan('bad-name')
29
+ expect(plan.locator).toBeNull()
30
+ })
31
+
32
+ test('honors custom checks', () => {
33
+ const plan = buildValidationPlan('localhost/me/x', { checks: ['syntax'] })
34
+ expect(plan.checks).toEqual(['syntax'])
35
+ })
36
+ })
37
+
38
+ describe('executeValidationPlan — syntax phase', () => {
39
+ test('parse failure → invalid + suggested fix', async () => {
40
+ const plan = buildValidationPlan('bad-name')
41
+ const report = await executeValidationPlan(plan)
42
+ expect(report.status).toBe('invalid')
43
+ expect(report.phase).toBe('syntax')
44
+ expect(report.findings.parseError).toBeDefined()
45
+ expect(report.suggestedFixes.length).toBe(1)
46
+ })
47
+
48
+ test('localhost short-circuits — no remote fetch', async () => {
49
+ const plan = buildValidationPlan('localhost/me/my-skill')
50
+ let fetched = false
51
+ const io = ioWith(async () => {
52
+ fetched = true
53
+ return new Response(null, { status: 200 })
54
+ })
55
+ const report = await executeValidationPlan(plan, io)
56
+ expect(fetched).toBe(false)
57
+ expect(report.status).toBe('valid')
58
+ })
59
+ })
60
+
61
+ describe('executeValidationPlan — remote phase', () => {
62
+ test('404 → invalid (repo-existence)', async () => {
63
+ const plan = buildValidationPlan('github.com/nope/nope')
64
+ const report = await executeValidationPlan(plan, ioWith(mockFetch(() => ({ status: 404 }))))
65
+ expect(report.status).toBe('invalid')
66
+ expect(report.phase).toBe('repo-existence')
67
+ expect(report.findings.repoExists).toBe(false)
68
+ })
69
+
70
+ test('rate-limit → ambiguous', async () => {
71
+ const plan = buildValidationPlan('github.com/owner/repo')
72
+ const report = await executeValidationPlan(
73
+ plan,
74
+ ioWith(mockFetch(() => ({ status: 403, headers: { 'X-RateLimit-Remaining': '0' } }))),
75
+ )
76
+ expect(report.status).toBe('ambiguous')
77
+ expect(report.phase).toBe('repo-existence')
78
+ })
79
+
80
+ test('private repo → ambiguous with hint', async () => {
81
+ const plan = buildValidationPlan('github.com/owner/repo')
82
+ const report = await executeValidationPlan(plan, ioWith(mockFetch(() => ({ status: 403 }))))
83
+ expect(report.status).toBe('ambiguous')
84
+ expect(report.findings.repoIsPrivate).toBe(true)
85
+ })
86
+ })
87
+
88
+ describe('executeValidationPlan — path phase', () => {
89
+ test('standalone (skill=null) with SKILL.md at root → valid', async () => {
90
+ const plan = buildValidationPlan('github.com/owner/standalone')
91
+ const fetch = mockFetch(() => ({
92
+ status: 200,
93
+ body: { tree: treeFor(['SKILL.md', 'README.md']) },
94
+ }))
95
+ const report = await executeValidationPlan(plan, ioWith(fetch))
96
+ expect(report.status).toBe('valid')
97
+ expect(report.phase).toBe('skill-md-existence')
98
+ expect(report.findings.skillMdFound).toBe(true)
99
+ })
100
+
101
+ test('standalone but skills exist in subdirs → invalid + qualified-locator suggestions', async () => {
102
+ const plan = buildValidationPlan('github.com/owner/repo')
103
+ const fetch = mockFetch(() => ({
104
+ status: 200,
105
+ body: { tree: treeFor(['skills/pdf/SKILL.md', 'skills/excel/SKILL.md']) },
106
+ }))
107
+ const report = await executeValidationPlan(plan, ioWith(fetch))
108
+ expect(report.status).toBe('ambiguous') // multiple candidates
109
+ expect(report.suggestedFixes.length).toBe(2)
110
+ expect(report.suggestedFixes.map((f) => f.newLocator).sort()).toEqual([
111
+ 'github.com/owner/repo/skills/excel',
112
+ 'github.com/owner/repo/skills/pdf',
113
+ ])
114
+ })
115
+
116
+ test('skill subpath matches → valid', async () => {
117
+ const plan = buildValidationPlan('github.com/anthropics/skills/skills/pdf')
118
+ const fetch = mockFetch(() => ({
119
+ status: 200,
120
+ body: { tree: treeFor(['skills/pdf/SKILL.md', 'skills/excel/SKILL.md']) },
121
+ }))
122
+ const report = await executeValidationPlan(plan, ioWith(fetch))
123
+ expect(report.status).toBe('valid')
124
+ })
125
+
126
+ test('skill subpath wrong → invalid + corrected suggestion', async () => {
127
+ const plan = buildValidationPlan('github.com/anthropics/skills/pdf') // missing skills/ prefix
128
+ const fetch = mockFetch(() => ({
129
+ status: 200,
130
+ body: { tree: treeFor(['skills/pdf/SKILL.md']) },
131
+ }))
132
+ const report = await executeValidationPlan(plan, ioWith(fetch))
133
+ expect(report.status).toBe('invalid')
134
+ expect(report.phase).toBe('path-existence')
135
+ expect(report.suggestedFixes.some((f) => f.newLocator === 'github.com/anthropics/skills/skills/pdf')).toBe(true)
136
+ })
137
+
138
+ test('repo with no SKILL.md anywhere → invalid + web-search hint', async () => {
139
+ const plan = buildValidationPlan('github.com/owner/random-repo')
140
+ const fetch = mockFetch(() => ({
141
+ status: 200,
142
+ body: { tree: treeFor(['README.md', 'src/index.ts']) },
143
+ }))
144
+ const report = await executeValidationPlan(plan, ioWith(fetch))
145
+ expect(report.status).toBe('invalid')
146
+ expect(report.suggestedFixes.some((f) => f.action === 'web-search')).toBe(true)
147
+ })
148
+ })
149
+
150
+ describe('executeValidationPlan — scoped checks', () => {
151
+ test('checks: ["syntax"] — no remote fetch even for non-localhost', async () => {
152
+ const plan = buildValidationPlan('github.com/o/r', { checks: ['syntax'] })
153
+ let fetched = false
154
+ const io = ioWith(async () => {
155
+ fetched = true
156
+ return new Response(null, { status: 200 })
157
+ })
158
+ const report = await executeValidationPlan(plan, io)
159
+ expect(fetched).toBe(false)
160
+ expect(report.status).toBe('valid')
161
+ })
162
+ })
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Validation plan + executor.
3
+ *
4
+ * `buildValidationPlan` is pure — it parses the input and chooses which
5
+ * checks to run. `executeValidationPlan` performs the side-effecting
6
+ * remote tree fetch via injected IO and produces a `ValidationReport`.
7
+ *
8
+ * Per ADR-20260507014124191, the report is structured data so agents
9
+ * can recover from validation failure (suggested fixes, detected paths)
10
+ * instead of parsing strings.
11
+ */
12
+ import type { Locator, SuggestedFix, ValidationFindings, ValidationReport } from './types.js'
13
+ import { parseLocator, formatLocator } from './parse-locator.js'
14
+ import type { FetchFn, TreeResponse } from './github-tree.js'
15
+ import { fetchRepoTree } from './github-tree.js'
16
+ import { inferSkillPath } from './infer-skill-path.js'
17
+
18
+ export type ValidationCheck = 'syntax' | 'remote' | 'path'
19
+
20
+ export interface ValidationPlan {
21
+ readonly rawInput: string
22
+ readonly locator: Locator | null
23
+ readonly checks: ReadonlyArray<ValidationCheck>
24
+ /** Optional git ref to validate against (default: HEAD). */
25
+ readonly ref?: string
26
+ }
27
+
28
+ export interface ValidationIO {
29
+ fetch?: FetchFn
30
+ log?: (msg: string) => void
31
+ }
32
+
33
+ const DEFAULT_CHECKS: ValidationCheck[] = ['syntax', 'remote', 'path']
34
+
35
+ export function buildValidationPlan(
36
+ rawInput: string,
37
+ opts?: { checks?: ReadonlyArray<ValidationCheck>; ref?: string },
38
+ ): ValidationPlan {
39
+ return {
40
+ rawInput,
41
+ locator: parseLocator(rawInput),
42
+ checks: opts?.checks ?? DEFAULT_CHECKS,
43
+ ref: opts?.ref,
44
+ }
45
+ }
46
+
47
+ export async function executeValidationPlan(
48
+ plan: ValidationPlan,
49
+ io?: ValidationIO,
50
+ ): Promise<ValidationReport> {
51
+ const fixes: SuggestedFix[] = []
52
+
53
+ // ── 1. Syntax phase ───────────────────────────────────────────────
54
+ if (!plan.locator) {
55
+ fixes.push({
56
+ action: 'update-locator',
57
+ confidence: 0.5,
58
+ message: 'Locator must be FQ: host.tld/owner/repo[/skill] or localhost/<name>. Bare names are rejected per ADR-20260502012643244.',
59
+ })
60
+ return {
61
+ status: 'invalid',
62
+ locator: plan.rawInput,
63
+ phase: 'syntax',
64
+ findings: { parseError: 'parseLocator returned null' },
65
+ suggestedFixes: fixes,
66
+ }
67
+ }
68
+
69
+ if (!plan.checks.includes('syntax')) {
70
+ // Skipped; treat as if we only got past parsing but not asked to remote-check.
71
+ }
72
+
73
+ // ── 2. Localhost early-return ─────────────────────────────────────
74
+ if (plan.locator.isLocalhost) {
75
+ return {
76
+ status: 'valid',
77
+ locator: plan.rawInput,
78
+ phase: 'syntax',
79
+ findings: {},
80
+ suggestedFixes: [],
81
+ }
82
+ }
83
+
84
+ // ── 3. Remote phase (and 4. Path phase) ───────────────────────────
85
+ if (!plan.checks.includes('remote') && !plan.checks.includes('path')) {
86
+ return {
87
+ status: 'valid',
88
+ locator: plan.rawInput,
89
+ phase: 'syntax',
90
+ findings: {},
91
+ suggestedFixes: [],
92
+ }
93
+ }
94
+
95
+ const tree: TreeResponse = await fetchRepoTree(
96
+ plan.locator.host,
97
+ plan.locator.owner!,
98
+ plan.locator.repo!,
99
+ plan.ref,
100
+ io?.fetch,
101
+ )
102
+
103
+ io?.log?.(`tree fetch: ${tree.status} (http ${tree.httpStatus})`)
104
+
105
+ if (tree.status === 'not-found') {
106
+ fixes.push({
107
+ action: 'update-locator',
108
+ confidence: 0.7,
109
+ message: `repo not found on ${plan.locator.host}. Verify owner/repo spelling.`,
110
+ })
111
+ return {
112
+ status: 'invalid',
113
+ locator: plan.rawInput,
114
+ phase: 'repo-existence',
115
+ findings: { repoExists: false, remoteStatus: 404 },
116
+ suggestedFixes: fixes,
117
+ }
118
+ }
119
+
120
+ if (tree.status === 'rate-limited' || tree.status === 'network-error' || tree.status === 'unsupported-host') {
121
+ // Cannot determine — return ambiguous, agent decides whether to clone-and-test
122
+ fixes.push({
123
+ action: 'prompt-user',
124
+ confidence: 0.3,
125
+ message: tree.message ?? 'remote check failed; locator may still be valid',
126
+ })
127
+ return {
128
+ status: 'ambiguous',
129
+ locator: plan.rawInput,
130
+ phase: 'repo-existence',
131
+ findings: { remoteStatus: tree.httpStatus },
132
+ suggestedFixes: fixes,
133
+ }
134
+ }
135
+
136
+ if (tree.status === 'private') {
137
+ fixes.push({
138
+ action: 'prompt-user',
139
+ confidence: 0.6,
140
+ message: `repo appears private; auth is not implemented in cold-pool. Clone manually if access is granted, then deck add will reuse the cold pool entry.`,
141
+ })
142
+ return {
143
+ status: 'ambiguous',
144
+ locator: plan.rawInput,
145
+ phase: 'repo-existence',
146
+ findings: { repoExists: true, repoIsPrivate: true, remoteStatus: 403 },
147
+ suggestedFixes: fixes,
148
+ }
149
+ }
150
+
151
+ // tree.status === 'ok'
152
+ if (!plan.checks.includes('path')) {
153
+ return {
154
+ status: 'valid',
155
+ locator: plan.rawInput,
156
+ phase: 'repo-existence',
157
+ findings: { repoExists: true, remoteStatus: 200 },
158
+ suggestedFixes: [],
159
+ }
160
+ }
161
+
162
+ // 4. Path phase: scan tree for SKILL.md
163
+ const inference = inferSkillPath(tree.entries, plan.locator.skill)
164
+
165
+ const findingsBase: ValidationFindings = {
166
+ repoExists: true,
167
+ remoteStatus: 200,
168
+ skillMdFound: inference.exactMatch !== null,
169
+ detectedPaths: inference.candidates,
170
+ }
171
+
172
+ if (plan.locator.skill === null) {
173
+ // standalone — expect SKILL.md at repo root
174
+ if (inference.candidates.includes('')) {
175
+ return {
176
+ status: 'valid',
177
+ locator: plan.rawInput,
178
+ phase: 'skill-md-existence',
179
+ findings: { ...findingsBase, skillMdFound: true },
180
+ suggestedFixes: [],
181
+ }
182
+ }
183
+ if (inference.candidates.length === 0) {
184
+ fixes.push({
185
+ action: 'web-search',
186
+ confidence: 0.4,
187
+ message: 'no SKILL.md found in this repo; the locator may not point to a skill repo',
188
+ })
189
+ return {
190
+ status: 'invalid',
191
+ locator: plan.rawInput,
192
+ phase: 'skill-md-existence',
193
+ findings: findingsBase,
194
+ suggestedFixes: fixes,
195
+ }
196
+ }
197
+ // standalone but skills exist in subdirs → suggest qualifying
198
+ const status = inference.candidates.length === 1 ? 'invalid' : 'ambiguous'
199
+ for (const candidate of inference.candidates) {
200
+ const newLocator = formatLocator({
201
+ ...plan.locator,
202
+ skill: candidate,
203
+ })
204
+ fixes.push({
205
+ action: 'update-locator',
206
+ confidence: inference.candidates.length === 1 ? 0.8 : 0.5,
207
+ message: `repo has skill at '${candidate}/'; qualify the locator`,
208
+ newLocator,
209
+ })
210
+ }
211
+ return {
212
+ status,
213
+ locator: plan.rawInput,
214
+ phase: 'skill-md-existence',
215
+ findings: findingsBase,
216
+ suggestedFixes: fixes,
217
+ }
218
+ }
219
+
220
+ // skill subpath given
221
+ if (inference.exactMatch !== null) {
222
+ return {
223
+ status: 'valid',
224
+ locator: plan.rawInput,
225
+ phase: 'skill-md-existence',
226
+ findings: findingsBase,
227
+ suggestedFixes: [],
228
+ }
229
+ }
230
+
231
+ // suggest candidates
232
+ for (const candidate of inference.candidates) {
233
+ const newLocator = formatLocator({
234
+ ...plan.locator,
235
+ skill: candidate || null,
236
+ })
237
+ fixes.push({
238
+ action: 'update-locator',
239
+ confidence: candidate.endsWith('/' + plan.locator.skill) || candidate === plan.locator.skill ? 0.9 : 0.4,
240
+ message: `SKILL.md found at '${candidate || '(repo root)'}'`,
241
+ newLocator,
242
+ })
243
+ }
244
+ if (inference.candidates.length === 0) {
245
+ fixes.push({
246
+ action: 'web-search',
247
+ confidence: 0.4,
248
+ message: 'no SKILL.md anywhere in this repo; the locator may not point to a skill repo',
249
+ })
250
+ }
251
+
252
+ return {
253
+ status: 'invalid',
254
+ locator: plan.rawInput,
255
+ phase: 'path-existence',
256
+ findings: findingsBase,
257
+ suggestedFixes: fixes,
258
+ }
259
+ }