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 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). 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.
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.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";