/** * Claude installer runtime-asset coverage (#2317 / #2454). * * `link_claude_skill_dirs` historically installed only SKILL.md (+ sections/) * per skill, so every skill that reads a sibling runtime file at * `.claude/skills//` — review's checklist.md + specialists/, qa's * templates/ + references/, gstack-upgrade's migrations/, careful/freeze's * bin/ — was broken on a fresh Claude install. This suite runs the REAL * installer functions (extracted from `setup`) against the live repo into a * temp skills dir and asserts the install is complete. * * Two-class referenced-paths assertion (eng review ENG-OV7): * - Class 1 (alias-relative): a `.claude/skills//` reference * in an INSTALLED SKILL.md must resolve under the install dir. These are * runtime reads against the flattened alias — a miss is a broken skill. * - Class 2 (repo-anchored): a `~/.claude/skills/gstack/` * reference must exist in the source tree, EXCEPT built artifacts * (browse/dist, design/dist, make-pdf/dist, the compiled * bin/gstack-global-discover) — the free suite never builds binaries, so * a naive "every path exists" either false-fails on dist or gets watered * down to uselessness. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; const ROOT = path.resolve(import.meta.dir, '..'); const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); /** Built-at-setup artifacts: allowed to be absent from a fresh clone. */ const BUILT_ARTIFACT_ALLOWLIST = [ 'browse/dist/', 'design/dist/', 'make-pdf/dist/', 'bin/gstack-global-discover', // compiled from bin/gstack-global-discover.ts at build time ]; /** * Repo-anchored references that are KNOWN BROKEN on the current tree. * Each entry must name the fix that removes it. An empty list is the goal — * do not add entries without an issue + a scheduled fix. */ const KNOWN_BROKEN_CLASS2: Record = { // (empty — #2250's bare bin names were the last entries; keep it that way) }; /** Extract a named shell function body (through its closing brace) from setup. */ function extractFn(name: string): string { const start = SETUP_SRC.indexOf(`${name}() {`); const end = SETUP_SRC.indexOf('\n}\n', start); if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); return SETUP_SRC.slice(start, end + 2); } const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-claude-install-')); beforeAll(() => { const script = [ 'set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', 'QUIET=1', '_WINDOWS_COPY_NOTE_PRINTED=1', extractFn('_link_or_copy'), extractFn('_print_windows_copy_note_once'), extractFn('_link_skill_runtime_assets'), extractFn('link_claude_skill_dirs'), `link_claude_skill_dirs "${ROOT}" "${installDir}"`, ].join('\n'); const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 }); if (result.status !== 0) { throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`); } }); afterAll(() => { // rmSync does not follow symlinks — the repo sources the links point at survive. fs.rmSync(installDir, { recursive: true, force: true }); }); function installedSkillDirs(): string[] { return fs .readdirSync(installDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) .filter((name) => fs.existsSync(path.join(installDir, name, 'SKILL.md'))); } describe('link_claude_skill_dirs installs every runtime asset (#2317, #2454)', () => { test('review skill ships its full runtime asset set', () => { const review = path.join(installDir, 'review'); for (const asset of [ 'checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md', 'specialists', ]) { expect(fs.existsSync(path.join(review, asset))).toBe(true); } // specialists/ resolves to real content, not an empty shell const specialists = fs.readdirSync(path.join(review, 'specialists')); expect(specialists.length).toBeGreaterThan(0); expect(specialists).toContain('testing.md'); }); test('the #2454 affected-skills table is fully installed', () => { const expected: Array<[string, string]> = [ ['qa', 'references'], ['qa', 'templates'], ['plan-devex-review', 'dx-hall-of-fame.md'], ['gstack-upgrade', 'migrations'], ['careful', 'bin'], ['freeze', 'bin'], ]; for (const [skill, asset] of expected) { expect(fs.existsSync(path.join(installDir, skill, asset))).toBe(true); } }); test('sections/ still installs for carved skills', () => { expect(fs.existsSync(path.join(installDir, 'ship', 'sections'))).toBe(true); expect( fs.readdirSync(path.join(installDir, 'ship', 'sections')).length, ).toBeGreaterThan(0); }); test('exclusion list holds: no node_modules, dist, test, or .tmpl installed', () => { for (const skill of installedSkillDirs()) { const entries = fs.readdirSync(path.join(installDir, skill)); expect(entries).not.toContain('node_modules'); expect(entries).not.toContain('dist'); expect(entries).not.toContain('test'); const tmpl = entries.filter((e) => e.endsWith('.tmpl')); expect(tmpl).toEqual([]); } }); test('hidden files are not installed', () => { for (const skill of installedSkillDirs()) { const hidden = fs .readdirSync(path.join(installDir, skill)) .filter((e) => e.startsWith('.')); expect(hidden).toEqual([]); } }); }); // --------------------------------------------------------------------------- // Two-class referenced-paths assertion (ENG-OV7) // --------------------------------------------------------------------------- interface Ref { fromSkill: string; skillName: string; rel: string; } const REF_RE = /~?\.claude\/skills\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.\/-]+)/g; /** Placeholder-ish captures (globs, template vars, examples) are prose, not paths. */ function isConcretePath(raw: string): boolean { return !/[<>*$(){}|]/.test(raw) && !raw.includes('..'); } function collectRefs(): Ref[] { const refs: Ref[] = []; for (const skill of installedSkillDirs()) { const content = fs.readFileSync(path.join(installDir, skill, 'SKILL.md'), 'utf-8'); for (const m of content.matchAll(REF_RE)) { const rel = m[2].replace(/[.,:;/]+$/, ''); if (!rel || !isConcretePath(rel)) continue; refs.push({ fromSkill: skill, skillName: m[1], rel }); } } return refs; } describe('two-class referenced-paths (ENG-OV7)', () => { test('class 1: alias-relative references resolve under the install dir', () => { const missing: string[] = []; for (const { fromSkill, skillName, rel } of collectRefs()) { if (skillName === 'gstack') continue; // class 2 // Prefix-mode prose may reference gstack-; the flat install dir // is the unprefixed name. const candidates = [skillName, skillName.replace(/^gstack-/, '')]; const found = candidates.some((c) => fs.existsSync(path.join(installDir, c, rel))); if (!found) missing.push(`${fromSkill}/SKILL.md → .claude/skills/${skillName}/${rel}`); } expect(missing).toEqual([]); }); test('class 2: repo-anchored references exist in the tree (modulo built artifacts)', () => { const missing: string[] = []; for (const { fromSkill, skillName, rel } of collectRefs()) { if (skillName !== 'gstack') continue; // class 1 if (rel.startsWith('.')) continue; // runtime state markers (.feature-prompted-*, .git) if (BUILT_ARTIFACT_ALLOWLIST.some((a) => rel === a || rel.startsWith(a))) continue; if (KNOWN_BROKEN_CLASS2[rel]) continue; if (!fs.existsSync(path.join(ROOT, rel))) { missing.push(`${fromSkill}/SKILL.md → ~/.claude/skills/gstack/${rel}`); } } expect(missing).toEqual([]); }); test('the referenced-path scan actually sees the review checklist refs (self-check)', () => { // Guard against the extraction regex silently rotting: the review skill is // KNOWN to carry alias-relative refs; if the scanner stops seeing them the // class-1 assertion is vacuous. const class1 = collectRefs().filter((r) => r.skillName !== 'gstack'); expect(class1.length).toBeGreaterThan(0); expect(class1.some((r) => r.skillName === 'review' && r.rel === 'checklist.md')).toBe(true); }); test('KNOWN_BROKEN_CLASS2 entries are still actually broken (ratchet)', () => { // When a fix lands, its entry MUST be removed so the class-2 assertion // guards the path again. for (const rel of Object.keys(KNOWN_BROKEN_CLASS2)) { expect(fs.existsSync(path.join(ROOT, rel))).toBe(false); } }); });