Files
gstack/test/llms-txt-shape.test.ts
Garry Tan 04a813e21f feat(gstack): generate llms.txt — single-file capability index for AI agents
Adds scripts/gen-llms-txt.ts: produces gstack/llms.txt at repo root,
indexing every skill (47), every browse command (75), and design
commands when the design CLI is present. Per the llmstxt.org
convention, agents can read one file to learn what gstack offers
instead of crawling 47 SKILL.md files.

Sources:
- skill SKILL.md.tmpl frontmatter (name + description block scalar)
- browse/src/commands.ts COMMAND_DESCRIPTIONS (sorted by category)
- design/src/commands.ts COMMAND_DESCRIPTIONS if present (best-effort)

Wired into scripts/gen-skill-docs.ts as a post-step so it regenerates
on every `bun run gen:skill-docs` (the same script that re-emits all
SKILL.md files). Failures are non-fatal warnings, not build breaks —
the generator never blocks SKILL.md regen.

Strict mode (--strict, also used by tests) throws when a skill is
missing name or description in its frontmatter, catching missing
metadata before it ships.

Tests: shape (top-level sections, sort order, single-line summary
discipline), every-skill-and-command-appears, strict-mode rejection of
incomplete frontmatter, and freshness check that the committed
gstack/llms.txt matches what the generator produces now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:35:49 -07:00

103 lines
4.3 KiB
TypeScript

import { describe, test, expect, beforeAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { generateLlmsTxt } from '../scripts/gen-llms-txt';
import { discoverTemplates } from '../scripts/discover-skills';
const ROOT = path.resolve(import.meta.dir, '..');
let generated: Awaited<ReturnType<typeof generateLlmsTxt>>;
beforeAll(async () => {
generated = await generateLlmsTxt({ root: ROOT });
});
describe('gen-llms-txt — shape', () => {
test('emits required top-level sections', () => {
expect(generated.content).toContain('# gstack');
expect(generated.content).toContain('## Skills');
expect(generated.content).toContain('## Browse Commands');
// Convention block
expect(generated.content).toContain('Skills are invoked by name');
expect(generated.content).toContain('Browse commands run as');
// Footer
expect(generated.content).toContain('## More');
expect(generated.content).toContain('auto-generated');
});
test('every skill .tmpl in the repo appears in the index', () => {
const templates = discoverTemplates(ROOT);
// Filter to those that successfully parsed (have name + description).
expect(generated.skills.length).toBeGreaterThan(0);
expect(generated.skills.length).toBeLessThanOrEqual(templates.length);
for (const skill of generated.skills) {
expect(generated.content).toMatch(new RegExp(`/${skill.name}\\b`));
}
});
test('every browse command in COMMAND_DESCRIPTIONS appears in the index', () => {
expect(generated.browseCommands.length).toBeGreaterThan(0);
for (const cmd of generated.browseCommands) {
// Use word boundaries; backtick-wrapped command name OR usage.
expect(generated.content).toContain(cmd);
}
});
test('skills are sorted alphabetically', () => {
const names = generated.skills.map((s) => s.name);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
test('description is collapsed to a single line per entry', () => {
// Find the Skills section and assert no entry contains a literal newline
// mid-bullet (descriptions can be multi-paragraph in frontmatter; oneLine
// collapses them).
const skillsSection = generated.content.split('## Skills')[1].split('## Browse Commands')[0];
const bullets = skillsSection.split('\n').filter((l) => l.startsWith('- ['));
for (const b of bullets) {
// No mid-bullet newline inside the bullet.
expect(b).not.toMatch(/\n/);
}
});
});
describe('gen-llms-txt — strict mode', () => {
test('does NOT throw on the live skill set (every gstack skill has name + description)', async () => {
// The point of strict mode: catch missing-frontmatter skills before they
// sneak past gen-skill-docs. The current repo state should pass strict.
await expect(generateLlmsTxt({ root: ROOT, strict: true })).resolves.toBeDefined();
});
test('throws on a synthesized skill missing description', async () => {
// Set up a temp repo-shaped tree with one skill that has only a name.
const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'llms-txt-strict-'));
try {
fs.mkdirSync(path.join(tmp, 'badskill'));
// Frontmatter has name but no description.
fs.writeFileSync(
path.join(tmp, 'badskill', 'SKILL.md.tmpl'),
'---\nname: badskill\n---\nbody\n',
);
// Need a dummy browse/src/commands.ts shape — but we read from real
// ROOT for browse commands. The strict failure should fire on the
// skill before that. So we point at the real browse/src indirectly
// through the absolute import in gen-llms-txt.ts (already imported
// at module load). That's fine — strict throws on parsing, before
// browse commands are read. But the real ROOT includes valid skills
// too. Use the temp tree as `root` to isolate.
await expect(generateLlmsTxt({ root: tmp, strict: true })).rejects.toThrow(/missing name or description/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe('gen-llms-txt — generated file is fresh', () => {
test('committed gstack/llms.txt matches what the generator produces now', () => {
const committed = fs.readFileSync(path.join(ROOT, 'gstack', 'llms.txt'), 'utf-8');
expect(committed).toBe(generated.content);
});
});