diff --git a/README.md b/README.md index d6df482..ee1923a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,14 @@ Options: --min-touch-target Minimum touch target size (default: 44) --skip Comma-separated checks to skip --pages Comma-separated page IDs to scan + --ignore Comma-separated screen-name globs to exclude from + the whole scan, e.g. "* — Dev,* — Demo,[FIX]*" + (only * is a wildcard; everything else literal) + --metrics Run ProtoScan Science metrics (WCAG contrast, Hick, + Miller, Fitts, typography, readability, Gestalt, ...) + --score Compute the 0-100 usability score with per-dimension + breakdown + top fixes (implies --metrics) + --ergonomics Alias for --score --simulate Run Playwright E2E simulator (slow, finds runtime bugs) --record [dir] Record simulator walkthrough video (requires --simulate) --upload Upload HTML report to GitHub Gist (requires GITHUB_TOKEN) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 7b7f7d2..b1a366d 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -41,6 +41,7 @@ program .option('--metrics', 'Run ProtoScan Science metrics (WCAG contrast, Hick, Miller, Fitts, typography, readability, Gestalt, emphasis, balance)') .option('--score', 'Compute the 0-100 usability score with per-dimension breakdown (implies --metrics)') .option('--ergonomics', 'Alias for --score — show the per-dimension usability breakdown') + .option('--ignore ', 'Comma-separated screen-name globs to exclude from the whole scan, e.g. "* — Dev,* — Demo,[FIX]*" (only * is a wildcard)') .action(async (input: string, options) => { // Parse Figma URL or raw file key const { fileKey, pageIds: urlPageIds } = parseFigmaInput(input); @@ -213,6 +214,7 @@ program additionalIssues: [...simulatorIssues, ...visionIssues], metrics: !!(options.metrics || options.score || options.ergonomics), score: !!(options.score || options.ergonomics), + ignorePatterns: options.ignore?.split(',').map((s: string) => s.trim()).filter(Boolean), }); const formatters: Record string> = { diff --git a/packages/core/src/scanner.test.ts b/packages/core/src/scanner.test.ts index 41598d8..f7c95f1 100644 --- a/packages/core/src/scanner.test.ts +++ b/packages/core/src/scanner.test.ts @@ -4,6 +4,8 @@ import { formatTerminal } from './reporters/terminal.js'; import { formatJson } from './reporters/json.js'; import { formatHtml } from './reporters/html.js'; import { box, solidFill, textNode, interactiveNode, frameNode, makeFile } from './testing/fixtures.js'; +import { globToRegExp, matchesIgnore } from './utils/filters.js'; +import type { Issue, Metric } from './types.js'; function demoFile() { return makeFile([ @@ -45,6 +47,74 @@ describe('scan() integration — metrics + score', () => { expect(result.summary.score).toBeUndefined(); }); + it('ignorePatterns excludes matching screens from issues, metrics, and score', async () => { + const mk = (name: string) => + frameNode({ + name, + box: box(0, 0, 375, 800), + fills: [solidFill(1, 1, 1)], + children: [textNode({ characters: 'faint', fontSize: 16, fills: [solidFill(0.7, 0.7, 0.7)], box: box(0, 0, 200, 14) })], + }); + const file = makeFile([mk('Home'), mk('Home (Dev)')]); + + const result = await scan(file, { fileKey: 'abc123', score: true, ignorePatterns: ['*(Dev)'] }); + + const screens = new Set([...result.issues.map((i) => i.screenName), ...result.metrics.map((m) => m.screenName)]); + expect(screens.has('Home')).toBe(true); + expect(screens.has('Home (Dev)')).toBe(false); // suppressed everywhere + // score still computed from the remaining (non-ignored) screen + expect(result.summary.score).toBeDefined(); + }); + + it('ignorePatterns also filters additionalIssues/additionalMetrics (simulator/vision path)', async () => { + const file = makeFile([frameNode({ name: 'Home', box: box(0, 0, 375, 800), children: [] })]); + const addIssue = (screenName: string): Issue => ({ + id: 'sim-' + screenName, + category: 'runtime-nav-failure', + severity: 'high', + confidence: 'certain', + screenId: screenName, + screenName, + message: 'sim', + evidence: {}, + }); + const addMetric = (screenName: string): Metric => ({ + id: 'vis-' + screenName, + framework: 'AALTO', + dimension: 'clutter', + citation: 'x', + claimType: 'signal', + tier: 'vision', + value: 1, + threshold: 0, + status: 'fail', + screenId: screenName, + screenName, + }); + const result = await scan(file, { + fileKey: 'abc123', + score: true, + ignorePatterns: ['*(Dev)'], + additionalIssues: [addIssue('Home'), addIssue('Home (Dev)')], + additionalMetrics: [addMetric('Home'), addMetric('Home (Dev)')], + }); + expect(result.issues.some((i) => i.screenName === 'Home (Dev)')).toBe(false); + expect(result.issues.some((i) => i.id === 'sim-Home')).toBe(true); + expect(result.metrics.some((m) => m.screenName === 'Home (Dev)')).toBe(false); + expect(result.metrics.some((m) => m.id === 'vis-Home')).toBe(true); + }); + + it('globToRegExp: only * is a wildcard; brackets/dashes are literal', () => { + expect(globToRegExp('*(Dev)').test('Home (Dev)')).toBe(true); + expect(globToRegExp('*(Dev)').test('Home')).toBe(false); + expect(globToRegExp('[FIX]*').test('[FIX] Home')).toBe(true); + expect(globToRegExp('[FIX]*').test('Home')).toBe(false); + expect(matchesIgnore('Login — Demo', ['* — Demo', '* — Dev'])).toBe(true); + expect(matchesIgnore('Login', ['* — Demo'])).toBe(false); + // file-level findings (empty name) are never suppressed, even by '*' + expect(matchesIgnore('', ['*'])).toBe(false); + }); + it('reporters render a scored result without throwing', async () => { const result = await scan(demoFile(), { fileKey: 'abc123', score: true }); const term = formatTerminal(result); diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index e5c1165..67a1266 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -8,6 +8,7 @@ import { overlayTrapAnalyzer } from './spatial/overlay-traps.js'; import { METRIC_CHECKS } from './metrics/registry.js'; import { metricToIssue } from './metrics/metric.js'; import { computeScore, annotatePointsImpact } from './scoring/score.js'; +import { matchesIgnore } from './utils/filters.js'; export interface ScanOptions extends AnalyzerOptions { fileKey: string; @@ -23,6 +24,9 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise !matchesIgnore(m.screenName, ignore)); + } annotatePointsImpact(metrics); // Dual-emit failed metrics as Issues so existing reporters render them — but // CAP per category. On large files a deterministic per-node scan yields @@ -76,14 +84,17 @@ export async function scan(file: FigmaFile, options: ScanOptions): Promise !matchesIgnore(i.screenName, ignore)) : issues; + const score: UsabilityScore | undefined = options.score ? computeScore(metrics) : undefined; const duration = Math.round(performance.now() - start); - const summary = buildSummary(issues, graph.nodes.size, score); + const summary = buildSummary(finalIssues, graph.nodes.size, score); return { file: { name: file.name, key: options.fileKey, lastModified: file.lastModified }, graph, - issues, + issues: finalIssues, metrics, summary, duration, diff --git a/packages/core/src/utils/filters.ts b/packages/core/src/utils/filters.ts index 17445fe..005f961 100644 --- a/packages/core/src/utils/filters.ts +++ b/packages/core/src/utils/filters.ts @@ -44,3 +44,23 @@ export const ONBOARDING_PATTERN = export function isOnboardingScreen(name: string): boolean { return ONBOARDING_PATTERN.test(name); } + +/** + * Convert a simple glob to a full-match, case-insensitive RegExp. Only `*` is a + * wildcard (→ `.*`); every other character is matched literally. So `[FIX]*` + * matches a literal "[FIX]" prefix, and `* — Dev` matches any "… — Dev" name. + */ +export function globToRegExp(glob: string): RegExp { + const pattern = glob.replace(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === '*' ? '.*' : '\\' + ch)); + return new RegExp('^' + pattern + '$', 'i'); +} + +/** + * True if `name` matches any of the user-supplied ignore glob patterns. + * An empty/missing name (file-level findings) NEVER matches — these are + * metadata diagnostics that screen-name globs (even `*`) must not suppress. + */ +export function matchesIgnore(name: string, patterns?: string[]): boolean { + if (!name || !patterns || patterns.length === 0) return false; + return patterns.some((p) => globToRegExp(p).test(name)); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e1262e5..66bf35e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -83,6 +83,7 @@ registerAppTool( format: z.enum(['terminal', 'json']).optional().default('terminal').describe('Output format for text response'), min_touch_target: z.number().optional().default(44).describe('Minimum touch target size in px'), skip: z.array(z.string()).optional().describe('Checks to skip: dead-end, orphan, back-nav, touch-target, overlap, scroll, overlay-trap, contrast, cognitive-load, fitts, typography, readability, gestalt, emphasis, balance'), + ignore: z.array(z.string()).optional().describe('Screen-name globs to exclude from the whole scan, e.g. ["* — Dev", "* — Demo", "[FIX]*"] (only * is a wildcard).'), }, _meta: { ui: { resourceUri: 'ui://protoscan/report' }, @@ -139,6 +140,7 @@ registerAppTool( additionalIssues: simulatorIssues.length ? simulatorIssues : undefined, metrics: !!(args.metrics || args.score || args.ergonomics), score: !!(args.score || args.ergonomics), + ignorePatterns: args.ignore?.filter(Boolean), }); // Update the HTML report for the MCP Apps UI resource