mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
Fix detector URL and advisory handling
Recover joined URL arguments without splitting local paths, derive advisory behavior from registry severity across consumers, inspect readable linked CSS in URL scans, and report only the dominant primary font. AI assistance disclosure: Implemented and verified with Codex under maintainer direction.
This commit is contained in:
@@ -427,6 +427,8 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
|
||||
The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
|
||||
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
|
||||
|
||||
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
|
||||
|
||||
For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: `<!-- impeccable-disable overused-font: exported brand doc -->`. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`.
|
||||
|
||||
@@ -1235,7 +1235,7 @@ if (IS_BROWSER) {
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
advisory: ap?.severity === 'advisory' || f.severity === 'advisory' || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -1277,6 +1277,36 @@ if (IS_BROWSER) {
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Same-origin CSS and readable CORS sheets participate; browser
|
||||
// security exceptions for cross-origin sheets are expected and skipped.
|
||||
function linkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) appendSheet(rule.styleSheet);
|
||||
else if (rule.cssText) parts.push(rule.cssText);
|
||||
}
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
catch { return ''; }
|
||||
for (const sheet of sheets) {
|
||||
const owner = sheet.ownerNode;
|
||||
if (owner?.tagName?.toLowerCase() !== 'link') continue;
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
||||
}
|
||||
@@ -1650,7 +1680,11 @@ if (IS_BROWSER) {
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
const html = docClone.outerHTML;
|
||||
const corpora = buildHtmlPatternCorpora(html);
|
||||
const linkedCss = linkedStylesheetText();
|
||||
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
|
||||
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
|
||||
+26
-6
@@ -37,13 +37,30 @@ function fileUrlToLocalPath(url) {
|
||||
}
|
||||
}
|
||||
|
||||
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
|
||||
|
||||
// Some agent runners hand a shell-ready URL list to Node as one argv value.
|
||||
// A browser accepts the spaces as part of one encoded URL, producing a
|
||||
// plausible scan attributed to a bogus joined path. Expand only when every
|
||||
// whitespace-delimited token is independently a URL, preserving ordinary
|
||||
// filesystem paths that contain spaces.
|
||||
function expandJoinedUrlTargets(targets) {
|
||||
return targets.flatMap((target) => {
|
||||
if (!/\s/.test(target)) return [target];
|
||||
const parts = target.trim().split(/\s+/).filter(Boolean);
|
||||
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
|
||||
? parts
|
||||
: [target];
|
||||
});
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return finding && finding.advisory === true;
|
||||
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
@@ -168,6 +185,10 @@ Advisory findings:
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
@@ -185,7 +206,7 @@ Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
URLs Puppeteer full browser rendering (auto-detected;
|
||||
http(s):// and file:// URLs)
|
||||
http(s):// and file:// URLs; accessible linked CSS included)
|
||||
|
||||
Examples:
|
||||
impeccable detect src/
|
||||
@@ -283,7 +304,7 @@ async function detectCli() {
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
@@ -297,13 +318,12 @@ async function detectCli() {
|
||||
// real cascade, real computed styles, real layout. Callers that want a
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlRe = /^(?:https?|file):\/\//i;
|
||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (urlRe.test(target)) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
|
||||
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
severity: 'advisory',
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
@@ -5293,14 +5293,17 @@ function checkTypography() {
|
||||
}
|
||||
|
||||
if (totalTextElements >= 20) {
|
||||
// A font is "primary" if it's used by at least 15% of text elements
|
||||
const PRIMARY_THRESHOLD = 0.15;
|
||||
for (const [font, count] of fontUsage) {
|
||||
// Report the actual primary face: the uniquely most-used family. The old
|
||||
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const [primary] = ranked;
|
||||
const tied = ranked[1]?.[1] === primary?.[1];
|
||||
if (primary && !tied) {
|
||||
const [font, count] = primary;
|
||||
const share = count / totalTextElements;
|
||||
if (share < PRIMARY_THRESHOLD) continue;
|
||||
if (!OVERUSED_FONTS.has(font)) continue;
|
||||
if (isBrandFontOnOwnDomain(font)) continue;
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8133,7 +8136,7 @@ if (IS_BROWSER) {
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
advisory: ap?.severity === 'advisory' || f.severity === 'advisory' || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -8175,6 +8178,36 @@ if (IS_BROWSER) {
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Same-origin CSS and readable CORS sheets participate; browser
|
||||
// security exceptions for cross-origin sheets are expected and skipped.
|
||||
function linkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) appendSheet(rule.styleSheet);
|
||||
else if (rule.cssText) parts.push(rule.cssText);
|
||||
}
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
catch { return ''; }
|
||||
for (const sheet of sheets) {
|
||||
const owner = sheet.ownerNode;
|
||||
if (owner?.tagName?.toLowerCase() !== 'link') continue;
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
||||
}
|
||||
@@ -8548,7 +8581,11 @@ if (IS_BROWSER) {
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
const html = docClone.outerHTML;
|
||||
const corpora = buildHtmlPatternCorpora(html);
|
||||
const linkedCss = linkedStylesheetText();
|
||||
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
|
||||
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAntipattern } from './registry/antipatterns.mjs';
|
||||
import { getAntipattern, isAdvisoryRule } from './registry/antipatterns.mjs';
|
||||
|
||||
function getAP(id) {
|
||||
return getAntipattern(id);
|
||||
@@ -11,7 +11,7 @@ function finding(id, filePath, snippet, line = 0) {
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
if (ap.advisory === true) base.advisory = true;
|
||||
if (isAdvisoryRule(id)) base.advisory = true;
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
severity: 'advisory',
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
@@ -588,9 +588,10 @@ function getAntipattern(id) {
|
||||
// Advisory rules are detected and reported, but never treated as failures:
|
||||
// the CLI lists them under a separate "Advisory" section, they do not affect
|
||||
// exit codes or the failure count, and the design hook skips them by default.
|
||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
||||
// `severity` is the canonical registry field. The runtime finding serializer
|
||||
// derives its `advisory: true` compatibility/output flag from this set.
|
||||
const ADVISORY_RULE_IDS = new Set(
|
||||
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
|
||||
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
|
||||
);
|
||||
|
||||
function isAdvisoryRule(id) {
|
||||
|
||||
@@ -4020,14 +4020,17 @@ function checkTypography() {
|
||||
}
|
||||
|
||||
if (totalTextElements >= 20) {
|
||||
// A font is "primary" if it's used by at least 15% of text elements
|
||||
const PRIMARY_THRESHOLD = 0.15;
|
||||
for (const [font, count] of fontUsage) {
|
||||
// Report the actual primary face: the uniquely most-used family. The old
|
||||
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const [primary] = ranked;
|
||||
const tied = ranked[1]?.[1] === primary?.[1];
|
||||
if (primary && !tied) {
|
||||
const [font, count] = primary;
|
||||
const share = count / totalTextElements;
|
||||
if (share < PRIMARY_THRESHOLD) continue;
|
||||
if (!OVERUSED_FONTS.has(font)) continue;
|
||||
if (isBrandFontOnOwnDomain(font)) continue;
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,17 +138,20 @@ export const IMMEDIATE_TIER_RULES = new Set([
|
||||
// the agent is never nagged about a taste call a human might make on purpose.
|
||||
// A project opts back in with `.impeccable/config.json`:
|
||||
// { "detector": { "advisoryRules": "include" } }
|
||||
// This set is the hook's own copy of the registry's `advisory: true` rules,
|
||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
||||
// self-contained and testable without loading the detector. Keep it in sync
|
||||
// with the registry (cli/engine/registry/antipatterns.mjs).
|
||||
// This legacy id fallback keeps older detector findings recognizable when they
|
||||
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||
// Current findings are classified by their serialized metadata below.
|
||||
export const ADVISORY_RULES = new Set([
|
||||
'em-dash-overuse',
|
||||
]);
|
||||
|
||||
export function isAdvisoryFinding(finding) {
|
||||
const id = finding && normalizeIgnoreRule(finding.antipattern);
|
||||
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
||||
return Boolean(id && (
|
||||
ADVISORY_RULES.has(id)
|
||||
|| finding.advisory === true
|
||||
|| finding.severity === 'advisory'
|
||||
));
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -52,6 +53,21 @@ function isolatedBrowserFixtureCases(name) {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
function runDetectCli(args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'skill', 'scripts', 'detect.mjs'), ...args],
|
||||
{ cwd: path.join(ROOT, 'tests', 'fixtures'), encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => resolve({
|
||||
code: typeof error?.code === 'number' ? error.code : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// Static server: maps /fixtures/* to tests/fixtures/* and
|
||||
// /js/detect-antipatterns-browser.js to cli/engine/detect-antipatterns-browser.js
|
||||
@@ -1346,6 +1362,64 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('CLI expands a joined multi-URL target and attributes both scans', async () => {
|
||||
const first = `${baseUrl}/fixtures/antipatterns/quality.html`;
|
||||
const second = `${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`;
|
||||
const result = await runDetectCli([
|
||||
'--json',
|
||||
'--viewport',
|
||||
'1280x800',
|
||||
`${first} ${second}`,
|
||||
]);
|
||||
assert.equal(result.code, 2, result.stderr);
|
||||
const files = new Set(JSON.parse(result.stdout).map(finding => finding.file));
|
||||
assert.ok(files.has(first), `missing first URL attribution: ${JSON.stringify([...files])}`);
|
||||
assert.ok(files.has(second), `missing second URL attribution: ${JSON.stringify([...files])}`);
|
||||
assert.equal(files.has(`${first} ${second}`), false);
|
||||
});
|
||||
|
||||
it('URL scans read linked CSS, serialize severity advisories, and flag only the dominant font', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await launchBrowser(puppeteer, {
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
try {
|
||||
const linkedPage = await browser.newPage();
|
||||
await linkedPage.goto(`${baseUrl}/fixtures/antipatterns/linked-url-patterns.html`, { waitUntil: 'load' });
|
||||
await linkedPage.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await linkedPage.evaluate(browserScript);
|
||||
const linkedFindings = await linkedPage.evaluate(() => window.impeccableDetect({ serialize: true })
|
||||
.flatMap(group => group.findings || []));
|
||||
const stripes = linkedFindings.filter(finding => finding.type === 'repeating-stripes-gradient');
|
||||
assert.equal(stripes.length, 1, JSON.stringify(linkedFindings));
|
||||
assert.equal(stripes[0].severity, 'advisory');
|
||||
assert.equal(stripes[0].advisory, true);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'codex-grid-background'), false);
|
||||
await linkedPage.close();
|
||||
|
||||
const fontPage = await browser.newPage();
|
||||
const primary = Array.from({ length: 82 }, (_, i) => `<span class="primary">Primary ${i}</span>`).join('');
|
||||
const secondary = Array.from({ length: 18 }, (_, i) => `<span class="secondary">Secondary ${i}</span>`).join('');
|
||||
await fontPage.setContent(`<!doctype html><style>
|
||||
.primary { font-family: Geist, sans-serif; }
|
||||
.secondary { font-family: "Geist Mono", monospace; }
|
||||
</style><main>${primary}${secondary}</main>`);
|
||||
await fontPage.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await fontPage.evaluate(browserScript);
|
||||
const fontFindings = await fontPage.evaluate(() => window.impeccableDetect({ serialize: true })
|
||||
.flatMap(group => group.findings || [])
|
||||
.filter(finding => finding.type === 'overused-font'));
|
||||
assert.equal(fontFindings.length, 1, JSON.stringify(fontFindings));
|
||||
assert.match(fontFindings[0].detail, /Primary font: geist \(82% of text\)/i);
|
||||
assert.doesNotMatch(fontFindings[0].detail, /geist mono/i);
|
||||
await fontPage.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and
|
||||
// color(srgb ...) verbatim in getComputedStyle output, so a detector that
|
||||
// cannot parse those reads every surface as unset, walks out of the page,
|
||||
|
||||
@@ -2717,23 +2717,30 @@ describe('CLI', () => {
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('Usage:');
|
||||
expect(stdout).toContain('--quiet');
|
||||
expect(stdout).toContain('Human-readable findings go to stderr');
|
||||
expect(stdout).not.toContain('--gpt');
|
||||
expect(stdout).not.toContain('--gemini');
|
||||
});
|
||||
|
||||
test('generated-UI tells run by default in the CLI', () => {
|
||||
test('severity advisory is non-blocking, flagged in JSON, and suppressible', () => {
|
||||
const { stdout, code } = run('--json', path.join(FIXTURES, 'gpt-tells.html'));
|
||||
expect(code).toBe(2);
|
||||
const ids = JSON.parse(stdout).map(f => f.antipattern);
|
||||
expect(code).toBe(0);
|
||||
const findings = JSON.parse(stdout);
|
||||
const ids = findings.map(f => f.antipattern);
|
||||
expect(ids).toContain('gpt-thin-border-wide-shadow');
|
||||
expect(ids).toContain('repeating-stripes-gradient');
|
||||
expect(ids).toContain('codex-grid-background');
|
||||
expect(ids).toContain('theater-slop-phrase');
|
||||
expect(findings.every(f => f.severity === 'advisory' && f.advisory === true)).toBe(true);
|
||||
|
||||
const hidden = run('--json', '--no-advisory', path.join(FIXTURES, 'gpt-tells.html'));
|
||||
expect(hidden.code).toBe(0);
|
||||
expect(JSON.parse(hidden.stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
test('legacy provider flags are accepted as deprecated no-ops', () => {
|
||||
const { stdout, stderr, code } = run('--gpt', '--json', path.join(FIXTURES, 'gpt-tells.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(code).toBe(0);
|
||||
expect(stderr).toContain('--gpt and --gemini are deprecated and ignored');
|
||||
expect(JSON.parse(stdout).some(f => f.antipattern === 'codex-grid-background')).toBe(true);
|
||||
});
|
||||
@@ -2744,14 +2751,30 @@ describe('CLI', () => {
|
||||
expect(stderr).not.toContain('cannot access detect');
|
||||
});
|
||||
|
||||
test('keeps a local path containing spaces as one scan target', () => {
|
||||
const fixture = writeStaticFixture({
|
||||
'page with spaces.html': '<!doctype html><html><body><main><h1>Plain page</h1></main></body></html>',
|
||||
});
|
||||
const file = path.join(fixture.dir, 'page with spaces.html');
|
||||
try {
|
||||
const { stdout, stderr, code } = run('--json', file);
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(stdout)).toEqual([]);
|
||||
expect(stderr).not.toContain('cannot access');
|
||||
} finally {
|
||||
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('should-pass exits 0', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test('should-flag exits 2 with findings', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
|
||||
const { stdout, code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(stdout).toBe('');
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
@@ -2899,7 +2922,7 @@ colors:
|
||||
`);
|
||||
|
||||
const full = runIn(dir, '--json', 'index.css');
|
||||
expect(full.code).toBe(2);
|
||||
expect(full.code).toBe(0);
|
||||
const fullIds = JSON.parse(full.stdout).map((finding) => finding.antipattern);
|
||||
expect(fullIds).toContain('design-system-font-size');
|
||||
expect(fullIds).toContain('design-system-color');
|
||||
|
||||
@@ -10,7 +10,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const cli = path.join(root, 'cli', 'bin', 'cli.js');
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stdin-dispatch-'));
|
||||
|
||||
function detectStdinFile(filePath) {
|
||||
function detectStdinFile(filePath, expectedStatus = 2) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'detect', '--json', '--no-config', '--no-design-system'],
|
||||
@@ -19,7 +19,7 @@ function detectStdinFile(filePath) {
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.status, expectedStatus, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
@@ -57,9 +57,11 @@ describe('detect CLI stdin file dispatch', () => {
|
||||
}
|
||||
`);
|
||||
|
||||
const findings = detectStdinFile(filePath);
|
||||
const findings = detectStdinFile(filePath, 0);
|
||||
assert.ok(findings.some(
|
||||
(item) => item.file === filePath && item.antipattern === 'codex-grid-background',
|
||||
(item) => item.file === filePath
|
||||
&& item.antipattern === 'codex-grid-background'
|
||||
&& item.advisory === true,
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.flag-linked-stripes {
|
||||
width: 160px;
|
||||
height: 80px;
|
||||
background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px);
|
||||
}
|
||||
|
||||
/* A shipped but unused selector must remain outside live URL findings. */
|
||||
.unused-linked-grid {
|
||||
background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Linked URL pattern detection</title>
|
||||
<link rel="stylesheet" href="/fixtures/antipatterns/linked-url-patterns.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Linked stylesheet pattern</h1>
|
||||
<div class="flag-linked-stripes">Rendered repeating stripes</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -665,6 +665,7 @@ describe('filterFindings()', () => {
|
||||
const filtered = filterFindings(findings, content, '.ts', {
|
||||
ignoreRules: ['side-tab'],
|
||||
minSeverity: 'error',
|
||||
advisoryRules: 'include',
|
||||
limits: DEFAULT_CONFIG.limits,
|
||||
});
|
||||
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
|
||||
@@ -674,6 +675,7 @@ describe('filterFindings()', () => {
|
||||
const findings = [
|
||||
finding('side-tab', 1),
|
||||
finding('em-dash-overuse', 2),
|
||||
finding('design-system-radius', 3, { severity: 'advisory' }),
|
||||
finding('gradient-text', 3),
|
||||
];
|
||||
const filtered = filterFindings(findings, '', '.html', {
|
||||
@@ -700,6 +702,7 @@ describe('filterFindings()', () => {
|
||||
assert.ok(ADVISORY_RULES.has('em-dash-overuse'));
|
||||
assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true);
|
||||
assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), true);
|
||||
assert.equal(isAdvisoryFinding({ antipattern: 'anything', severity: 'advisory' }), true);
|
||||
assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user