stylelint-plugin-rhythmguard 1.8.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/src/cli/audit.js CHANGED
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
 
7
7
  const { formatLength } = require('../utils/length');
8
8
  const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
9
+ const { createTailwindMotionAnalyzer } = require('../utils/tailwind-motion-analysis');
10
+ const { formatTime } = require('../utils/time');
9
11
  const {
10
12
  addDefinition,
11
13
  createTokenKindMatcher,
@@ -23,8 +25,9 @@ const DEFAULT_BASE_FONT_SIZE = 16;
23
25
  const DEFAULT_BASELINE_PATH = '.rhythmguard-baseline.json';
24
26
  const DEFAULT_CONFIG_PATH = '.rhythmguardrc.json';
25
27
  const DEFAULT_IGNORE_PATH = '.rhythmguardignore';
28
+ const DEFAULT_AUDIT_TOKEN_PATTERN = '^--(space|spacing)-';
26
29
  const DEFAULT_TOKEN_CANDIDATE_MIN_COUNT = 2;
27
- const VALID_FORMATS = new Set(['text', 'json', 'markdown']);
30
+ const VALID_FORMATS = new Set(['text', 'json', 'json-v1', 'markdown', 'html']);
28
31
  const SKIP_DIRS = new Set([
29
32
  '.git',
30
33
  '.next',
@@ -54,9 +57,11 @@ const TEMPLATE_EXTENSIONS = new Set([
54
57
  const HELP = `Usage: rhythmguard audit <dir> [options]
55
58
 
56
59
  Options:
57
- --format <text|json|markdown> Output format (default: text)
60
+ --format <text|json|json-v1|markdown|html> Output format (default: text)
58
61
  --json Alias for --format json
59
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
60
65
  --config <file> Load audit config (default: .rhythmguardrc.json when present)
61
66
  --no-config Ignore .rhythmguardrc.json discovery
62
67
  --ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
@@ -69,39 +74,17 @@ Options:
69
74
  --min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
70
75
  --since <git-ref> Scan only changed files since a git ref
71
76
  --staged Scan only staged files
77
+ --include-motion Include opt-in motion duration/easing drift
72
78
  --token-source <file> External token source (repeatable, comma-separated)
73
79
  --token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
74
- --token-kind <kind> Token kind: spacing, radius, typography, size, all (default: spacing)
80
+ --token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
75
81
  --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
76
82
  --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
77
83
  --base-font-size <number> px base for rem/em conversion (default: 16)
78
84
  `;
79
85
 
80
86
  function parseArgs(argv) {
81
- const parsed = {
82
- baselinePath: DEFAULT_BASELINE_PATH,
83
- baseFontSize: DEFAULT_BASE_FONT_SIZE,
84
- cliOptions: new Set(),
85
- configExplicit: false,
86
- configPath: DEFAULT_CONFIG_PATH,
87
- dir: null,
88
- failOnNewDrift: false,
89
- format: 'text',
90
- ignorePath: DEFAULT_IGNORE_PATH,
91
- ignorePatterns: [],
92
- maxFindings: null,
93
- minCleanliness: null,
94
- noConfig: false,
95
- scale: DEFAULT_SCALE,
96
- since: null,
97
- sinceBaseline: false,
98
- staged: false,
99
- tokenCandidateMinCount: DEFAULT_TOKEN_CANDIDATE_MIN_COUNT,
100
- tokenKind: 'spacing',
101
- tokenSourceFormat: 'auto',
102
- tokenSources: [],
103
- writeBaseline: false,
104
- };
87
+ const parsed = createDefaultAuditOptions();
105
88
 
106
89
  for (let index = 0; index < argv.length; index++) {
107
90
  const arg = argv[index];
@@ -111,6 +94,12 @@ function parseArgs(argv) {
111
94
  continue;
112
95
  }
113
96
 
97
+ if (arg === '--schema') {
98
+ parsed.schema = true;
99
+ parsed.format = 'json';
100
+ continue;
101
+ }
102
+
114
103
  if (arg === '--json') {
115
104
  parsed.format = 'json';
116
105
  continue;
@@ -121,6 +110,18 @@ function parseArgs(argv) {
121
110
  continue;
122
111
  }
123
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
+
124
125
  if (arg === '--ignore') {
125
126
  parsed.ignorePatterns.push(...parseIgnorePatterns(argv[++index]));
126
127
  parsed.cliOptions.add('ignore');
@@ -261,6 +262,12 @@ function parseArgs(argv) {
261
262
  continue;
262
263
  }
263
264
 
265
+ if (arg === '--include-motion') {
266
+ parsed.includeMotion = true;
267
+ parsed.cliOptions.add('includeMotion');
268
+ continue;
269
+ }
270
+
264
271
  if (arg === '--token-source') {
265
272
  parsed.tokenSources.push(...parseTokenSourcePaths(argv[++index]));
266
273
  parsed.cliOptions.add('tokenSources');
@@ -355,7 +362,7 @@ function parseArgs(argv) {
355
362
  }
356
363
 
357
364
  if (!VALID_FORMATS.has(parsed.format)) {
358
- 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.`);
359
366
  }
360
367
 
361
368
  if (parsed.since && parsed.staged) {
@@ -369,6 +376,36 @@ function parseArgs(argv) {
369
376
  return parsed;
370
377
  }
371
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
+
372
409
  function parsePathOption(raw, optionName) {
373
410
  if (!raw) {
374
411
  throw new Error(`Missing value for ${optionName}.`);
@@ -476,25 +513,27 @@ function parseBaseFontSize(raw) {
476
513
 
477
514
  function assertDirectory(dir) {
478
515
  if (!dir) {
479
- process.stderr.write(HELP);
480
- process.exit(1);
516
+ throw new Error('Missing audit directory.');
481
517
  }
482
518
 
483
519
  const resolvedDir = path.resolve(dir);
484
520
  if (!fs.existsSync(resolvedDir)) {
485
- process.stderr.write(`Directory not found: ${dir}\n`);
486
- process.exit(1);
521
+ throw new Error(`Directory not found: ${dir}`);
487
522
  }
488
523
 
489
524
  if (!fs.statSync(resolvedDir).isDirectory()) {
490
- process.stderr.write(`Not a directory: ${dir}\n`);
491
- process.exit(1);
525
+ throw new Error(`Not a directory: ${dir}`);
492
526
  }
493
527
 
494
528
  return resolvedDir;
495
529
  }
496
530
 
497
531
  function loadAuditConfig(parsed) {
532
+ parsed = {
533
+ ...createDefaultAuditOptions(),
534
+ ...parsed,
535
+ };
536
+
498
537
  if (parsed.noConfig) {
499
538
  return null;
500
539
  }
@@ -572,6 +611,7 @@ function applyAuditConfig(parsed, configResult) {
572
611
  applyConfigScalar(next, audit, cliOptions, 'tokenCandidateMinCount', 'tokenCandidateMinCount', parsePositiveInteger);
573
612
  applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
574
613
  applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
614
+ applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
575
615
  applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
576
616
  if (Array.isArray(value)) {
577
617
  return value;
@@ -591,6 +631,14 @@ function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, pars
591
631
  target[targetKey] = parser(audit[configKey], configKey);
592
632
  }
593
633
 
634
+ function parseBooleanOption(value, optionName) {
635
+ if (typeof value !== 'boolean') {
636
+ throw new Error(`${optionName} must be a boolean.`);
637
+ }
638
+
639
+ return value;
640
+ }
641
+
594
642
  function normalizeCliTokenSources(sources, format) {
595
643
  return sources.map((sourcePath) => ({
596
644
  baseDir: process.cwd(),
@@ -866,31 +914,41 @@ async function runStylelintAudit(cssFiles, options) {
866
914
  }
867
915
 
868
916
  const { default: stylelint } = await import('stylelint');
917
+ const rules = {
918
+ 'rhythmguard/use-scale': [
919
+ true,
920
+ {
921
+ baseFontSize: options.baseFontSize,
922
+ scale: options.scale,
923
+ severity: 'warning',
924
+ },
925
+ ],
926
+ 'rhythmguard/prefer-token': [
927
+ true,
928
+ {
929
+ baseFontSize: options.baseFontSize,
930
+ scale: options.scale,
931
+ severity: 'warning',
932
+ tokenMapFromCssCustomProperties: true,
933
+ tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
934
+ },
935
+ ],
936
+ };
937
+
938
+ if (options.includeMotion) {
939
+ rules['rhythmguard/use-motion-scale'] = [
940
+ true,
941
+ {
942
+ severity: 'warning',
943
+ },
944
+ ];
945
+ }
869
946
 
870
947
  const result = await stylelint.lint({
871
948
  files: cssFiles,
872
949
  config: {
873
950
  plugins: [pluginPath],
874
- rules: {
875
- 'rhythmguard/use-scale': [
876
- true,
877
- {
878
- baseFontSize: options.baseFontSize,
879
- scale: options.scale,
880
- severity: 'warning',
881
- },
882
- ],
883
- 'rhythmguard/prefer-token': [
884
- true,
885
- {
886
- baseFontSize: options.baseFontSize,
887
- scale: options.scale,
888
- severity: 'warning',
889
- tokenMapFromCssCustomProperties: true,
890
- tokenPattern: '^--spac(e|ing)-',
891
- },
892
- ],
893
- },
951
+ rules,
894
952
  },
895
953
  });
896
954
 
@@ -909,6 +967,12 @@ function collectCssFindings(fileResults) {
909
967
  const tokenMatch = text.match(
910
968
  /Unexpected raw scale value "([^"]+)"/,
911
969
  );
970
+ const motionDurationMatch = text.match(
971
+ /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
972
+ );
973
+ const motionEasingMatch = text.match(
974
+ /Unexpected raw motion easing "([^"]+)"/,
975
+ );
912
976
 
913
977
  findings.push({
914
978
  column: warning.column || 1,
@@ -916,8 +980,13 @@ function collectCssFindings(fileResults) {
916
980
  line: warning.line || 1,
917
981
  rule: warning.rule || 'rhythmguard',
918
982
  text,
919
- type: tokenMatch ? 'token-opportunity' : 'off-scale',
920
- value: tokenMatch ? tokenMatch[1] : offScaleMatch ? offScaleMatch[1] : null,
983
+ type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
984
+ value: getCssFindingValue({
985
+ motionDurationMatch,
986
+ motionEasingMatch,
987
+ offScaleMatch,
988
+ tokenMatch,
989
+ }),
921
990
  });
922
991
  }
923
992
  }
@@ -925,6 +994,47 @@ function collectCssFindings(fileResults) {
925
994
  return findings;
926
995
  }
927
996
 
997
+ function getCssFindingValue({
998
+ motionDurationMatch,
999
+ motionEasingMatch,
1000
+ offScaleMatch,
1001
+ tokenMatch,
1002
+ }) {
1003
+ if (tokenMatch) {
1004
+ return tokenMatch[1];
1005
+ }
1006
+
1007
+ if (offScaleMatch) {
1008
+ return offScaleMatch[1];
1009
+ }
1010
+
1011
+ if (motionDurationMatch) {
1012
+ return motionDurationMatch[1];
1013
+ }
1014
+
1015
+ if (motionEasingMatch) {
1016
+ return motionEasingMatch[1];
1017
+ }
1018
+
1019
+ return null;
1020
+ }
1021
+
1022
+ function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
1023
+ if (motionDurationMatch) {
1024
+ return 'motion-duration';
1025
+ }
1026
+
1027
+ if (motionEasingMatch) {
1028
+ return 'motion-easing';
1029
+ }
1030
+
1031
+ if (tokenMatch) {
1032
+ return 'token-opportunity';
1033
+ }
1034
+
1035
+ return 'off-scale';
1036
+ }
1037
+
928
1038
  function collectTailwindFindings(templateFiles, options) {
929
1039
  const analyzer = createTailwindClassAnalyzer(options);
930
1040
  const findings = [];
@@ -969,6 +1079,66 @@ function collectTailwindFindings(templateFiles, options) {
969
1079
  return findings;
970
1080
  }
971
1081
 
1082
+ function collectTailwindMotionFindings(templateFiles, options) {
1083
+ if (!options.includeMotion) {
1084
+ return [];
1085
+ }
1086
+
1087
+ const analyzer = createTailwindMotionAnalyzer(options);
1088
+ const findings = [];
1089
+
1090
+ for (const filePath of templateFiles) {
1091
+ let source = '';
1092
+ try {
1093
+ source = fs.readFileSync(filePath, 'utf8');
1094
+ } catch {
1095
+ continue;
1096
+ }
1097
+
1098
+ const lineStarts = getLineStarts(source);
1099
+
1100
+ for (const literal of findStringLiterals(source)) {
1101
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
1102
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
1103
+ findings.push({
1104
+ column: position.column,
1105
+ file: formatPath(filePath),
1106
+ fixedToken: analysis.fixedToken,
1107
+ line: position.line,
1108
+ nearest: analysis.nearest
1109
+ ? {
1110
+ lower: formatTime(analysis.nearest.lower, 'ms'),
1111
+ upper: formatTime(analysis.nearest.upper, 'ms'),
1112
+ }
1113
+ : null,
1114
+ rawValue: analysis.rawValue,
1115
+ rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
1116
+ text: buildTailwindMotionFindingText(segment.token, analysis),
1117
+ token: segment.token,
1118
+ type: analysis.reason === 'easing'
1119
+ ? 'tailwind-motion-easing'
1120
+ : 'tailwind-motion-duration',
1121
+ utility: analysis.utility,
1122
+ });
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ return findings;
1128
+ }
1129
+
1130
+ function buildTailwindMotionFindingText(token, analysis) {
1131
+ if (analysis.reason === 'easing') {
1132
+ return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
1133
+ }
1134
+
1135
+ if (analysis.reason === 'negative') {
1136
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
1137
+ }
1138
+
1139
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
1140
+ }
1141
+
972
1142
  function findStringLiterals(source) {
973
1143
  const literals = [];
974
1144
  const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
@@ -1250,6 +1420,8 @@ function buildReport({
1250
1420
  cssFindings,
1251
1421
  dir,
1252
1422
  externalTokenDefinitions,
1423
+ includeMotion,
1424
+ motionFindings,
1253
1425
  scanScope,
1254
1426
  templateFiles,
1255
1427
  tailwindFindings,
@@ -1266,17 +1438,21 @@ function buildReport({
1266
1438
  .map((finding) => finding.value));
1267
1439
  const tailwindArbitraryValues = countByValue(tailwindFindings
1268
1440
  .map((finding) => finding.rawValue));
1441
+ const motionValues = countByValue(motionFindings
1442
+ .map((finding) => finding.value || finding.rawValue));
1269
1443
  const issueFiles = new Set([
1270
1444
  ...cssFindings.map((finding) => finding.file),
1445
+ ...motionFindings.map((finding) => finding.file),
1271
1446
  ...tailwindFindings.map((finding) => finding.file),
1272
1447
  ]);
1273
1448
  const topAffectedFiles = sortCountMap(countByValue([
1274
1449
  ...cssFindings.map((finding) => finding.file),
1450
+ ...motionFindings.map((finding) => finding.file),
1275
1451
  ...tailwindFindings.map((finding) => finding.file),
1276
1452
  ])).slice(0, 10);
1277
1453
 
1278
1454
  const totalFiles = cssFiles.length + templateFiles.length;
1279
- const totalWarnings = cssFindings.length + tailwindFindings.length;
1455
+ const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
1280
1456
  const filesWithIssues = issueFiles.size;
1281
1457
  const scaleCleanliness = totalFiles > 0
1282
1458
  ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
@@ -1299,9 +1475,15 @@ function buildReport({
1299
1475
  filesWithIssues,
1300
1476
  findings: {
1301
1477
  css: cssFindings,
1478
+ motion: motionFindings,
1302
1479
  tailwind: tailwindFindings,
1303
1480
  },
1304
- formatVersion: 4,
1481
+ formatVersion: 5,
1482
+ motion: {
1483
+ enabled: includeMotion,
1484
+ findings: motionFindings.length,
1485
+ values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
1486
+ },
1305
1487
  offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
1306
1488
  scaleCleanliness,
1307
1489
  scanScope,
@@ -1313,6 +1495,7 @@ function buildReport({
1313
1495
  summary: {
1314
1496
  cssWarnings: cssFindings.length,
1315
1497
  filesWithIssues,
1498
+ motionFindings: motionFindings.length,
1316
1499
  rawValueMatches: tokenContract.summary.rawValueMatches,
1317
1500
  missingTokens: tokenContract.summary.missingTokens,
1318
1501
  rawValueCandidates: tokenContract.summary.rawValueCandidates,
@@ -1405,6 +1588,7 @@ function writeBaseline(report, baselinePath) {
1405
1588
  function getAllFindings(report) {
1406
1589
  return [
1407
1590
  ...report.findings.css,
1591
+ ...report.findings.motion,
1408
1592
  ...report.findings.tailwind,
1409
1593
  ];
1410
1594
  }
@@ -1470,6 +1654,7 @@ function renderText(report) {
1470
1654
  appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
1471
1655
  appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
1472
1656
  appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
1657
+ appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
1473
1658
  appendTokenContractText(lines, report.tokenContract);
1474
1659
  appendBaselineText(lines, report);
1475
1660
 
@@ -1622,6 +1807,7 @@ function renderMarkdown(report) {
1622
1807
  appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
1623
1808
  appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
1624
1809
  appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
1810
+ appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
1625
1811
  appendTokenContractMarkdown(lines, report.tokenContract);
1626
1812
  appendBaselineMarkdown(lines, report);
1627
1813
 
@@ -1828,76 +2014,49 @@ function formatPath(filePath) {
1828
2014
  return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
1829
2015
  }
1830
2016
 
1831
- async function run() {
1832
- let parsed;
1833
- try {
1834
- parsed = parseArgs(args);
1835
- } catch (err) {
1836
- process.stderr.write(`${err.message}\n\n${HELP}`);
1837
- process.exit(1);
1838
- }
1839
-
1840
- if (parsed.help) {
1841
- process.stdout.write(HELP);
1842
- return;
1843
- }
1844
-
1845
- try {
1846
- parsed = applyAuditConfig(parsed, loadAuditConfig(parsed));
1847
- } catch (err) {
1848
- process.stderr.write(`${err.message}\n`);
1849
- process.exit(1);
1850
- }
1851
-
2017
+ async function createAuditReport(options) {
2018
+ const parsed = normalizeCreateAuditOptions(options);
1852
2019
  const resolvedDir = assertDirectory(parsed.dir);
1853
2020
  let ignorePatterns;
1854
- try {
1855
- ignorePatterns = [
1856
- ...loadIgnorePatterns(parsed.ignorePath),
1857
- ...parsed.ignorePatterns,
1858
- ];
1859
- } catch (err) {
1860
- process.stderr.write(`${err.message}\n`);
1861
- process.exit(1);
1862
- }
2021
+ ignorePatterns = [
2022
+ ...loadIgnorePatterns(parsed.ignorePath),
2023
+ ...parsed.ignorePatterns,
2024
+ ];
1863
2025
 
1864
- let scanFiles;
1865
- try {
1866
- scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
1867
- } catch (err) {
1868
- process.stderr.write(`${err.message}\n`);
1869
- process.exit(1);
1870
- }
2026
+ const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
1871
2027
 
1872
2028
  const { cssFiles, scanScope, templateFiles } = scanFiles;
1873
- const options = {
2029
+ const lintOptions = {
1874
2030
  baseFontSize: parsed.baseFontSize,
2031
+ includeMotion: parsed.includeMotion,
1875
2032
  scale: parsed.scale,
1876
2033
  };
1877
2034
 
1878
- let cssResults;
1879
- try {
1880
- cssResults = await runStylelintAudit(cssFiles, options);
1881
- } catch (err) {
1882
- process.stderr.write(`Lint error: ${err.message}\n`);
1883
- process.exit(1);
1884
- }
2035
+ const cssResults = await runStylelintAudit(cssFiles, lintOptions);
1885
2036
 
1886
2037
  const tokenSourceResult = parseTokenSources({
1887
2038
  baseFontSize: parsed.baseFontSize,
1888
2039
  sources: parsed.tokenSources,
1889
2040
  tokenKind: parsed.tokenKind,
1890
2041
  });
2042
+ const stylelintFindings = collectCssFindings(cssResults);
2043
+ const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
2044
+ const motionFindings = [
2045
+ ...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
2046
+ ...collectTailwindMotionFindings(templateFiles, lintOptions),
2047
+ ];
1891
2048
 
1892
2049
  const report = buildReport({
1893
2050
  baseFontSize: parsed.baseFontSize,
1894
2051
  config: parsed.config,
1895
2052
  cssFiles,
1896
- cssFindings: collectCssFindings(cssResults),
2053
+ cssFindings,
1897
2054
  dir: parsed.dir,
1898
2055
  externalTokenDefinitions: tokenSourceResult.definitions,
2056
+ includeMotion: parsed.includeMotion,
2057
+ motionFindings,
1899
2058
  scanScope,
1900
- tailwindFindings: collectTailwindFindings(templateFiles, options),
2059
+ tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
1901
2060
  templateFiles,
1902
2061
  tokenCandidateMinCount: parsed.tokenCandidateMinCount,
1903
2062
  tokenKind: parsed.tokenKind,
@@ -1905,14 +2064,181 @@ async function run() {
1905
2064
  tokenSourceWarnings: tokenSourceResult.warnings,
1906
2065
  });
1907
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;
1908
2222
  try {
1909
- if (parsed.sinceBaseline) {
1910
- applyBaselineComparison(report, parsed.baselinePath);
1911
- }
2223
+ parsed = parseArgs(args);
2224
+ } catch (err) {
2225
+ process.stderr.write(`${err.message}\n\n${HELP}`);
2226
+ process.exit(1);
2227
+ }
1912
2228
 
1913
- if (parsed.writeBaseline) {
1914
- writeBaseline(report, parsed.baselinePath);
1915
- }
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);
1916
2242
  } catch (err) {
1917
2243
  process.stderr.write(`${err.message}\n`);
1918
2244
  process.exit(1);
@@ -1921,21 +2247,44 @@ async function run() {
1921
2247
  const auditFailures = getAuditFailures(report, parsed);
1922
2248
 
1923
2249
  if (parsed.format === 'json') {
1924
- 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);
1925
2257
  finish(auditFailures);
1926
2258
  return;
1927
2259
  }
1928
2260
 
1929
2261
  if (parsed.format === 'markdown') {
1930
- 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);
1931
2269
  finish(auditFailures);
1932
2270
  return;
1933
2271
  }
1934
2272
 
1935
- process.stdout.write(renderText(report));
2273
+ writeOutput(renderText(report), parsed.outputPath);
1936
2274
  finish(auditFailures);
1937
2275
  }
1938
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
+
1939
2288
  function finish(auditFailures) {
1940
2289
  if (auditFailures.length === 0) {
1941
2290
  return;
@@ -1945,4 +2294,16 @@ function finish(auditFailures) {
1945
2294
  process.exitCode = 1;
1946
2295
  }
1947
2296
 
1948
- 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
+ }