feat(design-lint): chrome, assets and finish families merged; maskedProse moves to html.ts; isTokenRule takes a Pick; ratchet watches every lint module

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 06:06:26 +00:00
1 parent 69a7ec0e82
commit 22d39449d1
11 files changed
+52 -40

No files matched your search

+1 -1
View File
@@ -155,7 +155,7 @@ export type Tokens = Record<string, string>;
const TOKEN_SEL = /^(?:html|:root)?(?:\[data-(?:theme|mode|scheme)[^\]]*\]|:root|:not\([^)]*\))*$/i;
/** Whether a rule belongs to the token block: :root / html / a data-theme scope, @font-face, @theme, or anything in tokens.css. */
export function isTokenRule(rule: CssRule): boolean {
export function isTokenRule(rule: Pick<CssRule, 'selector' | 'file'>): boolean {
if (rule.selector === '@font-face' || rule.selector === '@theme' || path.basename(rule.file).toLowerCase() === 'tokens.css') return true;
return rule.selector.split(',').every((p) => { const t = p.trim(); return t && TOKEN_SEL.test(t); });
}
+21
View File
@@ -572,4 +572,25 @@ export function closeIndex(doc: Doc, idx: number): number {
return doc.tags.length - 1;
}
// sloplint proseRun
const proseRun = (run: string): string => {
let r = run;
for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad);
return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r;
};
/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */
// sloplint maskedProse
export function maskedProse(doc: Doc): string {
const cached = doc.cache.prose as string | undefined;
if (cached != null) return cached;
const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src;
const prose = src
.replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad)
.replace(/<!--[\s\S]*?-->/g, pad)
.replace(/<[^>]*>/g, pad);
doc.cache.prose = prose;
return prose;
}
export const wordsOf = (t: string): string[] => plainText(t).split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w));
+1 -23
View File
@@ -9,31 +9,9 @@
*/
import { styleFor, type LintContext } from '../context';
import { resolveVars } from '../css';
import { ancestorMap, getAttr, innerOf, plainText, type Doc, type Tag } from '../html';
import { ancestorMap, getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html';
import { trunc, type Reporter } from '../report';
const pad = (m: string): string => m.replace(/[^\n]/g, ' ');
// sloplint proseRun
const proseRun = (run: string): string => {
let r = run;
for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad);
return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r;
};
/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */
// sloplint maskedProse
export function maskedProse(doc: Doc): string {
const cached = doc.cache.prose as string | undefined;
if (cached != null) return cached;
const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src;
const prose = src
.replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad)
.replace(/<!--[\s\S]*?-->/g, pad)
.replace(/<[^>]*>/g, pad);
doc.cache.prose = prose;
return prose;
}
// sloplint TELLS19 (gate 19), less the lines the copy tier owns
const HERO_TELLS: Array<[RegExp, string]> = [
+2 -2
View File
@@ -7,9 +7,9 @@
*/
import type { LintContext } from '../context';
import { lineIndex } from '../css';
import { getAttr, innerOf, plainText, type Doc, type Tag } from '../html';
import { getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html';
import { trunc, type Reporter } from '../report';
import { maskedProse, runChromeCopy } from './chrome-copy';
import { runChromeCopy } from './chrome-copy';
import { runEyebrow } from './chrome-eyebrow';
// sloplint check42 (gate 42)
+1 -1
View File
@@ -172,7 +172,7 @@ function check29Glow(ctx: LintContext, r: Reporter): void {
r.report('glow-wash', file, line, ev, { fix });
};
for (const rule of mergedRules(ctx)) {
if (isTokenRule({ ...rule, keyframes: null })) continue;
if (isTokenRule(rule)) continue;
const local: Tokens = {};
for (const d of rule.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;
const name = trunc(rule.selector, 36);
+5 -1
View File
@@ -93,7 +93,11 @@ function checkComponentText(ctx: LintContext, r: Reporter): void {
if (!text) continue;
if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\s*$/.test(text)) r.report('template-punctuation', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}" (<${t.name}>)`, { fix: 'a verb as the label; the link already says it goes somewhere' });
if ((text.match(/\s[·•]\s/g) || []).length >= 2) r.report('template-punctuation', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, { fix: 'line breaks, columns or hairlines; one dot in a byline at most' });
if (text.length <= 4 && EMOJI_ICON.test(text)) r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' });
if (text.length <= 4 && EMOJI_ICON.test(text)) {
const outer = r.findings.findIndex((f) => f.rule === 'emoji-decoration' && f.file === relPath(doc.file, r.cwd) && f.evidence.includes(`"${text}"`));
if (outer >= 0) r.findings.splice(outer, 1);
r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' });
}
}
}
}
+6 -9
View File
@@ -6,10 +6,9 @@
*/
import { mergedRules, type LintContext } from '../context';
import { HEADING_SEL, isTokenRule, lineIndex, pxOf, resolveVars, type MergedRule } from '../css';
import { getAttr } from '../html';
import { getAttr, maskedProse } from '../html';
import { trunc, type Reporter } from '../report';
import { twVariants } from '../tailwind';
import { maskedProse } from './chrome-copy';
/** The largest px, rem or em length in a value, at a 16px root; null when it has none. */
// sloplint maxPxIn
@@ -27,8 +26,6 @@ const baseSelector = (sel: string): string => sel.replace(/::?[a-z-]+(\([^)]*\))
const firstFile = (ctx: LintContext): string | null => ctx.cssFiles[0]?.file || ctx.docs[0]?.file || null;
/** isTokenRule over a merged rule (merging already dropped keyframes). */
const tokenRule = (rule: MergedRule): boolean => isTokenRule({ ...rule, keyframes: null });
// sloplint checkF1 (F1)
function checkStraightQuotes(ctx: LintContext, r: Reporter): void {
@@ -75,7 +72,7 @@ function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void {
const BTN = /\b(btn|button|cta)\b|__(btn|button|cta)\b/i;
const filled = new Map<string, { rule: MergedRule; line: number }>();
for (const rule of mergedRules(ctx)) {
if (tokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue;
if (isTokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue;
const first = baseSelector(rule.selector.split(',')[0]);
if (!BTN.test(first)) continue;
for (const d of rule.decls) {
@@ -93,7 +90,7 @@ function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void {
// sloplint checkF5 (F5)
function checkIconCoins(ctx: LintContext, r: Reporter): void {
for (const rule of mergedRules(ctx)) {
if (tokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue;
if (isTokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue;
const radius = rule.decls.find((d) => d.prop === 'border-radius');
if (!radius) continue;
const rv = resolveVars(radius.value, ctx.tokens);
@@ -115,7 +112,7 @@ function checkTabularFigures(ctx: LintContext, r: Reporter): void {
const covered = new Set(bySel.filter(hasTnum).map((rule) => baseSelector(rule.selector)));
const warned = new Set<string>();
for (const rule of bySel) {
if (tokenRule(rule) || !NUM_SEL.test(rule.selector)) continue;
if (isTokenRule(rule) || !NUM_SEL.test(rule.selector)) continue;
const base = baseSelector(rule.selector);
if (warned.has(base) || covered.has(base)) continue;
const size = rule.decls.find((d) => d.prop === 'font-size');
@@ -129,7 +126,7 @@ function checkTabularFigures(ctx: LintContext, r: Reporter): void {
function checkStampedRecipe(ctx: LintContext, r: Reporter): void {
const byKey = new Map<string, { sels: Set<string>; rule: MergedRule }>();
for (const rule of mergedRules(ctx)) {
if (tokenRule(rule) || /::(before|after)/.test(rule.selector)) continue;
if (isTokenRule(rule) || /::(before|after)/.test(rule.selector)) continue;
const get = (re: RegExp): string => {
const d = rule.decls.filter((x) => re.test(x.prop)).pop();
return d ? resolveVars(d.value, ctx.tokens).replace(/\s+/g, ' ').trim() : '';
@@ -155,7 +152,7 @@ function checkPillRadius(ctx: LintContext, r: Reporter): void {
const sels = new Set<string>();
let where: { rule: MergedRule; line: number } | null = null;
for (const rule of mergedRules(ctx)) {
if (tokenRule(rule)) continue;
if (isTokenRule(rule)) continue;
const d = rule.decls.filter((x) => x.prop === 'border-radius').pop();
if (!d || !/^(9999px|999px|50rem)$/.test(resolveVars(d.value, ctx.tokens).trim())) continue;
for (const part of rule.selector.split(',')) {
+1 -1
View File
@@ -293,7 +293,7 @@ function check67(ctx: LintContext, r: Reporter): void {
const upperDisplay = bySel.some((rule) => displayish(rule) && !LABEL67.test(rule.selector) && rule.decls.some((d) => d.prop === 'text-transform' && /uppercase/.test(d.value)));
const seen = new Set<string>();
for (const rule of bySel) {
if (isTokenRule({ ...rule, keyframes: null }) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue;
if (isTokenRule(rule) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue;
const caps = caps67(ctx, bySel, rule);
if (!caps) continue;
const { upper, smallCaps, em, px } = caps;
+1 -1
View File
@@ -237,7 +237,7 @@ describe('design-lint tailwind: type rules', () => {
const f = await lintFiles({ 'index.html': page(body) });
expect(only(f, 'kicker-above-heading').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'ask'], [BODY + 5, 'ask']]);
expect(only(f, 'kicker-above-heading').map((x) => x.evidence)).toEqual([
'eyebrow "Features" before <h2> (uppercase at 14px)',
'the eyebrow "Features" before <h2>',
'eyebrow "Fewer handoffs" before <h3> (text-indigo-600 at 14px)',
]);
expect(only(await lintFiles({ 'index.html': page(body) }, { scope: 'component' }), 'kicker-above-heading')).toEqual([]);
+1 -1
View File
@@ -21,7 +21,7 @@
<title>Gate 54 carve-out fixture (must pass)</title>
<meta name="description" content="Fragments that sit near a heading without announcing it; gate 54 must stay quiet on every one.">
<style>
/* gstack-lint-disable skipped-heading: unrelated fragments in one file, not a single document outline */
/* gstack-lint-disable skipped-heading: unrelated fragments in one file, not a single document outline; each heading opens its own crossing */
:root {
--color-paper: oklch(97% 0.006 250); --color-ink: oklch(20% 0.02 250);
--color-muted: oklch(52% 0.015 250); --color-accent: oklch(52% 0.14 250); --color-accent-ink: oklch(98% 0.005 250);
+12
View File
@@ -1,5 +1,17 @@
{
"newModules": [
{ "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/assets.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/chrome-copy.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/chrome-eyebrow.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/chrome.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/color.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/component.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/finish.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/motion.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/structure.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/type.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/tailwind.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/css.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/color.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/html.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },