Files
gstack/test/external-host-carve.test.ts
Garry Tanandcapy-ai[bot] 6c5b2d4612 v1.91.260.0 feat(design-review,plan-design-review): craft audit and Persuade plan checks; craft tells as checker-only catalog entries (GSTA-92 phase 2) (#3433)
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-shotgun,design-html): carry the chosen archetype into approved.json; persist accepted craft exceptions in finalized.json (GSTA-92 phase 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-review,plan-design-review): craft audit carved on every host, Persuade plan checks, craft tells as catalog entries (GSTA-92 phase 2)

Size pins and caps raised to measured sizes only: design-review (13 hosts), plan-design-review (12), design-consultation (9), design-consultation maxSizeRatio, and the new design-review parity guard. Garry 2026-10-25 "Do it" (GSTA-92).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor(design-catalog): craft tells are checker-only entries; trim design-review and plan-design-review growth to the planned size (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(touchfiles): design-html-slop-gate selects on the craft check it runs (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(touchfiles): design-review and plan-design-review cases select on the craft resolver; slop gate on the catalog (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(placeholders): allow the craft audit page path placeholder (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (GSTA-92)

A malformed or missing --accepted exceptions file now gets the full craft
check with nothing accepted, after a warning, still exiting 2. Before, it
printed nothing, and the craft section treats a missing CRAFT_RESULT as
'check unavailable', so one bad finalized.json switched the gate off
(found by both adversarial passes). The header comment and the craft
section now document the accepted-count suffix on CRAFT_RESULT. Tests
cover duplicate carried-forward exception ids and the slop-gate touchfiles
selection.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: scope --accepted to the same page; record current counts (GSTA-92)

The craft section passed --accepted whenever a finalized.json sat beside
the page, so a same-day Start fresh build inherited the old page's
exceptions. It now passes them only when evolving or rerunning a page it
finalized, records the count the check reports now, and writes [] when
nothing was accepted instead of the template's placeholder entry. A test
pins exit 2 for a clean page with a broken exceptions file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: note the archetype handoff in the shotgun and design-html loops (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: craft audit capture, exceptions and trigger from /ship review (GSTA-92)

- craft-audit: no CRAFT_RESULT line means unavailable (exit 2 after a
  malformed --accepted record still printed findings); --accepted only for a
  /design-html finalized page, never for needs-proof or lorem; never add
  [Needs proof] markers to a live page; Aside capture via pg.evaluate; the
  saved raw DOM is deleted in the checking block and never reported
- manifest trigger covers an explicit craft audit (skeleton -6 bytes; pins
  lowered to measured size, union ratio back under its cap)
- autoFixEntries reads RENDERED; DESIGN_CRAFT_RULE needs exactly one lead
- tests: checkerOnly contract, resolver throw paths, Persuade checks in both
  skills, resolver touchfile selection, exception state cells
- CHANGELOG: host counts and the audit's new rules

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: craft audit live-page limits, claim handling and guards (GSTA-92)

- craft-audit: linked CSS/JS is unchecked on a saved page (judge by eye);
  a mount-node shell is framework source; --accepted takes that screen's
  finalized.json or none; on a live page a claim without proof is filed and
  asked about, never marked or cut; the saved page is removed whatever the
  exit code; shorter fix-order and Tailwind prose keeps the union under cap
- tests: exact-lead red cases, autoFixEntries in the checkerOnly union, the
  audit's outcome/exception/session-data clauses; carve guard checks the
  session-data rule and records measured sizes (skeleton cap 137_767)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: craft check skips mount-node shells and script bodies; audit capture order (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* revert: craft checker back to bed645a's reviewed behavior; textless mount-node shell judged by eye (GSTA-92)

cycle-cap exception (GSTA-92 lane, 2026-10-25): reverted 216987e's checker half to bed645a's reviewed behavior; byte-neutral prose reword; no new logic

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: craft audit judges any mount-node shell by eye; pin the clause (GSTA-92)

second cycle-cap exception: GStack Minder's decision under Garry's 'do it' on phase 2 (GSTA-92, 2026-10-25): one deleted word plus one test

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: craft resolver placeholders, carved-on-every-host list, design-craft resolver (GSTA-92 /ship docs audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes after main merge: claim rule covers repo files, craft temp var, guard tests (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: craft audit files failed Persuade checks as deferred High, judges source tells by eye; stderr pin (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-review): never cut a claim a rule or printed fix says to drop

Pass R3 (native adversarial, Medium): the CRAFT_CHECK fix text for
round-number and needs-proof says to drop or remove the claim, which
could lead Phase 8 to delete a real claim. The audit sentence now
covers both a rule and the printed fix: defer the claim and ask for
the proof. Byte-neutral (80 -> 80); design-review/SKILL.md 137,767,
union 145,037, unchanged. The test pin moves with the sentence; no
carve-guard mustContain held this sentence.

third cycle-cap exception (Captain, 2026-10-26): zero-byte reword closing the content-deletion path; test pin and carve-guard mustContain moved

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-craft: reconcile ids with the design lint (layout-transition is static-tier; legacy transition-all exceptions map to it)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 01:14:07 +00:00

151 lines
8.5 KiB
TypeScript

/**
* C4 (#2777): ship, plan-ceo-review, office-hours and design-review are carved on every host, and no
* generated SKILL.md exceeds 160,000 UTF-8 bytes on any host or model overlay
* setup can select. Carved sections on external hosts are pointed at relative
* to the installed skill directory: `$GSTACK_ROOT/<skill>/sections/...` does
* not exist in external runtime roots (Codex's holds bin, lib, browse, review).
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ALL_HOST_CONFIGS } from '../hosts';
import { ALL_MODEL_NAMES } from '../scripts/models';
import { runGeneration } from '../scripts/gen-skill-docs';
import { sectionPath, SKILL_BYTE_CEILING, usesLazySections } from '../scripts/resolvers/sections';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
import { prepareMethodology } from '../bin/gstack-autoplan-snapshot';
const ROOT = path.resolve(import.meta.dir, '..');
const EXTERNAL = ALL_HOST_CONFIGS.filter(c => c.name !== 'claude');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-external-carve-'));
const context = (host: string, skillName: string): TemplateContext => ({ host, skillName, tmplPath: '', paths: HOST_PATHS[host] });
const skillDir = (host: (typeof ALL_HOST_CONFIGS)[number], out: string, skill: string) =>
host.name === 'claude' ? path.join(out, skill) : path.join(out, host.hostSubdir, 'skills', `gstack-${skill}`);
function skillFiles(out: string): string[] {
const files: string[] = [];
const walk = (dir: string) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(path.join(dir, e.name)); }
else if (e.name === 'SKILL.md') files.push(path.join(dir, e.name));
}
};
walk(out);
return files;
}
const renders: Record<string, string> = {};
beforeAll(async () => {
renders.all = path.join(tmp, 'all');
const all = await runGeneration({ host: 'all', outputRoot: renders.all });
expect(all.diagnostics.filter(d => d.kind === 'error' || d.kind === 'warning').map(d => d.message)).toEqual([]);
for (const model of ALL_MODEL_NAMES) {
renders[model] = path.join(tmp, `codex-${model}`);
const r = await runGeneration({ host: 'codex', model, outputRoot: renders[model] });
expect(r.diagnostics.filter(d => d.kind === 'error').map(d => d.message)).toEqual([]);
}
}, 120_000);
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
describe('C4: external section pointers', () => {
for (const config of EXTERNAL) {
test(`${config.name}: ship and plan-ceo-review point relative to the installed skill directory`, () => {
expect(sectionPath(context(config.name, 'ship'), 'ship', 'tests'))
.toBe('`sections/tests.md` relative to the installed `gstack-ship` SKILL.md directory');
expect(sectionPath(context(config.name, 'plan-ceo-review'), 'plan-ceo-review', 'review-sections'))
.toBe('`sections/review-sections.md` relative to the installed `gstack-plan-ceo-review` SKILL.md directory');
expect(sectionPath(context(config.name, 'ship'), 'ship', 'tests')).not.toContain('$GSTACK_ROOT');
});
}
test('claude keeps its global-root pointer (bytes unchanged)', () => {
expect(sectionPath(context('claude', 'ship'), 'ship', 'tests')).toBe('`~/.claude/skills/gstack/ship/sections/tests.md`');
});
});
describe('C4: ship, plan-ceo-review, office-hours and design-review are carved on every external host', () => {
for (const config of EXTERNAL) {
test(`${config.name}: every pointer in the carved skill resolves to a generated section file`, () => {
for (const skill of ['ship', 'plan-ceo-review', 'office-hours', 'design-review']) {
expect(usesLazySections(config.name, skill)).toBe(true);
const dir = skillDir(config, renders.all, skill);
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf8')) as { sections: Array<{ file: string }> };
const text = [fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8'),
...manifest.sections.map(s => fs.readFileSync(path.join(dir, 'sections', s.file), 'utf8'))].join('\n');
expect(text).not.toMatch(new RegExp(`\\$GSTACK_ROOT/${skill}/sections/`));
const pointers = [...text.matchAll(new RegExp(`\`sections/([\\w.-]+)\` relative to the installed \`gstack-${skill}\` SKILL\\.md directory`, 'g'))].map(m => m[1]);
expect(pointers.length).toBeGreaterThan(0);
for (const file of pointers) expect(fs.existsSync(path.join(dir, 'sections', file)), `${skill}/sections/${file}`).toBe(true);
expect(fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')).toContain('## Section index');
}
});
}
test('no external render points at <root>/<skill>/sections/ (runtime roots have no section trees)', () => {
const offenders: string[] = [];
for (const config of EXTERNAL) {
for (const file of skillFiles(path.join(renders.all, config.hostSubdir))) {
const hits = fs.readFileSync(file, 'utf8').match(/(?:\$GSTACK_ROOT|~\/[\w./-]*skills\/gstack)\/[a-z0-9-]+\/sections\/[\w.-]+/g) ?? [];
offenders.push(...hits.map(hit => `${path.relative(renders.all, file)}: ${hit}`));
}
}
expect(offenders).toEqual([]);
const codex = ALL_HOST_CONFIGS.find(c => c.name === 'codex')!;
expect(fs.readFileSync(path.join(skillDir(codex, renders.all, 'plan-eng-review'), 'SKILL.md'), 'utf8'))
.toContain('Read the `review-sections` section inlined in this SKILL.md');
const html = fs.readFileSync(path.join(skillDir(codex, renders.all, 'design-html'), 'SKILL.md'), 'utf8');
expect(html).not.toContain('detector-install-offer.md');
expect(html).toContain('the user has never answered this. Ask now');
});
test('codex: the autoplan methodology loader accepts the carved plan-ceo-review install', () => {
const codex = ALL_HOST_CONFIGS.find(c => c.name === 'codex')!;
const restore = path.join(tmp, 'restore.md');
fs.writeFileSync(restore, '# plan\n');
const prepared = prepareMethodology('ceo', path.join(skillDir(codex, renders.all, 'plan-ceo-review'), 'SKILL.md'), restore);
const methodology = fs.readFileSync(prepared.methodologyPath, 'utf8');
expect(methodology).toContain('sections/review-sections.md');
expect(methodology).toContain('## Review Sections');
});
});
describe('C4: UTF-8 byte gate (every host, every Codex model overlay)', () => {
test(`no generated SKILL.md exceeds ${SKILL_BYTE_CEILING} bytes`, () => {
const over: string[] = [];
let checked = 0;
for (const [name, out] of Object.entries(renders)) {
for (const file of skillFiles(out)) {
checked++;
const bytes = fs.statSync(file).size;
if (bytes > SKILL_BYTE_CEILING) over.push(`${name}: ${path.relative(out, file)} is ${bytes} bytes`);
}
}
expect(checked).toBeGreaterThan(500);
expect(over).toEqual([]);
});
test('the generator fails an oversized render in repo mode and only warns under --install-root', () => {
const fixture = path.join(tmp, 'oversized');
fs.mkdirSync(fixture);
for (const entry of fs.readdirSync(ROOT)) {
if (['scripts', 'retro', '.git'].includes(entry)) continue;
fs.symlinkSync(path.join(ROOT, entry), path.join(fixture, entry));
}
fs.cpSync(path.join(ROOT, 'scripts'), path.join(fixture, 'scripts'), { recursive: true });
fs.cpSync(path.join(ROOT, 'retro'), path.join(fixture, 'retro'), { recursive: true });
const tmpl = path.join(fixture, 'retro', 'SKILL.md.tmpl');
fs.appendFileSync(tmpl, `\n${`${'Padding for the oversized-render fixture. '.repeat(20)}\n`.repeat(250)}`);
const gen = (...args: string[]) => spawnSync(process.execPath, ['scripts/gen-skill-docs.ts', '--host', 'codex', ...args],
{ cwd: fixture, encoding: 'utf8', timeout: 60_000 });
const repo = gen('--out-dir', path.join(fixture, 'out-repo'));
expect(repo.status).toBe(1);
expect(repo.stderr).toMatch(/codex\/gstack-retro\/SKILL\.md is \d+ bytes, over the 160,000-byte limit\. Fix: carve sections with usesLazySections\(\) for this skill\./);
const install = gen('--out-dir', path.join(fixture, 'out-install'), '--install-root', '/opt/gstack');
expect(install.status, install.stderr).toBe(0);
expect(install.stderr).toContain('over the 160,000-byte limit');
expect(fs.existsSync(path.join(fixture, 'out-install', '.agents', 'skills', 'gstack-retro', 'SKILL.md'))).toBe(true);
});
});