fix(design): run the DOM dump in the page on both engines; align doctrine with the catalog

The DOM-dump script is an arrow function, not a self-calling IIFE: Aside's
`pg.evaluate($_DUMP)` receives the function and runs it in the page (the IIFE
form executed in the repl sandbox, where `document` does not exist), and the
fallback engine calls it with `$B js "($_DUMP)()" --out --raw`. Hygiene widens
to every URL-bearing attribute (src, srcset per candidate, poster, action,
formaction, data, ping, cite lose their query strings and fragments) and to
data: URLs inside existing <style> nodes. The persist and scan blocks restate
REPORT_DIR and RUN_ID literally instead of relying on a shell variable from an
earlier block; the baseline's targetSet is defined per mode (repo-relative
paths in source mode, page slugs in DOM mode) so DOM-mode deltas can match; the
PR-body Detector line lists the states the probe can actually print. The DOM
fixture is re-captured with the new script from outside the repo (the engine
walks up from cwd for DESIGN.md, which the metadata now records).

Doctrine contradictions the design specialist found: the landing-page motion
rule matches the one-authored-moment reflex; the background rule names the
catalog's halo/spotlight/stripe/grid slop instead of asking for gradients; the
universal font rule is scoped to the display voice with the body/UI exceptions;
"two typefaces max" allows the mono; the methodology's banned-font line renders
BANNED_FONTS; Courier New is banned outright; the Brutalist, Retro-Futuristic,
and Playful menu entries stop recommending system stacks, glow, and bounce; the
coherence nudge uses the decoration vocabulary; Path A's gate names the display
voice; font-loading prose points at the source the procedure verified;
centered-everything is MEDIUM (an aggregate heuristic); the mockup guard reads
"Never by default (unless the brief above asks for it)". The checklist's
AUTO-FIX list renders the catalog's auto-fix rules; category 9 and the Hard
Rules pointer count from the same partition helpers (detectorSlopEntries,
judgmentTellEntries); the handoff list renders from HANDOFF_COMMANDS; a missing
catalog id fails gen-skill-docs by name. gstack's own DESIGN.md gains border
tokens and Decisions Log rows for its live-feed pulse and 11px mono labels.
frontend-scope is case-sensitive like the bash arm. gen-skill-docs shares one
emitGenerated helper for sections and lib-derived assets; renderCatalog keeps
the one style with a caller. Tests: shared sliceBetween that fails on a missing
end marker, the slop-gate fixture's real end marker, an isolated browse daemon
for the DOM-mode E2E, the DOM hygiene test gated to CI or opt-in, docs notes
for the two superseded plan sentences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 17:31:30 +00:00
co-authored by Claude Fable 5.1
parent b4d88a0126
commit da6f0ff2f6
32 changed files with 413 additions and 219 deletions
+19 -25
View File
@@ -14,7 +14,7 @@ import { spawnSync } from 'child_process';
import {
DESIGN_SLOP_CATALOG, HANDOFF_COMMANDS, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, MOCKUP_NEVER_NAMES,
FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE,
catalogEntry, entryForImpeccableId, renderCatalog, selectCatalog,
catalogEntry, catalogEntries, entryForImpeccableId, renderCatalog, selectCatalog, detectorSlopEntries, judgmentTellEntries,
} from '../lib/design-catalog';
import { AI_SLOP_BLACKLIST } from '../scripts/resolvers/constants';
@@ -138,44 +138,38 @@ describe('fonts', () => {
});
test('banned fonts and overused fonts do not overlap; mono list is mono', () => {
for (const f of BANNED_FONTS) expect(OVERUSED_FONTS_DISPLAY).not.toContain(f);
for (const f of BANNED_FONTS) { expect(OVERUSED_FONTS_DISPLAY).not.toContain(f); expect(f).not.toMatch(/\(/); } // no role qualifiers: banned means every role
for (const f of FONTS_MONO_OK) expect(f).toMatch(/Mono|Code/);
});
});
describe('renderCatalog', () => {
test('ids style brackets only detector-known ids', () => {
const out = renderCatalog({ kind: 'slop', style: 'ids' });
expect(out).toContain('- [nested-cards] ');
expect(out).toContain('- [side-tab] ');
for (const e of DESIGN_SLOP_CATALOG.filter(x => !x.impeccableId)) {
expect(out).not.toContain(`[${e.id}]`);
}
// gstack-only prose still renders, unbracketed
expect(out).toContain('- ' + catalogEntry('hero-metrics')!.prose);
});
describe('renderCatalog + partitions', () => {
test('bullets style renders prose only, no ids anywhere', () => {
const out = renderCatalog({ kind: 'slop', style: 'bullets' });
const out = renderCatalog({ kind: 'slop' });
expect(out).not.toMatch(/^- \[/m);
expect(out.split('\n').length).toBe(selectCatalog({ kind: 'slop' }).length);
for (const line of AI_SLOP_BLACKLIST) expect(out).toContain(`- ${line}`);
});
test('compact style is one line of id: name pairs', () => {
const out = renderCatalog({ kind: 'quality', style: 'compact' });
expect(out.includes('\n')).toBe(false);
expect(out).toContain('low-contrast: Low contrast text');
expect(out.split('; ').length).toBe(selectCatalog({ kind: 'quality' }).length);
});
test('filters compose: category and omitImpact', () => {
const copy = selectCatalog({ kind: 'slop', category: 'copy' });
expect(copy.every(e => e.category === 'copy')).toBe(true);
test('omitImpact filters', () => {
const noPolish = selectCatalog({ kind: 'slop', omitImpact: ['polish'] });
expect(noPolish.some(e => e.impact === 'polish')).toBe(false);
expect(noPolish.length).toBeLessThan(selectCatalog({ kind: 'slop' }).length);
});
test('detector-known slop and judgment tells partition the non-legacy slop entries', () => {
const detector = detectorSlopEntries();
const tells = judgmentTellEntries();
expect(detector.every(e => e.impeccableId && !e.legacyBlacklist && e.kind === 'slop')).toBe(true);
expect(tells.every(e => !e.impeccableId && !e.legacyBlacklist && e.kind === 'slop')).toBe(true);
expect(detector.length + tells.length + 11).toBe(selectCatalog({ kind: 'slop' }).length);
expect(detectorSlopEntries({ omitPolish: true }).every(e => e.impact !== 'polish')).toBe(true);
});
test('catalogEntries throws with the missing id', () => {
expect(() => catalogEntries(['nested-cards', 'no-such-id'])).toThrow('no-such-id');
expect(catalogEntries(['nested-cards'])[0].name).toBe('Nested cards');
});
});
const MOCKUP_NEVER_IDS = ['kicker-above-heading', 'icon-tile-stack', 'gradient-text', 'ai-color-palette', 'cream-palette', 'nested-cards', 'dark-glow', 'pulsing-dot', 'identical-cards', 'hero-metrics'];
+109
View File
@@ -0,0 +1,109 @@
/**
* lib/dom-dump.js hygiene, exercised in a real browser through gstack's own
* browse binary (`$B eval <file> --out <path> --raw`, the same fallback path
* /design-review renders). Self-skips when no browse binary is built
* (`bun run build:gates`), like the other render gates.
*
* Pins the rules the DOM dump promises before a page leaves the browser:
* input values dropped, long data: URLs replaced (attributes, inlined CSS, and
* existing <style> nodes), <meta content> emptied (viewport kept), query
* strings cut from every URL attribute, script bodies emptied, linked
* stylesheets inlined with author hex restored, cross-origin sheets named in
* the trailing note, inlined <link> nodes removed.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { DOM_DUMP_STYLE_ATTR, DOM_DUMP_NOTE_PREFIX } from '../lib/dom-dump-script';
const ROOT = path.join(import.meta.dir, '..');
const CANDIDATES = [path.join(ROOT, 'browse', 'dist', 'browse'), path.join(os.homedir(), '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse')];
const BROWSE = CANDIDATES.find(p => fs.existsSync(p));
const POSIX = process.platform !== 'win32';
// Launching Chromium is load-sensitive (a cold daemon can miss the CLI's health
// window on a busy dev box). Runs in CI and on explicit opt-in; skips otherwise.
const OPTED_IN = Boolean(process.env.CI || process.env.GSTACK_DOM_DUMP_HYGIENE);
describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (CI or GSTACK_DOM_DUMP_HYGIENE=1)', () => {
test('applies every hygiene rule and inlines the linked stylesheet', async () => {
const site = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dom-dump-site-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dom-dump-out-'));
const server = Bun.serve({
hostname: '127.0.0.1', port: 0,
fetch(req) {
const p = new URL(req.url).pathname.replace(/^\//, '') || 'index.html';
const f = path.join(site, p);
return fs.existsSync(f) ? new Response(Bun.file(f)) : new Response('nope', { status: 404 });
},
});
const big = 'data:image/png;base64,' + 'A'.repeat(1500);
fs.writeFileSync(path.join(site, 'styles.css'), '.hero { background: linear-gradient(135deg, #6366f1, #8b5cf6); } .x { background-image: url("' + big + '"); }\n');
fs.writeFileSync(path.join(site, 'index.html'), `<!DOCTYPE html><html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="description" content="SECRET DESCRIPTION">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="http://127.0.0.1:1/cross-origin.css">
<script>window.__x = "SCRIPT BODY";</script></head><body>
<input value="SECRET INPUT"><textarea>SECRET TEXT</textarea>
<a href="/page?token=SECRET">link</a>
<img src="/img.png?sig=SECRETSIG" srcset="/a.png?s=SECRETSET 1x, /b.png?s=SECRETSET2 2x">
<form action="/submit?csrf=SECRETCSRF"><button formaction="/alt?f=SECRETFORM">go</button></form>
<style>.inline { background: url("${big}") }</style>
<div data-long="${'L'.repeat(40)}" data-short="ok" title="${big}">x</div>
<img src="${big}">
</body></html>`);
const url = `http://127.0.0.1:${server.port}/index.html`;
// Own daemon: BROWSE_STATE_FILE scopes the state dir, lock, port file, and
// profile to this test, so it never shares (or stops) another session's daemon.
fs.mkdirSync(path.join(tmp, '.gstack'), { recursive: true });
const env = { ...process.env, BROWSE_STATE_FILE: path.join(tmp, '.gstack', 'browse.json') };
const browse = (args: string[]) => spawnSync(BROWSE!, args, { encoding: 'utf-8', timeout: 90_000, env });
try {
// A cold daemon start can miss the CLI's ~8 s health window on a loaded
// machine (CI shards, a concurrent eval run). Bounded retries, then fail loud.
let go = browse(['goto', url]);
for (let attempt = 0; attempt < 6 && go.status !== 0; attempt++) {
Bun.sleepSync(10_000);
go = browse(['goto', url]);
}
expect(go.status, go.stderr + go.stdout).toBe(0);
// The same invocation the skill renders for the fallback engine: the arrow
// function spliced from lib/dom-dump.js and called in the page.
const dump = fs.readFileSync(path.join(ROOT, 'lib', 'dom-dump.js'), 'utf-8');
const out = path.join(tmp, 'index.dom.html');
const ev = browse(['js', `(${dump})()`, '--out', out, '--raw']);
expect(ev.status, ev.stderr + ev.stdout).toBe(0);
const html = fs.readFileSync(out, 'utf-8');
expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
expect(html).toContain(`<style ${DOM_DUMP_STYLE_ATTR}=""`);
expect(html).toContain('#6366f1');
expect(html).not.toMatch(/<link[^>]*href="styles\.css"/);
expect(html).toMatch(/<link[^>]*cross-origin\.css/);
expect(html).toContain(`<!-- ${DOM_DUMP_NOTE_PREFIX} `);
expect(html).toContain('cross-origin stylesheets not resolved');
expect(html).toContain('scripts stripped: 1');
expect(html).not.toContain('SCRIPT BODY');
expect(html).not.toContain('SECRET INPUT');
expect(html).not.toContain('SECRET TEXT');
expect(html).not.toContain('SECRET DESCRIPTION');
expect(html).toContain('content="width=device-width"');
expect(html).toContain('href="/page"');
expect(html).not.toContain('token=SECRET');
expect(html).not.toContain('SECRETSIG');
expect(html).not.toContain('SECRETSET');
expect(html).not.toContain('SECRETCSRF');
expect(html).not.toContain('SECRETFORM');
expect(html).toContain('srcset="/a.png 1x, /b.png 2x"');
expect(html).not.toContain('L'.repeat(40));
expect(html).toContain('data-short="ok"');
expect(html).not.toContain('A'.repeat(1500));
expect(html).toContain('data:,gstack-stripped');
} finally {
server.stop(true);
try { browse(['stop']); } catch {}
fs.rmSync(site, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
}
}, 120_000);
});
+2 -2
View File
@@ -1758,7 +1758,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
@@ -2414,7 +2414,7 @@ you missed it.>
## Design Review
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">
## Eval Results
+2 -2
View File
@@ -1765,7 +1765,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
4. **Apply the design checklist** against the changed files. For each item:
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
@@ -2849,7 +2849,7 @@ you missed it.>
## Design Review
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">
## Eval Results
+32 -7
View File
@@ -13,7 +13,12 @@
"upstream": "crates/live/assets/antipatterns.json",
"commit": "87d8f6d686782561fb572758d9a9bb8596a1a0e7",
"entries": 61,
"fields": ["id", "name", "category", "description"],
"fields": [
"id",
"name",
"category",
"description"
],
"note": "no severity or advisory field in the registry; advisory status (em-dash-overuse) is engine-side"
},
"captures": {
@@ -23,25 +28,45 @@
"cwd": "a copy of test/fixtures/review-eval-design-slop.{html,css} named index.html + styles.css so the <link> resolves",
"exit": 2,
"stderrBytes": 0,
"pathsNormalized": { "index.html": "test/fixtures/review-eval-design-slop.html", "styles.css": "test/fixtures/review-eval-design-slop.css" }
"pathsNormalized": {
"index.html": "test/fixtures/review-eval-design-slop.html",
"styles.css": "test/fixtures/review-eval-design-slop.css"
}
},
"impeccable-detect-dom-sample.json": {
"mode": "dom",
"dump": "review-eval-design-slop.dom.html",
"dumpedWith": "browse goto http://127.0.0.1:<port>/index.html; browse eval dom-dump.js --out <tmp>/review-eval-design-slop.dom.html --raw (script: lib/dom-dump-script.ts DOM_DUMP_SCRIPT)",
"dumpedWith": "browse goto http://127.0.0.1:<port>/index.html; browse js \"($(cat lib/dom-dump.js))()\" --out <tmp>/review-eval-design-slop.dom.html --raw (the arrow function in lib/dom-dump.js, called in the page; same form the skill renders for the fallback engine)",
"command": "impeccable detect --json review-eval-design-slop.dom.html",
"exit": 2,
"stderrBytes": 0,
"pathsNormalized": { "review-eval-design-slop.dom.html": "test/fixtures/review-eval-design-slop.dom.html", "http://127.0.0.1:<port>/": "http://127.0.0.1/" }
"pathsNormalized": {
"review-eval-design-slop.dom.html": "test/fixtures/review-eval-design-slop.dom.html",
"http://127.0.0.1:<port>/": "http://127.0.0.1/"
},
"cwd": "a temp dir holding only the dump: the engine searches upward from cwd for DESIGN.md, so scanning inside this repo (whose DESIGN.md is in the open format) adds design-system-* findings that the source sample, captured before the conversion, does not have"
},
"impeccable-detect-help.txt": { "command": "impeccable detect --help" }
"impeccable-detect-help.txt": {
"command": "impeccable detect --help"
}
},
"findingFields": ["antipattern", "name", "description", "severity", "category", "file", "line", "snippet"],
"findingFields": [
"antipattern",
"name",
"description",
"severity",
"category",
"file",
"line",
"snippet"
],
"notes": [
"HTML-mode findings carry line 0; snippet is the locator",
"the static engine reads inline <style> in a .html file: the DOM dump yields the same id set as the source scan",
"without the rgb()->hex fold in the dump script the DOM scan loses ai-color-palette (CSSOM serializes hex as rgb)",
"a <link rel=stylesheet> left in the dump makes the engine warn on stderr about an unreadable stylesheet; the dump script removes inlined links",
"the planted fixture has no border-left, so side-tab never fires on it; ai-color-palette is the deterministic slop id"
"the planted fixture has no border-left, so side-tab never fires on it; ai-color-palette is the deterministic slop id",
"engine 0.1.3 loads a gstack-emitted (spec-format) DESIGN.md from cwd: with the repo's converted DESIGN.md beside a violating page it emits design-system-color, design-system-font, and design-system-radius; without the file only low-contrast (verified 2026-09-08)",
"the engine discovers DESIGN.md by walking up from the scan's cwd (verified: a dump scanned from test/fixtures picked up the repo root DESIGN.md and emitted design-system-color/font/radius); the committed samples are scanned outside the repo so they pin page rules only"
]
}
+1 -1
View File
@@ -45,7 +45,7 @@ button { outline: none; background: #6366f1; color: white; border-width: medium;
<div class="footer">
<p class="override">Unlock the power of our platform today</p>
<a href="#" class="small-link">Terms of Service</a>
<a href="" class="small-link">Terms of Service</a>
</div>
+2
View File
@@ -40,6 +40,8 @@ const SAMPLES: Array<[string, boolean]> = [
['package.json', false],
['test/foo.test.ts', false],
['components.md', false],
['public/Index.HTML', false], // bash globs are case-sensitive; the mirror must agree
['src/App.TSX', false],
];
describe('isFrontendPath', () => {
+2 -2
View File
@@ -1888,7 +1888,7 @@ describe('DESIGN_DETECTOR resolver', () => {
expect(c).toContain('scan --changed <base> --format gstack --host claude');
expect(c).toContain('### DOM dump (DOM mode only');
expect(c).toContain('data-gstack-dom-css');
expect(c).toContain('$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw');
expect(c).toContain('$B js "($_DUMP)()" --out "$_TMP/{page}.dom.html" --raw');
expect(c).toContain('DOM_DUMP_OK');
expect(c).toContain('DOM_DUMP_REDACTION_BLOCKED');
expect(c).toContain('DOM_DUMP_TOO_LARGE');
@@ -1906,7 +1906,7 @@ describe('DESIGN_DETECTOR resolver', () => {
expect(c).not.toContain('document.documentElement.cloneNode');
expect(c).toContain('_DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
expect(c).toContain('const html = await pg.evaluate($_DUMP);');
expect(c).toContain('cp "$HOME/.claude/skills/gstack/lib/dom-dump.js" "$_TMP/"');
expect(c).toContain('_TMP=$(mktemp -d); _DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
});
test('design-html carries the probe and the bounded slop gate', () => {
+13
View File
@@ -250,3 +250,16 @@ export function extractSkillHead(skillDir: string, bodyLineCount = 30): string {
const head = bodyLines.slice(0, bodyLineCount).join('\n').trimEnd();
return `${frontmatter}\n${head}\n\n<!-- body truncated by test/helpers/skill-fixture.ts — routing fixture needs frontmatter only -->\n`;
}
/**
* Slice a rendered skill between two literal markers. Both must exist: a
* missing END marker would silently hand the agent the rest of the file, which
* is exactly the "copied the whole SKILL.md" failure the E2E fixtures avoid.
*/
export function sliceBetween(text: string, start: string, end: string): string {
const i = text.indexOf(start);
if (i < 0) throw new Error(`skill fixture: start marker not found: ${start}`);
const j = text.indexOf(end, i + start.length);
if (j < 0) throw new Error(`skill fixture: end marker not found after start: ${end}`);
return text.slice(i, j);
}
+9 -5
View File
@@ -135,19 +135,23 @@ describe('DOM_DUMP_SCRIPT contract', () => {
expect(DOM_DUMP_SCRIPT).not.toContain("'");
expect(DOM_DUMP_SCRIPT).not.toContain('${');
expect(DOM_DUMP_SCRIPT).not.toContain('`');
expect(DOM_DUMP_SCRIPT.trim().startsWith('(() => {')).toBe(true);
expect(DOM_DUMP_SCRIPT.trim().endsWith('})()')).toBe(true);
// Parses as a JS expression (what `$B eval` and Aside `pg.evaluate` wrap).
// An arrow FUNCTION, not a self-calling IIFE: Aside's pg.evaluate(fn) runs it in
// the page; the fallback engine calls it with `$B js "($_DUMP)()"`.
expect(DOM_DUMP_SCRIPT.trim().startsWith('() => {')).toBe(true);
expect(DOM_DUMP_SCRIPT.trim().endsWith('}')).toBe(true);
expect(() => new Function('return ' + DOM_DUMP_SCRIPT)).not.toThrow();
expect(typeof new Function('return ' + DOM_DUMP_SCRIPT)()).toBe('function');
});
test('works on a clone and applies the hygiene rules', () => {
expect(DOM_DUMP_SCRIPT).toContain('document.documentElement.cloneNode(true)');
expect(DOM_DUMP_SCRIPT).toContain('"srcset"');
expect(DOM_DUMP_SCRIPT).toContain('"formaction"');
expect(DOM_DUMP_SCRIPT).toContain(DOM_DUMP_STYLE_ATTR);
expect(DOM_DUMP_SCRIPT).toContain(DOM_DUMP_NOTE_PREFIX);
for (const rule of ['querySelectorAll("script")', 'querySelectorAll("textarea")', 'value.length > 32',
'name === "content" && el.nodeName === "META"', 'value.split("?")[0]', 'value.length > 1024',
'gstack-stripped', 'cloneLinks[i].remove()']) {
'name === "content" && el.nodeName === "META"', 'cutQuery(value)', 'value.length > 1024',
'gstack-stripped', 'cloneLinks[i].remove()', 'querySelectorAll("style")']) {
expect(DOM_DUMP_SCRIPT).toContain(rule);
}
});
+43 -26
View File
@@ -9,6 +9,8 @@ import {
createEvalCollector, finalizeEvalCollector, browseBin,
} from './helpers/e2e-helpers';
import { asideAvailable } from './helpers/aside-available';
import { installFakeImpeccable, DETECT_SAMPLE } from './helpers/fake-impeccable';
import { sliceBetween } from './helpers/skill-fixture';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
@@ -141,7 +143,10 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
// language" prose without any of the original four literals (run
// 33090283032, both attempts; inputs identical to the prior passing
// run 32899975845 — vocabulary variance, not a generation regression).
'Aesthetic': ['aesthetic', 'visual direction', 'design direction', 'visual identity', 'design language', 'visual language', 'design principle', 'look and feel', 'art direction'],
// Widened again 2026-09-08: the open DESIGN.md format's Overview opens with a
// "Creative North Star" and "Key characteristics" instead of an Aesthetic
// Direction heading (the judge passed both CI attempts on the vocabulary).
'Aesthetic': ['aesthetic', 'visual direction', 'design direction', 'visual identity', 'design language', 'visual language', 'design principle', 'look and feel', 'art direction', 'north star', 'key characteristics', '## overview'],
'Typography': ['typography', 'type', 'font', 'typeface'],
'Color': ['color', 'colour', 'palette', 'colors'],
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
@@ -264,11 +269,21 @@ Do NOT generate a full DESIGN.md — just research notes.`,
}, CAPTURE_LONG_MS);
testConcurrentIfSelected('design-consultation-existing', async () => {
// Pre-create a minimal DESIGN.md (independent of core test)
// Pre-create a LEGACY-format DESIGN.md (gstack's pre-spec shape, no marker) so
// Phase 0's format check has a real decision to make.
fs.writeFileSync(path.join(designDir, 'DESIGN.md'), `# Design System — CivicPulse
## Product Context
- **What this is:** Civic data platform
## Aesthetic Direction
- **Direction:** Industrial/Utilitarian
## Typography
Body: system-ui
- **Body:** system-ui
## Color
- **Primary:** #1D4ED8
`);
const result = await runSkillTest({
@@ -276,7 +291,7 @@ Body: system-ui
There is already a DESIGN.md in this repo. Update it with a complete design system for CivicPulse, a civic tech data platform for government employees.
Skip research. Skip font preview. Skip any AskUserQuestion calls this is non-interactive.`,
Run Phase 0's DESIGN.md format check exactly as written (the gstack bin directory is ${ROOT}/bin). Skip research. Skip font preview. Skip any AskUserQuestion calls this is non-interactive: where the skill asks whether to convert the legacy file, take option A (convert) without asking.`,
workingDirectory: designDir,
maxTurns: 20,
timeout: CAPTURE_LONG_MS,
@@ -298,11 +313,21 @@ Skip research. Skip font preview. Skip any AskUserQuestion calls — this is non
const hasColor = designContent.toLowerCase().includes('color');
const hasSpacing = designContent.toLowerCase().includes('spacing');
// Phase 0 format decision: the check ran, and the file left behind is either
// converted to the open format (marker on line 2) or explicitly kept legacy
// (marker on line 1). Either is the persisted-choice contract; "neither" is the bug.
const bash = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? ''));
const ranCheck = bash.some(c => c.includes('gstack-design-md.ts check'));
const marked = /^---\n# gstack: design-md-format=spec/.test(designContent) || designContent.startsWith('<!-- gstack: design-md-format=legacy-keep -->');
console.log(`design-consultation-existing: ranCheck=${ranCheck} marked=${marked}`);
recordE2E(evalCollector, '/design-consultation existing', 'Design Consultation E2E', result, {
passed: designExists && hasColor && hasSpacing && ['success', 'error_max_turns'].includes(result.exitReason),
passed: designExists && hasColor && hasSpacing && ranCheck && marked && ['success', 'error_max_turns'].includes(result.exitReason),
});
expect(['success', 'error_max_turns']).toContain(result.exitReason);
expect(ranCheck).toBe(true);
expect(marked).toBe(true);
expect(designExists).toBe(true);
if (designExists) {
expect(hasColor).toBe(true);
@@ -712,15 +737,6 @@ afterAll(async () => {
// section for the DOM case), never the 1,500-line SKILL.md, with the installed
// bin path pointed at THIS checkout so the test does not depend on ~/.claude.
const FAKE_ENGINE_SRC = path.join(ROOT, 'test', 'fixtures', 'fake-impeccable.ts');
const DETECT_SAMPLE = path.join(ROOT, 'test', 'fixtures', 'impeccable-detect-sample.json');
function sliceBetween(text: string, start: string, end: string): string {
const i = text.indexOf(start);
if (i < 0) throw new Error(`marker not found: ${start}`);
const j = text.indexOf(end, i + start.length);
return text.slice(i, j > i ? j : undefined);
}
/** design-review's detector prose with the installed bin/lib paths rewritten to this checkout. */
function detectorSkillText(sections: Array<[string, string]>): string {
@@ -731,10 +747,7 @@ function detectorSkillText(sections: Array<[string, string]>): string {
}
function makeFakeEngine(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-fake-impeccable-'));
fs.copyFileSync(FAKE_ENGINE_SRC, path.join(dir, 'impeccable'));
fs.chmodSync(path.join(dir, 'impeccable'), 0o755);
return dir;
return installFakeImpeccable('skill-e2e-fake-impeccable-').dir;
}
describeIfSelected('Design review detector shim E2E', ['design-review-detector-shim', 'design-review-detector-shim-dom'], () => {
@@ -789,7 +802,7 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
timeout: CAPTURE_MS,
testName: 'design-review-detector-shim',
runId,
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE },
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE },
});
logCost('/design-review detector shim (source)', result);
@@ -800,11 +813,13 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
expect(bash.some(c => c.includes('gstack-design-detect.ts probe'))).toBe(true);
expect(bash.some(c => /gstack-design-detect\.ts scan --changed main/.test(c))).toBe(true);
expect(bash.some(c => c.includes('npx impeccable'))).toBe(false);
expect(result.output).toContain('IMPECCABLE_READY');
// The sentinel is evidence in the tool output and the report, not something the
// agent must repeat in its closing message.
const toolOutputs = result.toolCalls.map(c => String(c.output ?? '')).join('\n');
const outPath = path.join(repoDir, 'detector-output.md');
expect(fs.existsSync(outPath)).toBe(true);
const out = fs.readFileSync(outPath, 'utf-8');
expect(toolOutputs.includes('IMPECCABLE_READY') || out.includes('IMPECCABLE_READY')).toBe(true);
expect(out).toContain('FINDING-001');
expect(out).toContain('[ai-color-palette]');
expect(out).toContain('[low-contrast]');
@@ -837,6 +852,8 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
// REPORT_DIR must sit under <gstack home>/projects/<slug>/designs/ for the wrapper's allow-list.
const allowed = path.join(gstackHome, 'projects', 'shim', 'designs', 'design-audit-20260908');
fs.mkdirSync(path.join(allowed, 'dom', 'run1'), { recursive: true });
// The agent's $B commands and this test's cleanup share ONE daemon, scoped to this run.
const browseState = path.join(gstackHome, 'browse.json');
try {
const result = await runSkillTest({
prompt: `Read design-review-detector.md (the /design-review detector block + Phase 0) and design-review-dom-dump.md (the Phase 3 DOM dump section).
@@ -848,13 +865,13 @@ Do not run npx. Do not fix anything.`,
timeout: CAPTURE_LONG_MS,
testName: 'design-review-detector-shim-dom',
runId,
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE, GSTACK_HOME: gstackHome },
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE, GSTACK_HOME: gstackHome, BROWSE_STATE_FILE: browseState },
});
logCost('/design-review detector shim (dom)', result);
recordE2E(evalCollector, '/design-review detector shim (dom)', 'Design review detector shim E2E (DOM mode)', result);
expect(result.exitReason).toBe('success');
const bash = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? ''));
expect(bash.some(c => c.includes('dom-dump.js') && c.includes('--out') && c.includes('--raw'))).toBe(true);
expect(bash.some(c => c.includes('dom-dump.js') && c.includes('--out') && c.includes('--raw'))).toBe(true); // $B js "($_DUMP)()" with the file spliced in
expect(bash.some(c => /gstack-design-detect\.ts scan /.test(c) && c.includes('dom/run1'))).toBe(true);
expect(bash.some(c => /gstack-design-detect\.ts scan --changed/.test(c))).toBe(false);
const dumps = fs.readdirSync(path.join(allowed, 'dom', 'run1')).filter(f => f.endsWith('.dom.html'));
@@ -865,7 +882,7 @@ Do not run npx. Do not fix anything.`,
expect(out).toContain('static scan of the rendered DOM');
} finally {
server?.stop(true); server = null;
try { spawnSync(browseBin, ['stop'], { stdio: 'pipe', timeout: 10_000 }); } catch {}
try { spawnSync(browseBin, ['stop'], { stdio: 'pipe', timeout: 10_000, env: { ...process.env, BROWSE_STATE_FILE: browseState } }); } catch {}
for (const d of [site, reportDir, gstackHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
}
},
@@ -892,7 +909,7 @@ describeIfSelected('Design HTML slop gate E2E', ['design-html-slop-gate'], () =>
engineDir = makeFakeEngine();
const full = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
const text = [
sliceBetween(full, '**Design detector (optional, deterministic):**', '<!-- SECTION_INDEX'),
sliceBetween(full, '**Design detector (optional, deterministic):**', '## Step 0: Input Detection'),
sliceBetween(full, '### Slop Gate (bounded, never a loop)', '### Verification Screenshots'),
].join('\n\n---\n\n').replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT);
fs.writeFileSync(path.join(workDir, 'design-html-gate.md'), text);
@@ -913,7 +930,7 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
timeout: CAPTURE_MS,
testName: 'design-html-slop-gate',
runId,
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE },
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE },
});
logCost('/design-html slop gate', result);