From e78db4e3098c6ff6d23d05281faf81d7cc3fd820 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 10:50:45 -0700 Subject: [PATCH] test(helpers): shared skill-census helper with three explicit counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit physicalSkillFiles (symlinked dirs included, root router included), authoredSkills (realpath-deduped, router excluded), registryEntries (what ./setup registers: unique frontmatter names + _gstack-command). One counting authority for the hermetic seeder, context-bill ground truth, and the catalog-budget test — connect-chrome's dir symlink and the root router otherwise produce three subtly different hand-rolled censuses. Ported-wave foundation (C11). Co-Authored-By: Claude Fable 5 --- test/helpers/skill-census.ts | 96 ++++++++++++++++++++++++++++++++++++ test/skill-census.test.ts | 69 ++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 test/helpers/skill-census.ts create mode 100644 test/skill-census.test.ts diff --git a/test/helpers/skill-census.ts b/test/helpers/skill-census.ts new file mode 100644 index 000000000..378201041 --- /dev/null +++ b/test/helpers/skill-census.ts @@ -0,0 +1,96 @@ +/** + * Shared skill census — the ONE place that counts skills (C11). + * + * Three consumers (hermetic seeding, context-bill ground truth, catalog-budget + * test) each need a DIFFERENT count of "the skills", and hand-rolled walks + * encode the wrong one somewhere. The counts diverge because of two facts: + * + * 1. `connect-chrome/` is a directory SYMLINK to `open-gstack-browser/`. + * `Dirent.isDirectory()` is false for it (scripts/discover-skills.ts + * skips it) while setup's trailing-slash shell glob follows it. + * 2. The root `SKILL.md` is a router, registered by `setup` under the + * alias name `_gstack-command`, not as an authored skill. + * + * So: use `physicalSkillFiles` when you mean "every SKILL.md a filesystem + * walk can reach" (context-bill's walker), `authoredSkills` when you mean + * "distinct skills a human maintains" (catalog budget), and + * `registryEntries` when you mean "what a host discovers after ./setup" + * (hermetic seeding must mirror this exactly). + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const SKIP = new Set(['node_modules', '.git', 'dist']); + +export interface SkillCensus { + /** Every reachable `/SKILL.md` (symlinked dirs INCLUDED) plus the + * root `SKILL.md` router. Relative paths from root. */ + physicalSkillFiles: string[]; + /** Distinct authored skills: symlink-deduped by realpath, root router + * EXCLUDED. Each entry is the canonical directory name. */ + authoredSkills: string[]; + /** What `setup` registers into `~/.claude/skills/` with prefixing off: + * one entry per unique frontmatter `name:` (falling back to dir name), + * plus the `_gstack-command` root alias when the root router exists. + * Symlinked dirs collapse here because they share a frontmatter name. */ + registryEntries: string[]; +} + +/** First `name:` from SKILL.md frontmatter, mirroring `setup`'s + * `grep -m1 '^name:'` (whitespace stripped). Empty string if absent. */ +export function frontmatterName(skillMdPath: string): string { + let body: string; + try { + body = fs.readFileSync(skillMdPath, 'utf-8'); + } catch { + return ''; + } + const m = body.match(/^name:\s*(.+)$/m); + return m ? m[1].replace(/\s+/g, '') : ''; +} + +export function skillCensus(root: string): SkillCensus { + const physicalSkillFiles: string[] = []; + const authoredByRealpath = new Map(); + const registrySet = new Set(); + + if (fs.existsSync(path.join(root, 'SKILL.md'))) { + physicalSkillFiles.push('SKILL.md'); + registrySet.add('_gstack-command'); + } + + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue; + const dirPath = path.join(root, entry.name); + // Follow directory symlinks like setup's shell glob does; Dirent alone + // reports symlinks as non-directories. + let isDir = entry.isDirectory(); + if (!isDir && entry.isSymbolicLink()) { + try { + isDir = fs.statSync(dirPath).isDirectory(); + } catch { + continue; // dangling symlink + } + } + if (!isDir) continue; + + const skillMd = path.join(dirPath, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + + physicalSkillFiles.push(`${entry.name}/SKILL.md`); + + const real = fs.realpathSync(dirPath); + if (!authoredByRealpath.has(real)) { + authoredByRealpath.set(real, path.basename(real)); + } + + registrySet.add(frontmatterName(skillMd) || entry.name); + } + + return { + physicalSkillFiles: physicalSkillFiles.sort(), + authoredSkills: [...authoredByRealpath.values()].sort(), + registryEntries: [...registrySet].sort(), + }; +} diff --git a/test/skill-census.test.ts b/test/skill-census.test.ts new file mode 100644 index 000000000..b9bb21578 --- /dev/null +++ b/test/skill-census.test.ts @@ -0,0 +1,69 @@ +/** + * Pins the three-count contract of test/helpers/skill-census.ts (C11). + * + * No hardcoded totals here — the catalog-budget test owns the ratchet. + * This file pins the STRUCTURAL relationships that make the three counts + * mean different things, using the live repo as the fixture. + */ + +import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { frontmatterName, skillCensus } from './helpers/skill-census'; + +const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const census = skillCensus(ROOT); + +describe('skillCensus', () => { + it('physicalSkillFiles includes the root router and the symlinked dir', () => { + expect(census.physicalSkillFiles).toContain('SKILL.md'); + expect(census.physicalSkillFiles).toContain('connect-chrome/SKILL.md'); + expect(census.physicalSkillFiles).toContain('open-gstack-browser/SKILL.md'); + }); + + it('authoredSkills dedupes the connect-chrome symlink and excludes the root router', () => { + expect(census.authoredSkills).toContain('open-gstack-browser'); + expect(census.authoredSkills).not.toContain('connect-chrome'); + // Root router is not an authored skill; its dir entry would be '' anyway. + for (const name of census.authoredSkills) expect(name.length).toBeGreaterThan(0); + }); + + it('registryEntries carries the root alias and collapses shared frontmatter names', () => { + expect(census.registryEntries).toContain('_gstack-command'); + expect( + census.registryEntries.filter((n) => n === 'open-gstack-browser'), + ).toHaveLength(1); + }); + + it('count relationships hold: physical = authored + root + symlink dups', () => { + const symlinkDups = census.physicalSkillFiles.length - 1 - census.authoredSkills.length; + expect(symlinkDups).toBeGreaterThanOrEqual(1); // connect-chrome today + // Registry = unique frontmatter names + root alias. It can only collapse + // entries relative to physical, never invent them. + expect(census.registryEntries.length).toBeLessThanOrEqual(census.physicalSkillFiles.length); + expect(census.registryEntries.length).toBeGreaterThan(census.authoredSkills.length - 1); + }); + + it('frontmatterName mirrors setup: first ^name: line, whitespace stripped', () => { + const qa = frontmatterName(path.join(ROOT, 'qa', 'SKILL.md')); + expect(qa).toBe('qa'); + const alias = frontmatterName(path.join(ROOT, 'connect-chrome', 'SKILL.md')); + expect(alias).toBe('open-gstack-browser'); + expect(frontmatterName(path.join(ROOT, 'no-such-dir', 'SKILL.md'))).toBe(''); + }); + + it('every registry entry a host would see resolves back to a physical SKILL.md', () => { + const names = new Set( + census.physicalSkillFiles + .filter((p) => p !== 'SKILL.md') + .map((p) => frontmatterName(path.join(ROOT, p)) || path.dirname(p)), + ); + for (const entry of census.registryEntries) { + if (entry === '_gstack-command') { + expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(true); + } else { + expect(names.has(entry)).toBe(true); + } + } + }); +});