maddox-engine 0.2.0 → 0.4.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/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # maddox
2
2
 
3
- Scans source code for design-token, motion, and component-state drift against your own design system.
3
+ Scans source code — or a live deployed page — for design-token, motion, and component-state drift against your own design system.
4
4
 
5
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
6
 
7
7
  ## What it checks
8
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.
9
+ - **Colors** — every hex literal in your source (or a live page's rendered HTML/CSS), 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). Source-scan only; see `--url` below.
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. Source-scan only.
12
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.
13
+ This is a source-code and rendered-output scanner, not a pixel/DOM visual-regression tool. It checks the values a page actually ships 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
14
 
15
15
  ## Usage
16
16
 
@@ -20,10 +20,12 @@ npx maddox-engine <target-source-dir> <path-to-globals.css-with-@theme-block> [o
20
20
 
21
21
  Options:
22
22
 
23
- - `--project <name>` — project name (defaults to the target directory's basename)
23
+ - `--project <name>` — project name (defaults to the target directory's basename, or the URL's hostname in `--url` mode)
24
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)
25
+ - `--states <path>` — path to a state-contract JSON file (see below). No effect in `--url` mode.
26
26
  - `--tokens-studio <path>` — path to a Tokens Studio / W3C Design Tokens JSON export (see below); merged with, and taking precedence over, `@theme` on any path both define
27
+ - `--url <page-url>` — scan a deployed page instead of local source (see below); pass a placeholder like `-` for `<target-source-dir>` when using this alone
28
+ - `--apply-fixes` — write near-miss color/font-size/spacing suggestions back into source files (see below); not available with `--url`
27
29
  - `--format text|json|markdown` — output format (default: `text`)
28
30
  - `--fail-below <0-100>` — exit non-zero if the drift health score falls below this threshold; omit to never fail
29
31
 
@@ -50,9 +52,39 @@ Ground truth is explicit — nothing is inferred about which states a component
50
52
 
51
53
  If your tokens live in Figma via the Tokens Studio plugin rather than (or alongside) a Tailwind `@theme` block, export them to JSON and point `--tokens-studio` at the file. A single-set export (the whole file is one token tree) and a multi-set export (top-level keys are set names, e.g. `global`, `dark`) are both supported — for a multi-set export, every set is merged, later sets overriding earlier ones by path. `{alias}` references are resolved automatically. Only token types that resolve to a single comparable value (`color`, `spacing`, `sizing`, `fontSizes`, `borderRadius`, `dimension`) are used — composite types like `typography` or `boxShadow` describe a bundle of properties, not one value to diff against, and are skipped rather than misclassified.
52
54
 
55
+ ### Scanning a live page
56
+
57
+ ```bash
58
+ npx maddox-engine - ./src/app/globals.css --url https://example.com --format text
59
+ ```
60
+
61
+ `--url` fetches the page's rendered HTML plus every same-origin `<link rel="stylesheet">` it links to, and runs the same color/font-size extraction against what's actually shipped — not what's in the repo at scan time. This is the difference between a source-linter and a production check: a stale CDN cache, a build step that silently drops a token, or a config typo can all make the deployed page diverge from what the source says, and only a live-URL scan catches that.
62
+
63
+ Two things don't carry over to `--url` mode:
64
+
65
+ - **Motion** — durations/eases only ever appear in rendered HTML/CSS as static literals if a component hardcodes them there, which real motion libraries don't do (they animate via the JS runtime). Motion findings are dropped from every `--url` scan rather than surfaced as noise from unrelated CSS.
66
+ - **Component states** — state-completeness is a "is this identifier referenced in this file's source" check; a fetched, already-rendered page has no source to check. `--states` is accepted but has no effect in `--url` mode.
67
+
68
+ Third-party stylesheets (a different origin than the page itself, e.g. a font CDN) are never fetched — only same-origin CSS, so findings only ever point at code the project actually owns.
69
+
53
70
  ### Suggested fixes
54
71
 
55
- Every `near-miss` finding with a resolved nearest token gets a `suggestion` — the concrete replacement text (`var(--token-name)` for a CSS value, or `motionTokens.path.to.value` for a motion token). It's advisory text for a human to apply, not an automatic edit: nothing in this package rewrites your source files. An `unrecognized` value with no close match, and a `missing`-state finding, never get one — there's no safe mechanical fix for either.
72
+ Every `near-miss` finding with a resolved nearest token gets a `suggestion` — the concrete replacement text (`var(--token-name)` for a CSS value, or `motionTokens.path.to.value` for a motion token). By default it's advisory text for you to apply yourself; pass `--apply-fixes` to write it back into the file. An `unrecognized` value with no close match, and a `missing`-state finding, never get a suggestion — there's no safe mechanical fix for either.
73
+
74
+ ### Applying fixes automatically
75
+
76
+ ```bash
77
+ npx maddox-engine ./src ./src/app/globals.css --apply-fixes
78
+ ```
79
+
80
+ `--apply-fixes` writes every `color`/`font-size`/`spacing` near-miss suggestion straight into the source file it was found in, replacing the exact literal with `var(--token-name)`. It's scoped deliberately narrow:
81
+
82
+ - **Motion suggestions are never applied.** `motionTokens.path.to.value` assumes an import named `motionTokens` exists in that file's scope — there's no way to verify that per-file, and applying it blind could silently break the build. Motion findings always stay advisory-only.
83
+ - **State findings never had a suggestion to apply.**
84
+ - If a finding's line no longer contains its reported value (the file changed since the scan ran), it's skipped rather than guessed at.
85
+ - Not available with `--url` — a fetched page has no local file to write to.
86
+
87
+ This is a real edit to your working tree, not a dry run — review the diff (`git diff`) before committing, same as you would any other automated change.
56
88
 
57
89
  ### Health score
58
90
 
@@ -74,11 +106,12 @@ This repo is also a GitHub Action — `omrdev1/maddox-cli` — that runs a scan,
74
106
 
75
107
  Inputs:
76
108
 
77
- - `target-dir` *(required)* — directory to scan
109
+ - `target-dir` *(required unless `url` is set)* — directory to scan
78
110
  - `theme-css` *(required)* — path to the CSS file containing the `@theme` block
79
111
  - `motion-tokens` — path to a motion-tokens JSON file
80
- - `states` — path to a state-contract JSON file
112
+ - `states` — path to a state-contract JSON file. No effect when `url` is set.
81
113
  - `tokens-studio` — path to a Tokens Studio / W3C Design Tokens JSON export
114
+ - `url` — a deployed page URL to scan instead of local source
82
115
  - `github-token` *(required)* — for posting the PR comment, usually `${{ secrets.GITHUB_TOKEN }}`
83
116
  - `fail-below` — fail the build below this health score; omit for comment-only
84
117
  - `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)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "maddox-engine",
3
- "version": "0.2.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.",
3
+ "version": "0.4.0",
4
+ "description": "Scans source code or a live deployed page for design-token, motion, and component-state drift against your own design system — the same engine behind Maddox Engine's CI checks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -0,0 +1,116 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Finding } from "./types.js";
4
+
5
+ // Only these kinds get a self-contained, always-valid replacement
6
+ // expression (var(--token-name), which needs no import and can't break
7
+ // compilation in any file it lands in). "motion" suggestions read
8
+ // motionTokens.path.to.value — text that assumes an import named
9
+ // motionTokens exists in scope, which suggestFix.ts has no way to verify
10
+ // per-file, so applying it mechanically could silently break the build.
11
+ // "state" findings never get a suggestion at all (see suggestFix.ts).
12
+ const AUTO_APPLIABLE_KINDS = new Set<Finding["kind"]>(["color", "font-size", "spacing"]);
13
+
14
+ export interface AppliedFix {
15
+ file: string;
16
+ line: number;
17
+ rawValue: string;
18
+ suggestion: string;
19
+ occurrences: number;
20
+ }
21
+
22
+ export interface ApplyFixesResult {
23
+ applied: AppliedFix[];
24
+ // Findings that had a suggestion but were skipped — motion (unsafe
25
+ // import assumption) or a line whose current content no longer
26
+ // contains rawValue (the file changed since the scan ran; applying
27
+ // blind here risks touching the wrong text).
28
+ skipped: Finding[];
29
+ }
30
+
31
+ /**
32
+ * Writes suggestFix's suggestions back into source files, for findings
33
+ * whose kind has a self-contained replacement expression. Never touches
34
+ * "motion" or "state" findings — see AUTO_APPLIABLE_KINDS.
35
+ *
36
+ * Applies per-file: reads once, replaces every exact-value occurrence
37
+ * on each finding's reported line (same literal repeated on one line —
38
+ * e.g. `color: #0d0d0d; border-color: #0d0d0d;` — is the same drift
39
+ * twice, both get fixed), writes once. A finding whose line no longer
40
+ * contains its rawValue (file changed since the scan that produced this
41
+ * Finding) is skipped rather than guessed at.
42
+ *
43
+ * rootDir is the same directory audit() was called with — Finding.file
44
+ * is relative to it, matching how scanSource reports paths.
45
+ */
46
+ export function applyFixes(rootDir: string, findings: Finding[]): ApplyFixesResult {
47
+ const applied: AppliedFix[] = [];
48
+ const skipped: Finding[] = [];
49
+
50
+ const byFile = new Map<string, Finding[]>();
51
+ for (const finding of findings) {
52
+ if (!finding.suggestion || !AUTO_APPLIABLE_KINDS.has(finding.kind)) {
53
+ if (finding.suggestion) skipped.push(finding);
54
+ continue;
55
+ }
56
+ const existing = byFile.get(finding.file) ?? [];
57
+ existing.push(finding);
58
+ byFile.set(finding.file, existing);
59
+ }
60
+
61
+ for (const [file, fileFindings] of byFile) {
62
+ const fullPath = join(rootDir, file);
63
+ let content: string;
64
+ try {
65
+ content = readFileSync(fullPath, "utf-8");
66
+ } catch {
67
+ skipped.push(...fileFindings);
68
+ continue;
69
+ }
70
+
71
+ const lines = content.split("\n");
72
+ let changed = false;
73
+
74
+ // Dedupe by (line, rawValue): the replace below already handles every
75
+ // occurrence of a value on its line in one pass, so if the scan
76
+ // reported the same value on the same line more than once (e.g. two
77
+ // separate regex matches for one literal repeated in a line), only
78
+ // the first needs to actually run the replacement — without this, a
79
+ // second finding for an already-fixed line would find nothing left to
80
+ // replace and land in `skipped`, misreporting an applied fix as one
81
+ // that failed.
82
+ const seen = new Set<string>();
83
+ const uniqueFindings = fileFindings.filter((f) => {
84
+ const key = `${f.line}:${f.rawValue}`;
85
+ if (seen.has(key)) return false;
86
+ seen.add(key);
87
+ return true;
88
+ });
89
+
90
+ for (const finding of uniqueFindings) {
91
+ const lineIndex = finding.line - 1;
92
+ const line = lines[lineIndex];
93
+ if (line === undefined || !line.includes(finding.rawValue)) {
94
+ skipped.push(finding);
95
+ continue;
96
+ }
97
+
98
+ const occurrences = line.split(finding.rawValue).length - 1;
99
+ lines[lineIndex] = line.split(finding.rawValue).join(finding.suggestion!);
100
+ changed = true;
101
+ applied.push({
102
+ file,
103
+ line: finding.line,
104
+ rawValue: finding.rawValue,
105
+ suggestion: finding.suggestion!,
106
+ occurrences,
107
+ });
108
+ }
109
+
110
+ if (changed) {
111
+ writeFileSync(fullPath, lines.join("\n"));
112
+ }
113
+ }
114
+
115
+ return { applied, skipped };
116
+ }
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
- import { audit, healthScore, loadGroundTruth, loadStateContract } from "./core.js";
4
+ import { applyFixes, audit, auditUrl, healthScore, loadGroundTruth, loadStateContract } from "./core.js";
5
5
  import { summarize, toJson, toMarkdown, toText, type ScanSummary } from "./format.js";
6
6
 
7
7
  async function uploadResult(apiUrl: string, apiKey: string, projectName: string, summary: ScanSummary) {
@@ -36,18 +36,26 @@ function flagValue(name: string): string | undefined {
36
36
  async function main() {
37
37
  const targetDir = process.argv[2];
38
38
  const themeCssPath = process.argv[3];
39
+ const url = flagValue("--url");
39
40
 
40
- if (!targetDir || !themeCssPath) {
41
+ if (!themeCssPath || (!targetDir && !url)) {
41
42
  console.error(
42
43
  "Usage: pnpm scan <target-source-dir> <path-to-globals.css-with-@theme-block> " +
43
44
  "[--project <name>] [--motion <path-to-motion-tokens.json>] " +
44
45
  "[--states <path-to-state-contract.json>] [--format text|json|markdown] " +
45
- "[--fail-below <0-100>] [--tokens-studio <path-to-tokens-studio-export.json>]"
46
+ "[--fail-below <0-100>] [--tokens-studio <path-to-tokens-studio-export.json>] " +
47
+ "[--url <live-page-url>] [--apply-fixes]\n" +
48
+ " --url scans a deployed page's rendered HTML/CSS instead of local source " +
49
+ "(pass a placeholder for <target-source-dir>, e.g. '-', when using --url alone). " +
50
+ "--states has no effect in --url mode: state-completeness is a source-code check.\n" +
51
+ " --apply-fixes writes near-miss color/font-size/spacing suggestions back into " +
52
+ "source files (color/font-size/spacing only — motion and state findings are " +
53
+ "never auto-applied). Not available with --url, which has no source to write to."
46
54
  );
47
55
  process.exit(1);
48
56
  }
49
57
 
50
- const projectName = flagValue("--project") ?? targetDir.split("/").pop() ?? "unnamed-project";
58
+ const projectName = flagValue("--project") ?? (url ? new URL(url).hostname : targetDir.split("/").pop()) ?? "unnamed-project";
51
59
  const format = flagValue("--format") ?? "text";
52
60
 
53
61
  // Motion tokens are an optional external JSON file (a plain object like
@@ -64,9 +72,29 @@ async function main() {
64
72
 
65
73
  const tokensStudioPath = flagValue("--tokens-studio");
66
74
  const groundTruth = loadGroundTruth(themeCssPath, motionTokens, tokensStudioPath);
67
- const result = await audit(targetDir, groundTruth, stateContract);
75
+
76
+ const result = url ? await auditUrl(url, groundTruth) : await audit(targetDir, groundTruth, stateContract);
68
77
  const summary = summarize(result.findings, result.filesScanned);
69
78
 
79
+ const applyFixesFlag = process.argv.includes("--apply-fixes");
80
+ if (applyFixesFlag) {
81
+ if (url) {
82
+ console.error("\n--apply-fixes has no effect with --url: there is no local source to write to.");
83
+ } else {
84
+ const { applied, skipped } = applyFixes(targetDir, result.findings);
85
+ if (applied.length > 0) {
86
+ console.error(`\nApplied ${applied.length} fix${applied.length === 1 ? "" : "es"}:`);
87
+ for (const fix of applied) {
88
+ console.error(` ${fix.file}:${fix.line} ${fix.rawValue} → ${fix.suggestion}`);
89
+ }
90
+ }
91
+ const motionOrStateSkipped = skipped.filter((f) => f.kind === "motion" || f.kind === "state").length;
92
+ if (motionOrStateSkipped > 0) {
93
+ console.error(`\n${motionOrStateSkipped} motion/state finding${motionOrStateSkipped === 1 ? "" : "s"} skipped — not auto-applied (see usage).`);
94
+ }
95
+ }
96
+ }
97
+
70
98
  if (format === "json") {
71
99
  console.log(toJson(summary));
72
100
  } else if (format === "markdown") {
package/src/core.ts CHANGED
@@ -2,12 +2,15 @@ export * from "./types.js";
2
2
  export * from "./groundTruth.js";
3
3
  export * from "./tokensStudio.js";
4
4
  export * from "./scan.js";
5
+ export * from "./crawl.js";
5
6
  export * from "./diff.js";
6
7
  export * from "./stateCheck.js";
7
8
  export * from "./healthScore.js";
8
9
  export * from "./suggestFix.js";
10
+ export * from "./applyFixes.js";
9
11
 
10
12
  import { scanSource, scanFiles } from "./scan.js";
13
+ import { crawlUrl } from "./crawl.js";
11
14
  import { diffUsages } from "./diff.js";
12
15
  import { checkStates } from "./stateCheck.js";
13
16
  import { attachSuggestions } from "./suggestFix.js";
@@ -32,3 +35,18 @@ export async function audit(
32
35
 
33
36
  return { findings, filesScanned: scannedFiles.size };
34
37
  }
38
+
39
+ /**
40
+ * The production-aware counterpart to audit(): fetches a live page (plus
41
+ * its same-origin stylesheets) instead of walking local source files, and
42
+ * diffs what's actually shipped against the same ground truth. No state
43
+ * contract here — component-state completeness is a source-code question
44
+ * (is the state referenced anywhere in this file's source), which has no
45
+ * equivalent for a fetched, already-rendered page.
46
+ */
47
+ export async function auditUrl(pageUrl: string, groundTruth: GroundTruth): Promise<ScanResult> {
48
+ const { usages, sourcesScanned } = await crawlUrl(pageUrl);
49
+ const findings = diffUsages(usages, groundTruth);
50
+ attachSuggestions(findings);
51
+ return { findings, filesScanned: sourcesScanned.length };
52
+ }
package/src/crawl.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { extractFromContent, type RawUsage } from "./scan.js";
2
+
3
+ // Only ever follow relative/absolute-path stylesheet links resolved
4
+ // against the page's own origin — never a third-party CDN's CSS, since
5
+ // that's not the project's own design system and would only add noise
6
+ // (and, worse, false "unrecognized" findings for code the project never
7
+ // wrote).
8
+ function isSameOrigin(href: string, pageUrl: URL): boolean {
9
+ try {
10
+ const resolved = new URL(href, pageUrl);
11
+ return resolved.origin === pageUrl.origin;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ // Matches <link ... rel="stylesheet" ... href="..."> and the reverse
18
+ // attribute order, case-insensitively, without a full HTML parser —
19
+ // good enough for the handful of <link> tags a real page head has.
20
+ const STYLESHEET_LINK_RE =
21
+ /<link\b[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*>|<link\b[^>]*\bhref=["']([^"']+)["'][^>]*\brel=["']stylesheet["'][^>]*>/gi;
22
+
23
+ function findStylesheetHrefs(html: string): string[] {
24
+ const hrefs: string[] = [];
25
+ STYLESHEET_LINK_RE.lastIndex = 0;
26
+ let m: RegExpExecArray | null;
27
+ while ((m = STYLESHEET_LINK_RE.exec(html)) !== null) {
28
+ hrefs.push(m[1] ?? m[2]);
29
+ }
30
+ return hrefs;
31
+ }
32
+
33
+ export interface CrawlResult {
34
+ usages: RawUsage[];
35
+ // The page URL plus every stylesheet URL actually fetched — reported
36
+ // back so a caller (or the CLI's own output) can show what was scanned,
37
+ // the live-URL equivalent of scanSource's file count.
38
+ sourcesScanned: string[];
39
+ }
40
+
41
+ /**
42
+ * Fetches a live page's rendered HTML plus every same-origin linked
43
+ * stylesheet, and runs the same color/font-size/motion literal
44
+ * extraction scanSource runs over local source files. This is what makes
45
+ * Maddox "production-aware" rather than local-source-only: it checks
46
+ * what a real deployed page actually ships, not just what's in the repo
47
+ * at scan time (a config typo, a stale CDN cache, or a build step that
48
+ * silently drops a token could all cause the two to diverge).
49
+ *
50
+ * Framer Motion / GSAP usage is deliberately dropped from the result.
51
+ * Real motion libraries set durations/eases via the JS animation runtime,
52
+ * not static markup, so they never legitimately appear in rendered
53
+ * HTML/CSS — but framework-compiled CSS reliably DOES contain unrelated
54
+ * matches for the same shared regex (confirmed against a real deploy:
55
+ * Tailwind's own `--default-transition-duration:.15s` custom property
56
+ * matches the duration pattern, `transition-timing-function` matches the
57
+ * ease pattern). Rather than surface those as noisy false "unrecognized"
58
+ * findings, motion usages are filtered out of every crawl result before
59
+ * it's returned — motion checking stays a source-scan-only capability.
60
+ */
61
+ export async function crawlUrl(pageUrl: string): Promise<CrawlResult> {
62
+ const url = new URL(pageUrl);
63
+ const usages: RawUsage[] = [];
64
+ const sourcesScanned: string[] = [];
65
+
66
+ const pageResponse = await fetch(url, { redirect: "follow" });
67
+ if (!pageResponse.ok) {
68
+ throw new Error(`Failed to fetch ${url}: ${pageResponse.status} ${pageResponse.statusText}`);
69
+ }
70
+ const html = await pageResponse.text();
71
+ usages.push(...extractFromContent(url.toString(), html));
72
+ sourcesScanned.push(url.toString());
73
+
74
+ const stylesheetHrefs = findStylesheetHrefs(html).filter((href) => isSameOrigin(href, url));
75
+
76
+ for (const href of stylesheetHrefs) {
77
+ const cssUrl = new URL(href, url).toString();
78
+ try {
79
+ const cssResponse = await fetch(cssUrl);
80
+ if (!cssResponse.ok) continue;
81
+ const css = await cssResponse.text();
82
+ usages.push(...extractFromContent(cssUrl, css));
83
+ sourcesScanned.push(cssUrl);
84
+ } catch {
85
+ // A single unreachable stylesheet shouldn't fail the whole crawl —
86
+ // same tolerance readScannedFiles already has for an unreadable
87
+ // local file.
88
+ }
89
+ }
90
+
91
+ return { usages: usages.filter((u) => u.kind !== "motion"), sourcesScanned };
92
+ }