Files
gstack/test/design-printed-paths.test.ts
Garry Tanandcapy-ai[bot] a14c28aca7 v1.91.289.0 feat: skill diet pilots for plan-design-review, office-hours and design-review (GSTA-134) (#3525)
* feat(plan-design-review): skill diet (GSTA-134 pilot 1)

SKILL.md 66,063 -> 56,209 B (claude host); review-sections.md 52,243 -> 50,183 B.
Cut model-known prose (cognitive patterns, verbose designer prose, duplicated
formatting rules); UX principles kept as a five-line Krug summary after the
blind judge showed wayfinding loss without it. Section layout unchanged:
autoplan's methodology snapshot requires review-sections.md as the only indexed
section.

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

* feat(office-hours): skill diet (GSTA-134 pilot 2)

Moves the Phase 3.5 Codex second opinion and the Phase 4.25 visual design
(designer mockups / HTML sketch) out of the eager SKILL.md into on-demand
sections behind STOP pointers; the CODEX_SECOND_OPINION, DESIGN_MOCKUP and
DESIGN_SKETCH resolvers are untouched. Claude-host SKILL.md 73,248 -> 43,885
bytes. skill-diet before/after (Opus, 9 tasks x 3): success 26/27 -> 27/27,
median tokens per successful run 170,197 -> 124,941; blind judge after 10,
before 5 of 15. YC pitch, flattery and promo text unchanged (sections
byte-identical to main).

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

* feat(design-review): skill diet (GSTA-134 pilot 3)

Moves the outside voices and the test-framework bootstrap into on-demand
sections (the bootstrap now loads at Phase 8e.5, its only consumer: a
JS-behavior fix that needs a regression test with no known test command),
replaces the UX principles block with a Krug summary, and tightens the
DESIGN_METHODOLOGY prose (design-review is its only consumer). Shared
resolvers TEST_BOOTSTRAP and DESIGN_OUTSIDE_VOICES are untouched.
Claude-host SKILL.md 115,947 -> 84,869 bytes. skill-diet before/after
(Opus, 12 tasks x 3): success 32/36 -> 34/36, median tokens per successful
run 326,662 -> 257,819; blind judge after 9, before 5, tie 1.

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

* test: re-pin size, context and carve guards for the GSTA-134 skill diets

Every pin moves down except office-hours' parity maxSizeRatio (1.1796 ->
1.1921, GStack Minder's call as merge-queue owner): its union grew 1,662 B
because the second-opinion question now sits in the skeleton while the
untouched CODEX_SECOND_OPINION resolver keeps its own copy. design-review
leaves the size allowlist on every host.

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

* fix: drop the stale <affected-url> placeholder after the design-review Phase 8d diet

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

* fix: design-review checklist headers count the items they list after the diet

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

* docs: design-review report path and UX_PRINCIPLES consumers after the skill diets

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

* chore: CHANGELOG [Unreleased] for the GSTA-134 skill diets

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-28 04:17:22 +00:00

167 lines
8.7 KiB
TypeScript

/**
* Tripwire: design skills read the paths `$D` printed for this round, never a
* fixed variant name or a directory glob, because `$D` never overwrites and a
* second round lands at bumped names (variant-A-2.png). Runs on the generated
* SKILL.md files (with their sections) and the resolver sources.
*/
import { describe, expect, test } from 'bun:test';
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
/** The one reasoned exception, anchored on content rather than a line number. */
const ALLOWLIST = [{
file: 'design-html/SKILL.md',
line: '_VARIANTS=$(ls -t "$GSTACK_STATE_ROOT/projects/$SLUG/designs/"*/variant-*.png 2>/dev/null | head -1)',
reason: 'cross-session discovery probe: design-html runs no generator, it only detects that earlier rounds exist; its approved image comes from approved.json',
}];
const GENERATOR = /(?:\$D|"\$D")\s+(generate|variants|iterate|evolve)\b/;
const CONSUMER = /^\s*(?:\$D|"\$D")\s+(compare|check)\b/m;
function flowFiles(): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const skill = `${entry.name}/SKILL.md`;
if (fs.existsSync(path.join(ROOT, skill))) out.push(skill);
const sections = `${entry.name}/sections`;
if (fs.existsSync(path.join(ROOT, sections))) {
for (const f of fs.readdirSync(path.join(ROOT, sections))) if (f.endsWith('.md')) out.push(`${sections}/${f}`);
}
}
for (const f of fs.readdirSync(path.join(ROOT, 'scripts/resolvers'))) if (f.endsWith('.ts')) out.push(`scripts/resolvers/${f}`);
return out;
}
function bashLines(text: string): string[] {
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)].flatMap(m => m[1].split('\n'));
}
function argAfter(line: string, flag: string): string | null {
const m = line.match(new RegExp(`${flag}\\s+("([^"]+)"|(\\S+))`));
return m ? (m[2] ?? m[3]) : null;
}
/** Concrete bans only; returns one message per violation. */
export function scanForBannedReads(file: string, text: string): string[] {
const violations: string[] = [];
const allowed = new Set(ALLOWLIST.filter(a => a.file === file).map(a => a.line.trim()));
const outputs = new Set<string>();
for (const line of text.split('\n')) {
if (GENERATOR.test(line)) {
const out = argAfter(line, '--output');
if (out) outputs.add(out);
}
}
text.split('\n').forEach((line, i) => {
const where = `${file}:${i + 1}`;
if (allowed.has(line.trim())) return;
if (/variant-[A-Z]\.png/.test(line)) violations.push(`${where} fixed variant name: ${line.trim()}`);
if (/variant-\*\.png/.test(line)) violations.push(`${where} variant glob: ${line.trim()}`);
if (/variant-\{letter\}/.test(line) && !/gstack-design-claim/.test(line)) violations.push(`${where} variant-{letter} placeholder read: ${line.trim()}`);
const copy = line.match(/(?:^|[\s;&|(`])(?:cp|mv)\s+(.*)$/);
if (copy && (/variant|responsive-|outputPath/.test(copy[1]) || [...outputs].some(o => copy[1].includes(o)))) {
violations.push(`${where} cp/mv of a $D output (publish with gstack-design-claim): ${line.trim()}`);
}
const image = /\$D\s+check\b/.test(line) ? argAfter(line, '--image') : /\$D\s+verify\b/.test(line) ? argAfter(line, '--mockup') : null;
if (image && outputs.has(image)) violations.push(`${where} reads the requested --output instead of the printed path: ${line.trim()}`);
});
return violations;
}
const files = flowFiles();
const read = (f: string) => fs.readFileSync(path.join(ROOT, f), 'utf8');
describe('design printed-paths tripwire', () => {
// Value: protects=no skill reads a fixed variant name, glob or requested --output after $D bumps a name;
// fails_when=a template reintroduces variant-A.png, variant-*.png, a cp of a $D output or a literal check path; why_new=#1529; seam=none
test('generated skills and resolvers read only printed paths', () => {
const violations = files.flatMap(f => scanForBannedReads(f, read(f)));
expect(violations).toEqual([]);
});
// Value: protects=the allowlist cannot rot into a blanket pass; fails_when=the probe line changes and the entry no longer anchors;
// why_new=content-anchored allowlist; seam=none
test('the allowlist entry still anchors to real content', () => {
for (const a of ALLOWLIST) {
expect(read(a.file).split('\n').map(l => l.trim())).toContain(a.line);
expect(a.reason.length).toBeGreaterThan(20);
}
});
// Value: protects=the scanner actually catches each banned form; fails_when=a regex is loosened until it matches nothing;
// why_new=negative controls for the tripwire; seam=none
test('fixtures with each banned form fail', () => {
const cases = [
'$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png"',
'_IMAGES=$(ls "$_DESIGN_DIR"/variant-*.png)',
'5. Quality check: {$D path} check --image {_DESIGN_DIR}/variant-{letter}.png',
'cp /tmp/variant-B.png "$_DESIGN_DIR/"',
'$D generate --brief "x" --output "$DIR/target.png"\n$D verify --mockup "$DIR/target.png" --screenshot after.png',
'$D generate --brief "x" --output "$DIR/target.png"\n$D check --image "$DIR/target.png" --brief x',
];
for (const c of cases) expect(scanForBannedReads('fixture.md', c).length, c).toBeGreaterThan(0);
expect(scanForBannedReads('fixture.md', 'FINAL=$(gstack-design-claim "<outputPath>" "{dir}/variant-{letter}.png")')).toEqual([]);
});
});
describe('design generating flows', () => {
const flows: Record<string, string[]> = {
'office-hours': ['office-hours/sections/visual-design.md'],
'plan-design-review': ['plan-design-review/SKILL.md'],
'design-consultation': ['design-consultation/sections/proposal-and-preview.md'],
'design-shotgun': ['design-shotgun/SKILL.md'],
};
for (const [skill, sources] of Object.entries(flows)) {
// Value: protects=round accounting (with the zero-saved stop) comes before the first board or check in each flow;
// fails_when=a template moves the board or check above the accounting marker or drops the stop; why_new=CEO accounting position; seam=none
test(`${skill}: round accounting precedes the first consumer and stops on zero saved`, () => {
const text = sources.map(read).join('\n');
const accounting = text.indexOf('<!-- design:round-accounting -->');
const board = text.indexOf('<!-- design:board -->');
const consumer = text.search(CONSUMER);
expect(accounting).toBeGreaterThan(-1);
expect(board).toBeGreaterThan(accounting);
expect(consumer).toBeGreaterThan(accounting);
const span = text.slice(accounting, Math.min(board, consumer));
expect(span).toMatch(/stop/i);
expect(span).toMatch(/nothing was saved|saves nothing|zero/i);
});
}
// Value: protects=generating steps capture JSON and the exit code instead of dying under set -e;
// fails_when=a bare `$D generate ...` line returns; why_new=eng template exit handling; seam=none
test('every generating bash line captures stdout and the exit code', () => {
const bare = files.filter(f => f.endsWith('.md')).flatMap(f => bashLines(read(f))
.filter(l => GENERATOR.test(l) && !/=\$\(.*\);\s*_?RC=\$\?/.test(l))
.map(l => `${f}: ${l.trim()}`));
expect(bare).toEqual([]);
});
// Value: protects=every approval writer records approved_path so a bumped round resolves to the right image;
// fails_when=a writer goes back to approved_variant only; why_new=approved-image identity; seam=none
test('every approval-record writer stores approved_path', () => {
const writers = files.flatMap(f => read(f).split('\n')
.filter(l => /approved_variant/.test(l) && (/>\s*"\$_DESIGN_DIR\/approved\.json"/.test(l) || /Write of `\$_DESIGN_DIR\/approved\.json`/.test(l)))
.map(l => ({ f, l })));
expect(writers.length).toBeGreaterThanOrEqual(3);
for (const { f, l } of writers) expect(l, f).toContain('approved_path');
});
// Value: protects=shotgun generation stages in a fresh mktemp dir and publishes through the no-clobber helper;
// fails_when=the shared /tmp/variant file and cp return; why_new=eng staging requirement; seam=none
test('design-shotgun stages per run and publishes with gstack-design-claim', () => {
const shotgun = read('design-shotgun/SKILL.md');
const prompt = shotgun.slice(shotgun.indexOf('### Step 3c'), shotgun.indexOf('### Step 3d'));
// INV-3: the per-run staging dir honors TMPDIR.
expect(prompt).toContain('mktemp -d "${TMPDIR:-/tmp}/');
expect(prompt).toMatch(/gstack-design-claim "<saved path>"/);
expect(prompt).not.toMatch(/\/tmp\/variant-/);
expect(prompt).toMatch(/every published path/);
});
});