stylelint-plugin-rhythmguard 1.9.0 → 2.0.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/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.0.0] - 2026-05-23
10
+
11
+ ### Changed
12
+
13
+ - `rhythmguard audit --format json` now emits the stable audit contract with `schemaVersion: "2.0"`.
14
+ - Moved the pre-2.0 audit JSON shape to `--format json-v1` for migration compatibility.
15
+ - Updated audit defaults to use the explicit spacing token pattern `^--(space|spacing)-`.
16
+
17
+ ### Added
18
+
19
+ - Added `stylelint-plugin-rhythmguard/audit` with `createAuditReport`, `loadAuditConfig`, `parseTokenSources`, and `toAuditContractReport`.
20
+ - Added `rhythmguard audit --format html`, `--output <file>`, and `--schema`.
21
+ - Added `rhythmguard doctor` checks for `.rhythmguardrc.json`, configured token sources, motion audit config, and baseline files.
22
+ - Added `docs/MIGRATING_TO_2.md`.
23
+
9
24
  ## [1.9.0] - 2026-05-23
10
25
 
11
26
  ### Added
package/README.md CHANGED
@@ -97,9 +97,11 @@ npx rhythmguard audit ./src --staged --max-findings 0
97
97
  npx rhythmguard audit ./src --token-source ./tokens.json
98
98
  npx rhythmguard audit ./src --token-source ./theme.css --token-source-format css
99
99
  npx rhythmguard audit ./src --include-motion
100
+ npx rhythmguard audit ./src --format html --output rhythmguard-report.html
101
+ npx rhythmguard audit --schema
100
102
  ```
101
103
 
102
- The report covers authored CSS declarations, Tailwind arbitrary spacing values in common template/source files, and token-contract drift such as missing spacing tokens, unused spacing tokens, repeated raw values that deserve token review, raw values that match known tokens, and conflicting token values. Scan paths are scoped to the directory argument. Use `--ignore`, `.rhythmguardignore`, or `--ignore-path` for generated or legacy subtrees, then add baselines and CI thresholds when you are ready to gate new drift. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
104
+ The report covers authored CSS declarations, Tailwind arbitrary spacing values in common template/source files, and token-contract drift such as missing spacing tokens, unused spacing tokens, repeated raw values that deserve token review, raw values that match known tokens, conflicting token values, and opt-in motion rhythm drift. Scan paths are scoped to the directory argument. Use `--ignore`, `.rhythmguardignore`, or `--ignore-path` for generated or legacy subtrees, then add baselines and CI thresholds when you are ready to gate new drift. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
103
105
 
104
106
  ```md
105
107
  # Rhythmguard Design-System Audit
@@ -136,6 +138,44 @@ For large codebases, put shared audit settings in `.rhythmguardrc.json`:
136
138
 
137
139
  `rhythmguard audit` loads `.rhythmguardrc.json` automatically when present. Use `--config <file>` for another config, `--no-config` to skip config discovery, and `--token-source <file>` for extra canonical token files. Token source paths in config files resolve from the config file directory; CLI token source paths resolve from the current working directory. Supported source formats are CSS custom properties and Tailwind v4 `@theme`, flat JSON maps, Style Dictionary JSON, and DTCG JSON.
138
140
 
141
+ ### Audit JSON 2.0 and API
142
+
143
+ In Rhythmguard 2.0, `--format json` emits the stable audit contract:
144
+
145
+ ```json
146
+ {
147
+ "schemaVersion": "2.0",
148
+ "command": { "directory": "./src", "scanScope": "full" },
149
+ "summary": { "totalFindings": 12, "scaleCleanliness": 94 },
150
+ "scanned": { "cssFiles": 10, "templateFiles": 20 },
151
+ "contracts": {
152
+ "scale": {},
153
+ "tokens": {},
154
+ "motion": {}
155
+ },
156
+ "findings": {
157
+ "css": [],
158
+ "tailwind": [],
159
+ "motion": []
160
+ },
161
+ "baseline": null
162
+ }
163
+ ```
164
+
165
+ Use `--format json-v1` for the pre-2.0 JSON shape during migration.
166
+
167
+ Programmatic usage:
168
+
169
+ ```js
170
+ const {
171
+ createAuditReport,
172
+ toAuditContractReport,
173
+ } = require('stylelint-plugin-rhythmguard/audit');
174
+
175
+ const report = await createAuditReport({ dir: './src', noConfig: true });
176
+ const contract = toAuditContractReport(report);
177
+ ```
178
+
139
179
  ## Installation
