design-lint: fifth review cycle tests and docs

test/design-lint-fifth-cycle.test.ts (21 tests) pins the comment-dead placeholder, the linear-time digit run, 699k-rule sheet, unclosed JSX and tracked-label pages, per-sheet waivers and the remaining state-matrix cells, every wrong-shaped render field, and the rule judgements; the fourth-cycle shell test asserts the observable effect, the RSS assertion is structural, time bounds are widened for shared CI shards, the stale empty block is gone, and the FREE_FIXTURES inventory names both cycle files. docs/design-lint.md states the project-root bound on relative hrefs and the three narrow readings (whole-page focus ring, unconditional 4px scale, scroll-container-blind double-sticky).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 10:06:28 +00:00
1 parent 1595ff68c2
commit 3f0c62582c
5 files changed
+274 -23

No files matched your search

+13 -1
View File
@@ -32,7 +32,10 @@ gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope pa
A page or stylesheet under a lint root that the lint skipped (over the cap,
unreadable, unparseable) is also one `internal-error` finding at ask, so a
page the lint could not read is not a pass; a linked stylesheet over the cap
is only warned about, because the page that links it is still read.
is only warned about, because the page that links it is still read. A
relative stylesheet href may not climb out of the project (the nearest
directory holding a `package.json` or `.git`); outside any project it is
bounded only by the disk, and only `.css` files are read.
- Markup inside an HTML comment is dead for every rule, a commented-out
`<style>` or `<script>` included. A tree nests at most 512 elements deep
(the browser's own cap); a start tag deeper than that is read as empty. An
@@ -312,6 +315,15 @@ deduplicated at the end, so forty pages on one bundle cost forty passes over
it. `overused-font` and `banned-font` match a catalog stem to the whole
family name or its first words (`Cormorant Garamond`, `DM Serif Display`,
`Comic Sans MS`), never to a longer word (`Interstate` is not `Inter`).
Three checklist items are read more narrowly here than the LLM pass reads
them: `missing-focus-visible` reports a removed outline only when no
`:focus` rule anywhere on the page draws a ring (the per-element check is the
render tier's focus walk); `off-scale-spacing` holds every px length to the
4px scale whether or not DESIGN.md declares one (`raw-spacing` is the rule
gated on `--space-*` tokens); and `double-sticky` pairs any two `top: 0`
sticky selectors without reading their scroll containers, so a sticky table
header inside an `overflow: auto` wrapper under a sticky nav reports and
wants a waiver on the record.
## What is deliberately not ported
+239
View File
@@ -0,0 +1,239 @@
/**
* The fifth review cycle's regressions: the last comment-blanked reader, hostile inputs that stayed quadratic or blew
* the stack (a digit run, 699k rules, unclosed JSX, thousands of tracked labels), per-sheet waivers, the render tier
* holding its measured findings against every wrong-shaped field, and the rule judgements the design pass asked for.
*/
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 type { RenderResult, RenderSpec } from '../lib/aside-render';
import { parseDoc } from '../lib/design-lint/html';
import { lint } from '../lib/design-lint/index';
import { lintRender, type Runner } from '../lib/design-lint/render';
import { formatFinding, Reporter, type Finding } from '../lib/design-lint/report';
import { lintStatic, MAX_FILE_BYTES, resolveRef } from '../lib/design-lint/static';
import { twDataText } from '../lib/design-lint/tailwind';
import { FLOOR_REFUSAL, resolveWaivers } from '../lib/design-lint/waivers';
const tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --color-accent: oklch(50% 0.16 30); --color-accent-ink: oklch(98% 0.01 30); --color-muted: oklch(50% 0.02 250); --font-display: "Literata", serif; --font-body: "Public Sans", sans-serif; }';
const HEAD = '<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Harbour office</title><meta name="description" content="A test page."><link rel="icon" href="data:,">\n';
const page = (css: string, body: string): string => HEAD + '<style>\n' + TOKENS + '\n' + css + '\n</style>\n</head>\n<body>\n' + body + '\n</body>\n</html>\n';
function tree(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-cycle5-'));
tmpDirs.push(dir);
for (const [name, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
fs.writeFileSync(path.join(dir, name), text);
}
return dir;
}
const quiet = { warn: () => {} };
const run = (html: string, files: Record<string, string> = {}): Finding[] => { const dir = tree({ 'index.html': html, ...files }); return lintStatic([dir], { cwd: dir, ...quiet }).findings; };
const only = (f: Finding[], rule: string) => f.filter((x) => x.rule === rule);
const timed = (fn: () => void): number => { const t = performance.now(); fn(); return performance.now() - t; };
describe('design-lint fifth cycle: dead markup and hostile inputs', () => {
test('an emoji placeholder inside an HTML comment is dead', () => {
const f = run(page('', '<main><h1>Hi there</h1><p>Text.</p>\n<!-- <input placeholder="🚀 Search"> -->\n</main>'));
expect(only(f, 'emoji-decoration')).toEqual([]);
const live = run(page('', '<main><h1>Hi there</h1><form><input placeholder="🚀 Search"></form></main>'));
expect(only(live, 'emoji-decoration').length).toBe(1);
});
test('a long digit run, a long mono word and a run of scheme prefixes each cost their length', () => {
const dir = tree({ 'index.html': page('', '<main><h1>Hi there</h1><p>' + '7'.repeat(200_000) + '</p><p>' + 'https://'.repeat(20_000) + '</p></main>') });
expect(timed(() => lintStatic([dir], { cwd: dir, ...quiet }))).toBeLessThan(4000);
expect(timed(() => twDataText('a'.repeat(400_000)))).toBeLessThan(500);
});
test('a 2 MB stylesheet of 699k rules lints instead of blowing the stack', () => {
const dir = tree({ 'big.css': 'a{}'.repeat(Math.floor(MAX_FILE_BYTES / 3) - 1), 'index.html': HEAD + '<link rel="stylesheet" href="big.css"></head><body><main><h1>Hello there</h1><p>Body.</p></main></body></html>' });
expect(() => lintStatic([dir], { cwd: dir, ...quiet })).not.toThrow();
});
test('a JSX file of unclosed elements parses in linear time', () => {
const times = [5000, 10000].map((n) => timed(() => parseDoc('Page.tsx', 'export default function P(){ return (<div>' + '<Card className="p-4">text\n'.repeat(n) + '</div>); }')));
expect(times[1]).toBeLessThan(Math.max(times[0] * 3, 300));
expect(times[1]).toBeLessThan(4000);
});
test('a page of many tracked-caps labels lints in linear time and a well-formed JSX file still yields its classes', () => {
const times = [4000, 8000].map((n) => { const dir = tree({ 'index.html': page('', '<main><h1>Hello world</h1>' + '<span class="text-xs uppercase tracking-widest">Label one</span>\n'.repeat(n) + '</main>') }); return timed(() => lintStatic([dir], { cwd: dir, ...quiet })); });
expect(times[1]).toBeLessThan(Math.max(times[0] * 2.5, 500));
const doc = parseDoc('Page.tsx', 'export default function P(){ return (<div className="p-4"><Card className="rounded-xl">x</Card></div>); }');
expect(doc.classAt.size + doc.virtuals.length).toBeGreaterThan(0);
});
test('a fact after an inline close tag is counted in its own section', () => {
const sec = (i: number) => '<section><h2>S' + i + '</h2><p><strong>Price:</strong> $5 a month</p></section>';
const f = run(page('', '<main><h1>Hi there</h1>' + [0, 1, 2, 3].map(sec).join('') + '</main>'));
expect(only(f, 'repeated-fact').length).toBe(1);
});
test('a relative stylesheet href that climbs out of the project attaches nothing; inside the project it attaches', () => {
const dir = tree({ 'site/package.json': '{}', 'site/pages/index.html': HEAD + '<link rel="stylesheet" href="../../outside.css"></head><body><main><h1>t</h1><p>Body.</p></main></body></html>', 'outside.css': 'html, body { overflow-x: clip; }' });
const r = lintStatic([path.join(dir, 'site')], { cwd: dir, ...quiet });
expect(r.contexts[0].cssFiles).toEqual([]);
expect(only(r.findings, 'overflow-x-unclipped').length).toBe(1);
expect(resolveRef('css/site.css', path.join(dir, 'site'), path.join(dir, 'site'))).toBe(path.join(dir, 'site', 'css/site.css'));
expect(resolveRef('../outside.css', path.join(dir, 'site'), path.join(dir, 'site'))).toBeNull();
});
test('an unreadable top-level path beside a readable page is one internal-error finding with its own fix', () => {
const dir = tree({ 'index.html': page('', '<main><h1>Hi</h1><p>Body.</p></main>') });
const r = lintStatic([dir, path.join(dir, 'nowhere.html')], { cwd: dir, ...quiet });
expect(r.files.map((f) => path.basename(f))).toEqual(['index.html']);
expect(only(r.findings, 'internal-error').map((f) => [f.grade, f.file, f.line, /cannot be read/.test(f.evidence), f.fix])).toEqual([['ask', 'nowhere.html', 1, true, 'check the path']]);
});
});
describe('design-lint fifth cycle: waivers per sheet and the untested cells', () => {
const claim = (rule: string, reason: string) => ({ rule, reason, file: 'a.html', line: 3 });
const PAGE_TEXT = 'The manifesto sets long declarative lines. The ledger separates its rows with rules. Three links for the paper modes.';
test('two linked stylesheets each waive the same rule for themselves; a short second claim is refused for its reason', () => {
const sheets = (b: string) => ({
'index.html': HEAD + '<link rel="stylesheet" href="a.css"><link rel="stylesheet" href="b.css"></head><body><main><h1>The ledger</h1><p>The ledger keeps its grey rows grey.</p></main></body></html>',
'a.css': '/* gstack-lint-disable zero-chroma-neutrals: the ledger keeps its grey rows grey in the theme sheet */\n' + TOKENS + '\n:root { --color-gray: oklch(55% 0 0); }\n.row { border-color: var(--color-gray); }',
'b.css': '/* gstack-lint-disable zero-chroma-neutrals: ' + b + ' */\n:root { --color-gray-print: oklch(45% 0 0); }\n.plate { color: var(--color-gray-print); }',
});
const both = lintStatic([tree(sheets('the print sheet keeps grey plates grey for the press'))], { cwd: tmpDirs[tmpDirs.length - 1], ...quiet }).findings;
expect(only(both, 'waiver')).toEqual([]);
expect(only(both, 'zero-chroma-neutrals').map((x) => [x.file, x.grade]).sort()).toEqual([['a.css', 'waived'], ['b.css', 'waived']]);
const short = lintStatic([tree(sheets('short'))], { cwd: tmpDirs[tmpDirs.length - 1], ...quiet }).findings;
expect(only(short, 'waiver').map((x) => x.evidence)).toEqual(['waiver on zero-chroma-neutrals refused: reason under 24 characters']);
expect(only(short, 'zero-chroma-neutrals').map((x) => [x.file, x.grade]).sort()).toEqual([['a.css', 'waived'], ['b.css', 'ask']]);
});
test('a stylesheet claim with a boilerplate reason is refused and its finding stays ask', () => {
const dir = tree({ 'index.html': HEAD + '<link rel="stylesheet" href="t.css"></head><body><main><h1>Ledger</h1><p>Rows.</p></main></body></html>', 't.css': '/* gstack-lint-disable zero-chroma-neutrals: this is intentional and by design for the sheet */\n' + TOKENS + '\n:root { --color-gray: oklch(55% 0 0); }\n.row { color: var(--color-gray); }' });
const f = lintStatic([dir], { cwd: dir, ...quiet }).findings;
expect(only(f, 'waiver').map((x) => x.evidence)).toEqual(['waiver on zero-chroma-neutrals refused: boilerplate reason']);
expect(only(f, 'zero-chroma-neutrals').map((x) => x.grade)).toEqual(['ask']);
});
test('a second page claim on an accepted rule is refused as the duplicate even when its reason is short, and three accepted plus one refused is not a house style', () => {
const dup = resolveWaivers([claim('gradient-text', 'the ledger separates its rows with rules'), claim('gradient-text', 'short')], PAGE_TEXT);
expect([...dup.accepted.keys()]).toEqual(['gradient-text']);
expect(dup.refused.map((f) => f.evidence)).toEqual(['waiver on gradient-text refused: one waiver per rule']);
const four = resolveWaivers([claim('line-length', 'the manifesto sets long declarative lines'), claim('section-gap', 'the ledger separates its rows with rules'), claim('template-nav', 'three links for the paper modes'), claim('overused-font', 'short')], PAGE_TEXT);
expect(four.accepted.size).toBe(3);
expect(four.refused.map((f) => f.rule)).toEqual(['waiver']);
});
test('suspected leaves an ask or polish rule at its own tier', () => {
const r = new Reporter('page', '/cwd');
r.report('section-gap', '/cwd/a.html', 1, 'x', { suspected: true });
r.report('text-sliver', '/cwd/a.html', 1, 'x', { suspected: true, source: 'render' });
expect(r.findings.map((f) => [f.tier, f.grade])).toEqual([['ask', 'ask'], ['polish', 'polish']]);
});
});
describe('design-lint fifth cycle: render answers of the wrong shape, field by field', () => {
const PAGE = HEAD + '</head><body><main><h1>t</h1><p class="faint">Body.</p><a href="#">Go</a></main></body></html>';
const SWATCH = path.join(path.resolve(import.meta.dir, '..'), 'test', 'fixtures', 'design-lint', 'render', 'swatch.png');
const SETUP = 1, FIRST = 2, FOCUS = 13;
const SCROLL = { rule: 'horizontal-scroll', grade: 'fail', evidence: 'the hero overflows' };
type Patch = (evals: Record<number, string>, spec: RenderSpec) => void;
const runner = (patch: Patch, stdout = ''): Runner => async (spec: RenderSpec): Promise<RenderResult> => {
const evals: Record<number, string> = {};
const shot = spec.steps.findIndex((s) => s.kind === 'screenshot');
if (shot < 0) {
evals[SETUP] = '{"lost":[]}';
for (let i = FIRST; i <= FOCUS; i++) evals[i] = '[]';
patch(evals, spec);
return { ok: true, engine: 'browse', outputs: [], evals, stdout };
}
spec.steps.forEach((s, i) => {
if (s.kind !== 'screenshot') return;
fs.copyFileSync(SWATCH, s.out);
const width = s.width ?? 0;
evals[i - 1] = JSON.stringify([{ x: 0, y: 0, w: width / 2, h: width }]);
});
return { ok: true, engine: 'browse', outputs: [], evals, stdout };
};
const measured = (f: Finding[]) => only(f, 'horizontal-scroll').map((x) => x.evidence);
const noAbort = (f: Finding[]) => only(f, 'render').filter((x) => /could not be measured/.test(x.evidence));
test('a non-string at.cls, a ring without its colour, a probe without fg and a non-string PAGE_ERRORS entry each keep the measured findings', async () => {
const dir = tree({ 'index.html': PAGE });
const file = path.join(dir, 'index.html');
const cls = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'nested-cards', grade: 'fail', evidence: 'x', at: { tag: 'p', text: 'Body.', cls: {} } }, SCROLL]); }) });
expect(measured(cls.findings)).toEqual(['[320px] the hero overflows']);
expect(only(cls.findings, 'nested-cards').map((f) => f.line)).toEqual([4]);
expect(noAbort(cls.findings)).toEqual([]);
const ring = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); ev[FOCUS] = JSON.stringify([{ key: 'a', path: 'main>a', at: { tag: 'a', text: 'Go' }, bare: false, ratio: 1.2, pad: 2 }]); }) });
expect(measured(ring.findings)).toEqual(['[320px] the hero overflows']);
expect(noAbort(ring.findings)).toEqual([]);
expect(only(ring.findings, 'render').map((f) => /\((.*?)\)/.exec(f.evidence)![1])).toEqual(['focus']);
const fg = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); ev[FIRST + 5] = JSON.stringify([{ rule: 'low-contrast', grade: 'fail', evidence: 'computed contrast 2.50:1 under the 4.5:1 threshold on p.faint (2.50:1)', probe: { path: 'main>p', threshold: 4.5 } }, { rule: 'low-contrast', grade: 'fail', evidence: 'y', probe: true }]); }) });
expect(measured(fg.findings)).toEqual(['[320px] the hero overflows']);
expect(only(fg.findings, 'low-contrast').map((f) => f.evidence)).toEqual(['[1280px] computed contrast 2.50:1 under the 4.5:1 threshold on p.faint (2.50:1)', '[1280px] y']);
expect(noAbort(fg.findings)).toEqual([]);
const errs = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); }, 'PAGE_ERRORS=[1]\n') });
expect(measured(errs.findings)).toEqual(['[320px] the hero overflows']);
expect(only(errs.findings, 'render')).toEqual([]);
});
test('a malformed entry claiming the render rule is reported as tampering with evidence on the line', async () => {
const dir = tree({ 'index.html': PAGE });
const file = path.join(dir, 'index.html');
const r = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'render', grade: 'x', evidence: 'y' }]); }) });
expect(only(r.findings, 'render').map((f) => f.evidence)).toEqual([expect.stringMatching(/does not know \(render\)/)]);
expect(formatFinding(only(r.findings, 'render')[0])).toMatch(/^LINT: blocks render \S+:\d+ .+ \| fix: .+$/);
});
test('a short claim on a rule the render tier measured is refused as the Floor after both tiers settle', async () => {
const dir = tree({ 'index.html': '<!-- gstack-lint-disable horizontal-scroll: table scrolls -->\n' + PAGE });
const r = await lint([dir], { cwd: dir, render: true, warn: () => {}, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); }) });
expect(only(r.findings, 'waiver').map((f) => f.evidence)).toEqual([`waiver on horizontal-scroll refused: ${FLOOR_REFUSAL}`]);
expect(only(r.findings, 'horizontal-scroll').map((f) => f.grade)).toEqual(['blocks']);
});
});
describe('design-lint fifth cycle: rule judgement', () => {
test('a textarea taller than 52px is not a control-height finding; a button is', () => {
const body = '<main><h1>Hi there</h1><form><textarea></textarea><button>Go</button></form></main>';
expect(only(run(page('textarea { min-height: 120px; }', body)), 'control-height')).toEqual([]);
expect(only(run(page('button { height: 30px; }', body)), 'control-height').length).toBe(1);
});
test('a border width change on an attribute-selected input is input-state-shift', () => {
const f = run(page('input[type="text"] { border: 1px solid #333; } input[type="text"]:focus { border: 2px solid #000; outline: 2px solid red; }', '<main><h1>Hi there</h1><form><input type="text"></form></main>'));
expect(only(f, 'input-state-shift').length).toBe(1);
});
test('an <i> holding only a glyph is not an italic word; a word is', () => {
expect(only(run(page('', '<main><h1>Our favourite tools <i class="icon">★</i></h1><p>Body.</p></main>')), 'italic-word-in-heading')).toEqual([]);
expect(only(run(page('', '<main><h1>Our <i>favourite</i> tools and more</h1><p>Body.</p></main>')), 'italic-word-in-heading').length).toBe(1);
});
test('a bounce keyframe by name on a UI selector is bounce-easing', () => {
const f = run(page('@keyframes bounce { from { transform: none } to { transform: translateY(-4px) } } .btn:hover { animation: bounce .4s; }', '<main><h1>Hi there</h1><a class="btn" href="#">Go</a></main>'));
expect(only(f, 'bounce-easing').map((x) => x.evidence)).toEqual([expect.stringMatching(/^bounce animation bounce on/)]);
});
test('a single-quoted inline style colour literal is literal-outside-tokens like its double-quoted twin', () => {
const single = run(page('', "<main><h1>Hi there</h1><p style='color:#ff0000'>x</p></main>"));
const double = run(page('', '<main><h1>Hi there</h1><p style="color:#ff0000">x</p></main>'));
expect(only(single, 'literal-outside-tokens').map((x) => x.evidence)).toEqual(only(double, 'literal-outside-tokens').map((x) => x.evidence));
expect(only(single, 'literal-outside-tokens').length).toBe(1);
});
test('a glass panel mixed from black names black; a tracked-only control is not called uppercase; emoji icon tiles stop at eight', () => {
const glass = run(page('.glass::after { backdrop-filter: blur(8px); background: color-mix(in oklch, black 40%, transparent); }', '<main><h1>Hi there</h1><div class="glass">x</div></main>'));
expect(only(glass, 'glow-wash').map((x) => x.evidence)).toEqual([expect.stringMatching(/translucent black/)]);
const tracked = run(page('.btn { font-size: 13px; letter-spacing: .06em; }', '<main><h1>Hi there</h1><a class="btn" href="#">Go now</a></main>'));
expect(only(tracked, 'wide-tracking').map((x) => x.evidence)).toEqual([expect.stringMatching(/^\.btn: a control tracked 0\.06em at 13px$/)]);
const tiles = run(page('', '<main><h1>Hi there</h1>' + Array.from({ length: 12 }, (_, i) => '<div class="card"><span class="icon">🚀</span><h3>T' + i + '</h3></div>').join('') + '</main>'));
expect(only(tiles, 'emoji-decoration').length).toBeLessThanOrEqual(8);
});
});
+10 -7
View File
@@ -54,9 +54,12 @@ describe('design-lint fourth cycle: commented-out markup is dead for every rule'
expect(only(after, 'literal-outside-tokens').concat(only(after, 'italic-word-in-heading'), only(after, 'broken-image'), only(after, 'fake-avatar'), only(after, 'generic-hero-copy'))).toEqual([]);
});
test('a commented-out module script and stylesheet link do not make the page a build-only app', () => {
const live = run(page('', '<main><h1>Harbour</h1><p>Rows.</p></main>\n<!-- <script type="module" src="/src/main.tsx"></script> -->'));
expect(only(live, 'unbuilt-source')).toEqual([]);
test('a commented-out module script does not make the page a build-only shell, so its page rules still run', () => {
const body = '<main><h1>Harbour</h1><p>Rows.</p></main>';
const shell = HEAD + '<script type="module" src="/src/main.tsx"></script></head><body>' + body + '</body></html>';
const commented = HEAD + '<!-- <script type="module" src="/src/main.tsx"></script> --></head><body>' + body + '</body></html>';
expect(only(run(shell), 'overflow-x-unclipped')).toEqual([]);
expect(only(run(commented), 'overflow-x-unclipped').length).toBe(1);
});
});
@@ -97,7 +100,7 @@ describe('design-lint fourth cycle: one pairing for every tree reader', () => {
}
const t = performance.now();
scriptSites('<template>' + '<script setup>\nconst a = 1;\n'.repeat(8000) + '</template>', 'vue');
expect(performance.now() - t).toBeLessThan(500);
expect(performance.now() - t).toBeLessThan(2000);
});
});
@@ -107,7 +110,7 @@ describe('design-lint fourth cycle: hostile CSS', () => {
expect(resolveVars('var(--a)', { '--a': 'var(--a) ' + 'x'.repeat(40) })).toContain('x');
expect(resolveVars('var(--a)', { '--a': 'var(--b)', '--b': 'var(--a)' })).toMatch(/var\(--[ab]\)/);
expect(resolveVars('var(--a)', { '--a': 'var(--b) 1px', '--b': 'red' })).toBe('red 1px');
expect(performance.now() - t).toBeLessThan(1000);
expect(performance.now() - t).toBeLessThan(2000);
const grown = resolveVars('var(--a)', { '--a': 'var(--b) var(--b)', '--b': 'var(--c) var(--c)', '--c': 'var(--d) var(--d)', '--d': 'var(--e) var(--e)', '--e': 'y'.repeat(2048) });
expect(grown.length).toBeLessThan(40000);
});
@@ -221,7 +224,7 @@ describe('design-lint fourth cycle: render answers of the wrong shape', () => {
}) });
expect(only(odd.findings, 'horizontal-scroll').map((f) => f.evidence)).toEqual(['[320px] the hero overflows']);
expect(only(odd.findings, 'nested-cards').map((f) => [f.grade, f.line])).toEqual([['blocks', 1]]);
expect(only(odd.findings, 'render').map((f) => /\((.*?)\)/.exec(f.evidence)![1])).toEqual(['nested-cards', '7']);
expect(only(odd.findings, 'render').map((f) => /\((.*?)\)/.exec(f.evidence)![1])).toEqual(['nested-cards', '7', 'focus']);
expect(only(odd.findings, 'missing-focus-visible')).toEqual([]);
});
@@ -248,7 +251,7 @@ describe('design-lint fourth cycle: never-waivable classes refuse by class befor
test('a short reason on an own rule, a measured Floor rule and a Floor rule nobody found is refused by class, never for its length', () => {
const measured = new Reporter('page', '/x');
measured.report('nested-cards', '/x/a.html', 4, 'div.card > div.card');
const claims = ['render', 'nested-cards', 'horizontal-scroll'].map((rule) => ({ rule, reason: 'fine here', file: '/x/a.html', line: 1, raw: '' }));
const claims = ['render', 'nested-cards', 'horizontal-scroll'].map((rule) => ({ rule, reason: 'fine here', file: '/x/a.html', line: 1 }));
const set = resolveWaivers(claims, 'the ledger closes the month', '/x', suspectedRules(measured.findings));
expect(set.refused.map((f) => f.evidence)).toEqual([
`waiver on render refused: ${OWN_RULE_REFUSAL}`,
+11 -14
View File
@@ -240,7 +240,7 @@ describe('design-lint: the static tier\'s files and contexts', () => {
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', { reason: 'the ledger separates its rows with rules', sheet: 'site/local.css' }], ['template-nav', { reason: 'the ledger keeps three links for its three modes', sheet: 'shared/theme.css' }]]);
expect([...set.accepted.entries()]).toEqual([['section-gap@site/local.css', { reason: 'the ledger separates its rows with rules', sheet: 'site/local.css' }], ['template-nav@shared/theme.css', { reason: 'the ledger keeps three links for its three modes', sheet: 'shared/theme.css' }]]);
expect(set.refused).toEqual([]);
});
@@ -283,7 +283,7 @@ describe('design-lint: the copy tier', () => {
describe('design-lint: waivers', () => {
const page = 'The manifesto sets long declarative lines on black paper for the ledger.';
const claim = (rule: string, reason: string) => ({ rule, reason, file: 'a.html', line: 3, raw: '' });
const claim = (rule: string, reason: string) => ({ rule, reason, file: 'a.html', line: 3 });
test('an ask or polish rule is waived with the rule id and a reason about the page; the Floor is refused', () => {
const set = resolveWaivers([claim('line-length', 'the manifesto sets long declarative lines'), claim('nested-cards', 'the manifesto nests its ledger on purpose for the paper')], page);
@@ -380,7 +380,7 @@ describe('design-lint: third review cycle', () => {
test('a comma-heavy nested selector and a long unclosed list each lint in linear time', async () => {
let t = performance.now();
parseCss('a{' + ','.repeat(80000) + '{color:red}}', 'x.css');
expect(performance.now() - t).toBeLessThan(1500);
expect(performance.now() - t).toBeLessThan(3000);
const dir = tmpTree({ 'index.html': '<!doctype html><html lang="en"><head><title>t</title></head><body><ul>' + '<li>item text here'.repeat(8000) + '</ul></body></html>' });
t = performance.now();
await lint([dir], { warn: () => {} });
@@ -487,15 +487,15 @@ describe('design-lint: hardening (ship review)', () => {
const suspected = new Reporter('page', '/x');
suspected.report('invented-proof', '/x/a.html', 4, 'the metric "10,000 teams" has no source on the page', { suspected: true });
const page = 'ten thousand teams use the product; the founder supplied the count';
const ok = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2, raw: '' }], page, '/x', suspectedRules(suspected.findings));
const ok = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2 }], page, '/x', suspectedRules(suspected.findings));
expect(applyWaivers(suspected.findings, ok).map((f) => [f.rule, f.tier, f.grade])).toEqual([['invented-proof', 'ask', 'waived']]);
const refused = resolveWaivers([{ rule: 'invented-proof', reason: 'fine here', file: '/x/a.html', line: 2, raw: '' }], page, '/x', suspectedRules(suspected.findings));
const refused = resolveWaivers([{ rule: 'invented-proof', reason: 'fine here', file: '/x/a.html', line: 2 }], page, '/x', suspectedRules(suspected.findings));
expect(applyWaivers(suspected.findings, refused).map((f) => [f.rule, f.tier, f.grade])).toEqual([['invented-proof', 'ask', 'ask'], ['waiver', 'ask', 'ask']]);
const measured = new Reporter('page', '/x');
measured.report('invented-proof', '/x/a.html', 4, 'measured');
measured.report('invented-proof', '/x/a.html', 9, 'suspected too', { suspected: true });
expect(suspectedRules(measured.findings).size).toBe(0);
const floor = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2, raw: '' }], page, '/x', suspectedRules(measured.findings));
const floor = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2 }], page, '/x', suspectedRules(measured.findings));
expect(floor.refused.map((f) => f.evidence)).toEqual(['waiver on invented-proof refused: it blocks; the Floor is never waivable']);
});
@@ -530,14 +530,12 @@ describe('design-lint: hardening (ship review)', () => {
test("the lint's own meta rules cannot be waived", () => {
const page = 'The manifesto sets long declarative lines on black paper for the ledger.';
for (const rule of ['internal-error', 'waiver', 'house-style', 'render']) {
const set = resolveWaivers([{ rule, reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
const set = resolveWaivers([{ rule, reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1 }], page);
expect([...set.accepted.keys()]).toEqual([]);
expect(set.refused.map((f) => f.evidence)).toEqual([`waiver on ${rule} refused: the lint's own rule, never waivable`]);
}
const unknown = resolveWaivers([{ rule: 'no-such-thing', reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
const unknown = resolveWaivers([{ rule: 'no-such-thing', reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1 }], page);
expect(unknown.refused.map((f) => f.evidence)).toEqual(['waiver on no-such-thing refused: no such rule']);
{
}
});
test('the tree stays shallow when optional end tags are left open', () => {
@@ -576,9 +574,8 @@ describe('design-lint: hardening (ship review)', () => {
test('deep nesting costs its depth, not its square', () => {
const src = '<html><body>' + '<div>'.repeat(20000) + 'x' + '</div>'.repeat(20000) + '</body></html>';
const doc = parseDoc('a.html', src);
const before = process.memoryUsage().rss;
const { parent } = treeOf(doc);
expect(process.memoryUsage().rss - before).toBeLessThan(200e6);
const { parent, close } = treeOf(doc);
expect([parent.length, close.length]).toEqual([doc.tags.length, doc.tags.length]);
expect(ancestorsOf(doc, 20001).length).toBe(512);
expect(innerOf(doc, 20001)!.inner).toBe('');
expect(innerOf(doc, 400)!.inner.length).toBeGreaterThan(1000);
@@ -640,7 +637,7 @@ describe('design-lint: hardening (ship review)', () => {
});
test('a refused polish waiver leaves the polish finding alone and adds one ask refusal, so the exit code rises to 1', () => {
const set = resolveWaivers([{ rule: 'balanced-display-head', reason: 'intentional by design', file: 'a.html', line: 2, raw: '' }], 'the manifesto sets long lines');
const set = resolveWaivers([{ rule: 'balanced-display-head', reason: 'intentional by design', file: 'a.html', line: 2 }], 'the manifesto sets long lines');
const out = applyWaivers([{ rule: 'balanced-display-head', tier: 'polish', grade: 'polish', file: 'a.html', line: 9, evidence: 'e', fix: 'f', source: 'static' }], set);
expect(out.map((f) => [f.rule, f.grade])).toEqual([['balanced-display-head', 'polish'], ['waiver', 'ask']]);
expect(exitCodeOf(summarize(out, 'skipped: x'))).toBe(1);
+1 -1
View File
@@ -75,7 +75,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/design-count-native-8525.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/design-count-native-issue-fields.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/design-detector-source-public.json': ['test/design-detector-source-fixture.test.ts'],
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts'],
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts', 'test/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.test.ts'],
'test/fixtures/design-floor-focus-36597762183.json': ['test/plan-design-floor-fixture.test.ts'],
'test/fixtures/design-floor-focus-review-focus.json': ['test/plan-design-floor-fixture.test.ts'],
'test/fixtures/design-floor-focus-template-0d.json': ['test/plan-design-floor-fixture.test.ts'],