maddox-engine 0.1.0 → 0.2.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 +10 -0
- package/fixtures/tokens-studio-export.json +30 -0
- package/package.json +1 -1
- package/src/cli.ts +3 -2
- package/src/core.ts +5 -0
- package/src/format.ts +3 -3
- package/src/groundTruth.ts +14 -2
- package/src/suggestFix.ts +61 -0
- package/src/tokensStudio.ts +106 -0
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Options:
|
|
|
23
23
|
- `--project <name>` — project name (defaults to the target directory's basename)
|
|
24
24
|
- `--motion <path>` — path to a JSON file of motion tokens (e.g. a vendored copy of your motion-tokens export)
|
|
25
25
|
- `--states <path>` — path to a state-contract JSON file (see below)
|
|
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
|
|
26
27
|
- `--format text|json|markdown` — output format (default: `text`)
|
|
27
28
|
- `--fail-below <0-100>` — exit non-zero if the drift health score falls below this threshold; omit to never fail
|
|
28
29
|
|
|
@@ -45,6 +46,14 @@ An optional JSON file mapping a component-name pattern to the state names that c
|
|
|
45
46
|
|
|
46
47
|
Ground truth is explicit — nothing is inferred about which states a component "should" have.
|
|
47
48
|
|
|
49
|
+
### Tokens Studio ground truth
|
|
50
|
+
|
|
51
|
+
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
|
+
|
|
53
|
+
### Suggested fixes
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
48
57
|
### Health score
|
|
49
58
|
|
|
50
59
|
`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.
|
|
@@ -69,6 +78,7 @@ Inputs:
|
|
|
69
78
|
- `theme-css` *(required)* — path to the CSS file containing the `@theme` block
|
|
70
79
|
- `motion-tokens` — path to a motion-tokens JSON file
|
|
71
80
|
- `states` — path to a state-contract JSON file
|
|
81
|
+
- `tokens-studio` — path to a Tokens Studio / W3C Design Tokens JSON export
|
|
72
82
|
- `github-token` *(required)* — for posting the PR comment, usually `${{ secrets.GITHUB_TOKEN }}`
|
|
73
83
|
- `fail-below` — fail the build below this health score; omit for comment-only
|
|
74
84
|
- `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)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"global": {
|
|
3
|
+
"color": {
|
|
4
|
+
"brand": {
|
|
5
|
+
"500": { "value": "#4f46e5", "type": "color" },
|
|
6
|
+
"600": { "value": "{color.brand.500}", "type": "color" }
|
|
7
|
+
},
|
|
8
|
+
"neutral": {
|
|
9
|
+
"900": { "value": "#0a0a0b", "type": "color" }
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"spacing": {
|
|
13
|
+
"sm": { "value": "8px", "type": "spacing" },
|
|
14
|
+
"md": { "value": "16px", "type": "spacing" }
|
|
15
|
+
},
|
|
16
|
+
"typography": {
|
|
17
|
+
"heading": {
|
|
18
|
+
"value": { "fontFamily": "Inter", "fontSize": "32px" },
|
|
19
|
+
"type": "typography"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dark": {
|
|
24
|
+
"color": {
|
|
25
|
+
"neutral": {
|
|
26
|
+
"900": { "value": "#000000", "type": "color" }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maddox-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -42,7 +42,7 @@ async function main() {
|
|
|
42
42
|
"Usage: pnpm scan <target-source-dir> <path-to-globals.css-with-@theme-block> " +
|
|
43
43
|
"[--project <name>] [--motion <path-to-motion-tokens.json>] " +
|
|
44
44
|
"[--states <path-to-state-contract.json>] [--format text|json|markdown] " +
|
|
45
|
-
"[--fail-below <0-100>]"
|
|
45
|
+
"[--fail-below <0-100>] [--tokens-studio <path-to-tokens-studio-export.json>]"
|
|
46
46
|
);
|
|
47
47
|
process.exit(1);
|
|
48
48
|
}
|
|
@@ -62,7 +62,8 @@ async function main() {
|
|
|
62
62
|
const statesPath = flagValue("--states");
|
|
63
63
|
const stateContract = statesPath ? loadStateContract(statesPath) : undefined;
|
|
64
64
|
|
|
65
|
-
const
|
|
65
|
+
const tokensStudioPath = flagValue("--tokens-studio");
|
|
66
|
+
const groundTruth = loadGroundTruth(themeCssPath, motionTokens, tokensStudioPath);
|
|
66
67
|
const result = await audit(targetDir, groundTruth, stateContract);
|
|
67
68
|
const summary = summarize(result.findings, result.filesScanned);
|
|
68
69
|
|
package/src/core.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export * from "./groundTruth.js";
|
|
3
|
+
export * from "./tokensStudio.js";
|
|
3
4
|
export * from "./scan.js";
|
|
4
5
|
export * from "./diff.js";
|
|
5
6
|
export * from "./stateCheck.js";
|
|
6
7
|
export * from "./healthScore.js";
|
|
8
|
+
export * from "./suggestFix.js";
|
|
7
9
|
|
|
8
10
|
import { scanSource, scanFiles } from "./scan.js";
|
|
9
11
|
import { diffUsages } from "./diff.js";
|
|
10
12
|
import { checkStates } from "./stateCheck.js";
|
|
13
|
+
import { attachSuggestions } from "./suggestFix.js";
|
|
11
14
|
import type { GroundTruth, ScanResult, StateContract } from "./types.js";
|
|
12
15
|
|
|
13
16
|
export async function audit(
|
|
@@ -25,5 +28,7 @@ export async function audit(
|
|
|
25
28
|
for (const f of files) scannedFiles.add(f.file);
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
attachSuggestions(findings);
|
|
32
|
+
|
|
28
33
|
return { findings, filesScanned: scannedFiles.size };
|
|
29
34
|
}
|
package/src/format.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function toText(summary: ScanSummary): string {
|
|
|
30
30
|
lines.push(
|
|
31
31
|
`${f.file}:${f.line} [${f.kind}] ${f.rawValue} → ${f.severity}${
|
|
32
32
|
f.nearestToken ? ` (nearest: ${f.nearestToken})` : ""
|
|
33
|
-
}`
|
|
33
|
+
}${f.suggestion ? ` fix: ${f.suggestion}` : ""}`
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
return lines.join("\n");
|
|
@@ -60,10 +60,10 @@ export function toMarkdown(summary: ScanSummary): string {
|
|
|
60
60
|
return lines.join("\n");
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
lines.push("<details><summary>Findings</summary>", "", "| Location | Kind | Value | Severity | Nearest token |", "
|
|
63
|
+
lines.push("<details><summary>Findings</summary>", "", "| Location | Kind | Value | Severity | Nearest token | Suggested fix |", "|---|---|---|---|---|---|");
|
|
64
64
|
for (const f of nonMatch.slice(0, 100)) {
|
|
65
65
|
lines.push(
|
|
66
|
-
`| \`${f.file}:${f.line}\` | ${f.kind} | \`${f.rawValue}\` | ${f.severity} | ${f.nearestToken ? `\`${f.nearestToken}\`` : "—"} |`
|
|
66
|
+
`| \`${f.file}:${f.line}\` | ${f.kind} | \`${f.rawValue}\` | ${f.severity} | ${f.nearestToken ? `\`${f.nearestToken}\`` : "—"} | ${f.suggestion ? `\`${f.suggestion}\`` : "—"} |`
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
if (nonMatch.length > 100) {
|
package/src/groundTruth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { loadTokensStudioFile } from "./tokensStudio.js";
|
|
2
3
|
import type { GroundTruth, MotionToken, StateContract, TokenMap } from "./types.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -52,10 +53,21 @@ export function loadStateContract(path: string): StateContract {
|
|
|
52
53
|
|
|
53
54
|
export function loadGroundTruth(
|
|
54
55
|
themeCssPath: string,
|
|
55
|
-
motionTokensObj: Record<string, unknown
|
|
56
|
+
motionTokensObj: Record<string, unknown>,
|
|
57
|
+
tokensStudioPath?: string
|
|
56
58
|
): GroundTruth {
|
|
59
|
+
// When both a @theme CSS file and a Tokens Studio export are given, the
|
|
60
|
+
// Tokens Studio export (the design tool's source of truth) wins on any
|
|
61
|
+
// path both define — the CSS file is usually the codegen'd output of
|
|
62
|
+
// the same tokens, so a conflict means the CSS is stale, not that the
|
|
63
|
+
// design tokens are wrong.
|
|
64
|
+
const tokens = {
|
|
65
|
+
...parseThemeTokens(themeCssPath),
|
|
66
|
+
...(tokensStudioPath ? loadTokensStudioFile(tokensStudioPath) : {}),
|
|
67
|
+
};
|
|
68
|
+
|
|
57
69
|
return {
|
|
58
|
-
tokens
|
|
70
|
+
tokens,
|
|
59
71
|
motion: flattenMotionTokens(motionTokensObj),
|
|
60
72
|
};
|
|
61
73
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Finding } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the suggested source replacement for a single Finding's raw
|
|
5
|
+
* value, given the token/motion-path name diff.ts already matched it
|
|
6
|
+
* against (`nearestToken`). Returns undefined when no safe mechanical
|
|
7
|
+
* suggestion exists — either there's no nearestToken to point at, or the
|
|
8
|
+
* finding kind isn't a value swap at all (state completeness).
|
|
9
|
+
*
|
|
10
|
+
* Deliberately conservative: this only ever proposes replacing the exact
|
|
11
|
+
* literal Maddox found with a reference to the token it's closest to. It
|
|
12
|
+
* never tries to rewrite surrounding code, infer intent for an
|
|
13
|
+
* "unrecognized" value with no near match, or auto-apply anything — the
|
|
14
|
+
* suggestion is text for a human (or a future codemod) to review, not an
|
|
15
|
+
* automatic edit.
|
|
16
|
+
*/
|
|
17
|
+
export function suggestFix(finding: Finding): string | undefined {
|
|
18
|
+
if (!finding.nearestToken) return undefined;
|
|
19
|
+
if (finding.severity !== "near-miss" && finding.severity !== "match") return undefined;
|
|
20
|
+
|
|
21
|
+
switch (finding.kind) {
|
|
22
|
+
case "color":
|
|
23
|
+
case "font-size":
|
|
24
|
+
case "spacing":
|
|
25
|
+
// nearestToken is a "--token-name" CSS custom property name — the
|
|
26
|
+
// replacement is how that token is actually consumed in Tailwind
|
|
27
|
+
// v4 / plain CSS: var(--token-name).
|
|
28
|
+
return `var(${finding.nearestToken})`;
|
|
29
|
+
|
|
30
|
+
case "motion": {
|
|
31
|
+
// nearestToken is a dotted motion-token path, e.g.
|
|
32
|
+
// "transition.duration" — how it's actually referenced depends on
|
|
33
|
+
// the project's own motion tokens object import, which Maddox has
|
|
34
|
+
// no fixed name for, so this suggests the access path relative to
|
|
35
|
+
// a generic `motionTokens` import rather than guessing a project's
|
|
36
|
+
// real variable name.
|
|
37
|
+
return `motionTokens.${finding.nearestToken}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case "state":
|
|
41
|
+
// No mechanical value swap exists for a missing state — this needs
|
|
42
|
+
// a human to add real handling.
|
|
43
|
+
return undefined;
|
|
44
|
+
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Applies suggestFix to every finding in place, populating `.suggestion`
|
|
52
|
+
* where a safe suggestion exists. Mutates and returns the same array —
|
|
53
|
+
* matches diffUsages' existing per-finding transform style rather than
|
|
54
|
+
* introducing a different pattern for one more derived field.
|
|
55
|
+
*/
|
|
56
|
+
export function attachSuggestions(findings: Finding[]): Finding[] {
|
|
57
|
+
for (const finding of findings) {
|
|
58
|
+
finding.suggestion = suggestFix(finding);
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { TokenMap } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// A single node in a Tokens Studio / W3C Design Tokens export: either a
|
|
5
|
+
// leaf token ({ value, type }) or a group of further-nested nodes. Groups
|
|
6
|
+
// and leaves are told apart by the presence of a "value" key, per the W3C
|
|
7
|
+
// Design Tokens Community Group format Tokens Studio exports.
|
|
8
|
+
interface TokenNode {
|
|
9
|
+
value?: string | number;
|
|
10
|
+
type?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type TokenTree = { [key: string]: TokenNode | TokenTree };
|
|
15
|
+
|
|
16
|
+
// Only these Tokens Studio types resolve to the single raw CSS value that
|
|
17
|
+
// Maddox's TokenMap expects (a color hex, or a px/rem/em length) — the
|
|
18
|
+
// others (typography, boxShadow, border, composite tokens) describe a
|
|
19
|
+
// bundle of properties, not one comparable value, and are skipped rather
|
|
20
|
+
// than half-mapped into a shape that would silently misclassify usages.
|
|
21
|
+
const SUPPORTED_TYPES = new Set(["color", "fontSizes", "spacing", "sizing", "borderRadius", "dimension"]);
|
|
22
|
+
|
|
23
|
+
function isLeaf(node: TokenNode | TokenTree): node is TokenNode {
|
|
24
|
+
return typeof (node as TokenNode).value !== "undefined";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a Tokens Studio alias reference, e.g. "{color.brand.500}", by
|
|
29
|
+
* looking up the dotted path in the flattened token map already built so
|
|
30
|
+
* far. Aliases must point at a token defined earlier in traversal order;
|
|
31
|
+
* circular or forward references are left unresolved (returned as-is)
|
|
32
|
+
* rather than causing an infinite loop.
|
|
33
|
+
*/
|
|
34
|
+
function resolveAlias(raw: string, resolved: Map<string, string>, depth = 0): string {
|
|
35
|
+
const match = /^\{([^}]+)\}$/.exec(raw.trim());
|
|
36
|
+
if (!match || depth > 10) return raw;
|
|
37
|
+
|
|
38
|
+
const target = resolved.get(match[1]);
|
|
39
|
+
if (target === undefined) return raw;
|
|
40
|
+
return resolveAlias(target, resolved, depth + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Flattens a Tokens Studio JSON tree (single set, e.g. the "global" set
|
|
45
|
+
* from a multi-set export, or the whole file for a single-set export)
|
|
46
|
+
* into a flat `path.to.token` -> raw-css-value map, resolving `{alias}`
|
|
47
|
+
* references along the way. Unsupported token types are skipped, not
|
|
48
|
+
* coerced.
|
|
49
|
+
*/
|
|
50
|
+
export function flattenTokensStudioTree(tree: TokenTree): TokenMap {
|
|
51
|
+
const resolved = new Map<string, string>();
|
|
52
|
+
|
|
53
|
+
function walk(node: TokenNode | TokenTree, path: string): void {
|
|
54
|
+
if (isLeaf(node)) {
|
|
55
|
+
if (node.type && !SUPPORTED_TYPES.has(node.type)) return;
|
|
56
|
+
const raw = String(node.value);
|
|
57
|
+
const value = raw.startsWith("{") ? resolveAlias(raw, resolved) : raw;
|
|
58
|
+
resolved.set(path, value);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const [key, child] of Object.entries(node)) {
|
|
62
|
+
if (key.startsWith("$")) continue; // Tokens Studio metadata keys, e.g. $themes, $metadata
|
|
63
|
+
walk(child as TokenNode | TokenTree, path ? `${path}.${key}` : key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
walk(tree, "");
|
|
68
|
+
|
|
69
|
+
const tokens: TokenMap = {};
|
|
70
|
+
for (const [path, value] of resolved) {
|
|
71
|
+
// Drop any alias that never resolved to a raw value (dangling/forward
|
|
72
|
+
// reference) — an unresolved "{...}" string is not a comparable CSS
|
|
73
|
+
// value and would never match a scanned usage anyway.
|
|
74
|
+
if (value.startsWith("{")) continue;
|
|
75
|
+
tokens[`--${path.replace(/\./g, "-")}`] = value;
|
|
76
|
+
}
|
|
77
|
+
return tokens;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Loads a Tokens Studio JSON export from disk and flattens it into a
|
|
82
|
+
* TokenMap. Handles both a single-set export (the whole file is one
|
|
83
|
+
* token tree) and a multi-set export (top-level keys are set names, e.g.
|
|
84
|
+
* "global", "dark" — every non-$-prefixed top-level key is merged, later
|
|
85
|
+
* sets overriding earlier ones by path, since Maddox compares against one
|
|
86
|
+
* flat ground truth rather than a per-theme one).
|
|
87
|
+
*/
|
|
88
|
+
export function loadTokensStudioFile(path: string): TokenMap {
|
|
89
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as TokenTree;
|
|
90
|
+
|
|
91
|
+
const topLevelKeys = Object.keys(raw).filter((k) => !k.startsWith("$"));
|
|
92
|
+
const looksLikeMultiSet = topLevelKeys.every(
|
|
93
|
+
(k) => typeof raw[k] === "object" && raw[k] !== null && !isLeaf(raw[k] as TokenNode)
|
|
94
|
+
&& Object.values(raw[k] as TokenTree).some((v) => typeof v === "object")
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (!looksLikeMultiSet) {
|
|
98
|
+
return flattenTokensStudioTree(raw);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const merged: TokenMap = {};
|
|
102
|
+
for (const key of topLevelKeys) {
|
|
103
|
+
Object.assign(merged, flattenTokensStudioTree(raw[key] as TokenTree));
|
|
104
|
+
}
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -24,6 +24,13 @@ export interface Finding {
|
|
|
24
24
|
rawValue: string;
|
|
25
25
|
severity: Severity;
|
|
26
26
|
nearestToken?: string;
|
|
27
|
+
// A suggested source replacement for `rawValue`, only ever set for
|
|
28
|
+
// "near-miss" or "match"-adjacent findings with a resolvable
|
|
29
|
+
// nearestToken — see suggestFix.ts. Left undefined (never an empty
|
|
30
|
+
// string) when no safe mechanical suggestion exists, e.g. a "missing"
|
|
31
|
+
// state finding, which needs a human to add real handling, not a
|
|
32
|
+
// value swap.
|
|
33
|
+
suggestion?: string;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export interface ScanResult {
|