From 0e5d11fd914a736d45c594cbb21716dacb608bf2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 01:48:59 +0000 Subject: [PATCH] test: cover the shared-preamble hoist (ship Step 7) Adds test/preamble-hoist.test.ts (tier 1 unchanged; tier 2+ SKILL.md plus sections/preamble.md reproduces the pre-hoist preamble; pointer position and id) and two gen-skill-docs-checks cases (one shared preamble per host, beside every pointer and named by its id; preamble artifact kind). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/gen-skill-docs-checks.test.ts | 37 +++++++++++ test/preamble-hoist.test.ts | 100 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 test/preamble-hoist.test.ts diff --git a/test/gen-skill-docs-checks.test.ts b/test/gen-skill-docs-checks.test.ts index e60ed1b8a..8e6a73ec0 100644 --- a/test/gen-skill-docs-checks.test.ts +++ b/test/gen-skill-docs-checks.test.ts @@ -55,6 +55,29 @@ describe('generator artifact and dry-run contract', () => { ].sort((a, b) => a.relativePath.localeCompare(b.relativePath))); }); + // Generated by /ship Step 7 coverage audit (GSTA-96). + // Value: protects=on each host every tier 2+ SKILL.md pointer has a sections/preamble.md beside it, all byte-identical under one id that the pointer names; + // fails_when=a post-hash host rewrite or skill-dependent render gives two skills different shared files under one id, or a pointer or file is orphaned; + // why_new=the all-host validation above checks commands only; goldens pin ship's SKILL.md, never the shared files across skills; + // seam=none; no_claim=that installs copy the file (host-conformance checks installed pointers resolve) + test('each host renders one shared preamble, beside every pointer and named by its id', () => { + const pointerId = /^## Shared preamble \(load first\)\n\nThis skill follows gstack shared preamble `([0-9a-f]{12})`/m; + const preambles = generated.artifacts.filter(a => a.kind === 'preamble'); + const skills = generated.artifacts.filter(a => a.kind === 'skill' && pointerId.test(fs.readFileSync(path.join(render, a.relativePath), 'utf-8'))); + expect(skills.length).toBeGreaterThan(40 * ALL_HOST_NAMES.length); + expect(preambles.map(a => `${a.host}:${path.dirname(path.dirname(a.relativePath))}`).sort()) + .toEqual(skills.map(a => `${a.host}:${path.dirname(a.relativePath)}`).sort()); + for (const host of ALL_HOST_NAMES) { + const bodies = new Set(preambles.filter(a => a.host === host).map(a => fs.readFileSync(path.join(render, a.relativePath), 'utf-8'))); + expect(bodies.size, host).toBe(1); + const id = /^# gstack shared preamble ([0-9a-f]{12})$/m.exec([...bodies][0])?.[1]; + expect(id, host).toBeDefined(); + for (const skill of skills.filter(a => a.host === host)) { + expect(pointerId.exec(fs.readFileSync(path.join(render, skill.relativePath), 'utf-8'))?.[1], skill.relativePath).toBe(id); + } + } + }); + test('include-minus-skip semantics share one predicate', () => { const host = getHostConfig('claude'); expect(includesSkill(host, 'claude-code')).toBe(false); @@ -353,6 +376,20 @@ describe('shared content validators', () => { } }); + // Generated by /ship Step 7 coverage audit (GSTA-96). + // Value: protects=skill:check rejects an unknown $B command or snapshot flag in a generated sections/preamble.md; + // fails_when='preamble' is dropped from the artifact kinds whose commands validateGeneratedArtifact checks; + // why_new=the all-host run above only proves valid preamble files pass; the section row covers kind 'section' alone; + // seam=none; no_claim=none beyond protects + test('the shared preamble validates commands without requiring frontmatter', () => { + for (const [content, message] of [['```bash\n$B snapshot --bogus\n```', 'Unknown snapshot flag'], ['```bash\n$B nonexistent-command\n```', 'unknown command']]) { + fs.writeFileSync(file, content); + const diagnostics = validateGeneratedArtifact(base, { ...artifact, kind: 'preamble', host: 'claude' }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain(message); + } + }); + test('carved sections also validate commands without requiring frontmatter', () => { fs.writeFileSync(file, '```bash\n$B snapshot --bogus\n```'); const diagnostics = validateGeneratedArtifact(base, { ...artifact, kind: 'section', host: 'claude' }); diff --git a/test/preamble-hoist.test.ts b/test/preamble-hoist.test.ts new file mode 100644 index 000000000..8d914626c --- /dev/null +++ b/test/preamble-hoist.test.ts @@ -0,0 +1,100 @@ +/** + * Shared-preamble hoist (GSTA-96, scripts/resolvers/preamble-hoist.ts): the + * hoist moves preamble text, it never changes it. + * + * Generated by /ship Step 7 coverage audit (GSTA-96). + * + * Value: protects=every tier 2+ skill, on every host and render option, follows exactly the preamble sections it inlined before the hoist; tier 1 is unchanged; + * fails_when=a section is hoisted by heading instead of byte identity, a cut-down variant drops a changed subsection, or a section is neither inline nor shared; + * why_new=only ship's SKILL.md goldens pin hoist output and they regenerate with it; no test compares the hoisted render to generatePreamble; + * seam=none; no_claim=real template frontmatter and host post-render rewrites (synthetic contexts at the resolver boundary; rendered files are checked in gen-skill-docs-checks) + * + * Value: protects=the "Shared preamble (load first)" pointer opens a tier 2+ preamble, before the start-command section, and names the shared file's id; + * fails_when=the pointer moves after the start command, or its id stops matching the shared file's heading; + * why_new=skill-fixture.test.ts pins only that the pointer follows plan-eng-review's scope gate; + * seam=none; no_claim=that a model honors the pointer (an eval question; resolver output only) + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { generatePreamble } from '../scripts/resolvers/preamble'; +import { generateSharedPreambleFile, generateSkillPreamble, hoistsPreamble } from '../scripts/resolvers/preamble-hoist'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; +import { ALL_HOST_NAMES } from '../hosts/index'; +import { SHARED_POINTER_HEADING, SHARED_PREAMBLE_FILE, readSkillEffective } from './helpers/skill-startup'; + +const SKILLS = ['ship', 'review', 'qa', 'investigate', 'retro', 'office-hours', 'plan-ceo-review', 'plan-eng-review', + 'plan-design-review', 'plan-devex-review', 'make-pdf', 'gstack-upgrade', 'ios-fix']; +const OPTIONS: Partial[] = [{}, { interactive: true }, { interactive: false }, { explainLevel: 'terse' }, { planMode: false }, { model: 'opus-4-7' }]; + +const contexts = (): TemplateContext[] => ALL_HOST_NAMES.flatMap(host => SKILLS.flatMap(skillName => [1, 2, 3, 4].flatMap(tier => + OPTIONS.map(option => ({ skillName, tmplPath: `${skillName}/SKILL.md.tmpl`, host, paths: HOST_PATHS[host], preambleTier: tier, ...option } as TemplateContext))))); + +const label = (ctx: TemplateContext) => `${ctx.host}/${ctx.skillName} tier ${ctx.preambleTier} ${JSON.stringify({ interactive: ctx.interactive, explainLevel: ctx.explainLevel, planMode: ctx.planMode, model: ctx.model })}`; + +/** `## ` sections outside code fences, sorted: the effective view groups sections differently, never changes them. */ +function sections(text: string): string[] { + const parts: string[] = []; + let current: string[] = []; + let fence: string | null = null; + for (const line of text.split('\n')) { + const delimiter = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (delimiter) { + if (!fence) fence = delimiter; + else if (delimiter[0] === fence[0] && delimiter.length >= fence.length) fence = null; + } else if (!fence && line.startsWith('## ') && current.some(l => l.trim())) { + parts.push(current.join('\n').trim()); + current = []; + } + current.push(line); + } + if (current.some(l => l.trim())) parts.push(current.join('\n').trim()); + return parts.sort(); +} + +/** The headless upgrade-check rules attach to whichever section precedes them; compare them apart (as plan-mode-off-generation does). */ +const UPGRADE_RULES = /\n\nIf `PROACTIVE` is [^\n]*\n\nIf `SKILL_PREFIX` is [^\n]*/; +const apart = (text: string) => ({ sections: sections(text.replace(UPGRADE_RULES, '')), upgradeRules: UPGRADE_RULES.exec(text)?.[0] ?? null }); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-preamble-hoist-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +describe('shared-preamble hoist', () => { + test('tier 1 is unchanged; tier 2+ SKILL.md plus sections/preamble.md follows exactly the pre-hoist preamble', () => { + let hoisted = 0; + contexts().forEach((ctx, i) => { + const full = generatePreamble(ctx); + const own = generateSkillPreamble(ctx); + if (!hoistsPreamble(ctx)) { + expect(own, label(ctx)).toBe(full); + return; + } + const dir = path.join(tmp, String(i)); + fs.mkdirSync(path.join(dir, 'sections'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), `# fixture\n\n${own}\n`); + fs.writeFileSync(path.join(dir, 'sections', SHARED_PREAMBLE_FILE), generateSharedPreambleFile(ctx)); + const effective = readSkillEffective(path.join(dir, 'SKILL.md')).slice('# fixture\n\n'.length); + expect(apart(effective), label(ctx)).toEqual(apart(full)); + hoisted++; + }); + expect(hoisted).toBe(ALL_HOST_NAMES.length * SKILLS.length * 3 * OPTIONS.length); + }); + + test('the pointer opens a tier 2+ preamble, precedes the start command and names the shared file id', () => { + for (const ctx of contexts()) { + const own = generateSkillPreamble(ctx); + if (!hoistsPreamble(ctx)) { + expect(own, label(ctx)).not.toContain(SHARED_POINTER_HEADING); + continue; + } + expect(own.startsWith(`${SHARED_POINTER_HEADING}\n`), label(ctx)).toBe(true); + const start = own.search(/^## Preamble \((?:run first|after scope gate)\)$/m); + expect(start, label(ctx)).toBeGreaterThan(0); + expect(own.indexOf(`\n${SHARED_POINTER_HEADING}\n`), label(ctx)).toBe(-1); + const id = /^# gstack shared preamble ([0-9a-f]{12})$/m.exec(generateSharedPreambleFile(ctx))?.[1]; + expect(id, label(ctx)).toBeDefined(); + expect(own.slice(0, start), label(ctx)).toContain(`shared preamble \`${id}\``); + } + }); +});