mirror of https://github.com/garrytan/gstack.git
test(helpers): shared skill-census helper with three explicit counts
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 <noreply@anthropic.com>
This commit is contained in:
parent
d078622b73
commit
e78db4e309
|
|
@ -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 `<dir>/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<string, string>();
|
||||||
|
const registrySet = new Set<string>();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue