From 4bde3d2c8fa46f86f2c3923124cba5015803d6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki?= Date: Tue, 2 Jun 2026 13:35:48 -0300 Subject: [PATCH 1/2] feat(core): configurable --ignore screen-name globs (suppress variant/WIP frames) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the previously-declared-but-unused AnalyzerOptions.ignorePatterns: user-supplied screen-name globs that exclude matching screens from the ENTIRE scan — issues, metrics, and the score. Applied in scanner.ts after collection, so a pattern like "* — Dev" removes that screen from every check (orphan, contrast, emphasis, ...), not just one. No hardcoded Dev/Demo/[FIX] list — the user decides per file. - filters.ts: globToRegExp (only * is a wildcard; everything else literal, case-insensitive, full-match) + matchesIgnore. - CLI: --ignore ""; MCP: ignore: string[]. Verified on EDET: --ignore "* — Dev,* — Demo" drops 16 variant screens to 0, real screens untouched. 90 tests (+2), tsc strict clean across core/cli/mcp. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/index.ts | 2 ++ packages/core/src/scanner.test.ts | 29 +++++++++++++++++++++++++++++ packages/core/src/scanner.ts | 15 +++++++++++++-- packages/core/src/utils/filters.ts | 16 ++++++++++++++++ packages/mcp/src/index.ts | 2 ++ 5 files changed, 62 insertions(+), 2 deletions(-) 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..773174c 100644 --- a/packages/core/src/scanner.test.ts +++ b/packages/core/src/scanner.test.ts @@ -4,6 +4,7 @@ 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'; function demoFile() { return makeFile([ @@ -45,6 +46,34 @@ 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('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); + }); + 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..64962e5 100644 --- a/packages/core/src/utils/filters.ts +++ b/packages/core/src/utils/filters.ts @@ -44,3 +44,19 @@ 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. */ +export function matchesIgnore(name: string, patterns?: string[]): boolean { + if (!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..5c3cd51 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, }); // Update the HTML report for the MCP Apps UI resource From 6e5f137af48737d1e0eeeaa66619c66046a1869e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki?= Date: Tue, 2 Jun 2026 13:58:13 -0300 Subject: [PATCH 2/2] fix(core): harden --ignore after audit (file-level guard, additional* test, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the adversarial audit of PR #5: - filters.ts: matchesIgnore returns false for empty names, so screen-name globs (even "*") never suppress file-level diagnostics (metadata findings). - mcp: filter(Boolean) on the ignore array (drop empty strings, CLI parity). - scanner.test.ts: cover ignore filtering of additionalIssues/additionalMetrics (simulator/vision path) + the empty-name guard (the audit's only HIGH: a test-coverage gap — the filtering code was already correct). - README: document --ignore + the science flags (--metrics/--score/--ergonomics) that were missing from the Options section. 91 tests passing, tsc --noEmit strict clean across core/cli/mcp. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 ++++++ packages/core/src/scanner.test.ts | 41 ++++++++++++++++++++++++++++++ packages/core/src/utils/filters.ts | 8 ++++-- packages/mcp/src/index.ts | 2 +- 4 files changed, 56 insertions(+), 3 deletions(-) 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/packages/core/src/scanner.test.ts b/packages/core/src/scanner.test.ts index 773174c..f7c95f1 100644 --- a/packages/core/src/scanner.test.ts +++ b/packages/core/src/scanner.test.ts @@ -5,6 +5,7 @@ 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([ @@ -65,6 +66,44 @@ describe('scan() integration — metrics + score', () => { 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); @@ -72,6 +111,8 @@ describe('scan() integration — metrics + score', () => { 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 () => { diff --git a/packages/core/src/utils/filters.ts b/packages/core/src/utils/filters.ts index 64962e5..005f961 100644 --- a/packages/core/src/utils/filters.ts +++ b/packages/core/src/utils/filters.ts @@ -55,8 +55,12 @@ export function globToRegExp(glob: string): RegExp { return new RegExp('^' + pattern + '$', 'i'); } -/** True if `name` matches any of the user-supplied ignore glob patterns. */ +/** + * 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 (!patterns || patterns.length === 0) return false; + 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 5c3cd51..66bf35e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -140,7 +140,7 @@ registerAppTool( additionalIssues: simulatorIssues.length ? simulatorIssues : undefined, metrics: !!(args.metrics || args.score || args.ergonomics), score: !!(args.score || args.ergonomics), - ignorePatterns: args.ignore, + ignorePatterns: args.ignore?.filter(Boolean), }); // Update the HTML report for the MCP Apps UI resource