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 +15 -0
- package/README.md +41 -1
- package/package.json +5 -1
- package/src/audit/index.js +17 -0
- package/src/audit/index.mjs +11 -0
- package/src/cli/audit.js +287 -92
- package/src/cli/doctor.js +95 -0
- package/src/cli/index.js +1 -1
- package/src/index.js +1 -0
- package/src/index.mjs +1 -0
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,
|
|
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": "
|
|
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>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
process.exit(1);
|
|
521
|
+
throw new Error(`Directory not found: ${dir}`);
|
|
497
522
|
}
|
|
498
523
|
|
|
499
524
|
if (!fs.statSync(resolvedDir).isDirectory()) {
|
|
500
|
-
|
|
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:
|
|
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
|
|
1989
|
-
|
|
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
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
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
|
|
2029
|
+
const lintOptions = {
|
|
2031
2030
|
baseFontSize: parsed.baseFontSize,
|
|
2032
2031
|
includeMotion: parsed.includeMotion,
|
|
2033
2032
|
scale: parsed.scale,
|
|
2034
2033
|
};
|
|
2035
2034
|
|
|
2036
|
-
|
|
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,
|
|
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,
|
|
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, '&')
|
|
2215
|
+
.replace(/</g, '<')
|
|
2216
|
+
.replace(/>/g, '>')
|
|
2217
|
+
.replace(/"/g, '"');
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function run() {
|
|
2221
|
+
let parsed;
|
|
2074
2222
|
try {
|
|
2075
|
-
|
|
2076
|
-
|
|
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
|
-
|
|
2080
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/index.js
CHANGED
package/src/index.mjs
CHANGED