stylelint-plugin-rhythmguard 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +19 -3
  3. package/README.md +2 -1
  4. package/package.json +9 -8
  5. package/src/audit/args.js +103 -307
  6. package/src/audit/config.js +1 -1
  7. package/src/audit/contract.js +6 -28
  8. package/src/audit/index.js +4 -7
  9. package/src/audit/render-markdown.js +3 -0
  10. package/src/audit/render-text.js +2 -1
  11. package/src/audit/report.js +26 -19
  12. package/src/audit/scan/files.js +262 -0
  13. package/src/audit/scan/stylesheets.js +275 -0
  14. package/src/audit/scan/templates.js +175 -0
  15. package/src/cli/doctor.js +1 -1
  16. package/src/cli/quickstart.js +5 -2
  17. package/src/{utils → core}/length.js +24 -1
  18. package/src/{utils → core}/options.js +32 -108
  19. package/src/core/scale-inference.js +560 -0
  20. package/src/{utils → core}/token-packages.json +7 -0
  21. package/src/{utils → core}/token-sources.js +155 -17
  22. package/src/{utils/value-utils.js → core/value-nodes.js} +1 -17
  23. package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -2
  24. package/src/eslint/rules/tailwind-class-use-scale.js +2 -2
  25. package/src/rules/no-offscale-transform/index.js +22 -91
  26. package/src/rules/prefer-token/index.js +15 -73
  27. package/src/rules/report.js +75 -0
  28. package/src/rules/use-motion-scale/index.js +25 -42
  29. package/src/rules/use-scale/index.js +21 -94
  30. package/src/rules/validate.js +132 -0
  31. package/types/audit.d.ts +11 -0
  32. package/src/audit/scan.js +0 -676
  33. package/src/utils/scale-inference.js +0 -351
  34. /package/src/{utils/constants.js → core/css-vocabulary.js} +0 -0
  35. /package/src/{utils → core}/tailwind-class-analysis.js +0 -0
  36. /package/src/{utils → core}/tailwind-motion-analysis.js +0 -0
  37. /package/src/{utils → core}/time.js +0 -0
  38. /package/src/{utils → core}/token-map.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.4.0] - 2026-09-06
