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/CHANGELOG.md +24 -0
- package/README.md +85 -2
- package/package.json +13 -1
- package/src/audit/index.js +17 -0
- package/src/audit/index.mjs +11 -0
- package/src/cli/audit.js +476 -115
- package/src/cli/doctor.js +95 -0
- package/src/cli/index.js +1 -1
- package/src/configs/motion.js +13 -0
- package/src/configs/motion.mjs +4 -0
- package/src/eslint/index.js +3 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +116 -0
- package/src/index.js +5 -1
- package/src/index.mjs +1 -0
- package/src/rules/use-motion-scale/index.js +245 -0
- package/src/rules/use-motion-scale/index.mjs +7 -0
- package/src/utils/constants.js +4 -0
- package/src/utils/tailwind-motion-analysis.js +174 -0
- package/src/utils/time.js +89 -0
- package/src/utils/token-sources.js +3 -1
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>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
process.exit(1);
|
|
521
|
+
throw new Error(`Directory not found: ${dir}`);
|
|
487
522
|
}
|
|
488
523
|
|
|
489
524
|
if (!fs.statSync(resolvedDir).isDirectory()) {
|
|
490
|
-
|
|
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:
|
|
920
|
-
value:
|
|
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:
|
|
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
|
|
1832
|
-
|
|
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
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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
|
-
|
|
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
|
|
2029
|
+
const lintOptions = {
|
|
1874
2030
|
baseFontSize: parsed.baseFontSize,
|
|
2031
|
+
includeMotion: parsed.includeMotion,
|
|
1875
2032
|
scale: parsed.scale,
|
|
1876
2033
|
};
|
|
1877
2034
|
|
|
1878
|
-
|
|
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
|
|
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,
|
|
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, '&')
|
|
2215
|
+
.replace(/</g, '<')
|
|
2216
|
+
.replace(/>/g, '>')
|
|
2217
|
+
.replace(/"/g, '"');
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function run() {
|
|
2221
|
+
let parsed;
|
|
1908
2222
|
try {
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|