@lythos/skill-deck 0.9.0 → 0.9.2

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,160 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * validate.test.ts — unit tests for validate.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/validate.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import { spawnSync } from 'node:child_process'
13
+
14
+ let cleanup: string[] = []
15
+
16
+ afterEach(() => {
17
+ for (const dir of cleanup) {
18
+ rmSync(dir, { recursive: true, force: true })
19
+ }
20
+ cleanup = []
21
+ })
22
+
23
+ function makeTmp(): string {
24
+ const dir = mkdtempSync(join(tmpdir(), 'deck-validate-'))
25
+ cleanup.push(dir)
26
+ return dir
27
+ }
28
+
29
+ function placeSkill(coldPool: string, relPath: string): string {
30
+ const skillDir = join(coldPool, relPath)
31
+ mkdirSync(skillDir, { recursive: true })
32
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
33
+ return skillDir
34
+ }
35
+
36
+ function runValidate(deckPath: string, workdir: string) {
37
+ return spawnSync('bun', [join(import.meta.dir, 'cli.ts'), 'validate', '--deck', deckPath, '--workdir', workdir], {
38
+ cwd: workdir,
39
+ encoding: 'utf-8',
40
+ })
41
+ }
42
+
43
+ describe('validateDeck', () => {
44
+ it('C1: valid deck passes validation', () => {
45
+ const projectDir = makeTmp()
46
+ const coldPoolRel = 'cold-pool'
47
+ const coldPool = join(projectDir, coldPoolRel)
48
+
49
+ placeSkill(coldPool, 'github.com/owner/repo/skill')
50
+
51
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
52
+ const deckPath = join(projectDir, 'skill-deck.toml')
53
+ writeFileSync(deckPath, deckContent)
54
+
55
+ const result = runValidate(deckPath, projectDir)
56
+
57
+ expect(result.status).toBe(0)
58
+ expect(result.stdout).toContain('Validation passed')
59
+ })
60
+
61
+ it('C2: missing [deck] section errors', () => {
62
+ const projectDir = makeTmp()
63
+ const deckContent = `[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
64
+ const deckPath = join(projectDir, 'skill-deck.toml')
65
+ writeFileSync(deckPath, deckContent)
66
+
67
+ const result = runValidate(deckPath, projectDir)
68
+
69
+ expect(result.status).toBe(1)
70
+ expect(result.stderr).toContain('[deck] section is required')
71
+ })
72
+
73
+ it('C3: invalid max_cards errors', () => {
74
+ const projectDir = makeTmp()
75
+ const coldPoolRel = 'cold-pool'
76
+ const coldPool = join(projectDir, coldPoolRel)
77
+ placeSkill(coldPool, 'github.com/owner/repo/skill')
78
+
79
+ const deckContent = `[deck]\nmax_cards = -1\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
80
+ const deckPath = join(projectDir, 'skill-deck.toml')
81
+ writeFileSync(deckPath, deckContent)
82
+
83
+ const result = runValidate(deckPath, projectDir)
84
+
85
+ expect(result.status).toBe(1)
86
+ expect(result.stderr).toContain('deck.max_cards must be a positive integer')
87
+ })
88
+
89
+ it('C4: skill not found in cold pool errors', () => {
90
+ const projectDir = makeTmp()
91
+ const coldPoolRel = 'cold-pool'
92
+ const coldPool = join(projectDir, coldPoolRel)
93
+ mkdirSync(coldPool, { recursive: true })
94
+ // do NOT place the skill
95
+
96
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo/nonexistent"\n`
97
+ const deckPath = join(projectDir, 'skill-deck.toml')
98
+ writeFileSync(deckPath, deckContent)
99
+
100
+ const result = runValidate(deckPath, projectDir)
101
+
102
+ expect(result.status).toBe(1)
103
+ expect(result.stderr).toContain('Skill not found')
104
+ })
105
+
106
+ it('C5: budget exceeded errors', () => {
107
+ const projectDir = makeTmp()
108
+ const coldPoolRel = 'cold-pool'
109
+ const coldPool = join(projectDir, coldPoolRel)
110
+ placeSkill(coldPool, 'github.com/owner/repo/skill-a')
111
+ placeSkill(coldPool, 'github.com/owner/repo/skill-b')
112
+
113
+ const deckContent = `[deck]\nmax_cards = 1\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n\n[tool.skills.skill-b]\npath = "github.com/owner/repo/skill-b"\n`
114
+ const deckPath = join(projectDir, 'skill-deck.toml')
115
+ writeFileSync(deckPath, deckContent)
116
+
117
+ const result = runValidate(deckPath, projectDir)
118
+
119
+ expect(result.status).toBe(1)
120
+ expect(result.stderr).toContain('Budget exceeded')
121
+ })
122
+
123
+ it('C6: toml parse error exits', () => {
124
+ const projectDir = makeTmp()
125
+ const deckPath = join(projectDir, 'skill-deck.toml')
126
+ writeFileSync(deckPath, '[invalid toml\n')
127
+
128
+ const result = runValidate(deckPath, projectDir)
129
+
130
+ expect(result.status).toBe(1)
131
+ expect(result.stderr).toContain('TOML parse error')
132
+ })
133
+
134
+ it('C7: deprecated string-array format warns', () => {
135
+ const projectDir = makeTmp()
136
+ const coldPoolRel = 'cold-pool'
137
+ const coldPool = join(projectDir, coldPoolRel)
138
+ placeSkill(coldPool, 'github.com/owner/repo/skill')
139
+
140
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner/repo/skill"]\n`
141
+ const deckPath = join(projectDir, 'skill-deck.toml')
142
+ writeFileSync(deckPath, deckContent)
143
+
144
+ const result = runValidate(deckPath, projectDir)
145
+
146
+ expect(result.status).toBe(0)
147
+ expect(result.stderr).toContain('deprecated')
148
+ })
149
+
150
+ it('C8: invalid transient expires errors', () => {
151
+ const projectDir = makeTmp()
152
+ const deckPath = join(projectDir, 'skill-deck.toml')
153
+ writeFileSync(deckPath, `[deck]\nmax_cards = 10\n\n[transient.foo]\npath = "./nonexistent"\nexpires = "not-a-date"\n`)
154
+
155
+ const result = runValidate(deckPath, projectDir)
156
+
157
+ expect(result.status).toBe(1)
158
+ expect(result.stderr).toContain('invalid expires')
159
+ })
160
+ })
package/src/validate.ts CHANGED
@@ -10,6 +10,7 @@ import { parse as parseToml } from "@iarna/toml";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
  import { findDeckToml, expandHome, findSource } from "./link.js";
