maddox-engine 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omar Ahmad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # maddox
2
+
3
+ Scans source code for design-token, motion, and component-state drift against your own design system.
4
+
5
+ This is the open-source scanning engine behind [Maddox Engine](https://www.maddoxengine.com) — the same code the hosted dashboard and GitHub Action run, extracted so you can run it locally or in CI with no account required.
6
+
7
+ ## What it checks
8
+
9
+ - **Colors** — every hex literal in your source, matched against your `@theme` tokens by exact value, then by RGB distance for near-misses.
10
+ - **Motion** — durations and easings used in code, checked against your real motion tokens (duration against duration, ease against ease — never cross-compared).
11
+ - **Component states** — an explicit contract you write yourself (a JSON file naming which states each kind of component must cover: `disabled`, `loading`, `error`, and so on). Confirms the state is referenced in the file; it doesn't verify it renders correctly — that's a static source check, not a visual one.
12
+
13
+ This is a static source-code scanner, not a pixel/DOM visual-regression tool. It checks the values developers actually wrote against the tokens that are supposed to govern them, so it catches drift that renders identically to a real token (and so produces zero visual diff) but was never written as one.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ npx maddox-engine <target-source-dir> <path-to-globals.css-with-@theme-block> [options]
19
+ ```
20
+
21
+ Options:
22
+
23
+ - `--project <name>` — project name (defaults to the target directory's basename)
24
+ - `--motion <path>` — path to a JSON file of motion tokens (e.g. a vendored copy of your motion-tokens export)
25
+ - `--states <path>` — path to a state-contract JSON file (see below)
26
+ - `--format text|json|markdown` — output format (default: `text`)
27
+ - `--fail-below <0-100>` — exit non-zero if the drift health score falls below this threshold; omit to never fail
28
+
29
+ ### Example
30
+
31
+ ```bash
32
+ npx maddox-engine ./src ./src/app/globals.css --project my-app --fail-below 80
33
+ ```
34
+
35
+ ### State contract
36
+
37
+ An optional JSON file mapping a component-name pattern to the state names that component kind must cover:
38
+
39
+ ```json
40
+ {
41
+ "Button": ["disabled", "loading"],
42
+ "*Input": ["error", "disabled"]
43
+ }
44
+ ```
45
+
46
+ Ground truth is explicit — nothing is inferred about which states a component "should" have.
47
+
48
+ ### Health score
49
+
50
+ `match` counts fully, `near-miss` counts half (it drifted, but is still recognizably close to a real token), `unrecognized` and a missing required state count for nothing. A scan with zero checks scores 100.
51
+
52
+ ## Using in CI
53
+
54
+ This repo is also a GitHub Action — `omrdev1/maddox-cli` — that runs a scan, posts the markdown report as a PR comment (updating the same comment on later pushes rather than piling up new ones), and optionally fails the build via `fail-below`.
55
+
56
+ ```yaml
57
+ - uses: actions/checkout@v4
58
+ - uses: omrdev1/maddox-cli@main
59
+ with:
60
+ target-dir: src
61
+ theme-css: src/app/globals.css
62
+ github-token: ${{ secrets.GITHUB_TOKEN }}
63
+ fail-below: "80"
64
+ ```
65
+
66
+ Inputs:
67
+
68
+ - `target-dir` *(required)* — directory to scan
69
+ - `theme-css` *(required)* — path to the CSS file containing the `@theme` block
70
+ - `motion-tokens` — path to a motion-tokens JSON file
71
+ - `states` — path to a state-contract JSON file
72
+ - `github-token` *(required)* — for posting the PR comment, usually `${{ secrets.GITHUB_TOKEN }}`
73
+ - `fail-below` — fail the build below this health score; omit for comment-only
74
+ - `api-key` / `api-url` — optional, upload results to a [Maddox Engine](https://www.maddoxengine.com) dashboard account for scan history and drift trends across projects (a separate hosted product, not required to use the Action itself)
75
+
76
+ Pin `@main` to a specific commit SHA if you want reproducible CI runs immune to changes on this branch.
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,24 @@
1
+ {
2
+ "progress": {
3
+ "easing": "easeOut",
4
+ "duration": 0.6
5
+ },
6
+ "entrance": {
7
+ "type": "spring",
8
+ "stiffness": 120,
9
+ "damping": 20
10
+ },
11
+ "transition": {
12
+ "ease": "easeOut",
13
+ "duration": 0.24
14
+ },
15
+ "stagger": {
16
+ "delay": 0.1
17
+ },
18
+ "nudge": {
19
+ "level1": { "opacity": 1, "y": 0, "transition": { "duration": 0.4, "ease": "easeOut" } },
20
+ "level2": { "opacity": 1, "x": 0, "transition": { "duration": 0.4, "ease": "easeOut" } },
21
+ "level3": { "opacity": 1, "x": 0, "transition": { "duration": 0.4, "ease": "easeOut" } },
22
+ "level4": { "opacity": 1, "x": 0, "transition": { "duration": 0.4, "ease": "easeOut" } }
23
+ }
24
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "maddox-engine",
3
+ "version": "0.1.0",
4
+ "description": "Scans source code for design-token, motion, and component-state drift against your own design system — the same engine behind Maddox Engine's CI checks.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "maddox": "./src/cli.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "fixtures"
13
+ ],
14
+ "keywords": [
15
+ "design-system",
16
+ "design-tokens",
17
+ "drift",
18
+ "css",
19
+ "cli",
20
+ "ci"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/omrdev1/maddox-cli.git"
25
+ },
26
+ "homepage": "https://www.maddoxengine.com",
27
+ "scripts": {
28
+ "scan": "tsx src/cli.ts",
29
+ "lint": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "tsx": "^4.19.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.9.0",
36
+ "@types/node": "^20.0.0"
37
+ }
38
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env -S npx tsx
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { audit, healthScore, loadGroundTruth, loadStateContract } from "./core.js";
5
+ import { summarize, toJson, toMarkdown, toText, type ScanSummary } from "./format.js";
6
+
7
+ async function uploadResult(apiUrl: string, apiKey: string, projectName: string, summary: ScanSummary) {
8
+ const response = await fetch(`${apiUrl}/api/scans`, {
9
+ method: "POST",
10
+ headers: {
11
+ "Content-Type": "application/json",
12
+ "X-Maddox-Api-Key": apiKey,
13
+ },
14
+ body: JSON.stringify({
15
+ projectName,
16
+ source: "cli",
17
+ filesScanned: summary.filesScanned,
18
+ findings: summary.findings,
19
+ }),
20
+ });
21
+
22
+ if (!response.ok) {
23
+ const body = await response.text();
24
+ throw new Error(`Upload failed (${response.status}): ${body}`);
25
+ }
26
+
27
+ const result = (await response.json()) as { scanRunId: string };
28
+ console.error(`\nUploaded. Scan run: ${result.scanRunId}`);
29
+ }
30
+
31
+ function flagValue(name: string): string | undefined {
32
+ const i = process.argv.indexOf(name);
33
+ return i !== -1 ? process.argv[i + 1] : undefined;
34
+ }
35
+
36
+ async function main() {
37
+ const targetDir = process.argv[2];
38
+ const themeCssPath = process.argv[3];
39
+
40
+ if (!targetDir || !themeCssPath) {
41
+ console.error(
42
+ "Usage: pnpm scan <target-source-dir> <path-to-globals.css-with-@theme-block> " +
43
+ "[--project <name>] [--motion <path-to-motion-tokens.json>] " +
44
+ "[--states <path-to-state-contract.json>] [--format text|json|markdown] " +
45
+ "[--fail-below <0-100>]"
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ const projectName = flagValue("--project") ?? targetDir.split("/").pop() ?? "unnamed-project";
51
+ const format = flagValue("--format") ?? "text";
52
+
53
+ // Motion tokens are an optional external JSON file (a plain object like
54
+ // @grafikui/motion's motionTokens export) — the CLI has no opinion on any
55
+ // one target's motion system, so this stays a vendored/passed-in file
56
+ // per project rather than a hardcoded import.
57
+ const motionPath = flagValue("--motion");
58
+ const motionTokens = motionPath
59
+ ? (JSON.parse(readFileSync(motionPath, "utf-8")) as Record<string, unknown>)
60
+ : {};
61
+
62
+ const statesPath = flagValue("--states");
63
+ const stateContract = statesPath ? loadStateContract(statesPath) : undefined;
64
+
65
+ const groundTruth = loadGroundTruth(themeCssPath, motionTokens);
66
+ const result = await audit(targetDir, groundTruth, stateContract);
67
+ const summary = summarize(result.findings, result.filesScanned);
68
+
69
+ if (format === "json") {
70
+ console.log(toJson(summary));
71
+ } else if (format === "markdown") {
72
+ console.log(toMarkdown(summary));
73
+ } else {
74
+ console.log(toText(summary));
75
+ }
76
+
77
+ const apiKey = process.env.MADDOX_API_KEY;
78
+ const apiUrl = process.env.MADDOX_API_URL ?? "http://localhost:3000";
79
+
80
+ if (apiKey) {
81
+ await uploadResult(apiUrl, apiKey, projectName, summary);
82
+ } else if (format === "text") {
83
+ console.log("\nSet MADDOX_API_KEY to upload results to the dashboard.");
84
+ }
85
+
86
+ const failBelowRaw = flagValue("--fail-below");
87
+ if (failBelowRaw !== undefined) {
88
+ const threshold = Number(failBelowRaw);
89
+ if (Number.isNaN(threshold)) {
90
+ console.error(`\n--fail-below expects a number, got "${failBelowRaw}"`);
91
+ process.exit(1);
92
+ }
93
+
94
+ const score = healthScore(summary.counts);
95
+ console.error(`\nHealth score: ${score} (threshold: ${threshold})`);
96
+
97
+ if (score < threshold) {
98
+ console.error(`Maddox: drift below threshold — failing build (${score} < ${threshold}).`);
99
+ process.exit(1);
100
+ }
101
+ }
102
+ }
103
+
104
+ main().catch((err) => {
105
+ console.error(err);
106
+ process.exit(1);
107
+ });
package/src/core.ts ADDED
@@ -0,0 +1,29 @@
1
+ export * from "./types.js";
2
+ export * from "./groundTruth.js";
3
+ export * from "./scan.js";
4
+ export * from "./diff.js";
5
+ export * from "./stateCheck.js";
6
+ export * from "./healthScore.js";
7
+
8
+ import { scanSource, scanFiles } from "./scan.js";
9
+ import { diffUsages } from "./diff.js";
10
+ import { checkStates } from "./stateCheck.js";
11
+ import type { GroundTruth, ScanResult, StateContract } from "./types.js";
12
+
13
+ export async function audit(
14
+ rootDir: string,
15
+ groundTruth: GroundTruth,
16
+ stateContract?: StateContract
17
+ ): Promise<ScanResult> {
18
+ const usages = await scanSource(rootDir);
19
+ const findings = diffUsages(usages, groundTruth);
20
+ const scannedFiles = new Set(usages.map((u) => u.file));
21
+
22
+ if (stateContract) {
23
+ const files = await scanFiles(rootDir);
24
+ findings.push(...checkStates(files, stateContract));
25
+ for (const f of files) scannedFiles.add(f.file);
26
+ }
27
+
28
+ return { findings, filesScanned: scannedFiles.size };
29
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,163 @@
1
+ import type { RawUsage } from "./scan.js";
2
+ import type { Finding, GroundTruth } from "./types.js";
3
+
4
+ const NEAR_MISS_RGB_THRESHOLD = 25;
5
+ const NEAR_MISS_NUMERIC_THRESHOLD = 0.05; // motion durations, e.g. seconds
6
+ const NEAR_MISS_FONT_SIZE_PX_THRESHOLD = 2;
7
+
8
+ function hexToRgb(hex: string): [number, number, number] | null {
9
+ // Only ever treat an actual "#..." literal as a color — a bare 3/6-digit
10
+ // string without the leading # (e.g. a z-index value like "999") is not
11
+ // a color and must not be matched against the color token pool.
12
+ if (!hex.startsWith("#")) return null;
13
+
14
+ const normalized = hex.replace(
15
+ /^#([a-f\d])([a-f\d])([a-f\d])$/i,
16
+ (_m, r, g, b) => `#${r}${r}${g}${g}${b}${b}`
17
+ );
18
+ const result = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized);
19
+ if (!result) return null;
20
+ return [
21
+ parseInt(result[1], 16),
22
+ parseInt(result[2], 16),
23
+ parseInt(result[3], 16),
24
+ ];
25
+ }
26
+
27
+ function colorDistance(a: [number, number, number], b: [number, number, number]): number {
28
+ return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
29
+ }
30
+
31
+ function diffColor(usage: RawUsage, tokens: GroundTruth["tokens"]): Finding {
32
+ const usageRgb = hexToRgb(usage.rawValue);
33
+ if (!usageRgb) {
34
+ return { ...usage, severity: "unrecognized" };
35
+ }
36
+
37
+ let nearestToken: string | undefined;
38
+ let nearestDistance = Infinity;
39
+
40
+ for (const [name, value] of Object.entries(tokens)) {
41
+ const tokenRgb = hexToRgb(value);
42
+ if (!tokenRgb) continue;
43
+ const distance = colorDistance(usageRgb, tokenRgb);
44
+ if (distance < nearestDistance) {
45
+ nearestDistance = distance;
46
+ nearestToken = name;
47
+ }
48
+ }
49
+
50
+ if (nearestDistance === 0) {
51
+ return { ...usage, severity: "match", nearestToken };
52
+ }
53
+ if (nearestDistance <= NEAR_MISS_RGB_THRESHOLD) {
54
+ return { ...usage, severity: "near-miss", nearestToken };
55
+ }
56
+ return { ...usage, severity: "unrecognized", nearestToken };
57
+ }
58
+
59
+ // Only these leaf names represent a duration in seconds — comparing a
60
+ // scanned `duration: 0.8` against unrelated numeric leaves like `opacity`,
61
+ // `stiffness`, or `damping` produces a nonsensical "nearest" match purely
62
+ // by numeric proximity, since those are different units entirely.
63
+ const DURATION_LEAF_NAMES = new Set(["duration", "delay"]);
64
+ const EASE_LEAF_NAMES = new Set(["ease", "easing"]);
65
+
66
+ function isDurationLeaf(path: string): boolean {
67
+ return DURATION_LEAF_NAMES.has(path.split(".").pop() ?? "");
68
+ }
69
+ function isEaseLeaf(path: string): boolean {
70
+ return EASE_LEAF_NAMES.has(path.split(".").pop() ?? "");
71
+ }
72
+
73
+ function diffMotion(usage: RawUsage, motion: GroundTruth["motion"]): Finding {
74
+ const usageNum = Number(usage.rawValue);
75
+ const isNumeric = !Number.isNaN(usageNum);
76
+ const candidates = motion.filter((t) => (isNumeric ? isDurationLeaf(t.path) : isEaseLeaf(t.path)));
77
+
78
+ let nearestToken: string | undefined;
79
+ let nearestDistance = Infinity;
80
+ let exact = false;
81
+
82
+ for (const token of candidates) {
83
+ if (isNumeric && typeof token.value === "number") {
84
+ const distance = Math.abs(usageNum - token.value);
85
+ if (distance < nearestDistance) {
86
+ nearestDistance = distance;
87
+ nearestToken = token.path;
88
+ exact = distance === 0;
89
+ }
90
+ } else if (!isNumeric && String(token.value) === usage.rawValue) {
91
+ nearestToken = token.path;
92
+ nearestDistance = 0;
93
+ exact = true;
94
+ break;
95
+ }
96
+ }
97
+
98
+ if (exact) return { ...usage, severity: "match", nearestToken };
99
+ if (isNumeric && nearestDistance <= NEAR_MISS_NUMERIC_THRESHOLD) {
100
+ return { ...usage, severity: "near-miss", nearestToken };
101
+ }
102
+ return { ...usage, severity: "unrecognized", nearestToken };
103
+ }
104
+
105
+ // Root font-size assumption for converting rem/em to px — the standard
106
+ // browser default. Good enough for near-miss comparison purposes; this
107
+ // isn't trying to resolve a page's actual computed root size.
108
+ const ROOT_PX = 16;
109
+
110
+ function toPx(value: string): number | null {
111
+ const m = /^([\d.]+)(px|rem|em)$/.exec(value.trim());
112
+ if (!m) return null;
113
+ const num = Number(m[1]);
114
+ if (m[2] === "px") return num;
115
+ return num * ROOT_PX;
116
+ }
117
+
118
+ function diffFontSize(usage: RawUsage, tokens: GroundTruth["tokens"]): Finding {
119
+ const exactMatch = Object.entries(tokens).find(([, value]) => value === usage.rawValue);
120
+ if (exactMatch) {
121
+ return { ...usage, severity: "match", nearestToken: exactMatch[0] };
122
+ }
123
+
124
+ const usagePx = toPx(usage.rawValue);
125
+ if (usagePx === null) {
126
+ return { ...usage, severity: "unrecognized" };
127
+ }
128
+
129
+ let nearestToken: string | undefined;
130
+ let nearestDistance = Infinity;
131
+
132
+ for (const [name, value] of Object.entries(tokens)) {
133
+ const tokenPx = toPx(value);
134
+ if (tokenPx === null) continue;
135
+ const distance = Math.abs(usagePx - tokenPx);
136
+ if (distance < nearestDistance) {
137
+ nearestDistance = distance;
138
+ nearestToken = name;
139
+ }
140
+ }
141
+
142
+ if (nearestDistance <= NEAR_MISS_FONT_SIZE_PX_THRESHOLD) {
143
+ return { ...usage, severity: "near-miss", nearestToken };
144
+ }
145
+ return { ...usage, severity: "unrecognized", nearestToken };
146
+ }
147
+
148
+ export function diffUsages(usages: RawUsage[], groundTruth: GroundTruth): Finding[] {
149
+ return usages.map((usage) => {
150
+ if (usage.kind === "color") return diffColor(usage, groundTruth.tokens);
151
+ if (usage.kind === "motion") return diffMotion(usage, groundTruth.motion);
152
+ if (usage.kind === "font-size") return diffFontSize(usage, groundTruth.tokens);
153
+ // spacing: exact-string match against token values for now
154
+ const match = Object.entries(groundTruth.tokens).find(
155
+ ([, value]) => value === usage.rawValue
156
+ );
157
+ return {
158
+ ...usage,
159
+ severity: match ? "match" : "unrecognized",
160
+ nearestToken: match?.[0],
161
+ } satisfies Finding;
162
+ });
163
+ }
package/src/format.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Finding } from "./core.js";
2
+
3
+ export interface ScanSummary {
4
+ filesScanned: number;
5
+ counts: { match: number; "near-miss": number; unrecognized: number; missing: number };
6
+ findings: Finding[];
7
+ }
8
+
9
+ export function summarize(findings: Finding[], filesScanned: number): ScanSummary {
10
+ const counts = { match: 0, "near-miss": 0, unrecognized: 0, missing: 0 };
11
+ for (const f of findings) counts[f.severity]++;
12
+ return { filesScanned, counts, findings };
13
+ }
14
+
15
+ export function toJson(summary: ScanSummary): string {
16
+ return JSON.stringify(summary, null, 2);
17
+ }
18
+
19
+ export function toText(summary: ScanSummary): string {
20
+ const lines = [
21
+ `Scanned ${summary.filesScanned} files.`,
22
+ ` match: ${summary.counts.match}`,
23
+ ` near-miss: ${summary.counts["near-miss"]}`,
24
+ ` unrecognized: ${summary.counts.unrecognized}`,
25
+ ` missing: ${summary.counts.missing}`,
26
+ "",
27
+ ];
28
+ for (const f of summary.findings) {
29
+ if (f.severity === "match") continue;
30
+ lines.push(
31
+ `${f.file}:${f.line} [${f.kind}] ${f.rawValue} → ${f.severity}${
32
+ f.nearestToken ? ` (nearest: ${f.nearestToken})` : ""
33
+ }`
34
+ );
35
+ }
36
+ return lines.join("\n");
37
+ }
38
+
39
+ const MARKER = "<!-- maddox-engine-drift-report -->";
40
+
41
+ export function toMarkdown(summary: ScanSummary): string {
42
+ const { counts } = summary;
43
+ const total = counts.match + counts["near-miss"] + counts.unrecognized + counts.missing;
44
+ const nonMatch = summary.findings.filter((f) => f.severity !== "match");
45
+
46
+ const lines = [
47
+ MARKER,
48
+ "## Maddox Engine — Drift Report",
49
+ "",
50
+ `Scanned **${summary.filesScanned}** files, **${total}** token/motion/state checks.`,
51
+ "",
52
+ `| Match | Near-miss | Unrecognized | Missing state |`,
53
+ `|---|---|---|---|`,
54
+ `| ${counts.match} | ${counts["near-miss"]} | ${counts.unrecognized} | ${counts.missing} |`,
55
+ "",
56
+ ];
57
+
58
+ if (nonMatch.length === 0) {
59
+ lines.push("No drift found. Every checked value matches the design system.");
60
+ return lines.join("\n");
61
+ }
62
+
63
+ lines.push("<details><summary>Findings</summary>", "", "| Location | Kind | Value | Severity | Nearest token |", "|---|---|---|---|---|");
64
+ for (const f of nonMatch.slice(0, 100)) {
65
+ lines.push(
66
+ `| \`${f.file}:${f.line}\` | ${f.kind} | \`${f.rawValue}\` | ${f.severity} | ${f.nearestToken ? `\`${f.nearestToken}\`` : "—"} |`
67
+ );
68
+ }
69
+ if (nonMatch.length > 100) {
70
+ lines.push(`| ... | ${nonMatch.length - 100} more findings not shown | | | |`);
71
+ }
72
+ lines.push("", "</details>");
73
+
74
+ return lines.join("\n");
75
+ }
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { GroundTruth, MotionToken, StateContract, TokenMap } from "./types.js";
3
+
4
+ /**
5
+ * Parses a Tailwind v4 `@theme { ... }` block into a flat token map.
6
+ * Only reads inside the @theme block, not the rest of the CSS file.
7
+ */
8
+ export function parseThemeTokens(cssFilePath: string): TokenMap {
9
+ const css = readFileSync(cssFilePath, "utf-8");
10
+ const themeMatch = css.match(/@theme\s*{([\s\S]*?)}/);
11
+ if (!themeMatch) return {};
12
+
13
+ const themeBlock = themeMatch[1];
14
+ const tokens: TokenMap = {};
15
+ const varRegex = /(--[\w-]+):\s*([^;]+);/g;
16
+ let match: RegExpExecArray | null;
17
+ while ((match = varRegex.exec(themeBlock)) !== null) {
18
+ tokens[match[1].trim()] = match[2].trim();
19
+ }
20
+ return tokens;
21
+ }
22
+
23
+ /**
24
+ * Flattens a nested motion tokens object (e.g. @grafikui/motion's
25
+ * `motionTokens` export) into dotted-path leaf values.
26
+ */
27
+ export function flattenMotionTokens(
28
+ obj: Record<string, unknown>,
29
+ prefix = ""
30
+ ): MotionToken[] {
31
+ const out: MotionToken[] = [];
32
+ for (const [key, value] of Object.entries(obj)) {
33
+ const path = prefix ? `${prefix}.${key}` : key;
34
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
35
+ out.push(...flattenMotionTokens(value as Record<string, unknown>, path));
36
+ } else if (typeof value === "number" || typeof value === "string") {
37
+ out.push({ path, value });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Loads a user-authored state contract: a JSON file mapping a component-name
45
+ * pattern to the state names that component kind must cover. There is no
46
+ * built-in inference of which states a component needs — the contract is
47
+ * the ground truth, same as the @theme block is for tokens.
48
+ */
49
+ export function loadStateContract(path: string): StateContract {
50
+ return JSON.parse(readFileSync(path, "utf-8")) as StateContract;
51
+ }
52
+
53
+ export function loadGroundTruth(
54
+ themeCssPath: string,
55
+ motionTokensObj: Record<string, unknown>
56
+ ): GroundTruth {
57
+ return {
58
+ tokens: parseThemeTokens(themeCssPath),
59
+ motion: flattenMotionTokens(motionTokensObj),
60
+ };
61
+ }
@@ -0,0 +1,20 @@
1
+ // A single 0-100 health score for a scan run, weighted by how close each
2
+ // checked value was to the design system: a full match counts fully, a
3
+ // near-miss counts half (it drifted, but is still recognizably close to a
4
+ // real token), and an unrecognized value counts for nothing. "missing"
5
+ // (a state-completeness failure) is treated the same as unrecognized —
6
+ // there's no partial credit for a component missing a required state.
7
+ export interface SeverityCounts {
8
+ match: number;
9
+ "near-miss": number;
10
+ unrecognized: number;
11
+ missing?: number;
12
+ }
13
+
14
+ export function healthScore(counts: SeverityCounts): number {
15
+ const total = counts.match + counts["near-miss"] + counts.unrecognized + (counts.missing ?? 0);
16
+ if (total === 0) return 100;
17
+
18
+ const weighted = counts.match * 1 + counts["near-miss"] * 0.5;
19
+ return Math.round((weighted / total) * 100);
20
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+
4
+ export interface RawUsage {
5
+ file: string;
6
+ line: number;
7
+ kind: "color" | "spacing" | "font-size" | "motion";
8
+ rawValue: string;
9
+ }
10
+
11
+ const HEX_RE = /#([a-f0-9]{3}|[a-f0-9]{6})\b/gi;
12
+ const FONT_SIZE_RE = /font-size:\s*([\d.]+(?:px|rem|em))/gi;
13
+ const MOTION_DURATION_RE = /duration:\s*([\d.]+)/gi;
14
+ const MOTION_EASE_RE = /ease:\s*['"]([\w-]+)['"]/gi;
15
+
16
+ function lineNumberAt(content: string, index: number): number {
17
+ return content.slice(0, index).split("\n").length;
18
+ }
19
+
20
+ /**
21
+ * Extracts raw color/font-size/motion literals from a single blob of
22
+ * content. Exported directly (not just via scanSource's filesystem walk)
23
+ * so callers with in-memory content — e.g. fetched HTML/CSS from a public
24
+ * URL, with no local checkout — can run the same extraction.
25
+ */
26
+ export function extractFromContent(file: string, content: string): RawUsage[] {
27
+ const usages: RawUsage[] = [];
28
+
29
+ for (const re of [HEX_RE]) {
30
+ re.lastIndex = 0;
31
+ let m: RegExpExecArray | null;
32
+ while ((m = re.exec(content)) !== null) {
33
+ usages.push({
34
+ file,
35
+ line: lineNumberAt(content, m.index),
36
+ kind: "color",
37
+ rawValue: m[0].toLowerCase(),
38
+ });
39
+ }
40
+ }
41
+
42
+ FONT_SIZE_RE.lastIndex = 0;
43
+ let m: RegExpExecArray | null;
44
+ while ((m = FONT_SIZE_RE.exec(content)) !== null) {
45
+ usages.push({
46
+ file,
47
+ line: lineNumberAt(content, m.index),
48
+ kind: "font-size",
49
+ rawValue: m[1],
50
+ });
51
+ }
52
+
53
+ MOTION_DURATION_RE.lastIndex = 0;
54
+ while ((m = MOTION_DURATION_RE.exec(content)) !== null) {
55
+ usages.push({
56
+ file,
57
+ line: lineNumberAt(content, m.index),
58
+ kind: "motion",
59
+ rawValue: m[1],
60
+ });
61
+ }
62
+
63
+ MOTION_EASE_RE.lastIndex = 0;
64
+ while ((m = MOTION_EASE_RE.exec(content)) !== null) {
65
+ usages.push({
66
+ file,
67
+ line: lineNumberAt(content, m.index),
68
+ kind: "motion",
69
+ rawValue: m[1],
70
+ });
71
+ }
72
+
73
+ return usages;
74
+ }
75
+
76
+ const SKIP_DIRS = new Set(["node_modules", ".next", ".git", ".turbo", "dist"]);
77
+ const SCAN_EXTENSIONS = new Set([".ts", ".tsx", ".css"]);
78
+
79
+ function walkDir(dir: string, out: string[]): void {
80
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
81
+ if (entry.isDirectory()) {
82
+ if (SKIP_DIRS.has(entry.name)) continue;
83
+ walkDir(join(dir, entry.name), out);
84
+ } else if (entry.isFile()) {
85
+ const ext = entry.name.slice(entry.name.lastIndexOf("."));
86
+ if (SCAN_EXTENSIONS.has(ext)) out.push(join(dir, entry.name));
87
+ }
88
+ }
89
+ }
90
+
91
+ export interface ScannedFile {
92
+ file: string; // relative to rootDir
93
+ content: string;
94
+ }
95
+
96
+ /**
97
+ * Walks .ts/.tsx/.css files under `rootDir` (skipping node_modules/.next)
98
+ * and returns each file's relative path and content. Shared by the
99
+ * token/motion usage scan and the component state-completeness check.
100
+ */
101
+ function readScannedFiles(rootDir: string): ScannedFile[] {
102
+ const filePaths: string[] = [];
103
+ walkDir(rootDir, filePaths);
104
+
105
+ const files: ScannedFile[] = [];
106
+ for (const fullPath of filePaths) {
107
+ try {
108
+ files.push({ file: relative(rootDir, fullPath), content: readFileSync(fullPath, "utf-8") });
109
+ } catch {
110
+ // unreadable file, skip
111
+ }
112
+ }
113
+ return files;
114
+ }
115
+
116
+ /**
117
+ * Walks .ts/.tsx/.css files under `rootDir` (skipping node_modules/.next)
118
+ * and extracts raw color/font-size/motion literals with file:line.
119
+ * Source-level scan, not a live-fetched-HTML scan.
120
+ */
121
+ export async function scanSource(rootDir: string): Promise<RawUsage[]> {
122
+ const usages: RawUsage[] = [];
123
+ for (const { file, content } of readScannedFiles(rootDir)) {
124
+ usages.push(...extractFromContent(file, content));
125
+ }
126
+ return usages;
127
+ }
128
+
129
+ /**
130
+ * Same file walk as scanSource, but returning raw file contents for the
131
+ * component state-completeness check rather than token/motion usages.
132
+ */
133
+ export async function scanFiles(rootDir: string): Promise<ScannedFile[]> {
134
+ return readScannedFiles(rootDir);
135
+ }
@@ -0,0 +1,67 @@
1
+ import type { ScannedFile } from "./scan.js";
2
+ import type { Finding, StateContract } from "./types.js";
3
+
4
+ // Heuristic identifiers that count as "this state is handled" when they
5
+ // appear anywhere in a component's file — a prop name, a CSS pseudo-class,
6
+ // or a status string used in a conditional. This is string-matching, not
7
+ // control-flow analysis: it can't tell whether the branch actually renders
8
+ // correctly, only whether the concept is referenced at all.
9
+ const STATE_IDENTIFIER_PATTERNS: Record<string, RegExp[]> = {
10
+ disabled: [/\bdisabled\b/i, /:disabled/i],
11
+ loading: [/\bloading\b/i, /\bisLoading\b/i, /\bpending\b/i],
12
+ error: [/\berror\b/i, /\bisError\b/i],
13
+ empty: [/\bempty\b/i, /\bisEmpty\b/i],
14
+ active: [/\bactive\b/i, /:active/i, /\bisActive\b/i],
15
+ hover: [/:hover/i, /\bonHover\b/i, /\bisHovered\b/i],
16
+ focus: [/:focus/i, /\bonFocus\b/i, /\bisFocused\b/i],
17
+ };
18
+
19
+ function hasState(content: string, state: string): boolean {
20
+ const patterns = STATE_IDENTIFIER_PATTERNS[state.toLowerCase()];
21
+ if (patterns) return patterns.some((re) => re.test(content));
22
+ // No built-in heuristic for this state name — fall back to a plain
23
+ // word-boundary match on the state name itself.
24
+ return new RegExp(`\\b${state}\\b`, "i").test(content);
25
+ }
26
+
27
+ function matchesPattern(fileName: string, pattern: string): boolean {
28
+ if (pattern.includes("*")) {
29
+ const re = new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
30
+ return re.test(fileName);
31
+ }
32
+ return fileName.toLowerCase().includes(pattern.toLowerCase());
33
+ }
34
+
35
+ /**
36
+ * Checks each scanned file against the state contract: if the file's name
37
+ * matches a contract pattern, every required state for that pattern must
38
+ * have at least one matching identifier somewhere in the file's content.
39
+ * Emits one Finding per missing state; matched states are not reported
40
+ * (kept consistent with diffUsages, which also only ever needs to explain
41
+ * non-matches — matches are implicit by absence of a finding).
42
+ */
43
+ export function checkStates(files: ScannedFile[], contract: StateContract): Finding[] {
44
+ const findings: Finding[] = [];
45
+
46
+ for (const { file, content } of files) {
47
+ const fileName = file.split("/").pop() ?? file;
48
+
49
+ for (const [pattern, requiredStates] of Object.entries(contract)) {
50
+ if (!matchesPattern(fileName, pattern)) continue;
51
+
52
+ for (const state of requiredStates) {
53
+ if (hasState(content, state)) continue;
54
+ findings.push({
55
+ file,
56
+ line: 1,
57
+ kind: "state",
58
+ rawValue: state,
59
+ severity: "missing",
60
+ nearestToken: pattern,
61
+ });
62
+ }
63
+ }
64
+ }
65
+
66
+ return findings;
67
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type TokenMap = Record<string, string>;
2
+
3
+ export interface MotionToken {
4
+ path: string; // e.g. "progress.duration"
5
+ value: number | string;
6
+ }
7
+
8
+ export interface GroundTruth {
9
+ tokens: TokenMap;
10
+ motion: MotionToken[];
11
+ }
12
+
13
+ // Maps a component-name pattern (matched against each scanned file's
14
+ // basename) to the list of state names that component kind must cover,
15
+ // e.g. { "Button": ["disabled", "loading"], "*Input": ["error", "disabled"] }.
16
+ export type StateContract = Record<string, string[]>;
17
+
18
+ export type Severity = "match" | "near-miss" | "unrecognized" | "missing";
19
+
20
+ export interface Finding {
21
+ file: string;
22
+ line: number;
23
+ kind: "color" | "spacing" | "font-size" | "motion" | "state";
24
+ rawValue: string;
25
+ severity: Severity;
26
+ nearestToken?: string;
27
+ }
28
+
29
+ export interface ScanResult {
30
+ findings: Finding[];
31
+ filesScanned: number;
32
+ }