);');
expect(tsx.kind).toBe('jsx');
expect(tsx.tags.map((t) => t.name).filter((n) => n === 'section' || n === 'h1')).toEqual(['section', 'h1', 'h1', 'section']);
expect(tsx.classAt.get(tsx.tags.find((t) => t.name === 'section')!.pos)).toEqual(['bg-indigo-500', 'p-4']);
});
});
describe('design-lint: the rule table', () => {
test('every id is kebab-case with a tier, a source gate, a title and a fix; docs/design-lint.md names each one', () => {
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'design-lint.md'), 'utf8');
for (const [id, m] of Object.entries(RULES)) {
expect(id).toMatch(/^[a-z][a-z0-9-]*$/);
expect(['blocks', 'ask', 'polish']).toContain(m.tier);
expect(m.gate.length).toBeGreaterThan(0);
expect(m.title.length).toBeGreaterThan(8);
expect(m.fix.length).toBeGreaterThan(3);
expect(docs).toContain('`' + id + '`');
}
expect(Object.keys(RULES).length).toBeGreaterThan(100);
expect(FAMILIES.map((f) => f.name)).toEqual(['color', 'type', 'structure', 'chrome', 'motion', 'assets', 'finish', 'component', 'copy']);
});
test('the three house rules gstack keeps at ask stay at ask, and the Floor rules the plan names block', () => {
expect(RULES['kicker-above-heading'].tier).toBe('ask');
expect(RULES['screen-word-budget'].tier).toBe('ask');
expect(RULES['em-dash-overuse'].tier).toBe('ask');
expect(RULES['overflow-x-unclipped'].tier).toBe('ask');
for (const id of ['purple-blue-gradient', 'nested-cards', 'wide-tracking', 'template-punctuation', 'missing-focus-visible', 'low-contrast', 'content-hidden-at-rest', 'hero-fold']) expect(RULES[id].tier).toBe('blocks');
});
});
describe('design-lint: the output contract', () => {
test('a finding prints as LINT: : | fix: ; a suspected blocking tell lands at ask; unknown ids throw', () => {
const r = new Reporter('page', '/cwd');
r.report('nested-cards', '/cwd/site/index.html', 12, 'div.card > div.card', {});
r.report('invented-proof', '/cwd/site/index.html', 30, '"Trusted by 500+ teams"', { suspected: true });
r.report('section-gap', '/cwd/site/index.html', 1, 'x');
expect(formatFinding(r.findings[0])).toBe('LINT: blocks nested-cards site/index.html:12 div.card > div.card | fix: one containment layer');
expect(r.findings[1].tier).toBe('ask');
expect(() => r.report('no-such-rule', 'f', 1, 'x')).toThrow(/unknown rule id/);
const component = new Reporter('component', '/cwd');
component.report('section-gap', '/cwd/a.tsx', 1, 'page-only rule under component scope');
expect(component.findings).toEqual([]);
});
test('blocks exit 2, an unanswered ask exits 1, polish alone exits 0; waived is counted apart from ask and never summed into blocks', () => {
const base = { file: 'a.html', line: 1, evidence: 'e', fix: 'f', source: 'static' as const };
const blocks = { rule: 'nested-cards', tier: 'blocks' as const, grade: 'blocks' as const, ...base };
const ask = { rule: 'section-gap', tier: 'ask' as const, grade: 'ask' as const, ...base };
const waived = { ...ask, grade: 'waived' as const, waiver: 'because the ledger separates rows with rules' };
const polish = { rule: 'straight-quotes', tier: 'polish' as const, grade: 'polish' as const, ...base };
expect(exitCodeOf(summarize([blocks, ask, polish], 'skipped: x'))).toBe(2);
expect(exitCodeOf(summarize([ask, polish], 'ran'))).toBe(1);
expect(exitCodeOf(summarize([polish, waived], 'ran'))).toBe(0);
expect(formatSummary(summarize([blocks, waived, waived, polish], 'ran'))).toBe('LINT_SUMMARY: blocks=1 ask=0 waived=2 polish=1 render=ran');
expect(finalize([polish, ask, blocks, blocks]).map((f) => f.grade)).toEqual(['blocks', 'ask', 'polish']);
expect(formatFinding(waived)).toContain('| fix: waived (because the ledger');
});
test('the bin prints the contract and exits by it; --rules lists the table; a usage error exits 3', () => {
const slopped = runBin(['test/fixtures/design-lint/slopped.html']);
expect(slopped.code).toBe(2);
const lines = slopped.out.trim().split('\n');
for (const l of lines.slice(0, -1)) expect(l).toMatch(/^LINT: (blocks|ask|polish|waived) [a-z][a-z0-9-]* \S+:\d+ .+ \| fix: .+$/);
expect(lines.at(-1)).toMatch(/^LINT_SUMMARY: blocks=\d+ ask=\d+ waived=\d+ polish=\d+ render=skipped: not requested$/);
expect(runBin(['--rules']).out).toContain('nested-cards');
expect(runBin([]).code).toBe(3);
expect(runBin(['--scope', 'app', 'x.html']).code).toBe(3);
const json = JSON.parse(runBin(['--json', 'test/fixtures/design-lint/slopped.html']).out);
expect(json.summary.blocks).toBeGreaterThan(0);
expect(Array.isArray(json.findings)).toBe(true);
});
test('a clean page passes: exit 0 and LINT_SUMMARY blocks=0 ask=0', async () => {
const file = tmpPage(fs.readFileSync(path.join(FIXTURES, 'clean', 'index.html'), 'utf8'));
const result = await lint([file], { cwd: path.dirname(file) });
const unexpected = result.findings.filter((f) => f.grade === 'blocks' || f.grade === 'ask');
expect(unexpected.map(formatFinding)).toEqual([]);
expect(result.exitCode).toBe(0);
expect(formatResult(result)).toContain('LINT_SUMMARY: blocks=0 ask=0');
});
// Value: protects=every usage exit (3) the bin promises and --help's 0; fails_when=a flag check is dropped or a usage error falls through to lint() and exits 0; why_new=the bin test only covers no-args and --scope; seam=none; no_claim=stderr wording beyond the key phrase
test('the bin exits 3 for a non-http --url, an unknown flag, a flag without its value, a path it cannot read and a tree with nothing to lint; --help exits 0', () => {
const url = runBin(['--url', 'ftp://example.test/page']);
expect([url.code, url.err]).toEqual([3, expect.stringContaining('http or https')]);
const flag = runBin(['--bogus', 'x.html']);
expect([flag.code, flag.err]).toEqual([3, expect.stringContaining('unknown flag --bogus')]);
const value = runBin(['x.html', '--job']);
expect([value.code, value.err]).toEqual([3, expect.stringContaining('--job needs a value')]);
const missing = runBin(['/nowhere/design-lint/none.html']);
expect([missing.code, missing.err]).toEqual([3, expect.stringContaining('cannot read /nowhere/design-lint/none.html')]);
expect(missing.err).toContain('no .html, .css, .jsx, .tsx, .vue, .svelte or .astro files found');
const nothing = runBin([tmpTree({ 'notes.txt': 'not a page', 'data.json': '{}' })]);
expect([nothing.code, nothing.out]).toEqual([3, '']);
const help = runBin(['--help']);
expect([help.code, help.out.trim(), help.err]).toEqual([0, expect.stringMatching(/^usage: gstack-design-lint ), '']);
});
// Value: protects=lint() wires --render/--url to the render tier: only HTML pages render, a URL is a target of its own; fails_when=render is requested but never reaches lintRender, or a URL target is dropped; why_new=lintRender is tested alone, the glue in lint() and the bin is not; seam=none; no_claim=the no-browser summary wording beyond its prefix
test('render requested on a tree without a page says so in the summary; --url with no file is a render target of its own', async () => {
const cssOnly = tmpTree({ 'tokens.css': ':root { --color-ink: oklch(22% 0.03 250); }', 'Hero.tsx': 'export const Hero = () =>
Ledger
;' });
const result = await lint([cssOnly], { cwd: cssOnly, render: true, warn: () => {} });
expect(result.files.map((f) => path.basename(f))).toEqual(['Hero.tsx', 'tokens.css']);
expect(result.summary.render).toBe('skipped: no HTML page to render');
expect(formatResult(result)).toMatch(/render=skipped: no HTML page to render$/);
const nothing = await lint([cssOnly], { cwd: cssOnly, warn: () => {} });
expect(nothing.summary.render).toBe('skipped: not requested');
const r = runBin(['--url', 'http://127.0.0.1:9/']);
if (browserAvailable()) {
expect(r.code).toBe(2);
expect(r.out).toMatch(/^LINT: blocks render http:\/\/127\.0\.0\.1:9\/:1 could not reach http:\/\/127\.0\.0\.1:9\/: .+ \| fix: .+$/m);
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=1 ask=0 waived=0 polish=0 render=ran$/m);
} else {
expect(r.code).toBe(0);
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: /m);
}
});
});
describe('design-lint: the static tier\'s files and contexts', () => {
// Value: protects=which files the walk reads and which stylesheet may carry a page's waiver; fails_when=vendored or built CSS is linted, or a linked stylesheet from elsewhere waives a page's finding; why_new=no test walks a tree with node_modules, dot or build directories, or a waiver in CSS; seam=none; no_claim=ordering inside one directory beyond sorted names
test('the walk skips node_modules, dot folders and build output below the top, warns on an unreadable path; a waiver in any stylesheet the page reads answers for that stylesheet', () => {
const dir = tmpTree({
'site/index.html': 'Ledger
The ledger
The ledger separates its rows with hairline rules.
',
'site/local.css': '/* gstack-lint-disable section-gap: the ledger separates its rows with rules */\n.row { border-top: 1px solid var(--color-line); }',
'shared/theme.css': '/* gstack-lint-disable template-nav: the ledger keeps three links for its three modes */\n:root { --color-line: oklch(85% 0.015 80); }',
'site/node_modules/lib/vendor.css': '.v { color: #123456; }',
'site/.cache/stale.html': '
stale
',
'site/dist/built.css': '.b { color: #654321; }',
'dist/top.css': '.t { color: var(--color-line); }',
});
const warned: string[] = [];
const files = collectFiles([path.join(dir, 'site'), path.join(dir, 'dist'), path.join(dir, 'site', 'index.html'), path.join(dir, 'gone')], (l) => warned.push(l));
expect(files.map((f) => path.relative(dir, f))).toEqual(['site/index.html', 'site/local.css', 'dist/top.css']);
expect(warned).toEqual([`design-lint: cannot read ${path.join(dir, 'gone')}`]);
const { contexts } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: (l) => { throw new Error(l); } });
expect(contexts).toHaveLength(1);
expect(contexts[0].cssFiles.map((c) => path.relative(dir, c.file))).toEqual(['site/local.css', 'shared/theme.css']);
const set = contextWaivers(contexts[0], dir);
expect([...set.accepted.entries()]).toEqual([['section-gap@site/local.css', 'the ledger separates its rows with rules'], ['template-nav@shared/theme.css', 'the ledger keeps three links for its three modes']]);
expect(set.refused).toEqual([]);
});
// Value: protects=two pages in one directory each own only the stylesheets they link; fails_when=grouping hands every stylesheet to every page, so one page's waiver answers the other's finding and the finding prints twice; why_new=every fixture directory holds one page; seam=none; no_claim=which page a stylesheet linked by neither belongs to
test('two pages in one directory: a finding in one page\'s stylesheet is reported once and is not waived by the other page', () => {
const dir = tmpTree({
'a.html': 'Ledger
', ``))).toEqual([]);
});
test('every copy-tier id is a lint rule; each but the shell check is a grep-detectable catalog entry whose fix the lint prints', () => {
expect(new Set(COPY_IDS).size).toBe(COPY_IDS.length);
for (const id of COPY_IDS) {
expect(RULES[id]?.gate, id).toBe('craft');
if (id === 'empty-mount-node') continue;
expect(catalogEntry(id)?.detect, id).toContain('grep');
expect(RULES[id].fix, id).toBe(catalogEntry(id)!.fix!);
}
expect(Object.keys(RULES).filter((id) => RULES[id].gate === 'craft').sort()).toEqual([...COPY_IDS].sort());
});
// Value: protects=checker-only craft entries render into no skill prose or checklist, and the lint reports each; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or a checker-only id has no lint rule; why_new=moved from test/design-craft.test.ts with the checker; seam=none; no_claim=byte size of any one skill (size guard owns it)
test('checker-only catalog entries are lint rules that no rendered list carries; catalog entries with a fix still render', () => {
const checkerOnly = DESIGN_SLOP_CATALOG.filter((e) => e.checkerOnly);
expect(checkerOnly.length).toBeGreaterThan(0);
const rendered = new Set([selectCatalog({}), detectorSlopEntries(), judgmentTellEntries(), checklistSlopEntries(), autoFixEntries()].flat().map((e) => e.id));
const prose = renderCatalog({ kind: 'slop' });
for (const e of checkerOnly) {
expect(RULES[e.id], e.id).toBeDefined();
expect(rendered.has(e.id), e.id).toBe(false);
expect(prose, e.id).not.toContain(e.prose);
}
for (const id of ['marketing-buzzword', 'layout-transition']) expect(rendered.has(id), id).toBe(true);
});
test('BUZZWORDS covers every word the catalog heuristic and the craft section name, and matches stems, not look-alikes', () => {
const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? '';
const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/);
expect(words.length).toBeGreaterThan(5);
for (const word of words) expect(BUZZWORDS).toContain(word);
const section = fs.readFileSync(path.join(ROOT, 'design-html', 'sections', 'craft.md.tmpl'), 'utf-8');
const buzz = section.match(/No buzzwords: ([^\n]+)\.\n/)?.[1].replace(/"/g, '').split(/,\s*/) ?? [];
expect(buzz.length).toBeGreaterThan(10);
for (const word of buzz) expect(BUZZWORDS).toContain(word);
expect(copy('
Unlocking seamless, next gen workflows that elevate teams
Take the elevator to the elevation map; streamers sold here
')).toEqual([]);
const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? [];
expect(names.length).toBe(5);
for (const name of names) expect(ids(`
${name}
`)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']);
});
test('the skip link is judged by position and target; aria-current is required only for route navigation', () => {
expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']);
expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']);
expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]);
expect(ids(CLEAN.replace('Skip to content', ''))).toEqual(['missing-skip-link']);
expect(ids(CLEAN.replace('HomePricing', 'FeaturesFAQ'))).toEqual([]);
expect(copy(CLEAN.replace(' aria-current="page"', '')).map((f) => [f.rule, f.line])).toEqual([['missing-aria-current', 4]]);
});
test('copy reads visible text: entities decode, svg text counts, and comments, scripts, styles and templates do not', () => {
expect(ids('
Start free trial')).toEqual([]);
});
// Value: protects=the documented craft-checker limits stay fixed in the fold: script payloads, single-quoted handlers, script text as presence, commented markup; fails_when=a code or presence rule reads the raw source again; why_new=#3433 listed these as checker limits for the follow-up; seam=none; no_claim=external scripts, which the lint does not fetch
test('a hydration payload, commented-out markup and script text are not page content; single-quoted handlers are read', () => {
expect(ids('