13
+ import { parseDeck } from "./parse-deck.js";
13
14
 
14
15
  export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
15
16
  const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
@@ -52,33 +53,27 @@ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
52
53
 
53
54
  // ── Validate skill declarations ────────────────────────────
54
55
 
56
+ const { entries: parsedEntries, deprecated: isDeprecated, errors: parseErrors } = parseDeck(deckRaw);
57
+ if (isDeprecated) {
58
+ warnings.push("string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
59
+ }
60
+ errors.push(...parseErrors);
61
+
55
62
  const declaredNames = new Set<string>();
56
63
  let declaredCount = 0;
57
64
 
58
- for (const section of ["innate", "tool", "combo"] as const) {
59
- const skills = deck[section]?.skills;
60
- if (skills === undefined) continue;
61
- if (!Array.isArray(skills)) {
62
- errors.push(`[${section}].skills must be an array`);
63
- continue;
65
+ for (const entry of parsedEntries) {
66
+ declaredCount++;
67
+ if (declaredNames.has(entry.path)) {
68
+ warnings.push(`Skill "${entry.path}" is declared in multiple sections`);
64
69
  }
65
- for (const name of skills) {
66
- if (!name || typeof name !== "string") {
67
- errors.push(`[${section}] contains invalid skill name`);
68
- continue;
69
- }
70
- declaredCount++;
71
- if (declaredNames.has(name)) {
72
- warnings.push(`Skill "${name}" is declared in multiple sections`);
73
- }
74
- declaredNames.add(name);
70
+ declaredNames.add(entry.path);
75
71
 
76
- const result = findSource(name, COLD_POOL, PROJECT_DIR);
77
- if (result.error) {
78
- errors.push(result.error);
79
- } else if (!result.path) {
80
- errors.push(`Skill not found: ${name} (${section})`);
81
- }
72
+ const result = findSource(entry.path, COLD_POOL, PROJECT_DIR);
73
+ if (result.error) {
74
+ errors.push(result.error);
75
+ } else if (!result.path) {
76
+ errors.push(`Skill not found: ${entry.path} (${entry.type})`);
82
77
  }
83
78
  }
84
79