feat(human-flow): AST-based scanner v2 + Friction Index rubric
Upgrade the human-flow skill (Gemini-assisted, Claude-reviewed): - scan.mjs rewritten to AST-based (@babel/parser/traverse) with 4 detectors: unlabeled-icon-button, tiny-target, missing-feedback-props, click-without-keyboard; regex fallback on parse failure. - Objective Friction Index (Motor 3.0 / Cognitive 2.5 / Keyboard 2.5 / Feedback 2.0); 0-10 Human Workflow Score. - New heuristics: State-Flow Audit, Precision Rail / Fumble Zones, Restraint-o-Meter (1-5) for the fancy pass. - `fix` command DISABLED for now (advisory only): the AST generator reprints whole files and produces noisy diffs; agents apply surgical fixes from the report. To be revisited with a string-splice editor. - Add @babel/* deps + package-lock.json. - Memory: agy review/review-files is NOT actually read-only (wrote files + ran npm despite documented plan-mode) — diff after every agy review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* human-flow scanner
|
||||
* human-flow scanner v2 (AST-Powered)
|
||||
*
|
||||
* Static analysis pass for mouse + keyboard workflow friction.
|
||||
* Expands the spirit of frontend-design and impeccable with a narrow,
|
||||
* human-motor-and-expectation focus.
|
||||
* Sophisticated analysis pass for mouse + keyboard workflow friction.
|
||||
* Uses @babel/parser for deep JSX/TSX understanding.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/scan.mjs --path dashboard/src --format json
|
||||
* node scripts/scan.mjs --path dashboard/src/features/sessions
|
||||
*
|
||||
* It is intentionally lightweight (regex + heuristics) so it can run fast
|
||||
* inside agent loops. The real intelligence comes from the agent combining
|
||||
* these findings with full component reading and task-flow understanding.
|
||||
* 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));
|
||||
|
||||
@@ -25,12 +26,13 @@ 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] === '--mode' && args[i + 1] === 'fancy') { mode = 'fancy'; i++; }
|
||||
if (args[i] === '--fix') applyFix = true;
|
||||
}
|
||||
|
||||
const absTarget = path.resolve(process.cwd(), targetPath);
|
||||
@@ -40,16 +42,40 @@ if (!fs.existsSync(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|css)$/.test(entry.name)) {
|
||||
} else if (/\.(tsx|jsx|ts|js)$/.test(entry.name)) {
|
||||
analyzeFile(full);
|
||||
}
|
||||
}
|
||||
@@ -58,197 +84,256 @@ function walk(dir) {
|
||||
function analyzeFile(file) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const rel = path.relative(process.cwd(), file).replace(/\\/g, '/');
|
||||
const lines = content.split('\n');
|
||||
|
||||
if (mode === 'fancy') {
|
||||
// Fancy / beauty & elegance pass — lighter static signals + prompts for qualitative review
|
||||
let match;
|
||||
|
||||
// Existing transitions / animations (look for opportunities to refine)
|
||||
const hasTransition = /transition:|transition-\w+:|animate-|@keyframes|ViewTransition|view-transition/i.test(content);
|
||||
if (hasTransition) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'fancy-existing',
|
||||
severity: 'info',
|
||||
pattern: 'existing-motion',
|
||||
message: 'This file already contains motion/transition code. Good candidate for the fancy pass to review quality, consistency, and restraint.',
|
||||
humanImpact: 'Existing fancy elements can feel either premium or cheap/janky depending on execution.',
|
||||
suggestion: 'In the fancy pass, evaluate easing curves, durations, performance, reduced-motion respect, and whether the motion serves the human workflow or just decorates.'
|
||||
});
|
||||
}
|
||||
|
||||
// Missing View Transitions API in SPA navigation contexts
|
||||
if (/(useNavigate|navigate\(|<Link|react-router|next\/|router\.push)/i.test(content) && !/document\.startViewTransition|View Transitions|view-transition/i.test(content)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'fancy-opportunity',
|
||||
severity: 'low',
|
||||
pattern: 'missing-view-transitions',
|
||||
message: 'Navigation or view change logic detected without use of the View Transitions API.',
|
||||
humanImpact: 'Page-like changes can feel abrupt or cheap. Modern "ajax-style" smooth transitions between views feel significantly more premium.',
|
||||
suggestion: 'Consider wrapping key navigation with document.startViewTransition() + CSS view-transition-name for elegant morphs or fades. Only where it genuinely improves perceived quality.'
|
||||
});
|
||||
}
|
||||
|
||||
// Basic hover without fancy enhancement
|
||||
if (/:hover\s*\{[^}]*background|transform|box-shadow|scale|opacity/i.test(content)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'fancy-opportunity',
|
||||
severity: 'low',
|
||||
pattern: 'basic-hover',
|
||||
message: 'Hover state exists but may be basic. Opportunity for more elegant micro-interaction.',
|
||||
humanImpact: 'A merely functional hover feels flat. A refined one (subtle lift + shadow + accent) makes the interface feel alive and high-craft.',
|
||||
suggestion: 'Layer tasteful depth (shadow + slight scale or translate) with excellent easing. Keep it restrained, especially in dense data views.'
|
||||
});
|
||||
}
|
||||
|
||||
return; // In fancy mode we mostly collect signals for the agent to do deep qualitative work
|
||||
}
|
||||
|
||||
// === FRICTION MODE (original) ===
|
||||
|
||||
// 1. Small / sm button targets in interactive contexts (very common friction)
|
||||
const smallButton = /size=["']sm["']|<button[^>]*className=.*btn--sm|height:\s*2[0-8]px|min-height:\s*2[0-8]px/g;
|
||||
let match;
|
||||
while ((match = smallButton.exec(content)) !== null) {
|
||||
const lineNo = content.substring(0, match.index).split('\n').length;
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'target-size',
|
||||
severity: 'high',
|
||||
pattern: 'small-button',
|
||||
message: 'Compact "sm" button or very small height used for an action. Frequent actions (especially in lists) become precision targets.',
|
||||
humanImpact: 'Operators must slow down and aim carefully for common tasks. High error rate under time pressure.',
|
||||
suggestion: 'Use default (md) size for primary/frequent actions. For true compact row actions, ensure generous invisible padding or switch to a larger always-visible treatment.'
|
||||
let modified = false;
|
||||
|
||||
try {
|
||||
const ast = parse(content, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'],
|
||||
errorRecovery: true
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Hover-revealed or low-opacity row actions (the classic operator console anti-pattern)
|
||||
if (/\.dt__rowactions|\.rowactions|\.actions\s*\{[^}]*opacity:\s*0\.[0-6]/s.test(content) ||
|
||||
/opacity:\s*0\.[0-6][^}]*hover|hover[^}]*opacity:\s*(1|0\.[7-9])/s.test(content)) {
|
||||
const lineNo = 1; // best effort
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'discoverability',
|
||||
severity: 'high',
|
||||
pattern: 'hover-only-actions',
|
||||
message: 'Row or list actions are dimmed or hidden until hover (or only fully visible on hover).',
|
||||
humanImpact: 'A human scanning a list with eyes + mouse must "paint" every row to discover what they can do. Keyboard users often never see the controls at full strength.',
|
||||
suggestion: 'Raise resting opacity to 0.7–1.0 so actions are scannable at a glance. Or move frequent actions into a dedicated, always-visible column or primary row target. Keep hover only for polish, not discovery.'
|
||||
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\(|<Link|router\.push)/i.test(content) && !/document\.startViewTransition/i.test(content)) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'FEEDBACK',
|
||||
severity: 'low',
|
||||
pattern: 'missing-view-transitions',
|
||||
message: 'Navigation detected without View Transitions API.',
|
||||
humanImpact: 'View changes feel abrupt. Transitions feel significantly more premium.',
|
||||
suggestion: 'Wrap navigation in document.startViewTransition() where appropriate.'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
traverse(ast, {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
const name = getComponentName(node);
|
||||
|
||||
// 1. Unlabeled Icon Button (with Fixer)
|
||||
if (isButtonLike(node) && !hasAriaLabel(node)) {
|
||||
const parent = path.parentPath.node;
|
||||
if (parent.children && hasOnlyIconChild(parent.children)) {
|
||||
if (applyFix) {
|
||||
const iconNode = parent.children.find(c => 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.'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. onClick without obvious keyboard support on non-native elements
|
||||
const clickNoKeyboard = /onClick=\{[^}]+}\s*(?!.*(onKeyDown|tabIndex|role=))/g;
|
||||
while ((match = clickNoKeyboard.exec(content)) !== null) {
|
||||
const lineNo = content.substring(0, match.index).split('\n').length;
|
||||
// Only flag if it looks like a custom interactive (div, span, custom component in list context)
|
||||
const context = content.substring(Math.max(0, match.index - 80), match.index + 120);
|
||||
if (/<\s*(div|span|tr|td|li|custom|Card|Row)[^>]*onClick|onClick[^>]*<\s*(div|span|tr|td|li|Card|Row)/.test(context)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'keyboard-parity',
|
||||
severity: 'high',
|
||||
pattern: 'click-without-keyboard',
|
||||
message: 'Custom element has onClick but no visible tabIndex/onKeyDown/Enter-Space handling in the immediate area.',
|
||||
humanImpact: 'Keyboard (or mixed mouse+keyboard) users cannot activate the same thing the mouse can without extra workarounds.',
|
||||
suggestion: 'Add tabIndex={0}, onKeyDown handler for Enter/Space, and strong :focus-visible styles. Prefer native <button> when possible.'
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Icon-only buttons without accessible name (common with small action icons)
|
||||
const iconButton = /<Button[^>]*>\s*<[^>]+Icon|<\s*button[^>]*>\s*<[^>]+Icon|<[A-Z][^>]*>\s*<[^>]+Icon/g;
|
||||
while ((match = iconButton.exec(content)) !== null) {
|
||||
const lineNo = content.substring(0, match.index).split('\n').length;
|
||||
const nearby = content.substring(Math.max(0, match.index - 30), match.index + 180);
|
||||
if (!/aria-label|title=/.test(nearby)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'discoverability',
|
||||
severity: 'medium',
|
||||
pattern: 'icon-only-no-label',
|
||||
message: 'Icon-only button or action with no aria-label or title.',
|
||||
humanImpact: 'Screen readers and keyboard users (and anyone who forgets what the tiny icon means) have no idea what it does until they activate it.',
|
||||
suggestion: 'Add aria-label (and preferably a visible label or tooltip that works on focus too).'
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// 5. Very narrow status / action columns (precision rail)
|
||||
if (/width:\s*2[0-9]px|width:\s*30px|padding-left:\s*0 !important/.test(content) && /status|actions|select/i.test(content)) {
|
||||
findings.push({
|
||||
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: 'target-size',
|
||||
severity: 'medium',
|
||||
pattern: 'narrow-rail',
|
||||
message: 'Very narrow column (status, select, or actions rail) used for interactive or important visual elements.',
|
||||
humanImpact: 'Mouse must be extremely precise to hit the control or even read the status comfortably.',
|
||||
suggestion: 'Widen the rail or make the entire left edge a larger hit area (see dt__checkwrap pattern). Status can be visual + text on hover/focus.'
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Row that is fully clickable + internal small actions (mis-click risk)
|
||||
if (/onRowClick|onClick.*row|tr.*onClick/.test(content) && /dt__rowactions|rowactions/.test(content)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'workflow',
|
||||
severity: 'medium',
|
||||
pattern: 'row-click-plus-internal-actions',
|
||||
message: 'Whole row is clickable (for detail/open) while also containing small action buttons inside the row.',
|
||||
humanImpact: 'Easy to accidentally trigger the row action when aiming for the small icon (or vice versa). Classic source of "I didn\'t mean to open that".',
|
||||
suggestion: 'Make the primary row action very clearly the dominant target (bigger visual weight, different treatment). Or stop making the whole row clickable and use a dedicated primary button + separate secondary actions.'
|
||||
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);
|
||||
|
||||
// Deduplicate similar findings per file
|
||||
const seen = new Set();
|
||||
const uniqueFindings = findings.filter(f => {
|
||||
const key = `${f.file}:${f.pattern}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (format === 'json') {
|
||||
console.log(JSON.stringify({ target: absTarget, mode, findings: uniqueFindings }, null, 2));
|
||||
if (applyFix) {
|
||||
console.log(`\nFixed ${fixesApplied} mechanical issues across the target.`);
|
||||
} else {
|
||||
const title = mode === 'fancy'
|
||||
? `Human-Flow "Fancy as Fuck" Signals for: ${absTarget}`
|
||||
: `Human-Flow Scan Results for: ${absTarget}`;
|
||||
|
||||
console.log(`${title}\n`);
|
||||
// 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;
|
||||
}, {});
|
||||
|
||||
if (uniqueFindings.length === 0) {
|
||||
if (mode === 'fancy') {
|
||||
console.log('No obvious static fancy signals detected.\nThis is normal — the real fancy pass is qualitative. Load references/fancy-as-fuck.md and evaluate the target for beauty, elegance, and appropriate delight opportunities.');
|
||||
} else {
|
||||
console.log('No obvious mouse/keyboard friction patterns detected by static rules.\nRun a full agent review with the references/heuristics.md for deeper semantic issues.');
|
||||
}
|
||||
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 {
|
||||
uniqueFindings.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`);
|
||||
});
|
||||
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`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode = mode === 'fancy' ? 0 : (uniqueFindings.length > 0 ? 2 : 0);
|
||||
process.exit(exitCode);
|
||||
Reference in New Issue
Block a user