140
180
 
141
181
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.9.0",
3
+ "version": "2.0.0",
4
4
  "description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -61,6 +61,10 @@
61
61
  "require": "./src/presets/index.js",
62
62
  "import": "./src/presets/index.mjs"
63
63
  },
64
+ "./audit": {
65
+ "require": "./src/audit/index.js",
66
+ "import": "./src/audit/index.mjs"
67
+ },
64
68
  "./rules/use-scale": {
65
69
  "require": "./src/rules/use-scale/index.js",
66
70
  "import": "./src/rules/use-scale/index.mjs"
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ AUDIT_JSON_SCHEMA,
5
+ createAuditReport,
6
+ loadAuditConfig,
7
+ toAuditContractReport,
8
+ } = require('../cli/audit');
9
+ const { parseTokenSources } = require('../utils/token-sources');
10
+
11
+ module.exports = {
12
+ AUDIT_JSON_SCHEMA,
13
+ createAuditReport,
14
+ loadAuditConfig,
15
+ parseTokenSources,
16
+ toAuditContractReport,
17
+ };
@@ -0,0 +1,11 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const audit = require('./index.js');
5
+
6
+ export default audit;
7
+ export const AUDIT_JSON_SCHEMA = audit.AUDIT_JSON_SCHEMA;
8
+ export const createAuditReport = audit.createAuditReport;
9
+ export const loadAuditConfig = audit.loadAuditConfig;
10
+ export const parseTokenSources = audit.parseTokenSources;
11
+ export const toAuditContractReport = audit.toAuditContractReport;
package/src/cli/audit.js CHANGED
@@ -25,8 +25,9 @@ const DEFAULT_BASE_FONT_SIZE = 16;
25
25
  const DEFAULT_BASELINE_PATH = '.rhythmguard-baseline.json';
26
26
  const DEFAULT_CONFIG_PATH = '.rhythmguardrc.json';
27
27
  const DEFAULT_IGNORE_PATH = '.rhythmguardignore';
28
+ const DEFAULT_AUDIT_TOKEN_PATTERN = '^--(space|spacing)-';
28
29
  const DEFAULT_TOKEN_CANDIDATE_MIN_COUNT = 2;
