@rune-kit/rune 2.2.2 → 2.2.4

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.
Files changed (45) hide show
  1. package/compiler/__tests__/adapters.test.js +109 -0
  2. package/compiler/__tests__/openclaw-adapter.test.js +149 -140
  3. package/compiler/__tests__/pack-split.test.js +145 -145
  4. package/compiler/__tests__/parser.test.js +3 -3
  5. package/compiler/__tests__/pipeline.test.js +110 -0
  6. package/compiler/__tests__/skill-validation.test.js +148 -0
  7. package/compiler/__tests__/transformer.test.js +70 -0
  8. package/compiler/__tests__/transforms.test.js +92 -0
  9. package/compiler/adapters/antigravity.js +5 -6
  10. package/compiler/adapters/claude.js +1 -1
  11. package/compiler/adapters/codex.js +69 -77
  12. package/compiler/adapters/cursor.js +5 -6
  13. package/compiler/adapters/generic.js +5 -6
  14. package/compiler/adapters/index.js +3 -3
  15. package/compiler/adapters/openclaw.js +146 -150
  16. package/compiler/adapters/opencode.js +78 -86
  17. package/compiler/adapters/windsurf.js +5 -6
  18. package/compiler/bin/rune.js +32 -23
  19. package/compiler/doctor.js +19 -7
  20. package/compiler/emitter.js +11 -18
  21. package/compiler/parser.js +5 -5
  22. package/compiler/transformer.js +3 -5
  23. package/compiler/transforms/branding.js +5 -4
  24. package/compiler/transforms/compliance.js +40 -40
  25. package/compiler/transforms/frontmatter.js +1 -1
  26. package/compiler/transforms/hooks.js +12 -14
  27. package/compiler/transforms/subagents.js +1 -3
  28. package/compiler/transforms/tool-names.js +1 -1
  29. package/docs/guides/index.html +48 -7
  30. package/docs/index.html +67 -2
  31. package/docs/style.css +18 -1
  32. package/extensions/security/PACK.md +4 -3
  33. package/extensions/security/skills/defense-in-depth.md +103 -0
  34. package/package.json +8 -1
  35. package/skills/completion-gate/SKILL.md +31 -1
  36. package/skills/cook/SKILL.md +20 -2
  37. package/skills/debug/SKILL.md +16 -1
  38. package/skills/hallucination-guard/SKILL.md +21 -6
  39. package/skills/plan/SKILL.md +90 -1
  40. package/skills/review/SKILL.md +1 -0
  41. package/skills/sentinel-env/SKILL.md +31 -1
  42. package/skills/skill-forge/SKILL.md +57 -1
  43. package/skills/team/SKILL.md +61 -15
  44. package/skills/test/SKILL.md +101 -10
  45. package/skills/verification/SKILL.md +29 -1
