qualia-framework 4.4.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -0
- package/CLAUDE.md +12 -63
- package/README.md +24 -18
- package/agents/builder.md +13 -33
- package/agents/plan-checker.md +18 -0
- package/agents/planner.md +17 -0
- package/agents/verifier.md +70 -0
- package/agents/visual-evaluator.md +132 -0
- package/bin/cli.js +64 -23
- package/bin/install.js +375 -29
- package/bin/qualia-ui.js +208 -1
- package/bin/slop-detect.mjs +362 -0
- package/bin/state.js +218 -2
- package/docs/erp-contract.md +5 -0
- package/docs/install-redesign-builder-prompt.md +290 -0
- package/docs/install-redesign-pilot.md +234 -0
- package/docs/playwright-loop-builder-prompt.md +185 -0
- package/docs/playwright-loop-design-notes.md +108 -0
- package/docs/playwright-loop-pilot-results.md +170 -0
- package/docs/playwright-loop-review-2026-05-03.md +65 -0
- package/docs/playwright-loop-tester-prompt.md +213 -0
- package/docs/reviews/matt-pocock-skills-analysis.md +300 -0
- package/guide.md +9 -5
- package/hooks/env-empty-guard.js +74 -0
- package/hooks/pre-compact.js +19 -9
- package/hooks/pre-deploy-gate.js +8 -2
- package/hooks/pre-push.js +26 -12
- package/hooks/supabase-destructive-guard.js +62 -0
- package/hooks/vercel-account-guard.js +91 -0
- package/package.json +2 -1
- package/rules/design-brand.md +114 -0
- package/rules/design-laws.md +148 -0
- package/rules/design-product.md +114 -0
- package/rules/design-rubric.md +157 -0
- package/rules/grounding.md +4 -0
- package/skills/qualia-build/SKILL.md +40 -46
- package/skills/qualia-discuss/SKILL.md +51 -68
- package/skills/qualia-handoff/SKILL.md +1 -0
- package/skills/qualia-issues/SKILL.md +151 -0
- package/skills/qualia-map/SKILL.md +78 -35
- package/skills/qualia-new/REFERENCE.md +139 -0
- package/skills/qualia-new/SKILL.md +85 -124
- package/skills/qualia-optimize/REFERENCE.md +202 -0
- package/skills/qualia-optimize/SKILL.md +72 -237
- package/skills/qualia-plan/SKILL.md +58 -65
- package/skills/qualia-polish/SKILL.md +180 -136
- package/skills/qualia-polish-loop/REFERENCE.md +265 -0
- package/skills/qualia-polish-loop/SKILL.md +201 -0
- package/skills/qualia-polish-loop/fixtures/broken.html +117 -0
- package/skills/qualia-polish-loop/fixtures/clean.html +196 -0
- package/skills/qualia-polish-loop/scripts/loop.mjs +302 -0
- package/skills/qualia-polish-loop/scripts/playwright-capture.mjs +197 -0
- package/skills/qualia-polish-loop/scripts/score.mjs +176 -0
- package/skills/qualia-report/SKILL.md +141 -180
- package/skills/qualia-research/SKILL.md +28 -33
- package/skills/qualia-road/SKILL.md +103 -0
- package/skills/qualia-ship/SKILL.md +1 -0
- package/skills/qualia-task/SKILL.md +1 -1
- package/skills/qualia-test/SKILL.md +50 -2
- package/skills/qualia-triage/SKILL.md +152 -0
- package/skills/qualia-verify/SKILL.md +63 -104
- package/skills/qualia-zoom/SKILL.md +51 -0
- package/skills/zoho-workflow/SKILL.md +64 -0
- package/templates/CONTEXT.md +36 -0
- package/templates/DESIGN.md +229 -435
- package/templates/PRODUCT.md +95 -0
- package/templates/decisions/ADR-template.md +30 -0
- package/tests/bin.test.sh +451 -7
- package/tests/state.test.sh +58 -0
- package/skills/qualia-design/SKILL.md +0 -169
package/bin/qualia-ui.js
CHANGED
|
@@ -522,6 +522,210 @@ function cmdPlanSummary(planPath) {
|
|
|
522
522
|
console.log(` ${RULE_DIM}`);
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
// ─── v5.1: live-progress primitives (consumed by install.js) ─────────
|
|
526
|
+
// Vanilla Node, zero deps. Auto-degrade to plain text when stdout is not
|
|
527
|
+
// a TTY so piped install logs stay readable (no orphan escape sequences,
|
|
528
|
+
// no \r overwrites, no spinner garbage).
|
|
529
|
+
|
|
530
|
+
const IS_TTY = !!(process.stdout && process.stdout.isTTY);
|
|
531
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
532
|
+
|
|
533
|
+
function visibleLength(str) {
|
|
534
|
+
return String(str).replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ANSI: clear current line and move cursor to column 0
|
|
538
|
+
function clearLine() {
|
|
539
|
+
process.stdout.write("\r\x1b[2K");
|
|
540
|
+
}
|
|
541
|
+
function hideCursor() { if (IS_TTY) process.stdout.write("\x1b[?25l"); }
|
|
542
|
+
function showCursor() { if (IS_TTY) process.stdout.write("\x1b[?25h"); }
|
|
543
|
+
|
|
544
|
+
// step(text) — prints " ⏳ text" then returns a handle:
|
|
545
|
+
// .ok(suffix?) → overwrites with " ✓ text [suffix]"
|
|
546
|
+
// .warn(message) → overwrites with " ! text — message"
|
|
547
|
+
// .fail(message) → overwrites with " ✗ text — message"
|
|
548
|
+
// In non-TTY mode, prints plain " → text" then " ✓ text" on a new line.
|
|
549
|
+
function step(text) {
|
|
550
|
+
const indent = " ";
|
|
551
|
+
if (!IS_TTY) {
|
|
552
|
+
// Skip the "doing" line in non-TTY mode — go straight to the result line
|
|
553
|
+
// when finalised. Avoids 2× line count in piped install logs.
|
|
554
|
+
let logged = false;
|
|
555
|
+
return {
|
|
556
|
+
ok(suffix) {
|
|
557
|
+
const tail = suffix ? ` ${DIM}${suffix}${RESET}` : "";
|
|
558
|
+
console.log(`${indent}${GREEN}✓${RESET} ${WHITE}${text}${RESET}${tail}`);
|
|
559
|
+
logged = true;
|
|
560
|
+
},
|
|
561
|
+
warn(msg) {
|
|
562
|
+
console.log(`${indent}${YELLOW}!${RESET} ${WHITE}${text}${RESET}${msg ? ` ${DIM}— ${msg}${RESET}` : ""}`);
|
|
563
|
+
logged = true;
|
|
564
|
+
},
|
|
565
|
+
fail(msg) {
|
|
566
|
+
console.log(`${indent}${RED}✗${RESET} ${WHITE}${text}${RESET}${msg ? ` ${DIM}— ${msg}${RESET}` : ""}`);
|
|
567
|
+
logged = true;
|
|
568
|
+
},
|
|
569
|
+
_logged() { return logged; },
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
process.stdout.write(`${indent}${DIM2}⏳${RESET} ${DIM}${text}${RESET}`);
|
|
573
|
+
return {
|
|
574
|
+
ok(suffix) {
|
|
575
|
+
clearLine();
|
|
576
|
+
const tail = suffix ? ` ${DIM}${suffix}${RESET}` : "";
|
|
577
|
+
process.stdout.write(`${indent}${GREEN}✓${RESET} ${WHITE}${text}${RESET}${tail}\n`);
|
|
578
|
+
},
|
|
579
|
+
warn(msg) {
|
|
580
|
+
clearLine();
|
|
581
|
+
process.stdout.write(`${indent}${YELLOW}!${RESET} ${WHITE}${text}${RESET}${msg ? ` ${DIM}— ${msg}${RESET}` : ""}\n`);
|
|
582
|
+
},
|
|
583
|
+
fail(msg) {
|
|
584
|
+
clearLine();
|
|
585
|
+
process.stdout.write(`${indent}${RED}✗${RESET} ${WHITE}${text}${RESET}${msg ? ` ${DIM}— ${msg}${RESET}` : ""}\n`);
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// spinner(text) — Braille spinner, ticks every 100ms.
|
|
591
|
+
// stop({ status: "ok"|"warn"|"fail", message? })
|
|
592
|
+
// Non-TTY → prints " → text" then " ✓ text" / " ! text" / " ✗ text" on stop.
|
|
593
|
+
function spinner(text) {
|
|
594
|
+
const indent = " ";
|
|
595
|
+
if (!IS_TTY) {
|
|
596
|
+
return {
|
|
597
|
+
stop(result) {
|
|
598
|
+
const r = result || { status: "ok" };
|
|
599
|
+
if (r.status === "warn") {
|
|
600
|
+
console.log(`${indent}${YELLOW}!${RESET} ${WHITE}${text}${RESET}${r.message ? ` ${DIM}— ${r.message}${RESET}` : ""}`);
|
|
601
|
+
} else if (r.status === "fail" || r.status === "error") {
|
|
602
|
+
console.log(`${indent}${RED}✗${RESET} ${WHITE}${text}${RESET}${r.message ? ` ${DIM}— ${r.message}${RESET}` : ""}`);
|
|
603
|
+
} else {
|
|
604
|
+
console.log(`${indent}${GREEN}✓${RESET} ${WHITE}${text}${RESET}${r.message ? ` ${DIM}${r.message}${RESET}` : ""}`);
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
// Windows cmd.exe historically had spotty Braille rendering; modern Win10+
|
|
610
|
+
// Terminal handles it fine. The fallback path is the non-TTY branch above
|
|
611
|
+
// (covers piped logs); for interactive cmd.exe we accept that cosmetic risk
|
|
612
|
+
// — install still completes correctly even if frames render as boxes.
|
|
613
|
+
let i = 0;
|
|
614
|
+
hideCursor();
|
|
615
|
+
const render = () => {
|
|
616
|
+
clearLine();
|
|
617
|
+
process.stdout.write(`${indent}${TEAL}${SPINNER_FRAMES[i]}${RESET} ${DIM}${text}${RESET}`);
|
|
618
|
+
i = (i + 1) % SPINNER_FRAMES.length;
|
|
619
|
+
};
|
|
620
|
+
render();
|
|
621
|
+
const handle = setInterval(render, 100);
|
|
622
|
+
return {
|
|
623
|
+
stop(result) {
|
|
624
|
+
clearInterval(handle);
|
|
625
|
+
clearLine();
|
|
626
|
+
const r = result || { status: "ok" };
|
|
627
|
+
const tail = r.message ? ` ${DIM}${r.message}${RESET}` : "";
|
|
628
|
+
let glyph = `${GREEN}✓${RESET}`;
|
|
629
|
+
if (r.status === "warn") glyph = `${YELLOW}!${RESET}`;
|
|
630
|
+
if (r.status === "fail" || r.status === "error") glyph = `${RED}✗${RESET}`;
|
|
631
|
+
process.stdout.write(`${indent}${glyph} ${WHITE}${text}${RESET}${tail}\n`);
|
|
632
|
+
showCursor();
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// progress(current, total, label) → renders a discrete progress bar line.
|
|
638
|
+
// Used by install.js for batch-copy phases (skills, templates) where step()
|
|
639
|
+
// would be too noisy. Caller updates by re-invoking; in TTY mode each call
|
|
640
|
+
// overwrites the previous line.
|
|
641
|
+
function progress(current, total, label) {
|
|
642
|
+
const indent = " ";
|
|
643
|
+
const width = 14;
|
|
644
|
+
const filled = total > 0 ? Math.round((current / total) * width) : 0;
|
|
645
|
+
const bar = `${TEAL}${"█".repeat(filled)}${DIM2}${"░".repeat(width - filled)}${RESET}`;
|
|
646
|
+
const line = `${indent}${bar} ${DIM}${current}/${total}${RESET} ${DIM}${label || ""}${RESET}`;
|
|
647
|
+
if (IS_TTY) {
|
|
648
|
+
clearLine();
|
|
649
|
+
process.stdout.write(line);
|
|
650
|
+
if (current >= total) process.stdout.write("\n");
|
|
651
|
+
} else if (current >= total) {
|
|
652
|
+
// Non-TTY: only emit the final line, avoid spamming the log per-tick.
|
|
653
|
+
console.log(line);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// box({title, lines, color}) — boxed multi-line panel.
|
|
658
|
+
// color is an ANSI escape string (default TEAL).
|
|
659
|
+
function box(opts) {
|
|
660
|
+
const title = opts.title || "";
|
|
661
|
+
const lines = Array.isArray(opts.lines) ? opts.lines : [];
|
|
662
|
+
const color = opts.color || TEAL;
|
|
663
|
+
const indent = " ";
|
|
664
|
+
const inner = Math.max(visibleLength(title), ...lines.map(visibleLength));
|
|
665
|
+
const width = Math.max(40, inner + 4);
|
|
666
|
+
const top = "─".repeat(width - 2);
|
|
667
|
+
console.log(`${indent}${color}┌${top}┐${RESET}`);
|
|
668
|
+
if (title) {
|
|
669
|
+
const padTitle = " ".repeat(Math.max(0, width - 4 - visibleLength(title)));
|
|
670
|
+
console.log(`${indent}${color}│${RESET} ${BOLD}${WHITE}${title}${RESET}${padTitle} ${color}│${RESET}`);
|
|
671
|
+
console.log(`${indent}${color}├${top}┤${RESET}`);
|
|
672
|
+
}
|
|
673
|
+
for (const line of lines) {
|
|
674
|
+
const pad = " ".repeat(Math.max(0, width - 4 - visibleLength(line)));
|
|
675
|
+
console.log(`${indent}${color}│${RESET} ${line}${pad} ${color}│${RESET}`);
|
|
676
|
+
}
|
|
677
|
+
console.log(`${indent}${color}└${top}┘${RESET}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// kv(label, value, options?) — left-padded label, value rendered in
|
|
681
|
+
// options.color (default WHITE). options.labelWidth pads label to column N.
|
|
682
|
+
function kv(label, value, options) {
|
|
683
|
+
const opts = options || {};
|
|
684
|
+
const valueColor = opts.color || WHITE;
|
|
685
|
+
const labelWidth = opts.labelWidth || 12;
|
|
686
|
+
const padded = pad(`${DIM}${label}${RESET}`, labelWidth + visibleLength(`${DIM}${RESET}`));
|
|
687
|
+
console.log(` ${padded}${valueColor}${value}${RESET}`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// divider(width?) — variable-width dim divider.
|
|
691
|
+
function divider(width) {
|
|
692
|
+
const w = width || 48;
|
|
693
|
+
console.log(` ${DIM2}${"━".repeat(w)}${RESET}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// section(title, count?) — section header with optional right-aligned count.
|
|
697
|
+
function section(title, count) {
|
|
698
|
+
console.log("");
|
|
699
|
+
const right = (count !== undefined && count !== null && count !== "")
|
|
700
|
+
? ` ${TEAL}${count}${RESET} ${DIM}✓${RESET}`
|
|
701
|
+
: "";
|
|
702
|
+
console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}${title}${RESET}${right}`);
|
|
703
|
+
console.log(` ${DIM2}${"─".repeat(40)}${RESET}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// sectionClose(summary) — single-line summary after a section completes.
|
|
707
|
+
function sectionClose(summary) {
|
|
708
|
+
console.log(` ${DIM2}└─${RESET} ${DIM}${summary}${RESET}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
module.exports = {
|
|
712
|
+
// Color constants — exported so install.js doesn't redefine them.
|
|
713
|
+
colors: { TEAL, TEAL_DIM, DIM, DIM2, GREEN, WHITE, YELLOW, RED, BLUE, RESET, BOLD },
|
|
714
|
+
IS_TTY,
|
|
715
|
+
// Live-progress primitives
|
|
716
|
+
step,
|
|
717
|
+
spinner,
|
|
718
|
+
progress,
|
|
719
|
+
box,
|
|
720
|
+
kv,
|
|
721
|
+
divider,
|
|
722
|
+
section,
|
|
723
|
+
sectionClose,
|
|
724
|
+
// Existing helpers (kept exposed for reuse)
|
|
725
|
+
pad,
|
|
726
|
+
visibleLength,
|
|
727
|
+
};
|
|
728
|
+
|
|
525
729
|
function cmdUpdate(current, latest) {
|
|
526
730
|
if (!current || !latest) return;
|
|
527
731
|
console.log("");
|
|
@@ -535,7 +739,10 @@ function cmdUpdate(current, latest) {
|
|
|
535
739
|
console.log("");
|
|
536
740
|
}
|
|
537
741
|
|
|
538
|
-
// ─── Main
|
|
742
|
+
// ─── Main (CLI dispatch — only when executed directly, not when required) ─
|
|
743
|
+
if (require.main !== module) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
539
746
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
540
747
|
switch (cmd) {
|
|
541
748
|
case "banner":
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* slop-detect — Standalone anti-pattern scanner for Qualia projects.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node bin/slop-detect.mjs # scan whole repo (default globs)
|
|
7
|
+
* node bin/slop-detect.mjs path/to/file.tsx # scan one file
|
|
8
|
+
* node bin/slop-detect.mjs src/components/ # scan a directory
|
|
9
|
+
* node bin/slop-detect.mjs --json # machine-readable output
|
|
10
|
+
* node bin/slop-detect.mjs --severity=critical # only critical findings
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 no critical findings
|
|
14
|
+
* 1 one or more critical findings
|
|
15
|
+
* 2 invocation error
|
|
16
|
+
*
|
|
17
|
+
* Builder agents call this BEFORE commit. A non-zero exit blocks the commit.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
21
|
+
import { join, extname, relative, resolve } from "node:path";
|
|
22
|
+
import { argv, exit, cwd } from "node:process";
|
|
23
|
+
|
|
24
|
+
// ── Severity rubric (from rules/grounding.md) ─────────────────────────
|
|
25
|
+
const CRITICAL = "critical";
|
|
26
|
+
const HIGH = "high";
|
|
27
|
+
const MEDIUM = "medium";
|
|
28
|
+
const LOW = "low";
|
|
29
|
+
|
|
30
|
+
// ── Anti-patterns ─────────────────────────────────────────────────────
|
|
31
|
+
// Each rule: { id, severity, label, fileGlob, pattern, allow?, fix }
|
|
32
|
+
const RULES = [
|
|
33
|
+
// ── CRITICAL: absolute bans (block commit) ──────────────────────────
|
|
34
|
+
{
|
|
35
|
+
id: "ABS-FONT",
|
|
36
|
+
severity: CRITICAL,
|
|
37
|
+
label: "Banned font (Inter/Roboto/Arial/system-ui/Space Grotesk)",
|
|
38
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
|
|
39
|
+
pattern: /(font-family|fontFamily)[^;{,]*(['"`])(Inter|Roboto|Arial|Helvetica|system-ui|Space\s*Grotesk)\b/i,
|
|
40
|
+
allow: /Inter\s*Display|Inter\s*Tight/,
|
|
41
|
+
fix: "Replace with a distinctive font (Fraunces, Geist, Söhne, JetBrains Mono, etc). See DESIGN.md §3.",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "ABS-PURPLE-GRAD",
|
|
45
|
+
severity: CRITICAL,
|
|
46
|
+
label: "Purple-blue gradient (the #1 AI-design tell)",
|
|
47
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
|
|
48
|
+
pattern: /(from-(blue|indigo|violet)-\d+\s+to-(purple|violet|fuchsia|pink)-\d+|from-(purple|violet|fuchsia|pink)-\d+\s+to-(blue|indigo|violet)-\d+|linear-gradient[^;]*(blue|indigo)[^;]*(purple|violet|fuchsia)|linear-gradient[^;]*(purple|violet|fuchsia)[^;]*(blue|indigo))/i,
|
|
49
|
+
fix: "Use one solid brand accent color. Emphasis via weight or size, not gradient text/bg.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "ABS-GRADIENT-TEXT",
|
|
53
|
+
severity: CRITICAL,
|
|
54
|
+
label: "Gradient text (background-clip: text)",
|
|
55
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
|
|
56
|
+
pattern: /(background-clip\s*:\s*text|bg-clip-text|-webkit-background-clip\s*:\s*text)/i,
|
|
57
|
+
fix: "Decorative, never meaningful. Use a single solid color. Emphasis via weight or size.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "ABS-PURE-BLACK-WHITE",
|
|
61
|
+
severity: CRITICAL,
|
|
62
|
+
label: "Pure #000 or #fff (untuned, generic)",
|
|
63
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
64
|
+
pattern: /#(000000|FFFFFF|000|FFF)\b/,
|
|
65
|
+
allow: /shadow|outline|currentColor|var\(/i,
|
|
66
|
+
fix: "Tint every neutral toward brand hue. Use OKLCH with chroma 0.005-0.015. See design-laws.md §1.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "ABS-HEX-IN-JSX",
|
|
70
|
+
severity: CRITICAL,
|
|
71
|
+
label: "Hardcoded hex color in JSX/TSX (use design tokens)",
|
|
72
|
+
fileGlob: /\.(tsx|jsx)$/,
|
|
73
|
+
pattern: /style=\{[^}]*['"]#[0-9a-fA-F]{3,8}['"]/,
|
|
74
|
+
fix: "Move color to a CSS custom property in DESIGN.md tokens. Reference via var(--name).",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "ABS-SIDE-STRIPE",
|
|
78
|
+
severity: CRITICAL,
|
|
79
|
+
label: "Side-stripe border (decorative border-left ≥2px)",
|
|
80
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
81
|
+
pattern: /border-(left|right)\s*:\s*\d+(px|rem)\s+solid|border-l-(2|4|8)|border-r-(2|4|8)/,
|
|
82
|
+
allow: /focus|active|outline/,
|
|
83
|
+
fix: "Use full borders, background tints, leading icons, or nothing. See design-laws.md §8.",
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// ── HIGH: strong tells ───────────────────────────────────────────────
|
|
87
|
+
{
|
|
88
|
+
id: "HI-MAX-W-CAP",
|
|
89
|
+
severity: HIGH,
|
|
90
|
+
label: "Hardcoded max-width container (no fluid full-width)",
|
|
91
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
92
|
+
pattern: /(max-w-7xl|max-w-\[1200|max-w-\[1280|max-w-\[1440|max-width\s*:\s*1200|max-width\s*:\s*1280)/,
|
|
93
|
+
fix: "Use fluid padding: clamp(1rem, 5vw, 4rem). Cap content via max-width: 65ch on prose only.",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "HI-OUTLINE-NONE",
|
|
97
|
+
severity: HIGH,
|
|
98
|
+
label: "outline:none without focus replacement (a11y violation)",
|
|
99
|
+
fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
|
|
100
|
+
pattern: /outline\s*:\s*none|outline-none/,
|
|
101
|
+
allow: /focus-visible|focus:ring|focus:outline/,
|
|
102
|
+
fix: "Replace with a visible focus ring: 2px offset, contrasting color. See design-laws.md §accessibility.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "HI-IMG-NO-ALT",
|
|
106
|
+
severity: HIGH,
|
|
107
|
+
label: "<img> without alt attribute",
|
|
108
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
|
|
109
|
+
pattern: /<img\s+(?![^>]*\balt=)[^>]*>/,
|
|
110
|
+
fix: "Every image needs alt text. Decorative: alt=\"\" + aria-hidden=\"true\". Meaningful: describe it.",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "HI-GENERIC-CTA",
|
|
114
|
+
severity: HIGH,
|
|
115
|
+
label: "Generic CTA copy (Get Started / Learn More / Click Here)",
|
|
116
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro|md)$/,
|
|
117
|
+
pattern: />\s*(Get Started|Learn More|Click Here|Welcome to|Read More|Find Out More)\s*</i,
|
|
118
|
+
fix: "Name the action: 'Download invoice', 'Continue setup', 'Try the demo'.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "HI-EM-DASH",
|
|
122
|
+
severity: HIGH,
|
|
123
|
+
label: "Em dash in user-facing copy (— or '--')",
|
|
124
|
+
// Scope: shipped UI files only. Markdown / docs prose is allowed em-dashes.
|
|
125
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
|
|
126
|
+
pattern: />\s*[^<]*[—][^<]*</,
|
|
127
|
+
fix: "Use commas, colons, semicolons, periods, or parentheses. See design-laws.md §7.",
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// ── MEDIUM: noisy signals ────────────────────────────────────────────
|
|
131
|
+
{
|
|
132
|
+
id: "MED-CARD-GRID-3",
|
|
133
|
+
severity: MEDIUM,
|
|
134
|
+
label: "Three/four-column card grid (likely identical-card slop)",
|
|
135
|
+
fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
|
|
136
|
+
pattern: /(grid-cols-3|grid-cols-4|grid-template-columns\s*:\s*repeat\(\s*[34]\s*,)/,
|
|
137
|
+
fix: "Vary card sizes and content shapes. Identical cards in a grid is the AI default hero pattern.",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "MED-GLASSMORPHISM",
|
|
141
|
+
severity: MEDIUM,
|
|
142
|
+
label: "Glassmorphism (backdrop-blur on multiple surfaces — likely default)",
|
|
143
|
+
fileGlob: /\.(tsx|jsx|css|scss)$/,
|
|
144
|
+
pattern: /(backdrop-blur|backdrop-filter\s*:\s*blur)/,
|
|
145
|
+
fix: "Glass effects are rare and purposeful, or nothing. Don't use them as default decoration.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "MED-CONTAINER-DEPTH",
|
|
149
|
+
severity: MEDIUM,
|
|
150
|
+
label: "Possible container-depth >2 (card on card on pill)",
|
|
151
|
+
fileGlob: /\.(tsx|jsx)$/,
|
|
152
|
+
pattern: /<div[^>]*className="[^"]*\b(card|panel|surface)[^"]*"[^>]*>\s*<div[^>]*className="[^"]*\b(card|panel|surface)/,
|
|
153
|
+
fix: "Container depth max 2. Flatten nested cards into a single container with content.",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "MED-ANIMATE-LAYOUT",
|
|
157
|
+
severity: MEDIUM,
|
|
158
|
+
label: "Animating layout properties (causes reflow, jank)",
|
|
159
|
+
fileGlob: /\.(tsx|jsx|css|scss)$/,
|
|
160
|
+
pattern: /transition\s*:\s*(width|height|top|left|right|bottom|margin|padding)\s/,
|
|
161
|
+
fix: "Animate transform and opacity only. Layout properties trigger reflow.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "MED-BOUNCE-EASING",
|
|
165
|
+
severity: MEDIUM,
|
|
166
|
+
label: "Bounce/elastic easing (banned per design-laws.md §6)",
|
|
167
|
+
fileGlob: /\.(tsx|jsx|css|scss|js|ts)$/,
|
|
168
|
+
pattern: /(bounce|elastic|backIn|backOut|cubic-bezier\([^)]*1\.\d+)/i,
|
|
169
|
+
fix: "Ease out with exponential curves: cubic-bezier(0.22, 1, 0.36, 1) or (0.16, 1, 0.3, 1).",
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// ── LOW: cleanup ─────────────────────────────────────────────────────
|
|
173
|
+
{
|
|
174
|
+
id: "LOW-CONSOLE-LOG",
|
|
175
|
+
severity: LOW,
|
|
176
|
+
label: "console.log in production code",
|
|
177
|
+
fileGlob: /\.(tsx|jsx|ts|js)$/,
|
|
178
|
+
pattern: /console\.(log|debug)\(/,
|
|
179
|
+
allow: /\/\/\s*(debug|todo)|test\.|spec\./i,
|
|
180
|
+
fix: "Remove or replace with a proper logger.",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
// ── File walker ───────────────────────────────────────────────────────
|
|
185
|
+
const SKIP_DIRS = new Set([
|
|
186
|
+
"node_modules", ".next", "dist", "build", ".git", ".turbo",
|
|
187
|
+
"coverage", ".cache", "out", ".vercel", ".vscode", ".idea",
|
|
188
|
+
".planning", ".qa-screenshots",
|
|
189
|
+
// v5.1: skip test fixtures by convention. Fixtures used as regression
|
|
190
|
+
// targets (e.g. /qualia-polish-loop's broken.html) intentionally violate
|
|
191
|
+
// the rules slop-detect enforces; scanning them flags real fixture bugs
|
|
192
|
+
// as production slop.
|
|
193
|
+
"fixtures", "__fixtures__",
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
function* walk(dir) {
|
|
197
|
+
let entries;
|
|
198
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
199
|
+
for (const name of entries) {
|
|
200
|
+
if (name.startsWith(".") && !["src", "app", "components", "lib"].includes(name)) {
|
|
201
|
+
// skip dotfiles except known source dirs
|
|
202
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
203
|
+
}
|
|
204
|
+
const path = join(dir, name);
|
|
205
|
+
let st;
|
|
206
|
+
try { st = statSync(path); } catch { continue; }
|
|
207
|
+
if (st.isDirectory()) {
|
|
208
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
209
|
+
yield* walk(path);
|
|
210
|
+
} else if (st.isFile()) {
|
|
211
|
+
yield path;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Scanner ───────────────────────────────────────────────────────────
|
|
217
|
+
function scanFile(path) {
|
|
218
|
+
const findings = [];
|
|
219
|
+
let content;
|
|
220
|
+
try { content = readFileSync(path, "utf8"); } catch { return findings; }
|
|
221
|
+
const lines = content.split("\n");
|
|
222
|
+
|
|
223
|
+
for (const rule of RULES) {
|
|
224
|
+
if (!rule.fileGlob.test(path)) continue;
|
|
225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
226
|
+
const line = lines[i];
|
|
227
|
+
if (!rule.pattern.test(line)) continue;
|
|
228
|
+
if (rule.allow && rule.allow.test(line)) continue;
|
|
229
|
+
findings.push({
|
|
230
|
+
rule: rule.id,
|
|
231
|
+
severity: rule.severity,
|
|
232
|
+
label: rule.label,
|
|
233
|
+
file: path,
|
|
234
|
+
line: i + 1,
|
|
235
|
+
snippet: line.trim().slice(0, 120),
|
|
236
|
+
fix: rule.fix,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return findings;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── CLI ───────────────────────────────────────────────────────────────
|
|
244
|
+
function parseArgs(argv) {
|
|
245
|
+
const args = { paths: [], json: false, severity: null, help: false };
|
|
246
|
+
for (const a of argv.slice(2)) {
|
|
247
|
+
if (a === "--json") args.json = true;
|
|
248
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
249
|
+
else if (a.startsWith("--severity=")) args.severity = a.split("=")[1];
|
|
250
|
+
else if (a.startsWith("--")) {
|
|
251
|
+
console.error(`Unknown flag: ${a}`);
|
|
252
|
+
exit(2);
|
|
253
|
+
} else args.paths.push(a);
|
|
254
|
+
}
|
|
255
|
+
return args;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function help() {
|
|
259
|
+
console.log(`slop-detect — Qualia anti-pattern scanner
|
|
260
|
+
|
|
261
|
+
Usage:
|
|
262
|
+
slop-detect [path ...] [--json] [--severity=critical|high|medium|low]
|
|
263
|
+
|
|
264
|
+
Examples:
|
|
265
|
+
slop-detect # scan whole repo
|
|
266
|
+
slop-detect src/components/Button.tsx # scan one file
|
|
267
|
+
slop-detect app/ # scan a directory
|
|
268
|
+
slop-detect --severity=critical # only critical findings
|
|
269
|
+
slop-detect --json > slop.json # machine-readable
|
|
270
|
+
|
|
271
|
+
Exit codes:
|
|
272
|
+
0 no critical findings
|
|
273
|
+
1 one or more critical findings
|
|
274
|
+
2 invocation error
|
|
275
|
+
`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function severityOrder(s) { return { critical: 4, high: 3, medium: 2, low: 1 }[s] || 0; }
|
|
279
|
+
function severityColor(s) { return { critical: "\x1b[31m", high: "\x1b[33m", medium: "\x1b[36m", low: "\x1b[37m" }[s] || ""; }
|
|
280
|
+
const RESET = "\x1b[0m";
|
|
281
|
+
const DIM = "\x1b[2m";
|
|
282
|
+
const BOLD = "\x1b[1m";
|
|
283
|
+
|
|
284
|
+
function main() {
|
|
285
|
+
const args = parseArgs(argv);
|
|
286
|
+
if (args.help) { help(); exit(0); }
|
|
287
|
+
|
|
288
|
+
const targets = args.paths.length ? args.paths.map(p => resolve(p)) : ["app", "components", "src", "lib", "pages"]
|
|
289
|
+
.map(d => resolve(cwd(), d))
|
|
290
|
+
.filter(d => existsSync(d));
|
|
291
|
+
|
|
292
|
+
if (targets.length === 0) targets.push(resolve(cwd()));
|
|
293
|
+
|
|
294
|
+
// Collect files
|
|
295
|
+
const files = [];
|
|
296
|
+
for (const t of targets) {
|
|
297
|
+
let st;
|
|
298
|
+
try { st = statSync(t); } catch {
|
|
299
|
+
console.error(`Path does not exist: ${t}`);
|
|
300
|
+
exit(2);
|
|
301
|
+
}
|
|
302
|
+
if (st.isFile()) files.push(t);
|
|
303
|
+
else for (const f of walk(t)) files.push(f);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Scan
|
|
307
|
+
const findings = [];
|
|
308
|
+
for (const f of files) findings.push(...scanFile(f));
|
|
309
|
+
|
|
310
|
+
// Filter by severity
|
|
311
|
+
const minSev = args.severity ? severityOrder(args.severity) : 1;
|
|
312
|
+
const filtered = findings.filter(f => severityOrder(f.severity) >= minSev);
|
|
313
|
+
|
|
314
|
+
// Output
|
|
315
|
+
if (args.json) {
|
|
316
|
+
console.log(JSON.stringify({
|
|
317
|
+
scanned_files: files.length,
|
|
318
|
+
total_findings: filtered.length,
|
|
319
|
+
by_severity: {
|
|
320
|
+
critical: filtered.filter(f => f.severity === CRITICAL).length,
|
|
321
|
+
high: filtered.filter(f => f.severity === HIGH).length,
|
|
322
|
+
medium: filtered.filter(f => f.severity === MEDIUM).length,
|
|
323
|
+
low: filtered.filter(f => f.severity === LOW).length,
|
|
324
|
+
},
|
|
325
|
+
findings: filtered,
|
|
326
|
+
}, null, 2));
|
|
327
|
+
} else {
|
|
328
|
+
if (filtered.length === 0) {
|
|
329
|
+
console.log(`${BOLD}\x1b[32m✓ no slop detected${RESET} (${files.length} files scanned)`);
|
|
330
|
+
} else {
|
|
331
|
+
// Group by severity, sorted highest first
|
|
332
|
+
const bySev = {};
|
|
333
|
+
for (const f of filtered) (bySev[f.severity] ||= []).push(f);
|
|
334
|
+
const order = ["critical", "high", "medium", "low"];
|
|
335
|
+
for (const sev of order) {
|
|
336
|
+
const items = bySev[sev];
|
|
337
|
+
if (!items) continue;
|
|
338
|
+
const color = severityColor(sev);
|
|
339
|
+
console.log(`\n${color}${BOLD}${sev.toUpperCase()}${RESET} ${items.length} finding${items.length === 1 ? "" : "s"}`);
|
|
340
|
+
console.log(`${DIM}${"─".repeat(60)}${RESET}`);
|
|
341
|
+
for (const f of items) {
|
|
342
|
+
const rel = relative(cwd(), f.file);
|
|
343
|
+
console.log(`${color}●${RESET} ${BOLD}${rel}:${f.line}${RESET} ${DIM}[${f.rule}]${RESET}`);
|
|
344
|
+
console.log(` ${f.label}`);
|
|
345
|
+
console.log(` ${DIM}${f.snippet}${RESET}`);
|
|
346
|
+
console.log(` ${color}→${RESET} ${f.fix}`);
|
|
347
|
+
console.log();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const crit = (bySev.critical || []).length;
|
|
351
|
+
const total = filtered.length;
|
|
352
|
+
console.log(`${BOLD}${total}${RESET} total · ${BOLD}${crit}${RESET} critical · ${files.length} files scanned`);
|
|
353
|
+
if (crit > 0) console.log(`${severityColor("critical")}${BOLD}commit blocked${RESET} — fix critical findings first`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Exit code
|
|
358
|
+
const criticalCount = filtered.filter(f => f.severity === CRITICAL).length;
|
|
359
|
+
exit(criticalCount > 0 ? 1 : 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
main();
|