29
- const VALID_FORMATS = new Set(['text', 'json', 'markdown']);
30
+ const VALID_FORMATS = new Set(['text', 'json', 'json-v1', 'markdown', 'html']);
30
31
  const SKIP_DIRS = new Set([
31
32
  '.git',
32
33
  '.next',
@@ -56,9 +57,11 @@ const TEMPLATE_EXTENSIONS = new Set([
56
57
  const HELP = `Usage: rhythmguard audit <dir> [options]
57
58
 
58
59
  Options:
59
- --format <text|json|markdown> Output format (default: text)
60
+ --format <text|json|json-v1|markdown|html> Output format (default: text)
60
61
  --json Alias for --format json
61
62
  --markdown Alias for --format markdown
63
+ --schema Print the audit JSON schema and exit
64
+ --output <file> Write json, markdown, or html output to a file
62
65
  --config <file> Load audit config (default: .rhythmguardrc.json when present)
63
66
  --no-config Ignore .rhythmguardrc.json discovery
64
67
  --ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
@@ -81,31 +84,7 @@ Options:
81
84
  `;
82
85
 
83
86
  function parseArgs(argv) {
84
- const parsed = {
85
- baselinePath: DEFAULT_BASELINE_PATH,
86
- baseFontSize: DEFAULT_BASE_FONT_SIZE,
87
- cliOptions: new Set(),
88
- configExplicit: false,
89
- configPath: DEFAULT_CONFIG_PATH,
90
- dir: null,
91
- failOnNewDrift: false,
92
- format: 'text',
93
- ignorePath: DEFAULT_IGNORE_PATH,
94
- ignorePatterns: [],
95
- includeMotion: false,
96
- maxFindings: null,
97
- minCleanliness: null,
98
- noConfig: false,
99
- scale: DEFAULT_SCALE,
100
- since: null,
101
- sinceBaseline: false,
102
- staged: false,
103
- tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
104
- tokenKind: 'spacing',
105
- tokenSourceFormat: 'auto',
106
- tokenSources: [],
107
- writeBaseline: false,
108
- };
87
+ const parsed = createDefaultAuditOptions();
109
88
 
110
89
  for (let index = 0; index < argv.length; index++) {
111
90
  const arg = argv[index];
@@ -115,6 +94,12 @@ function parseArgs(argv) {
115
94
  continue;
116
95
  }
117
96
 
97
+ if (arg === '--schema') {
98
+ parsed.schema = true;
99
+ parsed.format = 'json';
100
+ continue;
101
+ }
102
+
118
103
  if (arg === '--json') {
119
104
  parsed.format = 'json';
120
105
  continue;
@@ -125,6 +110,18 @@ function parseArgs(argv) {
125
110
  continue;
126
111
  }
127
112
 
113
+ if (arg === '--output') {
114
+ parsed.outputPath = parsePathOption(argv[++index], '--output');
115
+ parsed.cliOptions.add('outputPath');
116
+ continue;
117
+ }
118
+
119
+ if (arg.startsWith('--output=')) {
120
+ parsed.outputPath = parsePathOption(arg.slice('--output='.length), '--output');
121
+ parsed.cliOptions.add('outputPath');
122
+ continue;
123
+ }
124
+
128
125
  if (arg === '--ignore') {
129
126
  parsed.ignorePatterns.push(...parseIgnorePatterns(argv[++index]));
130
127
  parsed.cliOptions.add('ignore');
@@ -365,7 +362,7 @@ function parseArgs(argv) {
365
362
  }
366
363
 
367
364
  if (!VALID_FORMATS.has(parsed.format)) {
368
- throw new Error(`Invalid format "${parsed.format}". Expected text, json, or markdown.`);
365
+ throw new Error(`Invalid format "${parsed.format}". Expected text, json, json-v1, markdown, or html.`);
369
366
  }
370
367
 
371
368
  if (parsed.since && parsed.staged) {
@@ -379,6 +376,36 @@ function parseArgs(argv) {
379
376
  return parsed;
380
377
  }
381
378
 
379
+ function createDefaultAuditOptions() {
380
+ return {
381
+ baselinePath: DEFAULT_BASELINE_PATH,
382
+ baseFontSize: DEFAULT_BASE_FONT_SIZE,
383
+ cliOptions: new Set(),
384
+ configExplicit: false,
385
+ configPath: DEFAULT_CONFIG_PATH,
386
+ dir: null,
387
+ failOnNewDrift: false,
388
+ format: 'text',
389
+ ignorePath: DEFAULT_IGNORE_PATH,
390
+ ignorePatterns: [],
391
+ includeMotion: false,
392
+ maxFindings: null,
393
+ minCleanliness: null,
394
+ noConfig: false,
395
+ outputPath: null,
396
+ scale: DEFAULT_SCALE,
397
+ schema: false,
398
+ since: null,
399
+ sinceBaseline: false,
400
+ staged: false,
401
+ tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
402
+ tokenKind: 'spacing',
403
+ tokenSourceFormat: 'auto',
404
+ tokenSources: [],
405
+ writeBaseline: false,
406
+ };
407
+ }
408
+
382
409
  function parsePathOption(raw, optionName) {
383
410
  if (!raw) {
384
411
  throw new Error(`Missing value for ${optionName}.`);
@@ -486,25 +513,27 @@ function parseBaseFontSize(raw) {
486
513
 
487
514
  function assertDirectory(dir) {
488
515
  if (!dir) {
489
- process.stderr.write(HELP);
490
- process.exit(1);
516
+ throw new Error('Missing audit directory.');
491
517
  }
492
518
 
493
519
  const resolvedDir = path.resolve(dir);
494
520
  if (!fs.existsSync(resolvedDir)) {
495
- process.stderr.write(`Directory not found: ${dir}\n`);
496
- process.exit(1);
521
+ throw new Error(`Directory not found: ${dir}`);
497
522
  }
498
523
 
499
524
  if (!fs.statSync(resolvedDir).isDirectory()) {
500
- process.stderr.write(`Not a directory: ${dir}\n`);
501
- process.exit(1);
525
+ throw new Error(`Not a directory: ${dir}`);
502
526
  }
503
527
 
504
528
  return resolvedDir;
505
529
  }
506
530
 
507
531
  function loadAuditConfig(parsed) {
532
+ parsed = {
533
+ ...createDefaultAuditOptions(),
534
+ ...parsed,
535
+ };
536
+
508
537
  if (parsed.noConfig) {
509
538
  return null;
510
539
  }
@@ -901,7 +930,7 @@ async function runStylelintAudit(cssFiles, options) {
901
930
  scale: options.scale,
902
931
  severity: 'warning',
903
932
  tokenMapFromCssCustomProperties: true,
904
- tokenPattern: '^--spac(e|ing)-',
933
+ tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
905
934
  },
906
935
  ],
907
936
  };
@@ -1985,61 +2014,25 @@ function formatPath(filePath) {
1985
2014
  return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
1986
2015
  }
1987
2016
 
1988
- async function run() {
1989
- let parsed;
1990
- try {
1991
- parsed = parseArgs(args);
1992
- } catch (err) {
1993
- process.stderr.write(`${err.message}\n\n${HELP}`);
1994
- process.exit(1);
1995
- }
1996
-
1997
- if (parsed.help) {
1998
- process.stdout.write(HELP);
1999
- return;
2000
- }
2001
-
2002
- try {
2003
- parsed = applyAuditConfig(parsed, loadAuditConfig(parsed));
2004
- } catch (err) {
2005
- process.stderr.write(`${err.message}\n`);
2006
- process.exit(1);
2007
- }
2008
-
2017
+ async function createAuditReport(options) {
2018
+ const parsed = normalizeCreateAuditOptions(options);
2009
2019
  const resolvedDir = assertDirectory(parsed.dir);
2010
2020
  let ignorePatterns;
2011
- try {
2012
- ignorePatterns = [
2013
- ...loadIgnorePatterns(parsed.ignorePath),
2014
- ...parsed.ignorePatterns,
2015
- ];
2016
- } catch (err) {
2017
- process.stderr.write(`${err.message}\n`);
2018
- process.exit(1);
2019
- }
2021
+ ignorePatterns = [
2022
+ ...loadIgnorePatterns(parsed.ignorePath),
2023
+ ...parsed.ignorePatterns,
2024
+ ];
2020
2025
 
2021
- let scanFiles;
2022
- try {
2023
- scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
2024
- } catch (err) {
2025
- process.stderr.write(`${err.message}\n`);
2026
- process.exit(1);
2027
- }
2026
+ const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
2028
2027
 
2029
2028
  const { cssFiles, scanScope, templateFiles } = scanFiles;
2030
- const options = {
2029
+ const lintOptions = {
2031
2030
  baseFontSize: parsed.baseFontSize,
2032
2031
  includeMotion: parsed.includeMotion,
2033
2032
  scale: parsed.scale,
2034
2033
  };
2035
2034
 
2036
- let cssResults;
2037
- try {
2038
- cssResults = await runStylelintAudit(cssFiles, options);
2039
- } catch (err) {
2040
- process.stderr.write(`Lint error: ${err.message}\n`);
2041
- process.exit(1);
2042
- }
2035
+ const cssResults = await runStylelintAudit(cssFiles, lintOptions);
2043
2036
 
2044
2037
  const tokenSourceResult = parseTokenSources({
2045
2038
  baseFontSize: parsed.baseFontSize,
@@ -2050,7 +2043,7 @@ async function run() {
2050
2043
  const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
2051
2044
  const motionFindings = [
2052
2045
  ...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
2053
- ...collectTailwindMotionFindings(templateFiles, options),
2046
+ ...collectTailwindMotionFindings(templateFiles, lintOptions),
2054
2047
  ];
2055
2048
 
2056
2049
  const report = buildReport({
@@ -2063,7 +2056,7 @@ async function run() {
2063
2056
  includeMotion: parsed.includeMotion,
2064
2057
  motionFindings,
2065
2058
  scanScope,
2066
- tailwindFindings: collectTailwindFindings(templateFiles, options),
2059
+ tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
2067
2060
  templateFiles,
2068
2061
  tokenCandidateMinCount: parsed.tokenCandidateMinCount,
2069
2062
  tokenKind: parsed.tokenKind,
@@ -2071,14 +2064,181 @@ async function run() {
2071
2064
  tokenSourceWarnings: tokenSourceResult.warnings,
2072
2065
  });
2073
2066
 
2067
+ if (parsed.sinceBaseline) {
2068
+ applyBaselineComparison(report, parsed.baselinePath);
2069
+ }
2070
+
2071
+ if (parsed.writeBaseline) {
2072
+ writeBaseline(report, parsed.baselinePath);
2073
+ }
2074
+
2075
+ return report;
2076
+ }
2077
+
2078
+ function normalizeCreateAuditOptions(options = {}) {
2079
+ const parsed = {
2080
+ ...createDefaultAuditOptions(),
2081
+ ...options,
2082
+ cliOptions: options.cliOptions instanceof Set
2083
+ ? options.cliOptions
2084
+ : new Set(Object.keys(options)),
2085
+ };
2086
+
2087
+ if (!Array.isArray(parsed.ignorePatterns)) {
2088
+ parsed.ignorePatterns = [];
2089
+ }
2090
+
2091
+ if (!Array.isArray(parsed.tokenSources)) {
2092
+ parsed.tokenSources = [];
2093
+ }
2094
+
2095
+ if (parsed.configApplied) {
2096
+ delete parsed.cliOptions;
2097
+ return parsed;
2098
+ }
2099
+
2100
+ return applyAuditConfig(parsed, loadAuditConfig(parsed));
2101
+ }
2102
+
2103
+ function toAuditContractReport(report) {
2104
+ return {
2105
+ baseline: report.baseline || null,
2106
+ command: {
2107
+ config: report.config,
2108
+ directory: report.directory,
2109
+ scanScope: report.scanScope.mode,
2110
+ },
2111
+ contracts: {
2112
+ motion: report.motion,
2113
+ scale: {
2114
+ cleanliness: report.scaleCleanliness,
2115
+ offScaleValues: report.offScaleValues,
2116
+ tokenOpportunities: report.tokenOpportunities,
2117
+ },
2118
+ tokens: report.tokenContract,
2119
+ },
2120
+ findings: report.findings,
2121
+ scanned: report.scanned,
2122
+ schemaVersion: '2.0',
2123
+ summary: report.summary,
2124
+ };
2125
+ }
2126
+
2127
+ const AUDIT_JSON_SCHEMA = Object.freeze({
2128
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
2129
+ additionalProperties: true,
2130
+ properties: {
2131
+ baseline: { type: ['object', 'null'] },
2132
+ command: { type: 'object' },
2133
+ contracts: { type: 'object' },
2134
+ findings: { type: 'object' },
2135
+ scanned: { type: 'object' },
2136
+ schemaVersion: { const: '2.0' },
2137
+ summary: { type: 'object' },
2138
+ },
2139
+ required: ['schemaVersion', 'command', 'summary', 'scanned', 'contracts', 'findings'],
2140
+ title: 'Rhythmguard Audit Report',
2141
+ type: 'object',
2142
+ });
2143
+
2144
+ function renderHtml(report) {
2145
+ const contractReport = toAuditContractReport(report);
2146
+ const lines = [
2147
+ '<!doctype html>',
2148
+ '<html lang="en">',
2149
+ '<head>',
2150
+ '<meta charset="utf-8">',
2151
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
2152
+ '<title>Rhythmguard Design-System Audit</title>',
2153
+ '<style>',
2154
+ 'body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;color:#171717;background:#fafafa;}',
2155
+ 'main{max-width:1040px;margin:0 auto;padding:32px 20px;}',
2156
+ 'h1,h2{line-height:1.2;}',
2157
+ '.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}',
2158
+ '.metric{border:1px solid #ddd;background:#fff;padding:14px;border-radius:6px;}',
2159
+ '.metric strong{display:block;font-size:28px;}',
2160
+ 'table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #ddd;}',
2161
+ 'th,td{padding:10px;border-bottom:1px solid #eee;text-align:left;}',
2162
+ 'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}',
2163
+ '</style>',
2164
+ '</head>',
2165
+ '<body>',
2166
+ '<main>',
2167
+ '<h1>Rhythmguard Design-System Audit</h1>',
2168
+ `<p>Directory: <code>${escapeHtml(contractReport.command.directory)}</code></p>`,
2169
+ '<section class="grid">',
2170
+ metricHtml('Total findings', report.totalWarnings),
2171
+ metricHtml('Scale cleanliness', `${report.scaleCleanliness}%`),
2172
+ metricHtml('CSS files', report.cssFilesScanned),
2173
+ metricHtml('Template files', report.templateFilesScanned),
2174
+ '</section>',
2175
+ renderHtmlTable('Top Affected Files', ['File', 'Findings'], report.topAffectedFiles.map(({ file, count }) => [file, count])),
2176
+ renderHtmlTable('Token Contract Sources', ['File', 'Format', 'Tokens'], report.tokenContract.sources.map((source) => [
2177
+ source.file,
2178
+ source.format,
2179
+ source.tokenCount,
2180
+ ])),
2181
+ renderHtmlTable('Motion Rhythm Drift', ['Value', 'Count'], Object.entries(report.motion.values)),
2182
+ '<h2>Machine JSON</h2>',
2183
+ `<pre><code>${escapeHtml(JSON.stringify(contractReport, null, 2))}</code></pre>`,
2184
+ '</main>',
2185
+ '</body>',
2186
+ '</html>',
2187
+ ];
2188
+
2189
+ return `${lines.join('\n')}\n`;
2190
+ }
2191
+
2192
+ function metricHtml(label, value) {
2193
+ return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
2194
+ }
2195
+
2196
+ function renderHtmlTable(title, headers, rows) {
2197
+ if (rows.length === 0) {
2198
+ return '';
2199
+ }
2200
+
2201
+ return [
2202
+ `<h2>${escapeHtml(title)}</h2>`,
2203
+ '<table>',
2204
+ `<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join('')}</tr></thead>`,
2205
+ '<tbody>',
2206
+ ...rows.map((row) => `<tr>${row.map((cell) => `<td><code>${escapeHtml(cell)}</code></td>`).join('')}</tr>`),
2207
+ '</tbody>',
2208
+ '</table>',
2209
+ ].join('\n');
2210
+ }
2211
+
2212
+ function escapeHtml(value) {
2213
+ return String(value)
2214
+ .replace(/&/g, '&amp;')
2215
+ .replace(/</g, '&lt;')
2216
+ .replace(/>/g, '&gt;')
2217
+ .replace(/"/g, '&quot;');
2218
+ }
2219
+
2220
+ async function run() {
2221
+ let parsed;
2074
2222
  try {
2075
- if (parsed.sinceBaseline) {
2076
- applyBaselineComparison(report, parsed.baselinePath);
2077
- }
2223
+ parsed = parseArgs(args);
2224
+ } catch (err) {
2225
+ process.stderr.write(`${err.message}\n\n${HELP}`);
2226
+ process.exit(1);
2227
+ }
2078
2228
 
2079
- if (parsed.writeBaseline) {
2080
- writeBaseline(report, parsed.baselinePath);
2081
- }
2229
+ if (parsed.help) {
2230
+ process.stdout.write(HELP);
2231
+ return;
2232
+ }
2233
+
2234
+ if (parsed.schema) {
2235
+ writeOutput(`${JSON.stringify(AUDIT_JSON_SCHEMA, null, 2)}\n`, parsed.outputPath);
2236
+ return;
2237
+ }
2238
+
2239
+ let report;
2240
+ try {
2241
+ report = await createAuditReport(parsed);
2082
2242
  } catch (err) {
2083
2243
  process.stderr.write(`${err.message}\n`);
2084
2244
  process.exit(1);
@@ -2087,21 +2247,44 @@ async function run() {
2087
2247
  const auditFailures = getAuditFailures(report, parsed);
2088
2248
 
2089
2249
  if (parsed.format === 'json') {
2090
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
2250
+ writeOutput(`${JSON.stringify(toAuditContractReport(report), null, 2)}\n`, parsed.outputPath);
2251
+ finish(auditFailures);
2252
+ return;
2253
+ }
2254
+
2255
+ if (parsed.format === 'json-v1') {
2256
+ writeOutput(`${JSON.stringify(report, null, 2)}\n`, parsed.outputPath);
2091
2257
  finish(auditFailures);
2092
2258
  return;
2093
2259
  }
2094
2260
 
2095
2261
  if (parsed.format === 'markdown') {
2096
- process.stdout.write(renderMarkdown(report));
2262
+ writeOutput(renderMarkdown(report), parsed.outputPath);
2263
+ finish(auditFailures);
2264
+ return;
2265
+ }
2266
+
2267
+ if (parsed.format === 'html') {
2268
+ writeOutput(renderHtml(report), parsed.outputPath);
2097
2269
  finish(auditFailures);
2098
2270
  return;
2099
2271
  }
2100
2272
 
2101
- process.stdout.write(renderText(report));
2273
+ writeOutput(renderText(report), parsed.outputPath);
2102
2274
  finish(auditFailures);
2103
2275
  }
2104
2276
 
2277
+ function writeOutput(output, outputPath) {
2278
+ if (!outputPath) {
2279
+ process.stdout.write(output);
2280
+ return;
2281
+ }
2282
+
2283
+ const resolvedPath = path.resolve(process.cwd(), outputPath);
2284
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
2285
+ fs.writeFileSync(resolvedPath, output);
2286
+ }
2287
+
2105
2288
  function finish(auditFailures) {
2106
2289
  if (auditFailures.length === 0) {
2107
2290
  return;
@@ -2111,4 +2294,16 @@ function finish(auditFailures) {
2111
2294
  process.exitCode = 1;
2112
2295
  }
2113
2296
 
2114
- run();
2297
+ module.exports = {
2298
+ AUDIT_JSON_SCHEMA,
2299
+ createAuditReport,
2300
+ loadAuditConfig,
2301
+ parseArgs,
2302
+ renderHtml,
2303
+ run,
2304
+ toAuditContractReport,
2305
+ };
2306
+
2307
+ if (require.main === module) {
2308
+ run();
2309
+ }
package/src/cli/doctor.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { normalizeTokenSourceFormat } = require('../utils/token-sources');
5
6
 
6
7
  const cwd = process.cwd();
7
8
  let issues = 0;
@@ -205,6 +206,99 @@ function checkCustomSyntax(configContent) {
205
206
  }
206
207
  }
207
208
 
209
+ function checkRhythmguardConfig() {
210
+ const configPath = path.join(cwd, '.rhythmguardrc.json');
211
+ if (!fs.existsSync(configPath)) {
212
+ skip('rhythmguard config check skipped (.rhythmguardrc.json not found)');
213
+ return;
214
+ }
215
+
216
+ let parsed;
217
+ try {
218
+ parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
219
+ } catch {
220
+ fail('.rhythmguardrc.json is not valid JSON', 'Fix or remove .rhythmguardrc.json');
221
+ return;
222
+ }
223
+
224
+ const audit = parsed.audit;
225
+ if (!audit || typeof audit !== 'object' || Array.isArray(audit)) {
226
+ fail('.rhythmguardrc.json audit config missing', 'Add an "audit" object or remove the config file');
227
+ return;
228
+ }
229
+
230
+ pass('.rhythmguardrc.json audit config valid');
231
+ checkRhythmguardTokenSources(audit, path.dirname(configPath));
232
+ checkRhythmguardMotionConfig(audit);
233
+ checkRhythmguardBaseline(audit);
234
+ }
235
+
236
+ function checkRhythmguardTokenSources(audit, baseDir) {
237
+ if (audit.tokenSources === undefined) {
238
+ skip('token source config check skipped (not configured)');
239
+ return;
240
+ }
241
+
242
+ if (!Array.isArray(audit.tokenSources)) {
243
+ fail('audit.tokenSources must be an array', 'Use strings or { "path": "...", "format": "..." } entries');
244
+ return;
245
+ }
246
+
247
+ for (const source of audit.tokenSources) {
248
+ const sourcePath = typeof source === 'string' ? source : source && source.path;
249
+ const format = typeof source === 'string' ? 'auto' : source && source.format;
250
+
251
+ if (typeof sourcePath !== 'string' || sourcePath.trim().length === 0) {
252
+ fail('token source entry missing path', 'Use strings or objects with a non-empty path');
253
+ continue;
254
+ }
255
+
256
+ try {
257
+ normalizeTokenSourceFormat(format || 'auto');
258
+ } catch {
259
+ fail(`token source format invalid for ${sourcePath}`, 'Use auto, css, flat-json, style-dictionary, or dtcg');
260
+ }
261
+
262
+ if (fs.existsSync(path.resolve(baseDir, sourcePath))) {
263
+ pass(`token source found (${sourcePath})`);
264
+ } else {
265
+ fail(`token source not found (${sourcePath})`, 'Update audit.tokenSources or create the token file');
266
+ }
267
+ }
268
+ }
269
+
270
+ function checkRhythmguardMotionConfig(audit) {
271
+ if (audit.includeMotion === undefined) {
272
+ skip('motion audit check skipped (not configured)');
273
+ return;
274
+ }
275
+
276
+ if (typeof audit.includeMotion === 'boolean') {
277
+ pass(`motion audit config valid (${audit.includeMotion})`);
278
+ } else {
279
+ fail('audit.includeMotion must be a boolean', 'Use true or false');
280
+ }
281
+ }
282
+
283
+ function checkRhythmguardBaseline(audit) {
284
+ const baselinePath = path.resolve(cwd, audit.baseline || '.rhythmguard-baseline.json');
285
+ if (!fs.existsSync(baselinePath)) {
286
+ skip('baseline freshness check skipped (baseline not found)');
287
+ return;
288
+ }
289
+
290
+ try {
291
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
292
+ if (!Array.isArray(baseline.findings)) {
293
+ fail('baseline file does not include findings array', 'Regenerate it with rhythmguard audit --write-baseline');
294
+ return;
295
+ }
296
+ pass(`baseline file readable (${path.relative(cwd, baselinePath)})`);
297
+ } catch {
298
+ fail('baseline file is not valid JSON', 'Regenerate it with rhythmguard audit --write-baseline');
299
+ }
300
+ }
301
+
208
302
  function run() {
209
303
  process.stdout.write('\nRhythmguard Doctor\n\n');
210
304
 
@@ -214,6 +308,7 @@ function run() {
214
308
  checkTokenPattern(configContent);
215
309
  checkTailwindConfig(configContent);
216
310
  checkCustomSyntax(configContent);
311
+ checkRhythmguardConfig();
217
312
 
218
313
  process.stdout.write('\n');
219
314
 
package/src/cli/index.js CHANGED
@@ -27,7 +27,7 @@ if (!command || command === '--help' || command === '-h') {
27
27
  }
28
28
 
29
29
  if (command === 'audit') {
30
- require('./audit');
30
+ require('./audit').run();
31
31
  } else if (command === 'init') {
32
32
  require('./init');
33
33
  } else if (command === 'doctor') {
package/src/index.js CHANGED
@@ -25,3 +25,4 @@ module.exports.configs = {
25
25
  };
26
26
  module.exports.eslint = require('./eslint');
27
27
  module.exports.presets = require('./presets');
28
+ module.exports.audit = require('./audit');
package/src/index.mjs CHANGED
@@ -8,3 +8,4 @@ export const rules = plugin.rules;
8
8
  export const configs = plugin.configs;
9
9
  export const presets = plugin.presets;
10
10
  export const eslint = plugin.eslint;
11
+ export const audit = plugin.audit;