@@ -1,145 +1,145 @@
1
- import { parsePack, parseSkill } from '../parser.js';
2
- import { test } from 'node:test';
3
- import assert from 'node:assert';
4
- import { readFileSync, existsSync } from 'node:fs';
5
- import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- const EXTENSIONS_DIR = path.resolve(__dirname, '../../extensions');
10
-
11
- // --- Monolith format (regression) ---
12
-
13
- test('parsePack: monolith format has isSplit=false', () => {
14
- const content = `---
15
- name: "@rune/trading"
16
- description: "Trading patterns"
17
- metadata:
18
- version: "0.2.0"
19
- layer: L4
20
- price: free
21
- target: Traders
22
- ---
23
-
24
- # @rune/trading
25
-
26
- ## Purpose
27
- Trading pack.
28
-
29
- ## Skills Included
30
-
31
- ### risk-management
32
- Risk management workflow.
33
- `;
34
-
35
- const parsed = parsePack(content, 'extensions/trading/PACK.md');
36
-
37
- assert.strictEqual(parsed.isSplit, false);
38
- assert.deepStrictEqual(parsed.skillManifest, []);
39
- assert.strictEqual(parsed.name, '@rune/trading');
40
- assert.strictEqual(parsed.version, '0.2.0');
41
- assert.strictEqual(parsed.layer, 'L4');
42
- assert.ok(parsed.body.includes('risk-management'));
43
- });
44
-
45
- test('parsePack: monolith without metadata block defaults correctly', () => {
46
- const content = `---
47
- name: "@rune/test"
48
- description: "Test pack"
49
- ---
50
-
51
- # @rune/test
52
- Body here.
53
- `;
54
-
55
- const parsed = parsePack(content, 'test/PACK.md');
56
-
57
- assert.strictEqual(parsed.isSplit, false);
58
- assert.strictEqual(parsed.version, '1.0.0');
59
- });
60
-
61
- // --- Split format detection ---
62
-
63
- test('parsePack: split format detected via metadata.format', () => {
64
- const content = `---
65
- name: "@rune/backend"
66
- description: "Backend patterns"
67
- metadata:
68
- version: "0.3.0"
69
- layer: L4
70
- price: free
71
- target: Backend developers
72
- format: split
73
- ---
74
-
75
- # @rune/backend
76
-
77
- ## Purpose
78
- Backend pack index.
79
-
80
- ## Skills Included
81
- | Skill | Model | Description |
82
- |-------|-------|-------------|
83
- | api-design | sonnet | API patterns |
84
- | auth | sonnet | Auth patterns |
85
- `;
86
-
87
- const parsed = parsePack(content, 'extensions/backend/PACK.md');
88
-
89
- assert.strictEqual(parsed.isSplit, true);
90
- assert.strictEqual(parsed.version, '0.3.0');
91
- assert.strictEqual(parsed.name, '@rune/backend');
92
- });
93
-
94
- test('parsePack: skill manifest parsed from string array', () => {
95
- // Note: our YAML parser is simple — it handles nested objects but not arrays.
96
- // This test validates the parseSkillManifest function directly with object skills.
97
- const content = `---
98
- name: "@rune/test"
99
- description: "Test"
100
- metadata:
101
- format: split
102
- ---
103
-
104
- Body.
105
- `;
106
-
107
- const parsed = parsePack(content, 'test/PACK.md');
108
- assert.strictEqual(parsed.isSplit, true);
109
- // With simple YAML parser, skills array won't parse — manifest will be empty
110
- // Real usage will use the skills/ directory detection as fallback
111
- assert.ok(Array.isArray(parsed.skillManifest));
112
- });
113
-
114
- // --- Real pack regression: existing monolith packs parse correctly ---
115
-
116
- test('parsePack: real trading PACK.md parses as split', () => {
117
- const tradingPath = path.join(EXTENSIONS_DIR, 'trading', 'PACK.md');
118
- if (!existsSync(tradingPath)) {
119
- console.log(' skip: trading PACK.md not found');
120
- return;
121
- }
122
-
123
- const content = readFileSync(tradingPath, 'utf-8');
124
- const parsed = parsePack(content, tradingPath);
125
-
126
- assert.strictEqual(parsed.isSplit, true);
127
- assert.strictEqual(parsed.layer, 'L4');
128
- assert.strictEqual(parsed.group, 'extension');
129
- assert.ok(parsed.body.length > 50);
130
- });
131
-
132
- test('parsePack: real backend PACK.md parses as split (post-split)', () => {
133
- const backendPath = path.join(EXTENSIONS_DIR, 'backend', 'PACK.md');
134
- if (!existsSync(backendPath)) {
135
- console.log(' skip: backend PACK.md not found');
136
- return;
137
- }
138
-
139
- const content = readFileSync(backendPath, 'utf-8');
140
- const parsed = parsePack(content, backendPath);
141
-
142
- assert.strictEqual(parsed.isSplit, true);
143
- assert.strictEqual(parsed.layer, 'L4');
144
- assert.ok(parsed.sections.size > 0);
145
- });
1
+ import assert from 'node:assert';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { test } from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parsePack } from '../parser.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const EXTENSIONS_DIR = path.resolve(__dirname, '../../extensions');
10
+
11
+ // --- Monolith format (regression) ---
12
+
13
+ test('parsePack: monolith format has isSplit=false', () => {
14
+ const content = `---
15
+ name: "@rune/trading"
16
+ description: "Trading patterns"
17
+ metadata:
18
+ version: "0.2.0"
19
+ layer: L4
20
+ price: free
21
+ target: Traders
22
+ ---
23
+
24
+ # @rune/trading
25
+
26
+ ## Purpose
27
+ Trading pack.
28
+
29
+ ## Skills Included
30
+
31
+ ### risk-management
32
+ Risk management workflow.
33
+ `;
34
+
35
+ const parsed = parsePack(content, 'extensions/trading/PACK.md');
36
+
37
+ assert.strictEqual(parsed.isSplit, false);
38
+ assert.deepStrictEqual(parsed.skillManifest, []);
39
+ assert.strictEqual(parsed.name, '@rune/trading');
40
+ assert.strictEqual(parsed.version, '0.2.0');
41
+ assert.strictEqual(parsed.layer, 'L4');
42
+ assert.ok(parsed.body.includes('risk-management'));
43
+ });
44
+
45
+ test('parsePack: monolith without metadata block defaults correctly', () => {
46
+ const content = `---
47
+ name: "@rune/test"
48
+ description: "Test pack"
49
+ ---
50
+
51
+ # @rune/test
52
+ Body here.
53
+ `;
54
+
55
+ const parsed = parsePack(content, 'test/PACK.md');
56
+
57
+ assert.strictEqual(parsed.isSplit, false);
58
+ assert.strictEqual(parsed.version, '1.0.0');
59
+ });
60
+
61
+ // --- Split format detection ---
62
+
63
+ test('parsePack: split format detected via metadata.format', () => {
64
+ const content = `---
65
+ name: "@rune/backend"
66
+ description: "Backend patterns"
67
+ metadata:
68
+ version: "0.3.0"
69
+ layer: L4
70
+ price: free
71
+ target: Backend developers
72
+ format: split
73
+ ---
74
+
75
+ # @rune/backend
76
+
77
+ ## Purpose
78
+ Backend pack index.
79
+
80
+ ## Skills Included
81
+ | Skill | Model | Description |
82
+ |-------|-------|-------------|
83
+ | api-design | sonnet | API patterns |
84
+ | auth | sonnet | Auth patterns |
85
+ `;
86
+
87
+ const parsed = parsePack(content, 'extensions/backend/PACK.md');
88
+
89
+ assert.strictEqual(parsed.isSplit, true);
90
+ assert.strictEqual(parsed.version, '0.3.0');
91
+ assert.strictEqual(parsed.name, '@rune/backend');
92
+ });
93
+
94
+ test('parsePack: skill manifest parsed from string array', () => {
95
+ // Note: our YAML parser is simple — it handles nested objects but not arrays.
96
+ // This test validates the parseSkillManifest function directly with object skills.
97
+ const content = `---
98
+ name: "@rune/test"
99
+ description: "Test"
100
+ metadata:
101
+ format: split
102
+ ---
103
+
104
+ Body.
105
+ `;
106
+
107
+ const parsed = parsePack(content, 'test/PACK.md');
108
+ assert.strictEqual(parsed.isSplit, true);
109
+ // With simple YAML parser, skills array won't parse — manifest will be empty
110
+ // Real usage will use the skills/ directory detection as fallback
111
+ assert.ok(Array.isArray(parsed.skillManifest));
112
+ });
113
+
114
+ // --- Real pack regression: existing monolith packs parse correctly ---
115
+
116
+ test('parsePack: real trading PACK.md parses as split', () => {
117
+ const tradingPath = path.join(EXTENSIONS_DIR, 'trading', 'PACK.md');
118
+ if (!existsSync(tradingPath)) {
119
+ console.log(' skip: trading PACK.md not found');
120
+ return;
121
+ }
122
+
123
+ const content = readFileSync(tradingPath, 'utf-8');
124
+ const parsed = parsePack(content, tradingPath);
125
+
126
+ assert.strictEqual(parsed.isSplit, true);
127
+ assert.strictEqual(parsed.layer, 'L4');
128
+ assert.strictEqual(parsed.group, 'extension');
129
+ assert.ok(parsed.body.length > 50);
130
+ });
131
+
132
+ test('parsePack: real backend PACK.md parses as split (post-split)', () => {
133
+ const backendPath = path.join(EXTENSIONS_DIR, 'backend', 'PACK.md');
134
+ if (!existsSync(backendPath)) {
135
+ console.log(' skip: backend PACK.md not found');
136
+ return;
137
+ }
138
+
139
+ const content = readFileSync(backendPath, 'utf-8');
140
+ const parsed = parsePack(content, backendPath);
141
+
142
+ assert.strictEqual(parsed.isSplit, true);
143
+ assert.strictEqual(parsed.layer, 'L4');
144
+ assert.ok(parsed.sections.size > 0);
145
+ });
@@ -1,9 +1,9 @@
1
- import { parseSkill } from '../parser.js';
2
- import { readFileSync } from 'node:fs';
3
- import { test } from 'node:test';
4
1
  import assert from 'node:assert';
