#!/usr/bin/env node /** * human-flow scanner v2 (AST-Powered) * * Sophisticated analysis pass for mouse + keyboard workflow friction. * Uses @babel/parser for deep JSX/TSX understanding. * * Usage: * node scripts/scan.mjs --path src * node scripts/scan.mjs --path src --fix */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from '@babel/parser'; import _traverse from '@babel/traverse'; import _generate from '@babel/generator'; import * as t from '@babel/types'; const traverse = _traverse.default; const generate = _generate.default; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const args = process.argv.slice(2); let targetPath = 'src'; let format = 'text'; let mode = 'friction'; // 'friction' | 'fancy' let applyFix = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--path' || args[i] === '-p') targetPath = args[++i]; if (args[i] === '--format' || args[i] === '-f') format = args[++i]; if (args[i] === '--fancy' || args[i] === '--mode=fancy') mode = 'fancy'; if (args[i] === '--fix') applyFix = true; } const absTarget = path.resolve(process.cwd(), targetPath); if (!fs.existsSync(absTarget)) { console.error(`Target not found: ${absTarget}`); process.exit(1); } // `--fix` auto-apply is DISABLED for now: @babel/generator reprints the whole // AST, producing noisy diffs that touch untouched code. Until it does surgical // edits, run advisory only — agents apply fixes surgically from the report. if (applyFix) { console.error('[INFO] --fix (auto-apply) is disabled; running an advisory scan instead. Apply fixes surgically from the report.'); applyFix = false; } const findings = []; let fixesApplied = 0; // Friction Index Rubric Weights const WEIGHTS = { MOTOR: 3.0, COGNITIVE: 2.5, KEYBOARD: 2.5, FEEDBACK: 2.0 }; const SEVERITY_POINTS = { high: 1.0, medium: 0.5, low: 0.2 }; function walk(dir) { if (!fs.existsSync(dir)) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue; walk(full); } else if (/\.(tsx|jsx|ts|js)$/.test(entry.name)) { analyzeFile(full); } } } function analyzeFile(file) { const content = fs.readFileSync(file, 'utf8'); const rel = path.relative(process.cwd(), file).replace(/\\/g, '/'); let modified = false; try { const ast = parse(content, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'], errorRecovery: true }); if (mode === 'fancy') { // Fancy / beauty & elegance pass const hasMotion = /transition:|animate-|@keyframes|framer-motion|ViewTransition/i.test(content); if (hasMotion) { addFinding({ file: rel, line: 1, category: 'FEEDBACK', severity: 'low', pattern: 'existing-motion', message: 'Existing motion detected. Review for quality, easing, and restraint.', humanImpact: 'Motion can feel premium or cheap depending on execution.', suggestion: 'Check if easings match the Restraint-o-Meter Level 3-4 (150-250ms).' }); } // Missing View Transitions in SPA contexts if (/(useNavigate|navigate\(| c.type === 'JSXElement'); const iconName = getComponentName(iconNode.openingElement); const label = iconName.replace(/Icon$/, ''); node.attributes.push(t.jsxAttribute(t.jsxIdentifier('aria-label'), t.stringLiteral(label))); modified = true; fixesApplied++; } else { addFinding({ file: rel, line: node.loc.start.line, category: 'COGNITIVE', severity: 'high', pattern: 'unlabeled-icon-button', message: `Button "${name}" contains only an icon but has no aria-label or title.`, humanImpact: 'Keyboard and screen reader users have no way to know what this button does.', suggestion: 'Add an aria-label or title prop describing the action.' }); } } } // 2. Tiny Target Calculator if (isInteractive(node)) { const size = getTargetSize(node); if (size < 32) { addFinding({ file: rel, line: node.loc.start.line, category: 'MOTOR', severity: 'high', pattern: 'tiny-target', message: `Interactive element "${name}" has a detected size of ~${size}px.`, humanImpact: 'Small targets require high precision, leading to slower workflows and mis-clicks.', suggestion: 'Increase height/width to at least 32px (ideally 44px) or add generous padding.' }); } } // 3. Interaction Feedback Missing if (name === 'Button' || name === 'ActionButton') { if (!hasFeedbackProps(node)) { addFinding({ file: rel, line: node.loc.start.line, category: 'FEEDBACK', severity: 'medium', pattern: 'missing-feedback-props', message: `Button "${name}" lacks loading or active state props.`, humanImpact: 'Users may be unsure if their click was registered during long operations.', suggestion: 'Add isLoading or active props to provide immediate visual feedback.' }); } } // 4. Keyboard Parity: onClick without key handler if (hasProp(node, 'onClick') && !isNativeButton(node) && !hasKeyboardProps(node)) { addFinding({ file: rel, line: node.loc.start.line, category: 'KEYBOARD', severity: 'high', pattern: 'click-without-keyboard', message: `Custom element "${name}" has onClick but no keyboard handlers (onKeyDown) or tabIndex.`, humanImpact: 'Keyboard users cannot trigger this action, creating a complete blocker for some workflows.', suggestion: 'Add tabIndex={0} and an onKeyDown handler for Enter/Space.' }); } } }); if (modified && applyFix) { const output = generate(ast, { retainLines: true }, content); fs.writeFileSync(file, output.code); } } catch (e) { // Graceful degradation: Fallback to regex for critical failures runLegacyRegexScan(content, rel); } } function addFinding(f) { findings.push(f); } // Helpers function getComponentName(node) { if (node.name.type === 'JSXIdentifier') return node.name.name; if (node.name.type === 'JSXMemberExpression') return node.name.property.name; return 'unknown'; } function isButtonLike(node) { const name = getComponentName(node); return ['button', 'Button', 'IconButton', 'ActionButton'].includes(name) || hasProp(node, 'role', 'button'); } function isNativeButton(node) { return getComponentName(node) === 'button'; } function isInteractive(node) { const name = getComponentName(node); return isButtonLike(node) || ['a', 'input', 'select', 'textarea'].includes(name) || hasProp(node, 'onClick'); } function hasProp(node, propName, value) { return node.attributes.some(attr => { if (attr.type !== 'JSXAttribute') return false; if (attr.name.name !== propName) return false; if (value === undefined) return true; if (attr.value && attr.value.type === 'StringLiteral') return attr.value.value === value; return false; }); } function hasAriaLabel(node) { return hasProp(node, 'aria-label') || hasProp(node, 'title') || hasProp(node, 'label'); } function hasOnlyIconChild(children) { const visibleChildren = children.filter(c => c.type !== 'JSXText' || c.value.trim() !== ''); if (visibleChildren.length !== 1) return false; const child = visibleChildren[0]; if (child.type !== 'JSXElement') return false; const name = getComponentName(child.openingElement); return name.endsWith('Icon') || name === 'Icon'; } function getTargetSize(node) { let size = 44; // Default node.attributes.forEach(attr => { if (attr.type === 'JSXAttribute' && attr.name.name === 'size') { if (attr.value.value === 'sm' || attr.value.value === 'xs') size = 28; } if (attr.type === 'JSXAttribute' && attr.name.name === 'className') { const val = attr.value.value || ''; if (val.includes('btn--sm') || val.includes('h-6') || val.includes('h-4')) size = 24; } }); return size; } function hasFeedbackProps(node) { return hasProp(node, 'loading') || hasProp(node, 'isLoading') || hasProp(node, 'active'); } function hasKeyboardProps(node) { return hasProp(node, 'onKeyDown') || hasProp(node, 'onKeyPress') || hasProp(node, 'tabIndex'); } function runLegacyRegexScan(content, rel) { // Simple fallback for files that fail AST parsing if (/onClick=\{[^}]+}\s*(?!.*(onKeyDown|tabIndex|role=))/g.test(content)) { addFinding({ file: rel, line: 1, category: 'KEYBOARD', severity: 'high', pattern: 'regex-click-without-keyboard', message: 'Detected onClick without keyboard support via fallback scanner.', humanImpact: 'Potential keyboard blocker.', suggestion: 'Manually review for keyboard parity.' }); } } // Start Scan walk(absTarget); if (applyFix) { console.log(`\nFixed ${fixesApplied} mechanical issues across the target.`); } else { // Calculate Score const scoreDeductions = findings.reduce((acc, f) => { const dim = f.category; const points = SEVERITY_POINTS[f.severity] * WEIGHTS[dim]; acc[dim] = (acc[dim] || 0) + points; return acc; }, {}); const totalDeduction = Object.values(scoreDeductions).reduce((a, b) => a + b, 0); const finalScore = Math.max(0, Math.min(10, 10 - totalDeduction)).toFixed(1); if (format === 'json') { console.log(JSON.stringify({ target: absTarget, score: finalScore, findings }, null, 2)); } else { console.log(`## Human-Flow Scan: ${targetPath}`); console.log(`**Overall Human Workflow Score: ${finalScore}/10**\n`); if (findings.length === 0) { console.log('[OK] No friction detected. Workflow is clean.'); } else { findings.forEach((f, i) => { console.log(`${i + 1}. [${f.severity.toUpperCase()}] ${f.category} — ${f.pattern}`); console.log(` File: ${f.file}:${f.line}`); console.log(` ${f.message}`); console.log(` Human impact: ${f.humanImpact}`); console.log(` Suggestion: ${f.suggestion}\n`); }); } } }