feat(design-lint): copy tier reports each tell at its line; empty-mount-node; content-realism ids at ask

The copy tier holds its own patterns (from lib/design-craft.ts) and reads
visible text, script bodies, inline handlers and parsed tags, so payloads,
commented markup and script text no longer count. A text-less body that
loads a script reports empty-mount-node. placeholder-name, placeholder-brand,
lorem, round-number and needs-proof move to ask so a page-specific waiver
answers them (Garry 2026-10-26, option a).

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-27 03:19:25 +00:00
1 parent c2e01e4ba7
commit 018eedb137
8 files changed
+307 -571

No files matched your search

-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
// gstack-design-craft — free craft check for a page (/design-html Step 4,
// /design-review craft audit).
//
// Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]
//
// Prints one "CRAFT_CHECK: <id> <count> <fix>" line per tell, then
// "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: <n> issue(s)" (exit 1).
// --accepted reads the craft_exceptions a finalized.json recorded: a tell an
// exception covers (same id, count at most the accepted count) prints as
// "CRAFT_ACCEPTED: <id> <count> <reason>" and does not fail the check; the
// result line then adds " (<k> accepted)" or ", <k> accepted".
// Exit 2: usage error, a non-HTML file, or an unreadable or empty file, with
// nothing checked; or an unreadable or malformed exceptions file, after a
// warning and a full check with no exceptions accepted, so a bad record never
// hides the page's tells. Head and navigation rules run only on a full HTML
// document; a fragment gets the copy and code rules alone.
import { readFileSync } from 'fs';
import { acceptFindings, checkCraft, formatCraft, parseCraftExceptions, type CraftException } from '../lib/design-craft';
const USAGE = 'Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]';
const [file, flag, acceptedFile, ...rest] = process.argv.slice(2);
if (!file || (flag !== undefined && (flag !== '--accepted' || !acceptedFile)) || rest.length > 0) {
console.error(USAGE);
process.exit(2);
}
if (!/\.html?$/i.test(file)) {
console.error(`gstack-design-craft: ${file} is not an HTML file; check the rendered HTML preview instead`);
process.exit(2);
}
let source: string;
try {
source = readFileSync(file, 'utf-8');
} catch (err) {
console.error(`gstack-design-craft: cannot read ${file}: ${(err as Error).message}`);
process.exit(2);
}
if (source.trim() === '') {
console.error(`gstack-design-craft: ${file} is empty; nothing was checked`);
process.exit(2);
}
let exceptions: CraftException[] = [];
let badExceptions = false;
if (acceptedFile) {
try {
exceptions = parseCraftExceptions(readFileSync(acceptedFile, 'utf-8'));
} catch (err) {
console.error(`gstack-design-craft: cannot use exceptions from ${acceptedFile}: ${(err as Error).message}; checked with none accepted`);
badExceptions = true;
}
}
const { issues, accepted } = acceptFindings(checkCraft(source), exceptions);
console.log(formatCraft(issues, accepted));
process.exit(badExceptions ? 2 : issues.length === 0 ? 0 : 1);
+22 -15
View File
@@ -13,9 +13,9 @@ picks (Aside when it is running, otherwise the browse daemon `./setup` built).
The rules are ported from [Nutlope/hallmark](https://github.com/Nutlope/hallmark)'s
`sloplint` (MIT; see NOTICE.md and licenses/MIT-hallmark.txt), rewritten in
gstack's voice and mapped onto gstack's severity words. The copy tier is
`gstack-design-craft`'s content-realism and ship-floor check under the same
contract. Phase 1 (this document) is the tool alone; /design-html and
/design-review call it in Phase 2.
the content-realism and ship-floor check adapted from Elaya's design skills
(MIT; see NOTICE.md), each tell at its line. /design-html runs the lint on
every finalized page and /design-review on each audited page.
## Usage
@@ -271,12 +271,12 @@ waiver can answer it.
| `justified-text` | polish | F22 | justified text without hyphens: auto | hyphens: auto, or left-aligned |
| `missing-lang` | polish | F23 | a document without lang | lang on <html> |
| `hairline-under-shadow` | polish | F24 | a hairline border under a wide soft shadow on one surface | the shadow or the border, not both |
| `placeholder-name` | blocks | craft | a placeholder name (John Doe) | use a supplied name, or one that fits the product in sample data |
| `placeholder-brand` | blocks | craft | a placeholder brand (Acme, Nexus) | use a supplied customer, or a fitting name in sample data |
| `lorem` | blocks | craft | lorem ipsum placeholder text | write real copy |
| `placeholder-name` | ask | craft | a placeholder name (John Doe) | use a supplied name, or one that fits the product in sample data |
| `placeholder-brand` | ask | craft | a placeholder brand (Acme, Nexus) | use a supplied customer, or a fitting name in sample data |
| `lorem` | ask | craft | lorem ipsum placeholder text | write real copy |
| `marketing-buzzword` | ask | craft | a marketing buzzword | say the specific thing |
| `round-number` | blocks | craft | a round invented number | use the supplied figure, or drop the claim |
| `needs-proof` | blocks | craft | a [Needs proof] marker left in the page | supply the proof or remove the claim before publishing |
| `round-number` | ask | craft | a round invented number | use the supplied figure, or drop the claim |
| `needs-proof` | ask | craft | a [Needs proof] marker left in the page | supply the proof or remove the claim before publishing |
| `oops` | ask | craft | "oops" as an error message | say what happened and what to do |
| `scroll-listener` | ask | craft | a scroll event listener | use IntersectionObserver; read scroll inside requestAnimationFrame for a scroll-linked effect |
| `dead-link` | ask | craft | a dead link (href="#") | link it, or render the control disabled |
@@ -287,9 +287,14 @@ waiver can answer it.
| `missing-favicon` | polish | craft | a document without a favicon | add a favicon <link rel="icon"> |
| `missing-skip-link` | ask | craft | no skip link reaching <main> | add a "Skip to content" link first in <body> whose #id exists on <main> |
| `missing-aria-current` | ask | craft | route navigation without aria-current | mark the current nav link aria-current="page" |
| `empty-mount-node` | ask | craft | an empty mount node: the body has no text and a script renders it, so the lint read none of its content | lint the rendered page: save document.documentElement.outerHTML to a file and lint that |
Source gates are Nutlope/hallmark `references/gates.md` at 7ec8682; `craft`
marks a rule carried over from `gstack-design-craft` (Elaya-derived). Four
marks the copy tier (Elaya-derived). Its content-realism ids (placeholder
people and brands, lorem, round numbers, `[Needs proof:` markers) sit at
ask, so a real customer named Nexus or a supplied 50% figure is answered by a
waiver that names the page; `empty-mount-node` says a client-rendered shell
was not read, instead of passing it. Four
ids are the lint's own: `waiver` (a refused waiver, ask), `house-style` (more
than three waivers, polish), `render` (a page that did not render, blocks:
an unrendered page is never a pass) and `internal-error` (a page or stylesheet the lint could not read, or a rule family that
@@ -334,12 +339,14 @@ no `max-width` (`line-length` reads a declared `ch` value), an uppercase
paragraph at body size (`wide-tracking` reads caps at label size or tracked;
a 16px untracked uppercase `<p>` passes), and a fixed px `width` on a
container with no responsive handling (`wide-container` reads the opposite
case). The copy tier
reports at the document's first line with a count, because
`gstack-design-craft` counts matches without positions; Phase 2 folds those
patterns into the lint with lines. It reads the page with every open
`<script>`, `<style>`, `<template>` or `<nav>` that nothing closes after it
dropped, so a page of unclosed openers costs its length there too.
case). The copy tier reads the visible text (comments, scripts, styles and
`<template>` removed, entities decoded), script bodies and inline handlers
for `alert()` and scroll listeners, and the parsed tags for links, images and
the head, so a hydration payload or commented-out markup is not counted; it
does not fetch external scripts, and its head rules run only on a document
with a `<head>`. A body with no text (a `<noscript>` notice aside) that loads
a script is an `empty-mount-node`: lint the rendered DOM instead; `--url`
measures layout, it does not read the copy.
`tooltip-focus-delay` reads the delay slot of a tooltip's `transition`
(the second time in each comma part) or its `transition-delay`, 100ms and
over; a `0.2s` or `200ms` fade is a duration, not a delay. Where a rule id is shared with
-157
View File
@@ -1,157 +0,0 @@
/**
* Free craft check for a page (/design-html Step 4, /design-review craft
* audit). Counts the tells that make a page read as generated or unfinished:
* placeholder content, AI cliché copy, scroll listeners, 100vh, dead links,
* and the missing ship-floor tags. Copy checks read the visible text only
* (script, style and tags removed); code checks read the source. Head checks
* run only on a full document. Adapted from Elaya's landing-page-design and
* redesign-skill (MIT); see NOTICE.md. Every id it prints is a
* lib/design-catalog.ts entry and the fix beside it is that entry's `fix`
* (test-enforced): buzzwords report under `marketing-buzzword`, whose
* heuristic words BUZZWORDS covers, and `transition: all` under
* `layout-transition`.
*/
import { catalogEntries } from './design-catalog';
/** The catalog's marketing-buzzword words plus Elaya's AI cliché words. */
export const BUZZWORDS: readonly string[] = [
'seamless', 'effortless', 'supercharge', 'streamline', 'revolutionize', 'unlock', 'empower', 'elevate',
'unleash', 'next-gen', 'game-changer', 'delve', 'tapestry', 'in the world of',
];
function buzzwordPattern(): RegExp {
const stem = (w: string) => w.replace(/-/g, '[- ]').replace(/e$/, '');
return new RegExp(`\\b(?:${BUZZWORDS.map(stem).join('|')})(?:e|es|ed|ing|ly|er|ers|s|d|eration)?\\b`, 'gi');
}
export interface CraftFinding {
id: string;
count: number;
fix: string;
}
interface CountRule {
id: string;
on: 'source' | 'text' | 'script';
pattern: RegExp;
}
interface PresenceRule {
id: string;
pattern: RegExp;
}
const COUNT_RULES: CountRule[] = [
{ id: 'placeholder-name', on: 'text', pattern: /\b(?:John|Jane) Doe\b/gi },
{ id: 'placeholder-brand', on: 'text', pattern: /\b(?:Acme|Nexus|SmartFlow)\b/g },
{ id: 'lorem', on: 'text', pattern: /\blorem ipsum\b/gi },
{ id: 'marketing-buzzword', on: 'text', pattern: buzzwordPattern() },
{ id: 'round-number', on: 'text', pattern: /(?<![\d.])(?:99\.9+|[1-9]0|100)%\s+(?:uptime|faster|more|less|fewer|better|growth|increase|reduction|of (?:teams|users|customers|companies))\b|\b(?:\d+0|\d+(?:,\d{3})+)\+?\s+(?:happy |active )?(?:users|customers|teams|companies)\b|(?<![\d.])10x\s+(?:faster|more|better)\b/gi },
{ id: 'needs-proof', on: 'text', pattern: /\[Needs proof:/g },
{ id: 'oops', on: 'text', pattern: /\boops\b/gi },
{ id: 'scroll-listener', on: 'source', pattern: /addEventListener\(\s*['"`]scroll['"`]/g },
{ id: 'layout-transition', on: 'source', pattern: /\btransition\s*:\s*all\b|\btransition-all\b/g },
{ id: 'dead-link', on: 'source', pattern: /\bhref\s*=\s*["']#["']/g },
{ id: 'alert', on: 'script', pattern: /(?<![\w.$])(?:window\.)?alert\s*\(/g },
{ id: 'img-no-alt', on: 'source', pattern: /<img\b(?![^>]*(?<![\w-])alt\s*=)[^>]*>/gi },
];
const HEAD_RULES: PresenceRule[] = [
{ id: 'missing-title', pattern: /<title>\s*[^<\s][^<]*<\/title>/i },
{ id: 'missing-meta-description', pattern: /<meta\b[^>]*\bname\s*=\s*["']description["']/i },
{ id: 'missing-favicon', pattern: /<link\b[^>]*\brel\s*=\s*["'][^"']*\bicon\b/i },
];
/** Every id the check can print, in print order; each is a catalog entry with a fix (test-enforced). */
export const CRAFT_IDS: readonly string[] = [
...COUNT_RULES.map(r => r.id), 'vh-height', ...HEAD_RULES.map(r => r.id), 'missing-skip-link', 'missing-aria-current',
];
function finding(id: string, count: number): CraftFinding {
const fix = catalogEntries([id])[0].fix;
if (!fix) throw new Error(`lib/design-catalog.ts: entry "${id}" has no craft-check fix`);
return { id, count, fix };
}
export function visibleText(html: string): string {
return html
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<(script|style|template)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&(nbsp|lt|gt|quot|apos|amp);/g, (_, e) => ({ nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e as string] as string)
.replace(/\s+/g, ' ');
}
export function checkCraft(html: string): CraftFinding[] {
const text = visibleText(html);
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi), ...html.matchAll(/\bon[a-z]+\s*=\s*"([^"]*)"/gi)].map(m => m[1]).join('\n');
const findings: CraftFinding[] = [];
for (const rule of COUNT_RULES) {
const count = (rule.on === 'text' ? text : rule.on === 'script' ? scripts : html).match(rule.pattern)?.length ?? 0;
if (count > 0) findings.push(finding(rule.id, count));
}
const vh = [...html.matchAll(/\b((?:min-|max-)?height)\s*:\s*100vh\b(\s*!important)?([^}]*)/g)]
.filter(m => m[2] || !new RegExp(`(?<![\\w-])${m[1]}\\s*:\\s*100[dsl]vh\\b`).test(m[3])).length
+ (html.match(/\b(?:min-)?h-screen\b/g)?.length ?? 0);
if (vh > 0) findings.push(finding('vh-height', vh));
if (!/<head\b/i.test(html)) return findings;
for (const rule of HEAD_RULES) {
if (!rule.pattern.test(html)) findings.push(finding(rule.id, 1));
}
const body = html.slice(html.search(/<body\b/i) + 1);
const firstFocusable = body.match(/<(?:a\b[^>]*\bhref|button|input|select|textarea)\b[^>]*>/i)?.[0] ?? '';
const skipTarget = firstFocusable.match(/^<a\b[^>]*\bhref\s*=\s*["']#([^"']+)["']/i)?.[1];
const main = html.match(/<main\b[^>]*(?<![\w-])id\s*=\s*["']([^"']+)["']/i)?.[1]
?? html.match(/<[a-z]+\b[^>]*\brole\s*=\s*["']main["'][^>]*(?<![\w-])id\s*=\s*["']([^"']+)["']/i)?.[1];
if (!skipTarget || skipTarget !== main) {
findings.push(finding('missing-skip-link', 1));
}
const routeLinks = html.match(/<nav\b[\s\S]*?<\/nav>/gi)?.join('').match(/<a\b[^>]*\bhref\s*=\s*["'](?!#)[^"']+["']/gi)?.length ?? 0;
if (routeLinks >= 2 && !/\baria-current\s*=\s*["']page["']/i.test(html)) {
findings.push(finding('missing-aria-current', 1));
}
return findings;
}
/** An accepted finding recorded in finalized.json's `craft_exceptions`. */
export interface CraftException {
id: string;
count: number;
reason: string;
}
/** Reads `craft_exceptions` from a finalized.json; a record without one has none. Throws on a malformed record. */
export function parseCraftExceptions(json: string): CraftException[] {
const record = JSON.parse(json);
if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new Error('not a finalized.json object');
const list = record.craft_exceptions ?? [];
const valid = (e: any) => typeof e?.id === 'string' && Number.isInteger(e.count) && e.count > 0 && typeof e.reason === 'string' && e.reason.trim() !== '';
if (!Array.isArray(list) || !list.every(valid)) throw new Error('craft_exceptions must be a list of { id, count, reason } with a positive count and a reason');
return list.map(({ id, count, reason }) => ({ id: id === 'transition-all' ? 'layout-transition' : id, count, reason: reason.replace(/\s+/g, ' ').trim() }));
}
/** A finding is accepted when an exception names its id with at least its count; a higher count stays an issue and says how many were accepted. */
export function acceptFindings(findings: CraftFinding[], exceptions: CraftException[]): { issues: CraftFinding[]; accepted: CraftException[] } {
const issues: CraftFinding[] = [];
const accepted: CraftException[] = [];
for (const finding of findings) {
const exception = exceptions.filter(e => e.id === finding.id).sort((a, b) => b.count - a.count)[0];
if (!exception) issues.push(finding);
else if (finding.count <= exception.count) accepted.push({ id: finding.id, count: finding.count, reason: exception.reason });
else issues.push({ ...finding, fix: `${finding.fix} (${exception.count} accepted earlier: ${exception.reason})` });
}
return { issues, accepted };
}
export function formatCraft(findings: CraftFinding[], accepted: CraftException[] = []): string {
const lines = [
...findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`),
...accepted.map(a => `CRAFT_ACCEPTED: ${a.id} ${a.count} ${a.reason}`),
];
const note = accepted.length === 0 ? '' : findings.length === 0 ? ` (${accepted.length} accepted)` : `, ${accepted.length} accepted`;
lines.push(`${findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`}${note}`);
return lines.join('\n');
}
+140 -27
View File
@@ -1,43 +1,156 @@
/**
* The copy tier: gstack-design-craft's content-realism and ship-floor tells
* (lib/design-craft.ts, Elaya-derived, MIT) under the lint's contract. The
* craft check counts occurrences without positions, so these report at the
* document's first line with the count as evidence until Phase 2 folds the
* patterns into the lint. Tells the static tier already reports with a line
* (transition: all under layout-transition, 100vh under vh-height) are left
* to it.
* The copy tier: content realism and the ship floor (Elaya-derived, MIT; see
* NOTICE.md), each tell reported at its line. Copy rules read the visible
* text (comments, scripts, styles and templates removed, entities decoded);
* code rules read script bodies and inline handlers; tag rules read the
* parsed tags, so a hydration payload or commented-out markup is never
* counted. Head rules run only on a document with a <head>. A page whose body
* holds no text and loads a script is an empty mount node: script renders it,
* so nothing here saw its content, and it says so instead of passing.
*/
import { checkCraft } from '../../design-craft';
import type { LintContext } from '../context';
import type { Doc } from '../html';
import { getAttr, hasAttr, stripTags, textOf, treeOf, type Doc, type Tag } from '../html';
import type { Reporter } from '../report';
import { ruleMeta } from './ids';
import { RULES } from './ids';
const STATIC_OWNS = new Set(['layout-transition', 'vh-height']);
/** The catalog's marketing-buzzword words plus Elaya's AI cliché words. */
export const BUZZWORDS: readonly string[] = [
'seamless', 'effortless', 'supercharge', 'streamline', 'revolutionize', 'unlock', 'empower', 'elevate',
'unleash', 'next-gen', 'game-changer', 'delve', 'tapestry', 'in the world of',
];
/** The elements the craft check reads from open tag to close tag with a lazy scan. */
const SPANNED = new Set(['script', 'style', 'template', 'nav']);
const stem = (w: string) => w.replace(/-/g, '[- ]').replace(/e$/, '');
/** The page as the craft check reads it: an open script, style, template or nav tag that nothing closes after it is blanked, so each lazy scan to its close finds one and the page costs its length. */
function craftSource(doc: Doc): string {
const lastClose = new Map<string, number>();
for (const t of doc.tags) if (t.close && SPANNED.has(t.name)) lastClose.set(t.name, t.pos);
let out = '', from = 0;
for (const t of doc.tags) {
if (t.close || !SPANNED.has(t.name) || t.pos < (lastClose.get(t.name) ?? -1)) continue;
out += doc.src.slice(from, t.pos) + ' ';
from = t.pos + 1;
const TEXT_RULES: Array<[string, RegExp]> = [
['placeholder-name', /\b(?:John|Jane) Doe\b/gi],
['placeholder-brand', /\b(?:Acme|Nexus|SmartFlow)\b/g],
['lorem', /\blorem ipsum\b/gi],
['marketing-buzzword', new RegExp(`\\b(?:${BUZZWORDS.map(stem).join('|')})(?:e|es|ed|ing|ly|er|ers|s|d|eration)?\\b`, 'gi')],
['round-number', /(?<![\d.])(?:99\.9+|[1-9]0|100)%\s+(?:uptime|faster|more|less|fewer|better|growth|increase|reduction|of (?:teams|users|customers|companies))\b|\b(?:\d+0|\d+(?:,\d{3})+)\+?\s+(?:happy |active )?(?:users|customers|teams|companies)\b|(?<![\d.])10x\s+(?:faster|more|better)\b/gi],
['needs-proof', /\[Needs proof:/g],
['oops', /\boops\b/gi],
];
const SCRIPT_RULES: Array<[string, RegExp]> = [
['scroll-listener', /addEventListener\(\s*['"`]scroll['"`]/g],
['alert', /(?<![\w.$])(?:window\.)?alert\s*\(/g],
];
/** Every id the copy tier can print. */
export const COPY_IDS: readonly string[] = [
...TEXT_RULES.map(([id]) => id), ...SCRIPT_RULES.map(([id]) => id), 'dead-link', 'img-no-alt',
'missing-title', 'missing-meta-description', 'missing-favicon', 'missing-skip-link', 'missing-aria-current', 'empty-mount-node',
];
const ENTITY = /&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|(nbsp|lt|gt|quot|apos|amp));/iy;
const NAMED: Record<string, string> = { nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' };
/** The visible text with each character's source position: tags, comments, scripts, styles and the dropped elements removed, entities decoded, whitespace folded. */
function visible(doc: Doc, drop: ReadonlySet<string>): { text: string; at: number[] } {
const { close } = treeOf(doc);
const ranges: Array<[number, number]> = [];
doc.tags.forEach((t, i) => {
if (!t.close && !t.selfClosing && drop.has(t.name)) ranges.push([t.pos, close[i] < 0 ? doc.masked.length : doc.tags[close[i]].end]);
});
let blanked = '', from = 0;
for (const [a, b] of ranges) if (a >= from) { blanked += doc.masked.slice(from, a) + ' '.repeat(b - a); from = b; }
const src = stripTags(blanked + doc.masked.slice(from), true);
let text = '';
const at: number[] = [];
const push = (ch: string, pos: number) => {
if (/\s/.test(ch)) { if (!text.endsWith(' ')) { text += ' '; at.push(pos); } return; }
for (let k = 0; k < ch.length; k++) at.push(pos);
text += ch;
};
for (let i = 0; i < src.length;) {
if (src[i] === '&') {
ENTITY.lastIndex = i;
const m = ENTITY.exec(src);
const cp = m && (m[1] ? Number(m[1]) : m[2] ? parseInt(m[2], 16) : -1);
if (m && (cp === -1 || cp! <= 0x10ffff)) {
push(m[3] ? NAMED[m[3].toLowerCase()] : String.fromCodePoint(cp!), i);
i += m[0].length;
continue;
}
}
const c = src.codePointAt(i)!;
const ch = String.fromCodePoint(c);
push(ch, i);
i += ch.length;
}
return out + doc.src.slice(from);
return { text, at };
}
/** Each script body and inline event handler, with the position its code starts. */
function codeOf(doc: Doc): Array<{ code: string; pos: number }> {
const { close } = treeOf(doc);
const out: Array<{ code: string; pos: number }> = [];
doc.tags.forEach((t, i) => {
if (t.close) return;
if (t.name === 'script' && close[i] >= 0) out.push({ code: doc.src.slice(t.end, doc.tags[close[i]].pos), pos: t.end });
for (const m of t.attrs.matchAll(/(?:^|\s)on[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) out.push({ code: m[1] ?? m[2], pos: t.pos + 1 + t.name.length + m.index! });
});
return out;
}
const firstOpen = (doc: Doc, name: string): number => doc.tags.findIndex((t) => !t.close && t.name === name);
export function run(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
if (doc.kind !== 'html') continue;
for (const f of checkCraft(craftSource(doc))) {
if (STATIC_OWNS.has(f.id) || !ruleMeta(f.id)) continue;
const title = ruleMeta(f.id)!.title;
r.report(f.id, doc.file, 1, f.count === 1 ? title : `${title}, ${f.count} times`, { fix: f.fix, source: 'copy' });
const hits = new Map<string, Map<number, string[]>>();
const hit = (id: string, pos: number, what: string) => {
const byLine = hits.get(id) ?? hits.set(id, new Map()).get(id)!;
const line = doc.lineOf(pos);
const found = byLine.get(line);
if (found) found.push(what);
else byLine.set(line, [what]);
};
const { text, at } = visible(doc, new Set(['template']));
for (const [id, re] of TEXT_RULES) for (const m of text.matchAll(re)) hit(id, at[m.index!], `"${m[0]}"`);
for (const { code, pos } of codeOf(doc)) for (const [id, re] of SCRIPT_RULES) for (const m of code.matchAll(re)) hit(id, pos + m.index!, '');
for (const t of doc.tags) {
if (t.close) continue;
if (getAttr(t.attrs, 'href')?.trim() === '#') hit('dead-link', t.pos, '');
if (t.name === 'img' && !hasAttr(t.attrs, 'alt')) hit('img-no-alt', t.pos, '');
}
const head = firstOpen(doc, 'head');
const bodyIdx = firstOpen(doc, 'body');
const bodyTag: Tag | undefined = doc.tags[bodyIdx];
if (head >= 0) {
const at0 = doc.tags[head].pos;
const opens = (name: string) => doc.tags.map((t, i) => [t, i] as const).filter(([t]) => !t.close && t.name === name);
if (!opens('title').some(([, i]) => textOf(doc, i).trim() !== '')) hit('missing-title', at0, '');
if (!opens('meta').some(([t]) => getAttr(t.attrs, 'name')?.trim().toLowerCase() === 'description')) hit('missing-meta-description', at0, '');
if (!opens('link').some(([t]) => /\bicon\b/i.test(getAttr(t.attrs, 'rel') ?? ''))) hit('missing-favicon', at0, '');
const focusable = doc.tags.slice(Math.max(bodyIdx, 0)).find((t) => !t.close && ((t.name === 'a' && hasAttr(t.attrs, 'href')) || /^(?:button|input|select|textarea)$/.test(t.name)));
const skipTarget = focusable?.name === 'a' ? getAttr(focusable.attrs, 'href')?.match(/^#(.+)$/)?.[1] : undefined;
const mainTag = doc.tags.find((t) => !t.close && t.name === 'main' && getAttr(t.attrs, 'id'))
?? doc.tags.find((t) => !t.close && getAttr(t.attrs, 'role') === 'main' && getAttr(t.attrs, 'id'));
if (!skipTarget || skipTarget !== getAttr(mainTag?.attrs ?? '', 'id')) hit('missing-skip-link', bodyTag?.pos ?? at0, '');
const { close } = treeOf(doc);
const navs = doc.tags.map((t, i) => [t, i] as const).filter(([t]) => !t.close && t.name === 'nav');
const routeLinks = navs.flatMap(([, i]) => doc.tags.slice(i, close[i] < 0 ? doc.tags.length : close[i]))
.filter((t) => !t.close && t.name === 'a' && /^[^#]/.test(getAttr(t.attrs, 'href') ?? '')).length;
if (routeLinks >= 2 && !doc.tags.some((t) => !t.close && getAttr(t.attrs, 'aria-current') === 'page')) hit('missing-aria-current', navs[0][0].pos, '');
}
const bodyFrom = bodyTag?.pos ?? 0;
const shellText = visible(doc, new Set(['template', 'noscript']));
const bodyStart = shellText.at.findIndex((p) => p >= bodyFrom);
const bodyText = bodyStart < 0 ? '' : shellText.text.slice(bodyStart);
if (!/[\p{L}\p{N}]/u.test(bodyText) && doc.tags.some((t) => !t.close && t.name === 'script')) {
const { close } = treeOf(doc);
const mount = doc.tags.find((t, i) => !t.close && t.pos >= bodyFrom && !t.selfClosing && t.name !== 'script' && t.name !== 'noscript' && t.name !== 'body'
&& (getAttr(t.attrs, 'id') !== null || t.name.includes('-')) && close[i] === i + 1);
hit('empty-mount-node', mount?.pos ?? bodyFrom, mount ? `<${mount.name}${getAttr(mount.attrs, 'id') ? ` id="${getAttr(mount.attrs, 'id')}"` : ''}>` : '');
}
for (const [id, byLine] of hits) {
const title = RULES[id].title;
for (const [line, found] of byLine) {
const named = [...new Set(found.filter(Boolean))];
const evidence = named.length ? `${title}: ${named.join(', ')}` : found.length > 1 ? `${title}, ${found.length} on this line` : title;
r.report(id, doc.file, line, evidence, { source: 'copy' });
}
}
}
}
+9 -7
View File
@@ -21,7 +21,7 @@ export type Tier = 'blocks' | 'ask' | 'polish';
export interface RuleMeta {
tier: Tier;
/** The sloplint gate (or gstack-design-craft id) this rule was ported from. */
/** The sloplint gate this rule was ported from, or `craft` for the copy tier (rules/copy.ts). */
gate: string;
/** One line naming the tell. */
title: string;
@@ -151,13 +151,14 @@ export const RULES: Record<string, RuleMeta> = {
'justified-text': { tier: 'polish', gate: 'F22', title: 'justified text without hyphens: auto', fix: 'hyphens: auto, or left-aligned' },
'missing-lang': { tier: 'polish', gate: 'F23', title: 'a document without lang', fix: 'lang on <html>' },
'hairline-under-shadow': { tier: 'polish', gate: 'F24', title: 'a hairline border under a wide soft shadow on one surface', fix: 'the shadow or the border, not both' },
// ── Copy tier (gstack-design-craft, Elaya-derived; same ids) ──
'placeholder-name': { tier: 'blocks', gate: 'craft', title: 'a placeholder name (John Doe)', fix: 'use a supplied name, or one that fits the product in sample data' },
'placeholder-brand': { tier: 'blocks', gate: 'craft', title: 'a placeholder brand (Acme, Nexus)', fix: 'use a supplied customer, or a fitting name in sample data' },
'lorem': { tier: 'blocks', gate: 'craft', title: 'lorem ipsum placeholder text', fix: 'write real copy' },
// ── Copy tier (rules/copy.ts, Elaya-derived). The content-realism ids sit at ask: a real customer named
// Nexus or a supplied 50% figure is answered by a waiver that names the page (Garry, 2026-10-26, option a). ──
'placeholder-name': { tier: 'ask', gate: 'craft', title: 'a placeholder name (John Doe)', fix: 'use a supplied name, or one that fits the product in sample data' },
'placeholder-brand': { tier: 'ask', gate: 'craft', title: 'a placeholder brand (Acme, Nexus)', fix: 'use a supplied customer, or a fitting name in sample data' },
'lorem': { tier: 'ask', gate: 'craft', title: 'lorem ipsum placeholder text', fix: 'write real copy' },
'marketing-buzzword': { tier: 'ask', gate: 'craft', title: 'a marketing buzzword', fix: 'say the specific thing' },
'round-number': { tier: 'blocks', gate: 'craft', title: 'a round invented number', fix: 'use the supplied figure, or drop the claim' },
'needs-proof': { tier: 'blocks', gate: 'craft', title: 'a [Needs proof] marker left in the page', fix: 'supply the proof or remove the claim before publishing' },
'round-number': { tier: 'ask', gate: 'craft', title: 'a round invented number', fix: 'use the supplied figure, or drop the claim' },
'needs-proof': { tier: 'ask', gate: 'craft', title: 'a [Needs proof] marker left in the page', fix: 'supply the proof or remove the claim before publishing' },
'oops': { tier: 'ask', gate: 'craft', title: '"oops" as an error message', fix: 'say what happened and what to do' },
'scroll-listener': { tier: 'ask', gate: 'craft', title: 'a scroll event listener', fix: 'use IntersectionObserver; read scroll inside requestAnimationFrame for a scroll-linked effect' },
'dead-link': { tier: 'ask', gate: 'craft', title: 'a dead link (href="#")', fix: 'link it, or render the control disabled' },
@@ -168,6 +169,7 @@ export const RULES: Record<string, RuleMeta> = {
'missing-favicon': { tier: 'polish', gate: 'craft', title: 'a document without a favicon', fix: 'add a favicon <link rel="icon">', pageOnly: true },
'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching <main>', fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>', pageOnly: true },
'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"', pageOnly: true },
'empty-mount-node': { tier: 'ask', gate: 'craft', title: 'an empty mount node: the body has no text and a script renders it, so the lint read none of its content', fix: 'lint the rendered page: save document.documentElement.outerHTML to a file and lint that', pageOnly: true },
// ── The lint's own bookkeeping ──
'waiver': { tier: 'ask', gate: 'waiver', title: 'a waiver the lint refused', fix: 'fix the finding, or waive it with the rule id and a reason about this page' },
'house-style': { tier: 'polish', gate: 'waiver', title: 'more than three rules waived on one page', fix: 'lock the choices into DESIGN.md so later pages inherit them instead of re-arguing them' },
-284
View File
@@ -1,284 +0,0 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { acceptFindings, BUZZWORDS, CRAFT_IDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft';
import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog';
import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist';
import { selectTests } from './helpers/test-selection';
import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-craft');
const DIRTY = `<!doctype html><html><head><style>.hero{height:100vh;transition: all .3s}</style></head><body>
<nav><a href="#">Docs</a><a href="/">Home</a><a href="/pricing">Pricing</a></nav>
<h1>Elevate your workflow seamlessly</h1><p>Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]</p>
<img src="a.png"><p>Oops! Something broke. Lorem ipsum.</p>
<script>window.addEventListener('scroll', () => {}); alert('hi'); showAlert(1);</script></body></html>`;
const CLEAN = `<!doctype html><html lang="en"><head><title>Ledgerline: close the books in 2 days</title>
<meta name="description" content="Close faster."><meta property="og:image" content="/og.png"><link rel="icon" href="/favicon.svg">
<style>.hero{min-height:100vh;min-height:100dvh;transition: transform .4s var(--ease-out)} .w{width:100%}</style></head>
<body><a href="#main">Skip to content</a><a href="#pricing">Pricing</a><nav><a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a></nav>
<main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main>
<script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`;
function run(file?: string, ...extra: string[]) {
const r = Bun.spawnSync([BIN, ...(file ? [file] : []), ...extra], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
describe('design-craft check', () => {
test('a page with every tell reports each one with its count', () => {
const counts = Object.fromEntries(checkCraft(DIRTY).map(f => [f.id, f.count]));
expect(counts).toEqual({
'placeholder-name': 1, 'placeholder-brand': 1, lorem: 1, 'marketing-buzzword': 2, 'round-number': 3, 'needs-proof': 1, oops: 1,
'scroll-listener': 1, 'layout-transition': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1,
'missing-title': 1, 'missing-meta-description': 1, 'missing-favicon': 1,
'missing-skip-link': 1, 'missing-aria-current': 1,
});
});
test('a finished page is clean: CSS 100%, a 100vh line with its 100dvh fallback, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => {
expect(checkCraft(CLEAN)).toEqual([]);
const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8');
expect(checkCraft(CLEAN.replace('</body>', `<script type="module">${pretext}</script></body>`))).toEqual([]);
expect(formatCraft([])).toBe('CRAFT_RESULT: clean');
});
test('every id the check prints is a grep-detectable catalog entry, and the fix it prints is the catalog fix', () => {
expect(new Set(CRAFT_IDS).size).toBe(CRAFT_IDS.length);
for (const id of CRAFT_IDS) {
const entry = catalogEntry(id);
expect(entry, id).toBeDefined();
expect(entry!.detect, id).toContain('grep');
expect(entry!.fix?.length ?? 0, id).toBeGreaterThan(0);
}
const findings = checkCraft(DIRTY);
expect(findings.map(f => f.id)).toEqual([...CRAFT_IDS]);
for (const f of findings) expect(f.fix).toBe(catalogEntry(f.id)!.fix!);
});
// Value: protects=checker-only craft entries render into no skill prose or checklist, and each is a printed craft id; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or filters on fix; why_new=only goldens catch it, until regenerated; seam=none; no_claim=byte size of any one skill (size guard owns it)
test('checker-only catalog entries are craft ids 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(CRAFT_IDS, e.id).toContain(e.id);
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 marketing-buzzword heuristic names', () => {
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);
expect(checkCraft('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map(f => [f.id, f.count])).toEqual([['marketing-buzzword', 4]]);
expect(checkCraft('<p>Take the elevator to the elevation map; streamers sold here</p>')).toEqual([]);
});
test('the skip link must reach an existing id, and aria-current is required only for route navigation', () => {
const ids = (html: string) => checkCraft(html).map(f => f.id);
expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']);
const sectionNav = CLEAN.replace('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>');
expect(ids(sectionNav)).toEqual([]);
expect(ids(CLEAN.replace(' aria-current="page"', ''))).toEqual(['missing-aria-current']);
});
test('attribute look-alikes, encoded markers and 100vh ordering are read correctly', () => {
const ids = (html: string) => checkCraft(html).map(f => f.id);
expect(ids('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']);
expect(ids('<p>&#91;Needs proof: logo]</p>')).toEqual(['needs-proof']);
expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']);
expect(ids('<style>.a{min-height:100dvh;min-height:100vh}</style>')).toEqual(['vh-height']);
expect(ids('<style>.a{min-height:100vh !important;min-height:100dvh}</style>')).toEqual(['vh-height']);
});
test('every word and name the craft section bans is one the checker flags', () => {
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);
const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? [];
expect(names.length).toBe(5);
for (const name of names) expect(checkCraft(`<p>${name}</p>`).map(f => f.id)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']);
});
test('skip link is judged by position and target; alert() counts only in script; svg text is copy', () => {
const ids = (html: string) => checkCraft(html).map(f => f.id);
expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]);
expect(ids(CLEAN.replace('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']);
expect(ids('<p>Set a price alert (free)</p>')).toEqual([]);
expect(ids('<button onclick="alert(1)">Go</button>')).toEqual(['alert']);
expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']);
});
test('copy rules read visible text only; head rules skip a fragment', () => {
expect(visibleText('<style>.a{content:"Acme"}</style><script>// John Doe</script><p>Halvorsen</p>')).toBe(' Halvorsen ');
expect(checkCraft('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).toEqual([]);
});
test('the CLI prints CRAFT_CHECK lines and exits 1 on tells, 0 when clean, 2 on usage', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-'));
try {
fs.writeFileSync(path.join(dir, 'dirty.html'), DIRTY);
fs.writeFileSync(path.join(dir, 'clean.html'), CLEAN);
const dirty = run(path.join(dir, 'dirty.html'));
expect(dirty.code).toBe(1);
expect(dirty.out).toContain('CRAFT_CHECK: scroll-listener 1 ');
expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 18 issue(s)');
const clean = run(path.join(dir, 'clean.html'));
expect(clean).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' });
expect(run().code).toBe(2);
expect(run(path.join(dir, 'missing.html')).code).toBe(2);
fs.writeFileSync(path.join(dir, 'empty.html'), ' \n');
expect(run(path.join(dir, 'empty.html')).code).toBe(2);
fs.writeFileSync(path.join(dir, 'page.tsx'), 'export const P = () => <p>Acme</p>;');
expect(run(path.join(dir, 'page.tsx'))).toMatchObject({ code: 2, out: '' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
test('an exception covers its id up to the accepted count; a higher count stays an issue and names the accepted count', () => {
const findings = checkCraft(DIRTY);
const { issues, accepted } = acceptFindings(findings, [
{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' },
{ id: 'round-number', count: 2, reason: 'supplied figures' },
{ id: 'scroll-listener', count: 5, reason: 'unused' },
]);
expect(accepted).toEqual([{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, { id: 'scroll-listener', count: 1, reason: 'unused' }]);
expect(issues.find(f => f.id === 'round-number')?.fix).toEndWith('(2 accepted earlier: supplied figures)');
expect(issues).toHaveLength(findings.length - 2);
expect(acceptFindings([{ id: 'needs-proof', count: 1, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [], accepted: [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }] });
expect(acceptFindings([{ id: 'needs-proof', count: 2, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [{ id: 'needs-proof', count: 2, fix: 'f (1 accepted earlier: awaiting logo)' }], accepted: [] });
expect(formatCraft([], accepted)).toBe('CRAFT_ACCEPTED: placeholder-brand 1 Acme is the real customer\nCRAFT_ACCEPTED: scroll-listener 1 unused\nCRAFT_RESULT: clean (2 accepted)');
const carried = [{ id: 'round-number', count: 1, reason: 'first run' }, { id: 'round-number', count: 3, reason: 'supplied figures' }];
for (const list of [carried, [...carried].reverse()]) {
expect(acceptFindings([{ id: 'round-number', count: 3, fix: 'use the supplied figure' }], list)).toEqual({ issues: [], accepted: [{ id: 'round-number', count: 3, reason: 'supplied figures' }] });
}
});
test('parseCraftExceptions reads the finalized.json design-html writes and rejects a malformed list', () => {
const skill = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
const block = skill.slice(skill.indexOf('Write `finalized.json` alongside the HTML')).match(/```json\n([\s\S]*?)```/)![1];
const record = JSON.parse(block.replace(/<number of refinement iterations>|<n>/g, '1'));
expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '<craft id>', count: 1, reason: '<why accepted>' }]);
expect(parseCraftExceptions('{"mode":"freeform"}')).toEqual([]);
const legacy = parseCraftExceptions('{"craft_exceptions":[{"id":"transition-all","count":1,"reason":"recorded before the rename"}]}');
expect(legacy).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]);
expect(acceptFindings(checkCraft('<style>a { transition: all .2s }</style>'), legacy).accepted).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]);
expect(parseCraftExceptions('{"craft_exceptions":[{"id":"oops","count":1,"reason":"quoted\\nfrom the user"}]}')).toEqual([{ id: 'oops', count: 1, reason: 'quoted from the user' }]);
for (const bad of ['[]', 'null', '{"craft_exceptions":{}}', '{"craft_exceptions":[{"id":"oops","count":0,"reason":"x"}]}', '{"craft_exceptions":[{"id":"oops","count":1,"reason":" "}]}', '{"craft_exceptions":[{"id":"oops","count":1.5,"reason":"x"}]}']) {
expect(() => parseCraftExceptions(bad)).toThrow();
}
});
test('the CLI reports accepted tells as CRAFT_ACCEPTED lines: exit 0 when only accepted ones remain, 1 for the rest, 2 on a bad exceptions file after a full check with none accepted', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-accepted-'));
try {
const page = path.join(dir, 'finalized.html');
const record = path.join(dir, 'finalized.json');
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme'));
fs.writeFileSync(record, JSON.stringify({ mode: 'evolve', craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme Freight is the customer' }] }));
expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_ACCEPTED: placeholder-brand 1 Acme Freight is the customer\nCRAFT_RESULT: clean (1 accepted)\n' });
expect(run(page).code).toBe(1);
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus'));
const twice = run(page, '--accepted', record);
expect(twice.code).toBe(1);
expect(twice.out).toBe('CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data (1 accepted earlier: Acme Freight is the customer)\nCRAFT_RESULT: 1 issue(s)\n');
fs.writeFileSync(page, DIRTY);
const dirty = run(page, '--accepted', record);
expect(dirty.code).toBe(1);
expect(dirty.out).toContain('CRAFT_ACCEPTED: placeholder-brand 1 ');
expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 17 issue(s), 1 accepted');
fs.writeFileSync(path.join(dir, 'old.json'), JSON.stringify({ mode: 'freeform' }));
expect(run(page, '--accepted', path.join(dir, 'old.json')).out).toBe(run(page).out);
fs.writeFileSync(path.join(dir, 'bad.json'), '{"craft_exceptions":[{"id":"oops"}]}');
fs.writeFileSync(path.join(dir, 'broken.json'), '{');
for (const name of ['bad.json', 'broken.json', 'missing.json']) {
const bad = run(page, '--accepted', path.join(dir, name));
expect(bad).toMatchObject({ code: 2, out: run(page).out });
expect(bad.err).toContain('checked with none accepted');
expect(bad.err).toContain('cannot use exceptions');
}
fs.writeFileSync(page, CLEAN);
expect(run(page, '--accepted', path.join(dir, 'broken.json'))).toMatchObject({ code: 2, out: 'CRAFT_RESULT: clean\n' });
expect(run(page, '--accepted').code).toBe(2);
expect(run(page, '--strict').code).toBe(2);
expect(run(page, '--accepted', record, 'extra').code).toBe(2);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('an exception the page no longer triggers prints nothing, and a record is read only with --accepted', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-cells-'));
try {
const page = path.join(dir, 'finalized.html');
const record = path.join(dir, 'finalized.json');
fs.writeFileSync(page, CLEAN);
fs.writeFileSync(record, JSON.stringify({ craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme is the customer' }] }));
expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' });
expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' });
expect(acceptFindings([], [{ id: 'oops', count: 1, reason: 'x' }])).toEqual({ issues: [], accepted: [] });
expect(acceptFindings([], [{ id: 'lorem', count: 1, reason: 'draft copy' }])).toEqual({ issues: [], accepted: [] });
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus'));
expect(run(page)).toMatchObject({ code: 1, out: 'CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data\nCRAFT_RESULT: 1 issue(s)\n' });
fs.writeFileSync(page, CLEAN);
fs.writeFileSync(record, '{');
expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('design-shotgun to design-html handoff', () => {
const ARCHETYPES = ['classic-hero', 'long-form-story', 'minimal-conversion', 'comparison'];
const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8');
test('design-shotgun records the chosen archetype and outline in approved.json, and design-html takes them as given', () => {
const shotgun = read('design-shotgun', 'SKILL.md');
const craft = read('design-html', 'sections', 'craft.md');
const html = read('design-html', 'SKILL.md');
for (const id of ARCHETYPES) {
expect(shotgun).toContain(`\`${id}\``);
expect(craft).toContain(`\`${id}\``);
}
const save = shotgun.slice(shotgun.indexOf('## Step 6: Save & Next Steps'));
expect(save).toMatch(/add the chosen variant's `archetype` id and `sections`[^.]*to it with the Edit tool/);
expect(html.slice(html.indexOf('### Case A'), html.indexOf('### Case B'))).toContain('any `archetype`\nand `sections`');
expect(craft).toMatch(/When approved\.json carries an `archetype`[^.]*: take it and its `sections` outline as given[^.]*do not re-pick or re-ask/);
});
test('a change to the craft checker selects the design-html-slop-gate E2E that runs it', () => {
for (const file of ['lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft']) {
expect(selectTests([file], E2E_TOUCHFILES).selected).toContain('design-html-slop-gate');
}
});
// Value: protects=a change to the shared craft resolver reruns the paid cases whose prompts it renders; fails_when=scripts/resolvers/design-craft.ts drops from those touchfile lists; why_new=the row above covers lib/design-craft.ts and the bin only; seam=none; no_claim=that the selected cases pass
test('a change to the craft resolvers selects the design-review and plan-design-review cases that render them', () => {
const e2e = selectTests(['scripts/resolvers/design-craft.ts'], E2E_TOUCHFILES).selected;
for (const name of ['design-review-fix', 'design-review-detector-shim', 'design-review-detector-shim-dom', 'design-review-plugin-handoff', 'plan-design-review-plan-mode']) expect(e2e).toContain(name);
expect(selectTests(['scripts/resolvers/design-craft.ts'], LLM_JUDGE_TOUCHFILES).selected).toContain('plan-design-review/SKILL.md passes');
});
test('design-html persists accepted exceptions and passes them back with --accepted', () => {
const html = read('design-html', 'SKILL.md');
const craft = read('design-html', 'sections', 'craft.md');
expect(html).toMatch(/gstack-design-craft <path-to-finalized\.html>` \(plus `--accepted` per the section\)/);
expect(craft).toContain('records each one in `craft_exceptions`');
expect(craft).toContain('append `--accepted <that finalized.json>` to the command');
expect(craft).toContain('CRAFT_ACCEPTED: <id> <count> <reason>');
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ describe('design-lint over the recorded review slop page', () => {
expect(at('icon-coin')).toEqual([['polish', 'styles.css', 51]]);
// Issue 6: generic hero copy, the two lines the design catalog names for it and the buzzword behind them
expect(at('generic-hero-copy')).toEqual([['blocks', 'index.html', 12], ['blocks', 'index.html', 13]]);
expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 1]]);
expect(at('marketing-buzzword')).toEqual([['ask', 'index.html', 32], ['ask', 'index.html', 37]]);
// Tells the page does not annotate but hallmark's sloplint reports on the same files
expect(at('pure-black-white').map((f) => f[2])).toEqual([17, 63, 85]);
expect(at('overflow-x-unclipped')).toEqual([['ask', 'styles.css', 1]]);
+135 -25
View File
@@ -17,7 +17,9 @@ import { collectFiles, contextWaivers, lintStatic, MAX_FILE_BYTES } from '../lib
import type { LintContext } from '../lib/design-lint/context';
import { browserAvailable } from './helpers/browser-available';
import { RULES } from '../lib/design-lint/rules/ids';
import { CRAFT_IDS } from '../lib/design-craft';
import { BUZZWORDS, COPY_IDS } from '../lib/design-lint/rules/copy';
import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog';
import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist';
import { FAMILIES } from '../lib/design-lint/rules';
import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers';
@@ -262,23 +264,138 @@ describe('design-lint: the static tier\'s files and contexts', () => {
});
describe('design-lint: the copy tier', () => {
// Value: protects=design-craft tells reach the lint as findings at line 1 with their count, source copy, under the craft ids; fails_when=the craft bridge drops ids, loses the count suffix, or re-reports the static tier's 100vh and transition: all tells; why_new=no test asserts a single copy-tier finding (the fixtures only check line format); seam=none; no_claim=the craft patterns themselves, which lib/design-craft owns
test('lorem, missing alt and alert() report with their counts at line 1 as copy findings; 100vh and transition: all stay with the static tier; a component file gets none', () => {
const dir = tmpTree({
'index.html': '<!doctype html>\n<html lang="en">\n<head><title>Ledger</title><meta name="description" content="The ledger closes the month."><link rel="icon" href="/icon.svg"><style>.hero { min-height: 100vh; transition: all 0.2s; }</style></head>\n<body><a class="skip" href="#main">Skip to content</a><main id="main"><h1>Lorem ipsum dolor</h1><img src="a.png"><img src="b.png"><p>Lorem ipsum again on the ledger.</p></main>\n<script>alert("saved")</script></body></html>',
'Card.tsx': 'export const Card = () => <article className="p-4"><p>Lorem ipsum in a component</p><img src="c.png" /></article>;',
});
const { findings } = lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } });
const copy = findings.filter((f) => f.source === 'copy');
expect(copy.map((f) => [f.grade, f.rule, f.file, f.line, f.evidence])).toEqual([
['blocks', 'lorem', 'index.html', 1, 'lorem ipsum placeholder text, 2 times'],
['ask', 'alert', 'index.html', 1, 'alert() for a message'],
['blocks', 'img-no-alt', 'index.html', 1, 'an <img> without alt, 2 times'],
]);
expect(copy.find((f) => f.rule === 'img-no-alt')?.fix).toBe('add alt text (alt="" when decorative)');
expect(findings.filter((f) => f.rule === 'vh-height').map((f) => [f.source, f.grade])).toEqual([['static', 'blocks']]);
expect(findings.filter((f) => f.rule === 'layout-transition').map((f) => f.source)).toEqual(['static']);
expect(findings.some((f) => f.file === 'Card.tsx' && f.source === 'copy')).toBe(false);
const DIRTY = `<!doctype html><html><head><style>.hero{height:100vh;transition: all .3s}</style></head><body>
<nav><a href="#">Docs</a><a href="/">Home</a><a href="/pricing">Pricing</a></nav>
<h1>Elevate your workflow seamlessly</h1><p>Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]</p>
<img src="a.png"><p>Oops! Something broke. Lorem ipsum.</p>
<script>window.addEventListener('scroll', () => {}); alert('hi'); showAlert(1);</script></body></html>`;
const CLEAN = `<!doctype html><html lang="en"><head><title>Ledgerline: close the books in 2 days</title>
<meta name="description" content="Close faster."><meta property="og:image" content="/og.png"><link rel="icon" href="/favicon.svg">
<style>.hero{min-height:100vh;min-height:100dvh;transition: transform .4s var(--ease-out)} .w{width:100%}</style></head>
<body><a href="#main">Skip to content</a><a href="#pricing">Pricing</a><nav><a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a></nav>
<main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main>
<script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`;
const copy = (html: string, scope: 'page' | 'component' = 'page') => {
const file = tmpPage(html);
return lintStatic([file], { cwd: path.dirname(file), scope, warn: () => {} }).findings.filter((f) => f.source === 'copy');
};
const ids = (html: string) => [...new Set(copy(html).map((f) => f.rule))].sort();
// Value: protects=every copy-tier tell reports at the line that holds it, under the craft ids and catalog fixes; fails_when=a rule reports at line 1, drops a tell or re-reports the static tier's 100vh and transition: all; why_new=the fold replaced lib/design-craft's counts with positions; seam=none; no_claim=render-tier findings
test('a page with every tell reports each one at its line, with what matched; 100vh and transition: all stay with the static tier', () => {
const found = copy(DIRTY).map((f) => [f.rule, f.line, f.evidence]);
expect(found).toEqual(expect.arrayContaining([
['dead-link', 2, 'a dead link (href="#")'],
['missing-aria-current', 2, 'route navigation without aria-current'],
['marketing-buzzword', 3, 'a marketing buzzword: "Elevate", "seamlessly"'],
['placeholder-brand', 3, 'a placeholder brand (Acme, Nexus): "Acme"'],
['placeholder-name', 3, 'a placeholder name (John Doe): "John Doe"'],
['round-number', 3, 'a round invented number: "99.99% uptime", "50% faster", "10,000+ happy users"'],
['needs-proof', 3, 'a [Needs proof] marker left in the page: "[Needs proof:"'],
['img-no-alt', 4, 'an <img> without alt'],
['oops', 4, '"oops" as an error message: "Oops"'],
['lorem', 4, 'lorem ipsum placeholder text: "Lorem ipsum"'],
['scroll-listener', 5, 'a scroll event listener'],
['alert', 5, 'alert() for a message'],
['missing-title', 1, 'a document without a <title>'],
]));
expect(ids(DIRTY)).toEqual(['alert', 'dead-link', 'img-no-alt', 'lorem', 'marketing-buzzword', 'missing-aria-current', 'missing-favicon', 'missing-meta-description', 'missing-skip-link', 'missing-title', 'needs-proof', 'oops', 'placeholder-brand', 'placeholder-name', 'round-number', 'scroll-listener']);
for (const f of copy(DIRTY)) expect(f.fix, f.rule).toBe(catalogEntry(f.rule)!.fix!);
const file = tmpPage(DIRTY);
const all = lintStatic([file], { cwd: path.dirname(file), warn: () => {} }).findings;
expect(all.filter((f) => f.rule === 'vh-height').map((f) => f.source)).toEqual(['static']);
expect(all.filter((f) => f.rule === 'layout-transition').map((f) => f.source)).toEqual(['static']);
});
test('a finished page is clean: CSS 100%, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => {
expect(copy(CLEAN)).toEqual([]);
const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8');
expect(copy(CLEAN.replace('</body>', `<script type="module">${pretext}</script></body>`))).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('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map((f) => f.evidence)).toEqual(['a marketing buzzword: "Unlocking", "seamless", "next gen", "elevate"']);
expect(copy('<p>Take the elevator to the elevation map; streamers sold here</p>')).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(`<p>${name}</p>`)).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('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']);
expect(ids(CLEAN.replace('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>'))).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('<p>&#91;Needs proof: logo]</p>')).toEqual(['needs-proof']);
expect(ids('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']);
expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']);
expect(ids('<p>Set a price alert (free)</p>')).toEqual([]);
expect(ids('<style>.a{content:"Acme"}</style><script>// John Doe</script><template><p>Lorem ipsum</p></template><p>Halvorsen</p>')).toEqual([]);
expect(ids('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).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('<p>Halvorsen</p><script>window.__DATA__ = "<img src=a.png><a href=\\"#\\">x</a>";</script>')).toEqual([]);
expect(ids('<!-- <img src="a.png"><p>Lorem ipsum</p><a href="#">x</a> --><p>Halvorsen</p>')).toEqual([]);
expect(copy("<button onclick='alert(1)'>Go</button>\n<button onclick=\"alert(2)\">Go</button>").map((f) => [f.rule, f.line])).toEqual([['alert', 1], ['alert', 2]]);
const head = '<!doctype html><html lang="en"><head><meta name="description" content="d"><link rel="icon" href="/i.svg"><script>const t = "<title>Ledger</title>";</script></head><body><a href="#m">Skip</a><main id="m"><p>Halvorsen</p></main></body></html>';
expect(ids(head)).toEqual(['missing-title']);
});
// Value: protects=a client-rendered shell is named an empty mount node instead of passing as clean; fails_when=a <noscript> notice counts as body text, or an image-only page with no script is called a shell; why_new=#3433's craft audit left "mount-node shell" undefined and the checker gave shells a clean result; seam=none; no_claim=that the rendered page is clean
test('an empty mount node is reported at its line; a noscript notice does not count as content, an image-only page is not a shell', () => {
const shell = '<!doctype html><html lang="en"><head><title>App</title></head>\n<body>\n<noscript>You need to enable JavaScript to run this app.</noscript>\n<div id="root"></div>\n<script src="/main.js"></script></body></html>';
expect(copy(shell).filter((f) => f.rule === 'empty-mount-node').map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 4, `${RULES['empty-mount-node'].title}: <div id="root">`]]);
expect(copy('<!doctype html><html><head><title>A</title></head><body><my-app></my-app><script type="module" src="/a.js"></script></body></html>').some((f) => f.rule === 'empty-mount-node')).toBe(true);
expect(ids('<!doctype html><html><head><title>A</title></head><body><img src="a.png" alt="A harbour at dusk"></body></html>')).not.toContain('empty-mount-node');
expect(ids(CLEAN)).not.toContain('empty-mount-node');
expect(copy(shell, 'component').some((f) => f.rule === 'empty-mount-node')).toBe(false);
});
test('a component file gets no copy findings', () => {
const dir = tmpTree({ 'Card.tsx': 'export const Card = () => <article className="p-4"><p>Lorem ipsum in a component</p><img src="c.png" /></article>;' });
expect(lintStatic([dir], { cwd: dir, warn: () => {} }).findings.some((f) => f.source === 'copy')).toBe(false);
});
});
@@ -397,13 +514,6 @@ describe('design-lint: third review cycle', () => {
expect(files.map((f) => path.relative(dir, f))).toEqual(['site/index.html']);
expect(warned).toEqual([`design-lint: skipped ${path.join(dir, 'site', 'big.css')} (2 MB, over the 2 MB limit)`]);
});
test('every id gstack-design-craft can emit has a lint rule or belongs to the static tier', () => {
const ids = [...CRAFT_IDS];
expect(ids.length).toBeGreaterThan(15);
expect(ids.filter((id) => !(id in RULES))).toEqual([]);
expect(ids.filter((id) => RULES[id].gate !== 'craft')).toEqual(['layout-transition', 'vh-height']);
});
});
describe('design-lint: hardening (ship review)', () => {