maddox-engine 0.3.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 +17 -1
- package/package.json +1 -1
- package/src/applyFixes.ts +116 -0
- package/src/cli.ts +25 -3
- package/src/core.ts +1 -0
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Options:
|
|
|
25
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
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`
|
|
28
29
|
- `--format text|json|markdown` — output format (default: `text`)
|
|
29
30
|
- `--fail-below <0-100>` — exit non-zero if the drift health score falls below this threshold; omit to never fail
|
|
30
31
|
|
|
@@ -68,7 +69,22 @@ Third-party stylesheets (a different origin than the page itself, e.g. a font CD
|
|
|
68
69
|
|
|
69
70
|
### Suggested fixes
|
|
70
71
|
|
|
71
|
-
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).
|
|
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.
|
|
72
88
|
|
|
73
89
|
### Health score
|
|
74
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maddox-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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",
|
|
@@ -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, auditUrl, 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) {
|
|
@@ -44,10 +44,13 @@ async function main() {
|
|
|
44
44
|
"[--project <name>] [--motion <path-to-motion-tokens.json>] " +
|
|
45
45
|
"[--states <path-to-state-contract.json>] [--format text|json|markdown] " +
|
|
46
46
|
"[--fail-below <0-100>] [--tokens-studio <path-to-tokens-studio-export.json>] " +
|
|
47
|
-
"[--url <live-page-url>]\n" +
|
|
47
|
+
"[--url <live-page-url>] [--apply-fixes]\n" +
|
|
48
48
|
" --url scans a deployed page's rendered HTML/CSS instead of local source " +
|
|
49
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
|
|
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."
|
|
51
54
|
);
|
|
52
55
|
process.exit(1);
|
|
53
56
|
}
|
|
@@ -73,6 +76,25 @@ async function main() {
|
|
|
73
76
|
const result = url ? await auditUrl(url, groundTruth) : await audit(targetDir, groundTruth, stateContract);
|
|
74
77
|
const summary = summarize(result.findings, result.filesScanned);
|
|
75
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
|
+
|
|
76
98
|
if (format === "json") {
|
|
77
99
|
console.log(toJson(summary));
|
|
78
100
|
} else if (format === "markdown") {
|
package/src/core.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./diff.js";
|
|
|
7
7
|
export * from "./stateCheck.js";
|
|
8
8
|
export * from "./healthScore.js";
|
|
9
9
|
export * from "./suggestFix.js";
|
|
10
|
+
export * from "./applyFixes.js";
|
|
10
11
|
|
|
11
12
|
import { scanSource, scanFiles } from "./scan.js";
|
|
12
13
|
import { crawlUrl } from "./crawl.js";
|