2
+ import { readFileSync } from 'node:fs';
5
3
  import path from 'node:path';
4
+ import { test } from 'node:test';
6
5
  import { fileURLToPath } from 'node:url';
6
+ import { parseSkill } from '../parser.js';
7
7
 
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const SKILLS_DIR = path.resolve(__dirname, '../../skills');
@@ -0,0 +1,110 @@
1
+ /**
2
+ * E2E Pipeline Tests
3
+ *
4
+ * Tests the full compile pipeline: parse → transform → assemble output.
5
+ * Verifies that real skills produce valid output for each platform adapter.
6
+ */
7
+
8
+ import assert from 'node:assert';
9
+ import { readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { describe, test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { getAdapter, listPlatforms } from '../adapters/index.js';
14
+ import { parseSkill } from '../parser.js';
15
+ import { transformSkill } from '../transformer.js';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const SKILLS_DIR = path.resolve(__dirname, '../../skills');
19
+
20
+ // Use a real skill (cook) for e2e tests
21
+ const cookContent = readFileSync(path.join(SKILLS_DIR, 'cook/SKILL.md'), 'utf-8');
22
+ const cookParsed = parseSkill(cookContent, 'cook/SKILL.md');
23
+
24
+ const fixContent = readFileSync(path.join(SKILLS_DIR, 'fix/SKILL.md'), 'utf-8');
25
+ const fixParsed = parseSkill(fixContent, 'fix/SKILL.md');
26
+
27
+ // --- Full pipeline per platform ---
28
+
29
+ describe('full pipeline: cook skill', () => {
30
+ for (const platform of listPlatforms()) {
31
+ test(`${platform}: parse → transform → output`, () => {
32
+ const adapter = getAdapter(platform);
33
+ const result = transformSkill(cookParsed, adapter);
34
+
35
+ // Result structure
36
+ assert.ok(typeof result.header === 'string', `${platform}: header not string`);
37
+ assert.ok(typeof result.body === 'string', `${platform}: body not string`);
38
+ assert.ok(typeof result.footer === 'string', `${platform}: footer not string`);
39
+
40
+ // Body should contain meaningful content
41
+ assert.ok(result.body.length > 500, `${platform}: body too short (${result.body.length})`);
42
+
43
+ if (platform === 'claude') {
44
+ // Claude: passthrough, no header/footer
45
+ assert.strictEqual(result.header, '');
46
+ assert.strictEqual(result.footer, '');
47
+ // Should still have original cross-refs
48
+ assert.ok(result.body.includes('rune:'), `claude: should preserve rune: cross-refs`);
49
+ } else {
50
+ // Non-Claude: should have transformed cross-refs
51
+ assert.ok(!result.body.includes('`rune:cook`'), `${platform}: should not have backticked rune:cook cross-ref`);
52
+
53
+ // Should have footer with branding
54
+ assert.ok(result.footer.includes('Rune'), `${platform}: footer missing branding`);
55
+ }
56
+ });
57
+ }
58
+ });
59
+
60
+ describe('full pipeline: fix skill', () => {
61
+ test('cursor: cross-refs transformed to .mdc', () => {
62
+ const adapter = getAdapter('cursor');
63
+ const result = transformSkill(fixParsed, adapter);
64
+
65
+ // Fix references cook, debug, test, etc. — at least some should be transformed
66
+ assert.ok(result.body.includes('.mdc'), 'cursor output should contain .mdc references');
67
+ });
68
+
69
+ test('generic: cross-refs transformed to descriptive text', () => {
70
+ const adapter = getAdapter('generic');
71
+ const result = transformSkill(fixParsed, adapter);
72
+ assert.ok(result.body.includes('rule file'), 'generic output should contain "rule file" references');
73
+ });
74
+
75
+ test('openclaw: cross-refs transformed to .md file refs', () => {
76
+ const adapter = getAdapter('openclaw');
77
+ const result = transformSkill(fixParsed, adapter);
78
+ assert.ok(result.body.includes('.md'), 'openclaw output should contain .md references');
79
+ });
80
+ });
81
+
82
+ // --- Output assembly (header + body + footer) ---
83
+
84
+ describe('output assembly', () => {
85
+ test('cursor output assembles into valid .mdc file', () => {
86
+ const adapter = getAdapter('cursor');
87
+ const result = transformSkill(cookParsed, adapter);
88
+ const assembled = result.header + result.body + result.footer;
89
+
90
+ // Should start with YAML frontmatter
91
+ assert.ok(assembled.startsWith('---\n'), 'cursor .mdc should start with YAML frontmatter');
92
+ assert.ok(assembled.includes('description:'), 'cursor .mdc should have description');
93
+ assert.ok(assembled.includes('alwaysApply:'), 'cursor .mdc should have alwaysApply');
94
+ });
95
+
96
+ test('codex output has YAML frontmatter with name', () => {
97
+ const adapter = getAdapter('codex');
98
+ const result = transformSkill(cookParsed, adapter);
99
+ const assembled = result.header + result.body + result.footer;
100
+
101
+ assert.ok(assembled.includes('name: rune-cook'), 'codex should have name: rune-cook');
102
+ });
103
+
104
+ test('generic output has markdown heading', () => {
105
+ const adapter = getAdapter('generic');
106
+ const result = transformSkill(cookParsed, adapter);
107
+
108
+ assert.ok(result.header.includes('# rune-cook'), 'generic header should have # rune-cook');
109
+ });
110
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Skill Validation Tests
3
+ *
4
+ * Verifies ALL 58 SKILL.md files parse correctly and meet structural requirements.
5
+ * This is the "mesh integrity" test — if any skill breaks parsing, mesh connections fail.
6
+ */
7
+
8
+ import assert from 'node:assert';
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { describe, test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { parseSkill } from '../parser.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const SKILLS_DIR = path.resolve(__dirname, '../../skills');
17
+
18
+ // Discover all skill directories
19
+ const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true })
20
+ .filter((d) => d.isDirectory())
21
+ .map((d) => d.name)
22
+ .sort();
23
+
24
+ const VALID_LAYERS = ['L0', 'L1', 'L2', 'L3'];
25
+ const VALID_MODELS = ['haiku', 'sonnet', 'opus'];
26
+
27
+ // --- Discovery ---
28
+
29
+ test(`discovers all 58 core skills`, () => {
30
+ assert.ok(skillDirs.length >= 58, `expected >=58 skills, found ${skillDirs.length}`);
31
+ });
32
+
33
+ // --- Per-skill validation ---
34
+
35
+ describe('skill parsing', () => {
36
+ for (const skillName of skillDirs) {
37
+ const skillFile = path.join(SKILLS_DIR, skillName, 'SKILL.md');
38
+
39
+ test(`${skillName}/SKILL.md exists`, () => {
40
+ assert.ok(existsSync(skillFile), `missing SKILL.md for ${skillName}`);
41
+ });
42
+
43
+ test(`${skillName} parses without errors`, () => {
44
+ const content = readFileSync(skillFile, 'utf-8');
45
+ const parsed = parseSkill(content, `${skillName}/SKILL.md`);
46
+
47
+ // Must have a name
48
+ assert.ok(parsed.name, `${skillName}: missing name`);
49
+ assert.strictEqual(parsed.name, skillName, `${skillName}: parsed name mismatch`);
50
+ });
51
+
52
+ test(`${skillName} has valid layer`, () => {
53
+ const content = readFileSync(skillFile, 'utf-8');
54
+ const parsed = parseSkill(content, `${skillName}/SKILL.md`);
55
+ assert.ok(VALID_LAYERS.includes(parsed.layer), `${skillName}: invalid layer "${parsed.layer}"`);
56
+ });
57
+
58
+ test(`${skillName} has valid model`, () => {
59
+ const content = readFileSync(skillFile, 'utf-8');
60
+ const parsed = parseSkill(content, `${skillName}/SKILL.md`);
61
+ if (parsed.model) {
62
+ assert.ok(VALID_MODELS.includes(parsed.model), `${skillName}: invalid model "${parsed.model}"`);
63
+ }
64
+ });
65
+
66
+ test(`${skillName} has description`, () => {
67
+ const content = readFileSync(skillFile, 'utf-8');
68
+ const parsed = parseSkill(content, `${skillName}/SKILL.md`);
69
+ assert.ok(parsed.description && parsed.description.length > 10, `${skillName}: missing or too short description`);
70
+ });
71
+
72
+ test(`${skillName} has non-empty body`, () => {
73
+ const content = readFileSync(skillFile, 'utf-8');
74
+ const parsed = parseSkill(content, `${skillName}/SKILL.md`);
75
+ assert.ok(
76
+ parsed.body && parsed.body.length > 100,
77
+ `${skillName}: body too short (${parsed.body?.length || 0} chars)`,
78
+ );
79
+ });
80
+ }
81
+ });
82
+
83
+ // --- Mesh integrity ---
84
+
85
+ describe('mesh integrity', () => {
86
+ const allParsed = skillDirs.map((name) => {
87
+ const content = readFileSync(path.join(SKILLS_DIR, name, 'SKILL.md'), 'utf-8');
88
+ return parseSkill(content, `${name}/SKILL.md`);
89
+ });
90
+ const allNames = new Set(allParsed.map((s) => s.name));
91
+
92
+ test('all string cross-references point to existing skills', () => {
93
+ const broken = [];
94
+ for (const skill of allParsed) {
95
+ for (const ref of skill.crossRefs) {
96
+ // Skip non-string refs (parser quirk for some metadata formats)
97
+ if (typeof ref !== 'string') continue;
98
+ if (!allNames.has(ref)) {
99
+ broken.push(`${skill.name} → ${ref}`);
100
+ }
101
+ }
102
+ }
103
+ assert.strictEqual(broken.length, 0, `broken cross-refs:\n ${broken.join('\n ')}`);
104
+ });
105
+
106
+ test('L0 layer has exactly 1 skill (skill-router)', () => {
107
+ const l0 = allParsed.filter((s) => s.layer === 'L0');
108
+ assert.strictEqual(l0.length, 1);
109
+ assert.strictEqual(l0[0].name, 'skill-router');
110
+ });
111
+
112
+ test('L1 orchestrators exist', () => {
113
+ const l1 = allParsed.filter((s) => s.layer === 'L1');
114
+ const l1Names = l1.map((s) => s.name);
115
+ for (const expected of ['cook', 'team', 'launch', 'rescue', 'scaffold']) {
116
+ assert.ok(l1Names.includes(expected), `missing L1 orchestrator: ${expected}`);
117
+ }
118
+ });
119
+
120
+ test('mesh has 200+ total cross-references', () => {
121
+ const totalRefs = allParsed.reduce((sum, s) => sum + s.crossRefs.length, 0);
122
+ assert.ok(totalRefs >= 200, `expected >=200 cross-refs, found ${totalRefs}`);
123
+ });
124
+
125
+ test('no true orphan skills (every skill is referenced by at least one other)', () => {
126
+ // Collect all names referenced via crossRefs OR mentioned in body text as rune:name
127
+ const referenced = new Set();
128
+ const refPattern = /rune:([a-z][\w-]*)/g;
129
+ for (const skill of allParsed) {
130
+ // From parsed crossRefs
131
+ for (const ref of skill.crossRefs) {
132
+ if (typeof ref === 'string') referenced.add(ref);
133
+ }
134
+ // From body text mentions
135
+ let match;
136
+ while ((match = refPattern.exec(skill.body)) !== null) {
137
+ referenced.add(match[1]);
138
+ }
139
+ }
140
+ // Skills that are dispatched dynamically by skill-router (not via explicit rune:X refs)
141
+ const dynamicDispatch = new Set(['constraint-check', 'context-engine', 'sast', 'scope-guard', 'worktree']);
142
+ // A skill is orphan only if it's never referenced and not dynamically dispatched
143
+ const orphans = allParsed.filter(
144
+ (s) => !referenced.has(s.name) && !dynamicDispatch.has(s.name) && s.layer !== 'L0',
145
+ );
146
+ assert.strictEqual(orphans.length, 0, `orphan skills (never referenced): ${orphans.map((s) => s.name).join(', ')}`);
147
+ });
148
+ });
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert';
2
+ import { describe, test } from 'node:test';
3
+ import { getAdapter } from '../adapters/index.js';
4
+ import { transformSkill } from '../transformer.js';
5
+
6
+ // --- Transformer pipeline ---
7
+
8
+ describe('transformSkill', () => {
9
+ const mockSkill = {
10
+ name: 'fix',
11
+ layer: 'L2',
12
+ group: 'workflow',
13
+ description: 'Apply code fixes',
14
+ body: '# fix\n\nUse `rune:cook` for orchestration.\n\nUse `Read` to check files.\n\n## Steps\n\n1. Analyze\n2. Fix\n3. Verify',
15
+ crossRefs: ['cook'],
16
+ toolRefs: ['Read'],
17
+ hardGates: [],
18
+ frontmatter: { layer: 'L2', model: 'sonnet' },
19
+ sections: new Map(),
20
+ };
21
+
22
+ test('Claude adapter returns body unchanged', () => {
23
+ const claude = getAdapter('claude');
24
+ const result = transformSkill(mockSkill, claude);
25
+ assert.strictEqual(result.body, mockSkill.body);
26
+ assert.strictEqual(result.header, '');
27
+ assert.strictEqual(result.footer, '');
28
+ });
29
+
30
+ test('Cursor adapter transforms cross-refs to .mdc format', () => {
31
+ const cursor = getAdapter('cursor');
32
+ const result = transformSkill(mockSkill, cursor);
33
+ assert.ok(result.body.includes('@rune-cook.mdc'));
34
+ assert.ok(!result.body.includes('rune:cook'));
35
+ });
36
+
37
+ test('result has header, body, and footer', () => {
38
+ const cursor = getAdapter('cursor');
39
+ const result = transformSkill(mockSkill, cursor);
40
+ assert.ok(typeof result.header === 'string');
41
+ assert.ok(typeof result.body === 'string');
42
+ assert.ok(typeof result.footer === 'string');
43
+ assert.ok(result.header.length > 0, 'header should not be empty for non-Claude');
44
+ assert.ok(result.footer.length > 0, 'footer should not be empty for non-Claude');
45
+ });
46
+
47
+ test('Cursor header contains frontmatter with description', () => {
48
+ const cursor = getAdapter('cursor');
49
+ const result = transformSkill(mockSkill, cursor);
50
+ assert.ok(result.header.includes('description:'));
51
+ assert.ok(result.header.includes('alwaysApply:'));
52
+ });
53
+
54
+ test('Generic adapter transforms refs to descriptive text', () => {
55
+ const generic = getAdapter('generic');
56
+ const result = transformSkill(mockSkill, generic);
57
+ assert.ok(result.body.includes('rune-cook rule file'));
58
+ });
59
+
60
+ test('postProcess strips context: fork and agent: general-purpose', () => {
61
+ const skillWithDirectives = {
62
+ ...mockSkill,
63
+ body: 'context: fork\nagent: general-purpose\n# fix\n\nContent here.',
64
+ };
65
+ const cursor = getAdapter('cursor');
66
+ const result = transformSkill(skillWithDirectives, cursor);
67
+ assert.ok(!result.body.includes('context: fork'));
68
+ assert.ok(!result.body.includes('agent: general-purpose'));
69
+ });
70
+ });