10
+
11
+ ### Fixed
12
+
13
+ - The Sass token evaluator bounds map nesting (16 levels) and expression nesting (64 levels). Generated or adversarial input past those limits is skipped like any other expression it cannot evaluate, instead of overflowing the stack and aborting the audit.
14
+ - `scale: "auto"` prefers root-level declarations. When the custom properties in `:root`, `html`, `:host` or `@theme` (through conditional at-rules) form a scale on their own, variables declared inside component selectors no longer join it. Mantine now infers exactly its `--mantine-spacing-*` ladder instead of a mix with `--chip-spacing`. When the root carries no scale, every declaration still counts. ([#54](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/54))
15
+ - `postcss-value-parser` is declared as a runtime dependency. The rules require it at runtime but it was only a dev dependency, resolved through Stylelint's hoisted copy; strict package managers could not install the plugin without it.
16
+
17
+ ### Changed
18
+
19
+ - Internal architecture, no behaviour change (the 57-repository benchmark check shows zero moved findings): `src/utils` is now `src/core` with domain-named modules; Stylelint-coupled code lives in `src/rules/report.js` and `src/rules/validate.js`; the four rules are assembled from shared primitives (`fixedLengthValue`, `createPropertyScaleResolver`, `withResolvedScale`, `reportValueNode`); the audit uses the same CSS token scanner as token sources; project discovery for `scale: "auto"` is cached per working directory and revalidated by file mtime; audit CLI flags are one option table from which `--help` is rendered. Executable rules for layer direction, cycles, dependency declaration and CJS/ESM parity live under `test/contracts/`. The map is [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
20
+ - `src/audit/scan.js` (676 lines) is three modules by responsibility: `scan/files.js`, `scan/stylesheets.js`, `scan/templates.js`.
21
+ - Tests are grouped by what they prove: `test/{unit,rules,cli,api,contracts,compat,bench}`.
22
+ - CI and benchmark workflows cancel superseded runs on the same ref, skip docs-only changes, and can be dispatched by hand.
23
+
24
+ ## [3.3.0] - 2026-09-06
25
+
26
+ ### Fixed
27
+
28
+ - Whole numbers ending in zero were printed without their zeros: a scale containing `30px` and `60px` produced messages such as `nearest: 3px or 6px`, autofix wrote `3px` for a value snapped to `30px`, and scale inference added phantom small steps (`3px`, `6px`) to every scale that contained `30px` or `60px`. This affected all three Stylelint rules, the ESLint rule's suggested classes, the audit and `scale: "auto"`. Found while reading why GOV.UK's scale came out as `1, 2, 3, 4, 5, 6, 10, 15, ...`. If you use a scale with round-ten values, rerun `--fix` on files that were fixed with an earlier version.
29
+ - `scale: "auto"` accepts tokens named `spacer`, so PatternFly's `--pf-t--global--spacer--*` ladder is read instead of falling back. ([#85](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/85))
30
+ - Token-package discovery reads `@carbon/layout`, where Carbon defines `$spacing-01` to `$spacing-13`. `.scss` files are accepted as token sources, in `scaleSources` and `--token-source` too. ([#87](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/87))
31
+ - `scale: "auto"` and `--scale auto` reject an inferred scale that does not look like one: fewer than three steps, fractional values, no common step (2, 3, 4, 5 or 8), or, in the audit, tokens that come mostly from component files without a near-perfect ladder. The rule then uses the preset and says why in its first message; the audit reports `contracts.scale.rejected` with the source, values and reasons, and the Markdown and text output show it next to the scale source. Component-local variables such as `--chip-spacing: 3px` can no longer become the project scale. ([#88](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/88))
32
+ - Sass spacing maps with one namespace segment before the anchor, such as GOV.UK's `$govuk-spacing-points` and NHS.UK's `$nhsuk-spacing-points`, are read when they hold at least four distinct lengths. Namespaced scalars such as `$dropdown-spacer` and small maps stay excluded, and unitless Sass numbers (mixin parameter defaults such as `$spacing-responsive: 6`, multipliers such as `$spacing-unit: 8`) are no longer taken as lengths. ([#86](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/86))
33
+ - When several Tailwind v4 `--spacing` bases are found, only the first is expanded. A union of ladders from several theme files produced fractional steps that matched nothing on shadcn/ui. ([#89](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/89))
34
+
35
+ ### Changed
36
+
37
+ - The quiet benchmark manifest grew from 20 to 57 repositories: design systems (GOV.UK, NHS.UK, PatternFly, Cloudscape, Angular Material, Ionic, Element Plus, HashiCorp, SAP Fundamental Styles and UI5 Web Components, daisyUI, Skeleton, Semi, n8n, Utrecht, Mozilla Protocol, Foundation, CoreUI, Tabler, AdminLTE, Materialize, Pure, Tachyons) and CSS-heavy applications (Mastodon already; now Forem, Discourse, Zulip, Mattermost, Grafana, Metabase, Directus, Excalidraw, Ghost, Cal.com, VS Code base UI, Docusaurus, VitePress and Starlight themes). Every repository has a pinned snapshot and the State of Spacing edition is regenerated over the full set.
38
+ - `npm run bench:outreach` drafts one audit issue per benchmark repository under `docs/outreach/audits/`; a separate paced posting script runs a per-repository preflight and records every outcome in `docs/outreach/embed-log.md`.
39
+
9
40
  ## [3.2.0] - 2026-09-06
10
41
 
11
42
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -10,7 +10,7 @@ Thank you for being here. Rhythmguard is a small, opinionated tool with one job:
10
10
 
11
11
  **Review a drift list.** Each benchmark repo has a list of findings that heuristics call real drift. Labelling them as real or as an allowance in `benchmarks/quiet/labels/<repo>.json` with a one-line reason turns a heuristic number into a reviewed one, and any systematic pattern you find becomes a candidate default.
12
12
 
13
- **Teach inference a new token source.** Sass variables and maps, token packages, framework theme files. If your scale lives somewhere the tool cannot see, the fix is a parser in `src/utils/token-sources.js` and a test.
13
+ **Teach inference a new token source.** Sass variables and maps, token packages, framework theme files. If your scale lives somewhere the tool cannot see, the fix is a parser in `src/core/token-sources.js` and a test.
14
14
 
15
15
  **Improve a rule page.** Every rule has a page under `docs/rules/`. A clearer example or a missing edge case is a welcome PR.
16
16
 
@@ -27,7 +27,19 @@ npm ci
27
27
  npm test
28
28
  ```
29
29
 
30
- Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` and take about five seconds.
30
+ Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` and take about ten seconds. They are grouped by what they prove:
31
+
32
+ | Folder | Proves | Run one |
33
+ | --- | --- | --- |
34
+ | `test/unit/` | a core primitive (lengths, options, token sources, inference cache) | `node --test test/unit/length-format.test.js` |
35
+ | `test/rules/` | a Stylelint or ESLint rule end to end through the real linter | `node --test test/rules/use-scale.test.js` |
36
+ | `test/cli/` | a command as a user runs it, in a temp directory | `node --test test/cli/audit-cli.test.js` |
37
+ | `test/api/` | the programmatic audit API | |
38
+ | `test/contracts/` | what must stay true for consumers: package exports, CJS/ESM parity, layer rules, rule docs | `node --test test/contracts/architecture.test.js` |
39
+ | `test/compat/` | the Stylelint 16.0.0 floor | `npm run test:compat-floor` |
40
+ | `test/bench/` | the benchmark and report scripts | |
41
+
42
+ Put a new test where its subject lives. If it needs the real linter, it is a rule test; if it needs a temp directory and `spawnSync`, it is a CLI test; if it asserts something about the repository itself, it is a contract.
31
43
 
32
44
  ## The local gate
33
45
 
@@ -36,7 +48,7 @@ Run these before you push. They are what CI runs, so a green local run means a g
36
48
  ```bash
37
49
  npm run lint # eslint
38
50
  npm run typecheck # the published TypeScript declarations against a consumer file and the examples
39
- npm test # 150+ tests, Stylelint 16
51
+ npm test # 200+ tests, Stylelint 16
40
52
  ```
41
53
 
42
54
  Optional, depending on what you touched:
@@ -54,6 +66,10 @@ If your shell's npm registry is overridden by a corporate `.npmrc`, add `--regis
54
66
 
55
67
  CI for this repository runs on self-hosted runners for pushes and same-repo branches. For pull requests from forks a separate job runs on GitHub-hosted runners with Node 22 and current Stylelint 16, covering lint, typecheck and the test suite. You will see its result on your PR. The full matrix and the benchmark run after merge.
56
68
 
69
+ ## Where things live
70
+
71
+ [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) is the map: the layers under `src/`, which may depend on which, the primitives a rule is assembled from, and the invariants with the test that enforces each. Read it before a change that adds a module, and run `node --test test/contracts/architecture.test.js` after; it fails when a dependency crosses a layer line.
72
+
57
73
  ## How we write changes
58
74
 
59
75
  **Tests first.** Every behaviour change starts with a failing test. If you send a fix without one, expect the review to ask for it; the test is what proves the fix is the right one and keeps it that way.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=5" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
2
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=7" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
3
3
  </p>
4
4
 
5
5
  # stylelint-plugin-rhythmguard
@@ -97,6 +97,7 @@ A README badge comes from the same audit: `--format badge` writes a shields.io e
97
97
  - [For coding agents](docs/FOR_AGENTS.md): a paste-ready `AGENTS.md` block, installable with `npx rhythmguard init --agents all` for Claude Code, Cursor and Copilot
98
98
  - [Quiet benchmark](docs/QUIET_BENCHMARK.md): findings on public design systems, checked on every change
99
99
  - [State of Spacing](docs/STATE_OF_SPACING.md): dated editions of the same data, ranked by drift density, with the values and properties that drifted
100
+ - [Architecture](docs/ARCHITECTURE.md): the layers, the rule kit, the invariants and where each is enforced
100
101
  - [Product direction](docs/STRATEGY_2026-09.md)
101
102
  - Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/)
102
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -108,15 +108,16 @@
108
108
  "lint": "eslint .",
109
109
  "scales:add": "node scripts/scales/add-scale.mjs",
110
110
  "scales:validate": "node scripts/scales/validate-community-scales.mjs",
111
- "test": "node --test test/*.test.js",
112
- "test:compat-floor": "node --test test/floor-compat.test.js",
113
- "test:coverage": "c8 --reporter=text --reporter=lcov node --test test/*.test.js",
111
+ "test": "node scripts/test.mjs",
112
+ "test:compat-floor": "node --test test/compat/floor-compat.test.js",
113
+ "test:coverage": "c8 --reporter=text --reporter=lcov node scripts/test.mjs",
114
114
  "test:npm-smoke": "node scripts/ci/npm-registry-smoke.mjs --package stylelint-plugin-rhythmguard --version latest",
115
115
  "test:pack-smoke": "node scripts/ci/pack-smoke.mjs",
116
- "test:watch": "node --test --watch test/*.test.js",
116
+ "test:watch": "node scripts/test.mjs --watch",
117
117
  "typecheck": "tsc -p tsconfig.typecheck.json",
118
118
  "build:agents": "node scripts/build-agents.mjs",
119
- "bench:state-of-spacing": "node scripts/bench/state-of-spacing.mjs"
119
+ "bench:state-of-spacing": "node scripts/bench/state-of-spacing.mjs",
120
+ "bench:outreach": "node scripts/bench/outreach.mjs"
120
121
  },
121
122
  "repository": {
122
123
  "type": "git",
@@ -145,7 +146,8 @@
145
146
  }
146
147
  },
147
148
  "dependencies": {
148
- "known-css-properties": "^0.37.0"
149
+ "known-css-properties": "^0.37.0",
150
+ "postcss-value-parser": "^4.2.0"
149
151
  },
150
152
  "devDependencies": {
151
153
  "@eslint/js": "^9.39.5",
@@ -153,7 +155,6 @@
153
155
  "c8": "^10.1.3",
154
156
  "eslint": "^9.39.5",
155
157
  "postcss-scss": "^4.0.9",
156
- "postcss-value-parser": "^4.2.0",
157
158
  "stylelint": "^16.15.0",
158
159
  "stylelint-config-tailwindcss": "^1.0.1",
159
160
  "stylelint-plugin-logical-css": "^2.0.2",
package/src/audit/args.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const {
4
4
  normalizeTokenKind,
5
5
  normalizeTokenSourceFormat,
6
- } = require('../utils/token-sources');
6
+ } = require('../core/token-sources');
7
7
  const { BADGE_METRICS } = require('./render-badge');
8
8
  const {
9
9
  VALID_FORMATS,
@@ -11,325 +11,120 @@ const {
11
11
  normalizeIgnorePattern,
12
12
  } = require('./shared');
13
13
 
14
- const HELP = `Usage: rhythmguard audit <dir> [options]
15
-
16
- Options:
17
- --format <text|json|json-v1|markdown|html|github|badge> Output format (default: text)
18
- github = GitHub Actions workflow-command annotations
19
- badge = shields.io endpoint JSON for a README badge
20
- --badge-metric <drift|findings> Badge value: drift percent or off-scale count (default: drift)
21
- --json Alias for --format json
22
- --markdown Alias for --format markdown
23
- --schema Print the audit JSON schema and exit
24
- --output <file> Write json, markdown, html or badge output to a file
25
- --config <file> Load audit config (default: .rhythmguardrc.json when present)
26
- --no-config Ignore .rhythmguardrc.json discovery
27
- --ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
28
- --ignore-path <file> Load ignore patterns from file (default: .rhythmguardignore when present)
29
- --baseline <file> Baseline file path (default: .rhythmguard-baseline.json)
30
- --write-baseline [file] Write current findings as a baseline
31
- --since-baseline [file] Compare current findings against a baseline
32
- --fail-on-new-drift Exit 1 when --since-baseline finds new drift
33
- --max-findings <number> Exit 1 when total findings exceed this count
34
- --min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
35
- --since <git-ref> Scan only changed files since a git ref
36
- --staged Scan only staged files
37
- --include-motion Include opt-in motion duration/easing drift
38
- --token-source <file> External token source (repeatable, comma-separated)
39
- --token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
40
- --token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
41
- --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
42
- --scale <values|auto> Comma-separated scale values (default: 0,4,8,12,16,24,32);
43
- auto infers the scale from token sources, then --space-*/--spacing-*
44
- custom properties and Sass variables in scanned CSS/SCSS, then
45
- installed design-token packages, and reports where it came from
46
- --base-font-size <number> px base for rem/em conversion (default: 16)
47
-
48
- Scans .css files, and .scss files when postcss-scss is installed. Reports drift by
49
- value, by property and by file; text output prints histograms, markdown is PR-ready,
50
- json is the stable 2.0 contract.
51
- `;
14
+ /**
15
+ * Every audit flag is one entry here. `kind` says whether the flag takes a
16
+ * value (`value`), may take one (`optional`: the next argument is consumed
17
+ * unless it starts with a dash), or is a switch (`flag`). `apply` writes the
18
+ * parsed result; `cliOptions` records which keys the command line set so the
19
+ * config file loader can tell an explicit value from a default. The help text
20
+ * is rendered from this table, so a flag cannot exist without documentation.
21
+ */
22
+ const setFrom = (parsed, key) => parsed.cliOptions.add(key);
23
+
24
+ const OPTIONS = [
25
+ { flag: '--format', kind: 'value', value: '<text|json|json-v1|markdown|html|github|badge>', help: ['Output format (default: text)', 'github = GitHub Actions workflow-command annotations', 'badge = shields.io endpoint JSON for a README badge'],
26
+ apply: (p, raw) => { p.format = String(raw || '').toLowerCase(); } },
27
+ { flag: '--badge-metric', kind: 'value', value: '<drift|findings>', help: 'Badge value: drift percent or off-scale count (default: drift)',
28
+ apply: (p, raw) => { p.badgeMetric = String(raw || '').toLowerCase(); } },
29
+ { flag: '--json', kind: 'flag', help: 'Alias for --format json', apply: (p) => { p.format = 'json'; } },
30
+ { flag: '--markdown', kind: 'flag', help: 'Alias for --format markdown', apply: (p) => { p.format = 'markdown'; } },
31
+ { flag: '--schema', kind: 'flag', help: 'Print the audit JSON schema and exit', apply: (p) => { p.schema = true; p.format = 'json'; } },
32
+ { flag: '--output', kind: 'value', value: '<file>', help: 'Write json, markdown, html or badge output to a file',
33
+ apply: (p, raw) => { p.outputPath = parsePathOption(raw, '--output'); setFrom(p, 'outputPath'); } },
34
+ { flag: '--config', kind: 'value', value: '<file>', help: 'Load audit config (default: .rhythmguardrc.json when present)',
35
+ apply: (p, raw) => { p.configPath = parsePathOption(raw, '--config'); p.configExplicit = true; } },
36
+ { flag: '--no-config', kind: 'flag', help: 'Ignore .rhythmguardrc.json discovery', apply: (p) => { p.noConfig = true; } },
37
+ { flag: '--ignore', kind: 'value', value: '<pattern>', help: 'Exclude root-relative path/glob (repeatable, comma-separated)',
38
+ apply: (p, raw) => { p.ignorePatterns.push(...parseIgnorePatterns(raw)); setFrom(p, 'ignore'); } },
39
+ { flag: '--ignore-path', kind: 'value', value: '<file>', help: 'Load ignore patterns from file (default: .rhythmguardignore when present)',
40
+ apply: (p, raw) => { p.ignorePath = parsePathOption(raw, '--ignore-path'); setFrom(p, 'ignorePath'); } },
41
+ { flag: '--baseline', kind: 'value', value: '<file>', help: 'Baseline file path (default: .rhythmguard-baseline.json)',
42
+ apply: (p, raw) => { p.baselinePath = parsePathOption(raw, '--baseline'); setFrom(p, 'baselinePath'); } },
43
+ { flag: '--write-baseline', kind: 'optional', value: '[file]', help: 'Write current findings as a baseline',
44
+ apply: (p, raw) => { p.writeBaseline = true; setFrom(p, 'writeBaseline'); if (raw !== undefined) { p.baselinePath = parsePathOption(raw, '--write-baseline'); setFrom(p, 'baselinePath'); } } },
45
+ { flag: '--since-baseline', kind: 'optional', value: '[file]', help: 'Compare current findings against a baseline',
46
+ apply: (p, raw) => { p.sinceBaseline = true; setFrom(p, 'sinceBaseline'); if (raw !== undefined) { p.baselinePath = parsePathOption(raw, '--since-baseline'); setFrom(p, 'baselinePath'); } } },
47
+ { flag: '--fail-on-new-drift', kind: 'flag', help: 'Exit 1 when --since-baseline finds new drift',
48
+ apply: (p) => { p.failOnNewDrift = true; setFrom(p, 'failOnNewDrift'); } },
49
+ { flag: '--max-findings', kind: 'value', value: '<number>', help: 'Exit 1 when total findings exceed this count',
50
+ apply: (p, raw) => { p.maxFindings = parseNonNegativeInteger(raw, '--max-findings'); setFrom(p, 'maxFindings'); } },
51
+ { flag: '--min-cleanliness', kind: 'value', value: '<percent>', help: 'Exit 1 when scale cleanliness is lower than this percent',
52
+ apply: (p, raw) => { p.minCleanliness = parsePercentage(raw, '--min-cleanliness'); setFrom(p, 'minCleanliness'); } },
53
+ { flag: '--since', kind: 'value', value: '<git-ref>', help: 'Scan only changed files since a git ref',
54
+ apply: (p, raw) => { p.since = parsePathOption(raw, '--since'); setFrom(p, 'since'); } },
55
+ { flag: '--staged', kind: 'flag', help: 'Scan only staged files', apply: (p) => { p.staged = true; setFrom(p, 'staged'); } },
56
+ { flag: '--include-motion', kind: 'flag', help: 'Include opt-in motion duration/easing drift',
57
+ apply: (p) => { p.includeMotion = true; setFrom(p, 'includeMotion'); } },
58
+ { flag: '--token-source', kind: 'value', value: '<file>', help: 'External token source (repeatable, comma-separated)',
59
+ apply: (p, raw) => { p.tokenSources.push(...parseTokenSourcePaths(raw)); setFrom(p, 'tokenSources'); } },
60
+ { flag: '--token-source-format', kind: 'value', value: '<format>', help: 'Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)',
61
+ apply: (p, raw) => { p.tokenSourceFormat = normalizeTokenSourceFormat(raw); setFrom(p, 'tokenSourceFormat'); } },
62
+ { flag: '--token-kind', kind: 'value', value: '<kind>', help: 'Token kind: spacing, radius, typography, size, motion, all (default: spacing)',
63
+ apply: (p, raw) => { p.tokenKind = normalizeTokenKind(raw); setFrom(p, 'tokenKind'); } },
64
+ { flag: '--token-candidate-min-count', kind: 'value', value: '<n>', help: 'Minimum repeated raw value count for token candidates (default: 2)',
65
+ apply: (p, raw) => { p.tokenCandidateMinCount = parsePositiveInteger(raw, '--token-candidate-min-count'); setFrom(p, 'tokenCandidateMinCount'); } },
66
+ { flag: '--scale', kind: 'value', value: '<values|auto>', help: ['Comma-separated scale values (default: 0,4,8,12,16,24,32);', 'auto infers the scale from token sources, then --space-*/--spacing-*', 'custom properties and Sass variables in scanned CSS/SCSS, then', 'installed design-token packages, and reports where it came from'],
67
+ apply: (p, raw) => { p.scale = parseScale(raw); setFrom(p, 'scale'); } },
68
+ { flag: '--base-font-size', kind: 'value', value: '<number>', help: 'px base for rem/em conversion (default: 16)',
69
+ apply: (p, raw) => { p.baseFontSize = parseBaseFontSize(raw); setFrom(p, 'baseFontSize'); } },
70
+ { flag: '--help', alias: '-h', kind: 'flag', help: 'Show this help message', apply: (p) => { p.help = true; } },
71
+ ];
72
+
73
+ const HELP_COLUMN = 33;
74
+
75
+ function renderHelp() {
76
+ const lines = ['Usage: rhythmguard audit <dir> [options]', '', 'Options:'];
77
+ for (const option of OPTIONS) {
78
+ const label = ` ${option.flag}${option.value ? ` ${option.value}` : ''}`;
79
+ const help = Array.isArray(option.help) ? option.help : [option.help];
80
+ lines.push(`${label.padEnd(HELP_COLUMN)}${label.length >= HELP_COLUMN ? ' ' : ''}${help[0]}`);
81
+ for (const extra of help.slice(1)) {
82
+ lines.push(`${''.padEnd(HELP_COLUMN)}${extra}`);
83
+ }
84
+ }
85
+ lines.push(
86
+ '',
87
+ 'Scans .css files, and .scss files when postcss-scss is installed. Reports drift by',
88
+ 'value, by property and by file; text output prints histograms, markdown is PR-ready,',
89
+ 'json is the stable 2.0 contract.',
90
+ '',
91
+ );
92
+ return lines.join('\n');
93
+ }
94
+
95
+ const HELP = renderHelp();
96
+
97
+ function findOption(arg) {
98
+ return OPTIONS.find((option) => option.flag === arg || option.alias === arg) || null;
99
+ }
52
100
 
53
101
  function parseArgs(argv) {
54
102
  const parsed = createDefaultAuditOptions();
55
103
 
56
104
  for (let index = 0; index < argv.length; index++) {
57
105
  const arg = argv[index];
106
+ const equals = arg.indexOf('=');
107
+ const inline = arg.startsWith('--') && equals !== -1 ? findOption(arg.slice(0, equals)) : null;
58
108
 
59
- if (arg === '--help' || arg === '-h') {
60
- parsed.help = true;
61
- continue;
62
- }
63
-
64
- if (arg === '--schema') {
65
- parsed.schema = true;
66
- parsed.format = 'json';
67
- continue;
68
- }
69
-
70
- if (arg === '--json') {
71
- parsed.format = 'json';
72
- continue;
73
- }
74
-
75
- if (arg === '--markdown') {
76
- parsed.format = 'markdown';
77
- continue;
78
- }
79
-
80
- if (arg === '--output') {
81
- parsed.outputPath = parsePathOption(argv[++index], '--output');
82
- parsed.cliOptions.add('outputPath');
109
+ if (inline && inline.kind !== 'flag') {
110
+ inline.apply(parsed, arg.slice(equals + 1));
83
111
  continue;
84
112
  }
85
113
 
86
- if (arg.startsWith('--output=')) {
87
- parsed.outputPath = parsePathOption(arg.slice('--output='.length), '--output');
88
- parsed.cliOptions.add('outputPath');
89
- continue;
90
- }
91
-
92
- if (arg === '--ignore') {
93
- parsed.ignorePatterns.push(...parseIgnorePatterns(argv[++index]));
94
- parsed.cliOptions.add('ignore');
95
- continue;
96
- }
97
-
98
- if (arg.startsWith('--ignore=')) {
99
- parsed.ignorePatterns.push(...parseIgnorePatterns(arg.slice('--ignore='.length)));
100
- parsed.cliOptions.add('ignore');
101
- continue;
102
- }
103
-
104
- if (arg === '--config') {
105
- parsed.configPath = parsePathOption(argv[++index], '--config');
106
- parsed.configExplicit = true;
107
- continue;
108
- }
109
-
110
- if (arg.startsWith('--config=')) {
111
- parsed.configPath = parsePathOption(arg.slice('--config='.length), '--config');
112
- parsed.configExplicit = true;
113
- continue;
114
- }
115
-
116
- if (arg === '--no-config') {
117
- parsed.noConfig = true;
118
- continue;
119
- }
120
-
121
- if (arg === '--ignore-path') {
122
- parsed.ignorePath = parsePathOption(argv[++index], '--ignore-path');
123
- parsed.cliOptions.add('ignorePath');
124
- continue;
125
- }
126
-
127
- if (arg.startsWith('--ignore-path=')) {
128
- parsed.ignorePath = parsePathOption(arg.slice('--ignore-path='.length), '--ignore-path');
129
- parsed.cliOptions.add('ignorePath');
130
- continue;
131
- }
132
-
133
- if (arg === '--baseline') {
134
- parsed.baselinePath = parsePathOption(argv[++index], '--baseline');
135
- parsed.cliOptions.add('baselinePath');
136
- continue;
137
- }
138
-
139
- if (arg.startsWith('--baseline=')) {
140
- parsed.baselinePath = parsePathOption(arg.slice('--baseline='.length), '--baseline');
141
- parsed.cliOptions.add('baselinePath');
142
- continue;
143
- }
144
-
145
- if (arg === '--write-baseline') {
146
- parsed.writeBaseline = true;
147
- parsed.cliOptions.add('writeBaseline');
148
- if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
149
- parsed.baselinePath = parsePathOption(argv[++index], '--write-baseline');
150
- parsed.cliOptions.add('baselinePath');
114
+ const option = findOption(arg);
115
+ if (option) {
116
+ if (option.kind === 'flag') {
117
+ option.apply(parsed);
118
+ } else if (option.kind === 'value') {
119
+ option.apply(parsed, argv[++index]);
120
+ } else if (argv[index + 1] !== undefined && !argv[index + 1].startsWith('-')) {
121
+ option.apply(parsed, argv[++index]);
122
+ } else {
123
+ option.apply(parsed, undefined);
151
124
  }
152
125
  continue;
153
126
  }
154
127
 
155
- if (arg.startsWith('--write-baseline=')) {
156
- parsed.writeBaseline = true;
157
- parsed.baselinePath = parsePathOption(arg.slice('--write-baseline='.length), '--write-baseline');
158
- parsed.cliOptions.add('writeBaseline');
159
- parsed.cliOptions.add('baselinePath');
160
- continue;
161
- }
162
-
163
- if (arg === '--since-baseline') {
164
- parsed.sinceBaseline = true;
165
- parsed.cliOptions.add('sinceBaseline');
166
- if (argv[index + 1] && !argv[index + 1].startsWith('-')) {
167
- parsed.baselinePath = parsePathOption(argv[++index], '--since-baseline');
168
- parsed.cliOptions.add('baselinePath');
169
- }
170
- continue;
171
- }
172
-
173
- if (arg.startsWith('--since-baseline=')) {
174
- parsed.sinceBaseline = true;
175
- parsed.baselinePath = parsePathOption(arg.slice('--since-baseline='.length), '--since-baseline');
176
- parsed.cliOptions.add('sinceBaseline');
177
- parsed.cliOptions.add('baselinePath');
178
- continue;
179
- }
180
-
181
- if (arg === '--fail-on-new-drift') {
182
- parsed.failOnNewDrift = true;
183
- parsed.cliOptions.add('failOnNewDrift');
184
- continue;
185
- }
186
-
187
- if (arg === '--max-findings') {
188
- parsed.maxFindings = parseNonNegativeInteger(argv[++index], '--max-findings');
189
- parsed.cliOptions.add('maxFindings');
190
- continue;
191
- }
192
-
193
- if (arg.startsWith('--max-findings=')) {
194
- parsed.maxFindings = parseNonNegativeInteger(arg.slice('--max-findings='.length), '--max-findings');
195
- parsed.cliOptions.add('maxFindings');
196
- continue;
197
- }
198
-
199
- if (arg === '--min-cleanliness') {
200
- parsed.minCleanliness = parsePercentage(argv[++index], '--min-cleanliness');
201
- parsed.cliOptions.add('minCleanliness');
202
- continue;
203
- }
204
-
205
- if (arg.startsWith('--min-cleanliness=')) {
206
- parsed.minCleanliness = parsePercentage(
207
- arg.slice('--min-cleanliness='.length),
208
- '--min-cleanliness',
209
- );
210
- parsed.cliOptions.add('minCleanliness');
211
- continue;
212
- }
213
-
214
- if (arg === '--since') {
215
- parsed.since = parsePathOption(argv[++index], '--since');
216
- parsed.cliOptions.add('since');
217
- continue;
218
- }
219
-
220
- if (arg.startsWith('--since=')) {
221
- parsed.since = parsePathOption(arg.slice('--since='.length), '--since');
222
- parsed.cliOptions.add('since');
223
- continue;
224
- }
225
-
226
- if (arg === '--staged') {
227
- parsed.staged = true;
228
- parsed.cliOptions.add('staged');
229
- continue;
230
- }
231
-
232
- if (arg === '--include-motion') {
233
- parsed.includeMotion = true;
234
- parsed.cliOptions.add('includeMotion');
235
- continue;
236
- }
237
-
238
- if (arg === '--token-source') {
239
- parsed.tokenSources.push(...parseTokenSourcePaths(argv[++index]));
240
- parsed.cliOptions.add('tokenSources');
241
- continue;
242
- }
243
-
244
- if (arg.startsWith('--token-source=')) {
245
- parsed.tokenSources.push(...parseTokenSourcePaths(arg.slice('--token-source='.length)));
246
- parsed.cliOptions.add('tokenSources');
247
- continue;
248
- }
249
-
250
- if (arg === '--token-source-format') {
251
- parsed.tokenSourceFormat = normalizeTokenSourceFormat(argv[++index]);
252
- parsed.cliOptions.add('tokenSourceFormat');
253
- continue;
254
- }
255
-
256
- if (arg.startsWith('--token-source-format=')) {
257
- parsed.tokenSourceFormat = normalizeTokenSourceFormat(arg.slice('--token-source-format='.length));
258
- parsed.cliOptions.add('tokenSourceFormat');
259
- continue;
260
- }
261
-
262
- if (arg === '--token-kind') {
263
- parsed.tokenKind = normalizeTokenKind(argv[++index]);
264
- parsed.cliOptions.add('tokenKind');
265
- continue;
266
- }
267
-
268
- if (arg.startsWith('--token-kind=')) {
269
- parsed.tokenKind = normalizeTokenKind(arg.slice('--token-kind='.length));
270
- parsed.cliOptions.add('tokenKind');
271
- continue;
272
- }
273
-
274
- if (arg === '--token-candidate-min-count') {
275
- parsed.tokenCandidateMinCount = parsePositiveInteger(argv[++index], '--token-candidate-min-count');
276
- parsed.cliOptions.add('tokenCandidateMinCount');
277
- continue;
278
- }
279
-
280
- if (arg.startsWith('--token-candidate-min-count=')) {
281
- parsed.tokenCandidateMinCount = parsePositiveInteger(
282
- arg.slice('--token-candidate-min-count='.length),
283
- '--token-candidate-min-count',
284
- );
285
- parsed.cliOptions.add('tokenCandidateMinCount');
286
- continue;
287
- }
288
-
289
- if (arg === '--badge-metric') {
290
- parsed.badgeMetric = String(argv[++index] || '').toLowerCase();
291
- continue;
292
- }
293
-
294
- if (arg.startsWith('--badge-metric=')) {
295
- parsed.badgeMetric = arg.slice('--badge-metric='.length).toLowerCase();
296
- continue;
297
- }
298
-
299
- if (arg === '--format') {
300
- parsed.format = String(argv[++index] || '').toLowerCase();
301
- continue;
302
- }
303
-
304
- if (arg.startsWith('--format=')) {
305
- parsed.format = arg.slice('--format='.length).toLowerCase();
306
- continue;
307
- }
308
-
309
- if (arg === '--scale') {
310
- parsed.scale = parseScale(argv[++index]);
311
- parsed.cliOptions.add('scale');
312
- continue;
313
- }
314
-
315
- if (arg.startsWith('--scale=')) {
316
- parsed.scale = parseScale(arg.slice('--scale='.length));
317
- parsed.cliOptions.add('scale');
318
- continue;
319
- }
320
-
321
- if (arg === '--base-font-size') {
322
- parsed.baseFontSize = parseBaseFontSize(argv[++index]);
323
- parsed.cliOptions.add('baseFontSize');
324
- continue;
325
- }
326
-
327
- if (arg.startsWith('--base-font-size=')) {
328
- parsed.baseFontSize = parseBaseFontSize(arg.slice('--base-font-size='.length));
329
- parsed.cliOptions.add('baseFontSize');
330
- continue;
331
- }
332
-
333
128
  if (!arg.startsWith('-') && !parsed.dir) {
334
129
  parsed.dir = arg;
335
130
  continue;
@@ -339,7 +134,7 @@ function parseArgs(argv) {
339
134
  }
340
135
 
341
136
  if (!VALID_FORMATS.has(parsed.format)) {
342
- throw new Error(`Invalid format "${parsed.format}". Expected text, json, json-v1, markdown, html, or github.`);
137
+ throw new Error(`Invalid format "${parsed.format}". Expected ${Array.from(VALID_FORMATS).join(', ')}.`);
343
138
  }
344
139
 
345
140
  if (!BADGE_METRICS.has(parsed.badgeMetric)) {
@@ -459,6 +254,7 @@ function parseBaseFontSize(raw) {
459
254
  }
460
255
 
461
256
  module.exports = {
257
+ OPTIONS,
462
258
  HELP,
463
259
  parseArgs,
464
260
  parseBaseFontSize,
@@ -5,7 +5,7 @@ const path = require('node:path');
5
5
  const {
6
6
  normalizeTokenKind,
7
7
  normalizeTokenSourceFormat,
8
- } = require('../utils/token-sources');
8
+ } = require('../core/token-sources');
9
9
  const {
10
10
  parseBaseFontSize,
11
11
  parseNonNegativeInteger,