stylelint-plugin-rhythmguard 3.1.0 → 3.3.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 +28 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +5 -2
- package/agents/claude-code/SKILL.md +18 -0
- package/agents/copilot/copilot-instructions.md +13 -0
- package/agents/cursor/rhythmguard.mdc +19 -0
- package/package.json +9 -6
- package/src/audit/args.js +19 -2
- package/src/audit/contract.js +1 -0
- package/src/audit/render-badge.js +54 -0
- package/src/audit/render-markdown.js +3 -0
- package/src/audit/render-text.js +2 -1
- package/src/audit/report.js +15 -8
- package/src/audit/shared.js +2 -1
- package/src/cli/audit.js +8 -0
- package/src/cli/index.js +2 -0
- package/src/cli/init.js +78 -1
- package/src/cli/quickstart.js +4 -1
- package/src/utils/length.js +3 -1
- package/src/utils/scale-inference.js +82 -9
- package/src/utils/token-packages.json +7 -0
- package/src/utils/token-sources.js +34 -5
- package/types/audit.d.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.3.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- `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))
|
|
15
|
+
- 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))
|
|
16
|
+
- `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))
|
|
17
|
+
- 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))
|
|
18
|
+
- 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))
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- `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`.
|
|
24
|
+
|
|
25
|
+
## [3.2.0] - 2026-09-06
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `rhythmguard audit --format badge` writes a shields.io endpoint document for a README badge: `spacing drift` as a percentage by default, or the off-scale count with `--badge-metric findings`. Workflow in `docs/CI_ADOPTION.md`. ([#68](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/68))
|
|
30
|
+
- `rhythmguard init --agents <claude|cursor|copilot|all>` installs the agents block from `docs/FOR_AGENTS.md` as a Claude Code skill, a Cursor rule, or a block in `.github/copilot-instructions.md`. The packs ship in the package under `agents/`, are generated by `npm run build:agents`, and a test keeps them in sync with the docs. ([#69](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/69))
|
|
31
|
+
- `npm run bench:state-of-spacing` generates a dated State of Spacing edition from the quiet benchmark results: one row per repository with scale source, off-scale count, drift per 100 CSS files, cleanliness, top values, top properties, and the change since the previous edition. Benchmark results now record the property of each finding. First edition at 20 repositories in `docs/STATE_OF_SPACING.md`. ([#70](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/70))
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Dev dependencies: eslint 9.39.5; the unused `geist` package and its `next` and `sharp` chain are gone, and `npm audit` reports no findings. No runtime dependency changed.
|
|
36
|
+
|
|
9
37
|
## [3.1.0] - 2026-09-06
|
|
10
38
|
|
|
11
39
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -6,7 +6,7 @@ Thank you for being here. Rhythmguard is a small, opinionated tool with one job:
|
|
|
6
6
|
|
|
7
7
|
**Report a false positive.** A finding your team considers wrong is the most valuable report we get. It is how percentages and hairlines stopped being findings. Open a [false-positive report](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/new?template=false-positive.yml) with the CSS or class string and your config. No fix required.
|
|
8
8
|
|
|
9
|
-
**Add a repository to the quiet benchmark.** The benchmark audits public design systems and fails CI when the findings change. Adding a repo is one JSON entry in `benchmarks/quiet/repos.json` plus `npm run bench:quiet -- --only <name>` to create its snapshot. Systems with tokens in unusual places are the most useful additions.
|
|
9
|
+
**Add a repository to the quiet benchmark.** The benchmark audits public design systems and fails CI when the findings change. Adding a repo is one JSON entry in `benchmarks/quiet/repos.json` plus `npm run bench:quiet -- --only <name>` to create its snapshot. Systems with tokens in unusual places are the most useful additions. The same results feed the dated [State of Spacing](./docs/STATE_OF_SPACING.md) editions, so every repository you add widens that report.
|
|
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
|
|
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=
|
|
2
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=6" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# stylelint-plugin-rhythmguard
|
|
@@ -85,6 +85,8 @@ The audit scans CSS declarations, Tailwind class strings and your token contract
|
|
|
85
85
|
|
|
86
86
|
`npx rhythmguard init` writes a starter config for your stack. `npx rhythmguard doctor` checks the setup.
|
|
87
87
|
|
|
88
|
+
A README badge comes from the same audit: `--format badge` writes a shields.io endpoint document, see [`docs/CI_ADOPTION.md`](./docs/CI_ADOPTION.md#5-show-a-badge).
|
|
89
|
+
|
|
88
90
|
## Guides
|
|
89
91
|
|
|
90
92
|
- [Tailwind integration](docs/TAILWIND.md), including v4 `@theme` tokens and what each layer covers
|
|
@@ -92,8 +94,9 @@ The audit scans CSS declarations, Tailwind class strings and your token contract
|
|
|
92
94
|
- [Comparison with adjacent plugins](docs/COMPARISON.md) and migration recipes
|
|
93
95
|
- [Real before/after excerpts](docs/ADOPTION_DIFFS.md) from public codebases
|
|
94
96
|
- [For shared-config authors](docs/FOR_CONFIG_AUTHORS.md): the `embed` entry point and how inference works per consumer
|
|
95
|
-
- [For coding agents](docs/FOR_AGENTS.md): a paste-ready `AGENTS.md` block
|
|
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
|
|
96
98
|
- [Quiet benchmark](docs/QUIET_BENCHMARK.md): findings on public design systems, checked on every change
|
|
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
|
|
97
100
|
- [Product direction](docs/STRATEGY_2026-09.md)
|
|
98
101
|
- Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/)
|
|
99
102
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rhythmguard
|
|
3
|
+
description: Keep CSS and Tailwind spacing on the project scale. Use when writing or changing CSS, SCSS, CSS Modules or Tailwind class strings, and before finishing any such change.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Spacing scale
|
|
7
|
+
|
|
8
|
+
This project enforces its spacing scale with stylelint-plugin-rhythmguard.
|
|
9
|
+
|
|
10
|
+
- Before finishing any change to CSS or to Tailwind class strings, run `npx rhythmguard audit . --format json` (or `npx stylelint "**/*.css"` if a config exists) and fix every `off-scale` finding.
|
|
11
|
+
- A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
|
|
12
|
+
- Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
|
|
13
|
+
- Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
|
|
14
|
+
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
|
|
15
|
+
- When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
|
|
16
|
+
- If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
|
|
17
|
+
|
|
18
|
+
Source: docs/FOR_AGENTS.md in the stylelint-plugin-rhythmguard repository (https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FOR_AGENTS.md). Regenerate with `npx rhythmguard init --agents`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## Spacing scale
|
|
2
|
+
|
|
3
|
+
This project enforces its spacing scale with stylelint-plugin-rhythmguard.
|
|
4
|
+
|
|
5
|
+
- Before finishing any change to CSS or to Tailwind class strings, run `npx rhythmguard audit . --format json` (or `npx stylelint "**/*.css"` if a config exists) and fix every `off-scale` finding.
|
|
6
|
+
- A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
|
|
7
|
+
- Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
|
|
8
|
+
- Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
|
|
9
|
+
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
|
|
10
|
+
- When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
|
|
11
|
+
- If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
|
|
12
|
+
|
|
13
|
+
Source: docs/FOR_AGENTS.md in the stylelint-plugin-rhythmguard repository (https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FOR_AGENTS.md). Regenerate with `npx rhythmguard init --agents`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Spacing scale rules enforced by stylelint-plugin-rhythmguard
|
|
3
|
+
globs: ["**/*.css", "**/*.scss", "**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.astro", "**/*.html"]
|
|
4
|
+
alwaysApply: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Spacing scale
|
|
8
|
+
|
|
9
|
+
This project enforces its spacing scale with stylelint-plugin-rhythmguard.
|
|
10
|
+
|
|
11
|
+
- Before finishing any change to CSS or to Tailwind class strings, run `npx rhythmguard audit . --format json` (or `npx stylelint "**/*.css"` if a config exists) and fix every `off-scale` finding.
|
|
12
|
+
- A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
|
|
13
|
+
- Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
|
|
14
|
+
- Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
|
|
15
|
+
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
|
|
16
|
+
- When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
|
|
17
|
+
- If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
|
|
18
|
+
|
|
19
|
+
Source: docs/FOR_AGENTS.md in the stylelint-plugin-rhythmguard repository (https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FOR_AGENTS.md). Regenerate with `npx rhythmguard init --agents`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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"
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
91
|
"files": [
|
|
92
|
+
"agents",
|
|
92
93
|
"examples",
|
|
93
94
|
"scales",
|
|
94
95
|
"schemas",
|
|
@@ -113,7 +114,10 @@
|
|
|
113
114
|
"test:npm-smoke": "node scripts/ci/npm-registry-smoke.mjs --package stylelint-plugin-rhythmguard --version latest",
|
|
114
115
|
"test:pack-smoke": "node scripts/ci/pack-smoke.mjs",
|
|
115
116
|
"test:watch": "node --test --watch test/*.test.js",
|
|
116
|
-
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
117
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
118
|
+
"build:agents": "node scripts/build-agents.mjs",
|
|
119
|
+
"bench:state-of-spacing": "node scripts/bench/state-of-spacing.mjs",
|
|
120
|
+
"bench:outreach": "node scripts/bench/outreach.mjs"
|
|
117
121
|
},
|
|
118
122
|
"repository": {
|
|
119
123
|
"type": "git",
|
|
@@ -125,8 +129,8 @@
|
|
|
125
129
|
"email": "hello@petrilahdelma.com"
|
|
126
130
|
},
|
|
127
131
|
"peerDependencies": {
|
|
128
|
-
"stylelint": "^16.0.0 || ^17.0.0",
|
|
129
132
|
"postcss-scss": "^4.0.0",
|
|
133
|
+
"stylelint": "^16.0.0 || ^17.0.0",
|
|
130
134
|
"stylelint-config-tailwindcss": "^1.0.1",
|
|
131
135
|
"stylelint-plugin-logical-css": "^2.0.2"
|
|
132
136
|
},
|
|
@@ -145,11 +149,10 @@
|
|
|
145
149
|
"known-css-properties": "^0.37.0"
|
|
146
150
|
},
|
|
147
151
|
"devDependencies": {
|
|
148
|
-
"@eslint/js": "^9.
|
|
152
|
+
"@eslint/js": "^9.39.5",
|
|
149
153
|
"@types/node": "^22.20.1",
|
|
150
154
|
"c8": "^10.1.3",
|
|
151
|
-
"eslint": "^9.
|
|
152
|
-
"geist": "^1.7.0",
|
|
155
|
+
"eslint": "^9.39.5",
|
|
153
156
|
"postcss-scss": "^4.0.9",
|
|
154
157
|
"postcss-value-parser": "^4.2.0",
|
|
155
158
|
"stylelint": "^16.15.0",
|
package/src/audit/args.js
CHANGED
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
normalizeTokenKind,
|
|
5
5
|
normalizeTokenSourceFormat,
|
|
6
6
|
} = require('../utils/token-sources');
|
|
7
|
+
const { BADGE_METRICS } = require('./render-badge');
|
|
7
8
|
const {
|
|
8
9
|
VALID_FORMATS,
|
|
9
10
|
createDefaultAuditOptions,
|
|
@@ -13,12 +14,14 @@ const {
|
|
|
13
14
|
const HELP = `Usage: rhythmguard audit <dir> [options]
|
|
14
15
|
|
|
15
16
|
Options:
|
|
16
|
-
--format <text|json|json-v1|markdown|html|github> Output format (default: text)
|
|
17
|
+
--format <text|json|json-v1|markdown|html|github|badge> Output format (default: text)
|
|
17
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)
|
|
18
21
|
--json Alias for --format json
|
|
19
22
|
--markdown Alias for --format markdown
|
|
20
23
|
--schema Print the audit JSON schema and exit
|
|
21
|
-
--output <file> Write json, markdown, or
|
|
24
|
+
--output <file> Write json, markdown, html or badge output to a file
|
|
22
25
|
--config <file> Load audit config (default: .rhythmguardrc.json when present)
|
|
23
26
|
--no-config Ignore .rhythmguardrc.json discovery
|
|
24
27
|
--ignore <pattern> Exclude root-relative path/glob (repeatable, comma-separated)
|
|
@@ -283,6 +286,16 @@ function parseArgs(argv) {
|
|
|
283
286
|
continue;
|
|
284
287
|
}
|
|
285
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
|
+
|
|
286
299
|
if (arg === '--format') {
|
|
287
300
|
parsed.format = String(argv[++index] || '').toLowerCase();
|
|
288
301
|
continue;
|
|
@@ -329,6 +342,10 @@ function parseArgs(argv) {
|
|
|
329
342
|
throw new Error(`Invalid format "${parsed.format}". Expected text, json, json-v1, markdown, html, or github.`);
|
|
330
343
|
}
|
|
331
344
|
|
|
345
|
+
if (!BADGE_METRICS.has(parsed.badgeMetric)) {
|
|
346
|
+
throw new Error(`Invalid badge metric "${parsed.badgeMetric}". Expected drift or findings.`);
|
|
347
|
+
}
|
|
348
|
+
|
|
332
349
|
if (parsed.since && parsed.staged) {
|
|
333
350
|
throw new Error('Use either --since or --staged, not both.');
|
|
334
351
|
}
|
package/src/audit/contract.js
CHANGED
|
@@ -394,6 +394,7 @@ function toAuditContractReport(report) {
|
|
|
394
394
|
files: report.scale ? report.scale.files : [],
|
|
395
395
|
offScaleProperties: report.offScaleProperties || {},
|
|
396
396
|
offScaleValues: report.offScaleValues,
|
|
397
|
+
rejected: report.scale && report.scale.rejected ? report.scale.rejected : null,
|
|
397
398
|
source: report.scale ? report.scale.source : 'default',
|
|
398
399
|
tokenOpportunities: report.tokenOpportunities,
|
|
399
400
|
values: report.scale ? report.scale.values : null,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* shields.io endpoint badge: https://shields.io/badges/endpoint
|
|
5
|
+
*
|
|
6
|
+
* `drift` reports 100 minus scale cleanliness as a percentage (the share of
|
|
7
|
+
* scanned files with at least one finding). `findings` reports the number of
|
|
8
|
+
* off-scale CSS values plus Tailwind class-string findings.
|
|
9
|
+
*/
|
|
10
|
+
const BADGE_METRICS = new Set(['drift', 'findings']);
|
|
11
|
+
|
|
12
|
+
const THRESHOLDS = {
|
|
13
|
+
drift: [[2, 'brightgreen'], [5, 'green'], [15, 'yellow']],
|
|
14
|
+
findings: [[0, 'brightgreen'], [10, 'green'], [50, 'yellow']],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function badgeColor(metric, value) {
|
|
18
|
+
for (const [limit, color] of THRESHOLDS[metric]) {
|
|
19
|
+
if (value <= limit) {
|
|
20
|
+
return color;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return 'orange';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function badgeValue(report, metric) {
|
|
27
|
+
if (metric === 'drift') {
|
|
28
|
+
const cleanliness = Number.isFinite(report.scaleCleanliness) ? report.scaleCleanliness : 100;
|
|
29
|
+
return Math.max(0, Math.min(100, 100 - cleanliness));
|
|
30
|
+
}
|
|
31
|
+
const css = (report.findings && report.findings.css) || [];
|
|
32
|
+
const tailwind = (report.findings && report.findings.tailwind) || [];
|
|
33
|
+
return css.filter((finding) => finding.type === 'off-scale').length + tailwind.length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderBadge(report, { metric = 'drift' } = {}) {
|
|
37
|
+
if (!BADGE_METRICS.has(metric)) {
|
|
38
|
+
throw new Error(`Unknown badge metric "${metric}". Use one of: ${Array.from(BADGE_METRICS).join(', ')}.`);
|
|
39
|
+
}
|
|
40
|
+
const value = badgeValue(report, metric);
|
|
41
|
+
const badge = {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
label: metric === 'drift' ? 'spacing drift' : 'off-scale values',
|
|
44
|
+
message: metric === 'drift' ? `${value}%` : String(value),
|
|
45
|
+
color: badgeColor(metric, value),
|
|
46
|
+
};
|
|
47
|
+
return `${JSON.stringify(badge, null, 2)}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
BADGE_METRICS,
|
|
52
|
+
badgeColor,
|
|
53
|
+
renderBadge,
|
|
54
|
+
};
|
|
@@ -4,6 +4,9 @@ const { sortCountMap } = require('./contract');
|
|
|
4
4
|
const { escapeMarkdown } = require('./render-utils');
|
|
5
5
|
|
|
6
6
|
function describeScaleSource(scale) {
|
|
7
|
+
if (scale.rejected) {
|
|
8
|
+
return `${scale.source} (${scale.rejected.source} rejected: ${scale.rejected.reasons.join(', ')})`;
|
|
9
|
+
}
|
|
7
10
|
return scale.files.length > 0
|
|
8
11
|
? `${scale.source} (${scale.files.join(', ')})`
|
|
9
12
|
: scale.source;
|
package/src/audit/render-text.js
CHANGED
|
@@ -28,7 +28,8 @@ function renderText(report) {
|
|
|
28
28
|
if (report.scale) {
|
|
29
29
|
lines.push(` Scale ${report.scale.values.join(', ')}`);
|
|
30
30
|
const files = report.scale.files.length > 0 ? ` (${report.scale.files.join(', ')})` : '';
|
|
31
|
-
|
|
31
|
+
const rejected = report.scale.rejected ? ` (${report.scale.rejected.source} rejected: ${report.scale.rejected.reasons.join(', ')})` : '';
|
|
32
|
+
lines.push(` Scale source ${report.scale.source}${files}${rejected}`);
|
|
32
33
|
}
|
|
33
34
|
lines.push('');
|
|
34
35
|
|
package/src/audit/report.js
CHANGED
|
@@ -18,7 +18,8 @@ const {
|
|
|
18
18
|
} = require('./config');
|
|
19
19
|
const { buildReport, collectTokenDefinitions } = require('./contract');
|
|
20
20
|
const { DEFAULT_SCALE, formatPath } = require('./shared');
|
|
21
|
-
const {
|
|
21
|
+
const {
|
|
22
|
+
assessScale, discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
|
|
22
23
|
const {
|
|
23
24
|
assertDirectory,
|
|
24
25
|
collectCssFindings,
|
|
@@ -129,6 +130,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
|
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
let rejected = null;
|
|
132
134
|
const definitions = new Map();
|
|
133
135
|
const matchesKind = createTokenKindMatcher('spacing');
|
|
134
136
|
for (const filePath of cssFiles) {
|
|
@@ -155,12 +157,17 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
|
|
|
155
157
|
files.add(file);
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
const sortedFiles = Array.from(files).sort();
|
|
161
|
+
const assessment = assessScale({ files: sortedFiles, source: 'scanned-css', values });
|
|
162
|
+
if (assessment.plausible) {
|
|
163
|
+
return {
|
|
164
|
+
files: sortedFiles,
|
|
165
|
+
source: 'scanned-css',
|
|
166
|
+
tokenCount: definitions.size,
|
|
167
|
+
values,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
rejected = { files: sortedFiles, reasons: assessment.reasons, source: 'scanned-css', values };
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
173
|
|
|
@@ -178,7 +185,7 @@ function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResul
|
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
187
|
|
|
181
|
-
return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
|
|
188
|
+
return { files: [], ...(rejected ? { rejected } : {}), source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
function normalizeCreateAuditOptions(options = {}) {
|
package/src/audit/shared.js
CHANGED
|
@@ -18,7 +18,7 @@ const DEFAULT_AUDIT_TOKEN_PATTERN = '^--(space|spacing)-';
|
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TOKEN_CANDIDATE_MIN_COUNT = 2;
|
|
20
20
|
|
|
21
|
-
const VALID_FORMATS = new Set(['text', 'json', 'json-v1', 'markdown', 'html', 'github']);
|
|
21
|
+
const VALID_FORMATS = new Set(['text', 'json', 'json-v1', 'markdown', 'html', 'github', 'badge']);
|
|
22
22
|
|
|
23
23
|
const SKIP_DIRS = new Set([
|
|
24
24
|
'.git',
|
|
@@ -69,6 +69,7 @@ function createDefaultAuditOptions() {
|
|
|
69
69
|
configPath: DEFAULT_CONFIG_PATH,
|
|
70
70
|
dir: null,
|
|
71
71
|
failOnNewDrift: false,
|
|
72
|
+
badgeMetric: 'drift',
|
|
72
73
|
format: 'text',
|
|
73
74
|
ignorePath: DEFAULT_IGNORE_PATH,
|
|
74
75
|
ignorePatterns: [],
|
package/src/cli/audit.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
toAuditContractReport,
|
|
14
14
|
} = require('../audit/contract');
|
|
15
15
|
const { renderGithub } = require('../audit/render-github');
|
|
16
|
+
const { renderBadge } = require('../audit/render-badge');
|
|
16
17
|
const { renderHtml } = require('../audit/render-html');
|
|
17
18
|
const { renderMarkdown } = require('../audit/render-markdown');
|
|
18
19
|
const { renderText } = require('../audit/render-text');
|
|
@@ -73,6 +74,12 @@ async function run() {
|
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
if (parsed.format === 'badge') {
|
|
78
|
+
writeOutput(renderBadge(report, { metric: parsed.badgeMetric }), parsed.outputPath);
|
|
79
|
+
finish(auditFailures);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
if (parsed.format === 'github') {
|
|
77
84
|
writeOutput(renderGithub(report), parsed.outputPath);
|
|
78
85
|
finish(auditFailures);
|
|
@@ -108,6 +115,7 @@ module.exports = {
|
|
|
108
115
|
createAuditReport,
|
|
109
116
|
loadAuditConfig,
|
|
110
117
|
parseArgs,
|
|
118
|
+
renderBadge,
|
|
111
119
|
renderHtml,
|
|
112
120
|
run,
|
|
113
121
|
toAuditContractReport,
|
package/src/cli/index.js
CHANGED
|
@@ -12,6 +12,7 @@ Commands:
|
|
|
12
12
|
quickstart Same as running with no command
|
|
13
13
|
audit <dir> Report design-system drift across CSS and Tailwind class strings
|
|
14
14
|
init Scaffold a Rhythmguard config for your project
|
|
15
|
+
--agents <claude|cursor|copilot|all> installs the agent instruction packs instead
|
|
15
16
|
doctor Validate your Rhythmguard setup
|
|
16
17
|
|
|
17
18
|
Options:
|
|
@@ -22,6 +23,7 @@ Examples:
|
|
|
22
23
|
npx rhythmguard audit ./src
|
|
23
24
|
npx rhythmguard audit ./src --format markdown
|
|
24
25
|
npx rhythmguard init
|
|
26
|
+
npx rhythmguard init --agents all
|
|
25
27
|
npx rhythmguard doctor
|
|
26
28
|
`;
|
|
27
29
|
|
package/src/cli/init.js
CHANGED
|
@@ -98,7 +98,84 @@ function selectProfile(stack) {
|
|
|
98
98
|
return 'recommended';
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
const AGENT_TARGETS = {
|
|
102
|
+
claude: { source: 'claude-code/SKILL.md', target: path.join('.claude', 'skills', 'rhythmguard', 'SKILL.md'), mode: 'write' },
|
|
103
|
+
cursor: { source: 'cursor/rhythmguard.mdc', target: path.join('.cursor', 'rules', 'rhythmguard.mdc'), mode: 'write' },
|
|
104
|
+
copilot: { source: 'copilot/copilot-instructions.md', target: path.join('.github', 'copilot-instructions.md'), mode: 'append' },
|
|
105
|
+
};
|
|
106
|
+
const AGENT_BLOCK_START = '<!-- rhythmguard:agents:start -->';
|
|
107
|
+
const AGENT_BLOCK_END = '<!-- rhythmguard:agents:end -->';
|
|
108
|
+
|
|
109
|
+
function parseInitArgs(argv) {
|
|
110
|
+
const parsed = { agents: null };
|
|
111
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
112
|
+
const arg = argv[index];
|
|
113
|
+
if (arg === '--agents') {
|
|
114
|
+
parsed.agents = String(argv[++index] || 'all').toLowerCase();
|
|
115
|
+
} else if (arg.startsWith('--agents=')) {
|
|
116
|
+
parsed.agents = arg.slice('--agents='.length).toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Copies the generated packs under <package>/agents into the locations each
|
|
124
|
+
* agent reads. Claude Code and Cursor files are owned by Rhythmguard and
|
|
125
|
+
* overwritten; copilot-instructions.md is shared, so the block is appended
|
|
126
|
+
* between markers and replaced in place on later runs.
|
|
127
|
+
*/
|
|
128
|
+
function installAgents(selection, cwd = process.cwd()) {
|
|
129
|
+
const names = selection === 'all' ? Object.keys(AGENT_TARGETS) : [selection];
|
|
130
|
+
const unknown = names.filter((name) => !AGENT_TARGETS[name]);
|
|
131
|
+
if (unknown.length > 0) {
|
|
132
|
+
throw new Error(`Unknown agents target "${unknown[0]}". Use claude, cursor, copilot or all.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const packsDir = path.join(__dirname, '..', '..', 'agents');
|
|
136
|
+
const written = [];
|
|
137
|
+
for (const name of names) {
|
|
138
|
+
const { mode, source, target } = AGENT_TARGETS[name];
|
|
139
|
+
const content = fs.readFileSync(path.join(packsDir, source), 'utf8');
|
|
140
|
+
const targetPath = path.join(cwd, target);
|
|
141
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
142
|
+
|
|
143
|
+
if (mode === 'append' && fs.existsSync(targetPath)) {
|
|
144
|
+
const existing = fs.readFileSync(targetPath, 'utf8');
|
|
145
|
+
const block = `${AGENT_BLOCK_START}\n${content.trimEnd()}\n${AGENT_BLOCK_END}\n`;
|
|
146
|
+
const start = existing.indexOf(AGENT_BLOCK_START);
|
|
147
|
+
const end = existing.indexOf(AGENT_BLOCK_END);
|
|
148
|
+
const next = start !== -1 && end !== -1
|
|
149
|
+
? `${existing.slice(0, start)}${block}${existing.slice(end + AGENT_BLOCK_END.length).replace(/^\n/, '')}`
|
|
150
|
+
: `${existing.trimEnd()}\n\n${block}`;
|
|
151
|
+
fs.writeFileSync(targetPath, next);
|
|
152
|
+
} else {
|
|
153
|
+
fs.writeFileSync(targetPath, content);
|
|
154
|
+
}
|
|
155
|
+
written.push(target);
|
|
156
|
+
}
|
|
157
|
+
return written;
|
|
158
|
+
}
|
|
159
|
+
|
|
101
160
|
async function run() {
|
|
161
|
+
const args = parseInitArgs(process.argv.slice(3));
|
|
162
|
+
|
|
163
|
+
if (args.agents) {
|
|
164
|
+
let written;
|
|
165
|
+
try {
|
|
166
|
+
written = installAgents(args.agents);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
process.stderr.write(`${error.message}\n`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
process.stdout.write('\nRhythmguard agent packs\n\n');
|
|
172
|
+
for (const file of written) {
|
|
173
|
+
process.stdout.write(`✓ Wrote ${file}\n`);
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write('\nThe block is the one in docs/FOR_AGENTS.md. Trim it to the lines that apply.\n\n');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
102
179
|
const prompter = createPrompter();
|
|
103
180
|
|
|
104
181
|
try {
|
|
@@ -152,7 +229,7 @@ async function run() {
|
|
|
152
229
|
}
|
|
153
230
|
}
|
|
154
231
|
|
|
155
|
-
module.exports = { detect, run, selectProfile };
|
|
232
|
+
module.exports = { AGENT_TARGETS, detect, installAgents, run, selectProfile };
|
|
156
233
|
|
|
157
234
|
if (require.main === module) {
|
|
158
235
|
run();
|
package/src/cli/quickstart.js
CHANGED
|
@@ -160,7 +160,10 @@ async function run() {
|
|
|
160
160
|
out.push(' Scale');
|
|
161
161
|
out.push(` Scale ${scale.values.join(', ')}`);
|
|
162
162
|
out.push(` Source ${describeScale(scale)}`);
|
|
163
|
-
if (scale.source === 'fallback') {
|
|
163
|
+
if (scale.source === 'fallback' && scale.rejected) {
|
|
164
|
+
out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the rhythmic-4 preset is being used.`);
|
|
165
|
+
out.push(' Point scaleSources at your real token file, or tighten tokenPattern.');
|
|
166
|
+
} else if (scale.source === 'fallback') {
|
|
164
167
|
out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
|
|
165
168
|
out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
|
|
166
169
|
}
|
package/src/utils/length.js
CHANGED
|
@@ -69,8 +69,10 @@ function formatNumber(value) {
|
|
|
69
69
|
return '0';
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// String() already prints the shortest form (1.5, not 1.50). The old
|
|
73
|
+
// trailing-zero strip also turned 30 into 3 and 100 into 1.
|
|
72
74
|
const rounded = Math.round(value * 10000) / 10000;
|
|
73
|
-
return String(rounded)
|
|
75
|
+
return String(rounded);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
function formatLength(number, unit) {
|
|
@@ -9,7 +9,7 @@ const { collectScssTokens, createTokenKindMatcher, parseTokenSources } = require
|
|
|
9
9
|
const { getScalePreset } = require('../presets/scales');
|
|
10
10
|
|
|
11
11
|
// Matches the audit default so lint and audit agree on what a spacing token is.
|
|
12
|
-
const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing)(-|$)';
|
|
12
|
+
const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing|spacer)(-|$)';
|
|
13
13
|
// Tailwind v4 defines one base (`--spacing: 0.25rem`) and derives utilities by multiplying it.
|
|
14
14
|
const TAILWIND_BASE_TOKENS = new Set(['--spacing', '--space']);
|
|
15
15
|
const TAILWIND_SPACING_MULTIPLIERS = [
|
|
@@ -191,21 +191,38 @@ function scaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
|
191
191
|
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* Merge Tailwind-style base multiples into a scale when a bare --spacing/--space
|
|
196
|
+
* base is defined. Only the first base found is expanded: a project that ships
|
|
197
|
+
* several theme files with different bases (shadcn/ui) has one active base at a
|
|
198
|
+
* time, and a union of ladders is a scale nobody designed (issue #89).
|
|
199
|
+
*/
|
|
195
200
|
function expandTailwindBase(scale, baseKeys, baseFontSize) {
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
201
|
+
const base = firstPositivePx(baseKeys, baseFontSize);
|
|
202
|
+
if (base === null) {
|
|
198
203
|
return scale;
|
|
199
204
|
}
|
|
200
205
|
const values = new Set(scale);
|
|
201
|
-
for (const
|
|
202
|
-
|
|
203
|
-
values.add(Math.round(base * multiplier * 1000) / 1000);
|
|
204
|
-
}
|
|
206
|
+
for (const multiplier of TAILWIND_SPACING_MULTIPLIERS) {
|
|
207
|
+
values.add(Math.round(base * multiplier * 1000) / 1000);
|
|
205
208
|
}
|
|
206
209
|
return Array.from(values).sort((a, b) => a - b);
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
function firstPositivePx(keys, baseFontSize) {
|
|
213
|
+
for (const key of keys) {
|
|
214
|
+
const parsed = parseLengthToken(String(key));
|
|
215
|
+
if (!parsed) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const px = toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize);
|
|
219
|
+
if (px !== null && Number.isFinite(px) && px > 0) {
|
|
220
|
+
return px;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
209
226
|
function rcTokenSources(cwd) {
|
|
210
227
|
const rcPath = path.join(cwd, RC_FILE);
|
|
211
228
|
if (!fs.existsSync(rcPath)) {
|
|
@@ -290,6 +307,7 @@ function resolveAutoScale({
|
|
|
290
307
|
tokenRegex = new RegExp(DEFAULT_AUTO_TOKEN_PATTERN);
|
|
291
308
|
}
|
|
292
309
|
|
|
310
|
+
let rejected = null;
|
|
293
311
|
if (root) {
|
|
294
312
|
const stylesheetMap = buildEffectiveTokenMap({
|
|
295
313
|
options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
|
|
@@ -298,7 +316,11 @@ function resolveAutoScale({
|
|
|
298
316
|
});
|
|
299
317
|
const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
|
|
300
318
|
if (scale) {
|
|
301
|
-
|
|
319
|
+
const assessment = assessScale({ source: 'stylesheet', values: scale });
|
|
320
|
+
if (assessment.plausible) {
|
|
321
|
+
return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
|
|
322
|
+
}
|
|
323
|
+
rejected = { reasons: assessment.reasons, source: 'stylesheet', values: scale };
|
|
302
324
|
}
|
|
303
325
|
}
|
|
304
326
|
|
|
@@ -324,9 +346,56 @@ function resolveAutoScale({
|
|
|
324
346
|
return { source: 'token-package', ...fromPackages };
|
|
325
347
|
}
|
|
326
348
|
|
|
349
|
+
return fallbackInference(rejected);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const TRUSTED_SCALE_SOURCES = new Set(['scaleSources', 'rhythmguardrc', 'token-sources', 'tailwind', 'token-package', 'explicit', 'default']);
|
|
353
|
+
const TOKEN_FILE_PATTERN = /(token|variable|spacing|space|theme|primitive|global|scale|layout)/i;
|
|
354
|
+
const LADDER_STEPS = [2, 3, 4, 5, 8];
|
|
355
|
+
const MIN_ASSESSED_STEPS = 3;
|
|
356
|
+
// A scale with more steps than this must be a near-perfect ladder; otherwise it
|
|
357
|
+
// is a list of every value a codebase happens to use (Semi Design: 42 steps).
|
|
358
|
+
const MAX_LOOSE_STEPS = 24;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Inference can pick up component-local variables (`--chip-spacing: 3px`) and
|
|
362
|
+
* assemble a "scale" nobody designed. Explicit sources are trusted. An inferred
|
|
363
|
+
* scale is plausible when it has at least three positive steps, is mostly whole
|
|
364
|
+
* pixels, mostly shares a common step (2, 3, 4, 5 or 8), and, past two dozen
|
|
365
|
+
* steps, is a near-perfect ladder. When the source
|
|
366
|
+
* files are known, a set that comes mostly from component files must be a
|
|
367
|
+
* near-perfect ladder to pass. Fallback is never the project's scale.
|
|
368
|
+
*/
|
|
369
|
+
function assessScale({ files = null, source, values = [] } = {}) {
|
|
370
|
+
if (source === 'fallback') {
|
|
371
|
+
return { plausible: false, reasons: ['fallback'] };
|
|
372
|
+
}
|
|
373
|
+
if (TRUSTED_SCALE_SOURCES.has(source)) {
|
|
374
|
+
return { plausible: true, reasons: [] };
|
|
375
|
+
}
|
|
376
|
+
const positives = values.map(Number).filter((value) => Number.isFinite(value) && value > 0);
|
|
377
|
+
const integers = positives.filter(Number.isInteger);
|
|
378
|
+
const integerShare = positives.length ? integers.length / positives.length : 0;
|
|
379
|
+
const coherence = positives.length
|
|
380
|
+
? Math.max(...LADDER_STEPS.map((step) => integers.filter((value) => value % step === 0).length / positives.length))
|
|
381
|
+
: 0;
|
|
382
|
+
const reasons = [];
|
|
383
|
+
if (positives.length < MIN_ASSESSED_STEPS) reasons.push('fewer than three steps');
|
|
384
|
+
if (integerShare < 0.8) reasons.push('fractional values');
|
|
385
|
+
if (coherence < 0.7) reasons.push('no common step');
|
|
386
|
+
if (positives.length > MAX_LOOSE_STEPS && coherence < 0.9) reasons.push('too many steps');
|
|
387
|
+
if (Array.isArray(files) && files.length > 0) {
|
|
388
|
+
const tokenFileShare = files.filter((file) => TOKEN_FILE_PATTERN.test(file)).length / files.length;
|
|
389
|
+
if (tokenFileShare < 0.5 && coherence < 0.9) reasons.push('sources are component files');
|
|
390
|
+
}
|
|
391
|
+
return { plausible: reasons.length === 0, reasons };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function fallbackInference(rejected = null) {
|
|
327
395
|
return {
|
|
328
396
|
files: [],
|
|
329
397
|
preset: FALLBACK_PRESET,
|
|
398
|
+
...(rejected ? { rejected } : {}),
|
|
330
399
|
scale: getScalePreset(FALLBACK_PRESET),
|
|
331
400
|
source: 'fallback',
|
|
332
401
|
tokenCount: 0,
|
|
@@ -338,12 +407,16 @@ function autoScaleFallbackNote(inference) {
|
|
|
338
407
|
if (!inference || inference.source !== 'fallback') {
|
|
339
408
|
return '';
|
|
340
409
|
}
|
|
410
|
+
if (inference.rejected) {
|
|
411
|
+
return `The spacing tokens found do not form a spacing scale (${inference.rejected.reasons.join(', ')}); using preset "${inference.preset}".`;
|
|
412
|
+
}
|
|
341
413
|
|
|
342
414
|
return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".`;
|
|
343
415
|
}
|
|
344
416
|
|
|
345
417
|
module.exports = {
|
|
346
418
|
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
419
|
+
assessScale,
|
|
347
420
|
autoScaleFallbackNote,
|
|
348
421
|
discoverTokenPackages,
|
|
349
422
|
resolveAutoScale,
|
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
"dist/css/global-vars.css"
|
|
44
44
|
],
|
|
45
45
|
"note": "--spectrum-spacing-* in px"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "@carbon/layout",
|
|
49
|
+
"files": [
|
|
50
|
+
"scss/generated/_spacing.scss"
|
|
51
|
+
],
|
|
52
|
+
"note": "$spacing-01..13 in rem, Sass variables; Carbon's styles package forwards them from here"
|
|
46
53
|
}
|
|
47
54
|
]
|
|
48
55
|
}
|
|
@@ -38,7 +38,7 @@ const TOKEN_KIND_PATTERNS = Object.freeze({
|
|
|
38
38
|
// --spacing) but never letter-/word-spacing. Sass names must start with the scale word
|
|
39
39
|
// (an optional `system-` prefix allowed): $spacer, $spacers.3, $spacing-01, $system-spacing.
|
|
40
40
|
// Component variables such as $dropdown-spacer or $card-spacer-y are not scale tokens.
|
|
41
|
-
spacing: /^(?:\$(?:system-)?(?:space|spacing|spacer)s?(?:-|$|\.)|--(?:[\w-]*-)?(?<!letter-)(?<!word-)(?:space|spacing)(?:-|$))/,
|
|
41
|
+
spacing: /^(?:\$(?:system-)?(?:space|spacing|spacer)s?(?:-|$|\.)|--(?:[\w-]*-)?(?<!letter-)(?<!word-)(?:space|spacing|spacer)(?:-|$))/,
|
|
42
42
|
typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
|
|
43
43
|
});
|
|
44
44
|
|
|
@@ -193,7 +193,8 @@ function normalizeTokenSource(source) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
function detectSourceFormat(filePath) {
|
|
196
|
-
|
|
196
|
+
// .scss token files are read by the CSS collector too: custom properties plus Sass variables and maps.
|
|
197
|
+
if (['.css', '.scss'].includes(path.extname(filePath).toLowerCase())) {
|
|
197
198
|
return 'css';
|
|
198
199
|
}
|
|
199
200
|
|
|
@@ -283,16 +284,40 @@ function collectScssTokens(source, matchesKind) {
|
|
|
283
284
|
};
|
|
284
285
|
|
|
285
286
|
const tokens = [];
|
|
286
|
-
const push = (tokenName, value) => {
|
|
287
|
-
if (!value || !matchesKind(tokenName)) {
|
|
287
|
+
const push = (tokenName, value, force = false) => {
|
|
288
|
+
if (!value || (!force && !matchesKind(tokenName))) {
|
|
288
289
|
return;
|
|
289
290
|
}
|
|
290
|
-
|
|
291
|
+
const formatted = formatScssValue(value);
|
|
292
|
+
// A unitless number is a multiplier or a map index, not a length. Zero is fine.
|
|
293
|
+
if (/^-?\d*\.?\d+$/.test(formatted) && parseFloat(formatted) !== 0) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
tokens.push({ token: tokenName, value: formatted });
|
|
291
297
|
};
|
|
298
|
+
// Namespaced maps such as GOV.UK's $govuk-spacing-points are only accepted
|
|
299
|
+
// for the spacing kind, and only when they hold a real ladder (issue #86).
|
|
300
|
+
const spacingKind = matchesKind('$spacing-probe');
|
|
292
301
|
|
|
293
302
|
for (const [name, raw] of declarations) {
|
|
294
303
|
const tokenName = `$${name}`;
|
|
295
304
|
if (isScssMap(raw)) {
|
|
305
|
+
if (!matchesKind(tokenName) && spacingKind && NAMESPACED_SPACING_MAP.test(tokenName)) {
|
|
306
|
+
const entries = [];
|
|
307
|
+
walkScssMap(raw, [tokenName], (pathName, expression) => {
|
|
308
|
+
const value = evaluateScssExpression(expression, resolveVariable, new Set());
|
|
309
|
+
if (value) {
|
|
310
|
+
entries.push([pathName, value]);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
const distinct = new Set(entries.map(([, value]) => formatScssValue(value)).filter((value) => /^-?\d*\.?\d+(?:px|rem|em)$/.test(value) && parseFloat(value) !== 0));
|
|
314
|
+
if (distinct.size >= MIN_NAMESPACED_MAP_LENGTHS) {
|
|
315
|
+
for (const [pathName, value] of entries) {
|
|
316
|
+
push(pathName, value, true);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
296
321
|
walkScssMap(raw, [tokenName], (pathName, expression) => {
|
|
297
322
|
push(pathName, evaluateScssExpression(expression, resolveVariable, new Set()));
|
|
298
323
|
});
|
|
@@ -304,6 +329,10 @@ function collectScssTokens(source, matchesKind) {
|
|
|
304
329
|
return tokens;
|
|
305
330
|
}
|
|
306
331
|
|
|
332
|
+
/** `$<namespace>-spacing-points`, `$<ns>-space-scale`: one namespace segment before the anchor. */
|
|
333
|
+
const NAMESPACED_SPACING_MAP = /^\$[a-z0-9]+-(?:space|spacing|spacer)s?(?:-|$)/i;
|
|
334
|
+
const MIN_NAMESPACED_MAP_LENGTHS = 4;
|
|
335
|
+
|
|
307
336
|
function stripScssComments(source) {
|
|
308
337
|
return source
|
|
309
338
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
package/types/audit.d.ts
CHANGED
|
@@ -27,7 +27,9 @@ export interface AuditOptions {
|
|
|
27
27
|
configPath?: string;
|
|
28
28
|
dir?: string;
|
|
29
29
|
failOnNewDrift?: boolean;
|
|
30
|
-
|
|
30
|
+
/** Badge value when `format` is "badge": drift percent or off-scale count. */
|
|
31
|
+
badgeMetric?: "drift" | "findings";
|
|
32
|
+
format?: "json" | "json-v1" | "markdown" | "text" | "html" | "github" | "badge";
|
|
31
33
|
ignorePath?: string;
|
|
32
34
|
ignorePatterns?: string[];
|
|
33
35
|
includeMotion?: boolean;
|
|
@@ -96,9 +98,19 @@ export interface AuditBaselineComparison {
|
|
|
96
98
|
|
|
97
99
|
export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-package" | "token-sources";
|
|
98
100
|
|
|
101
|
+
export interface AuditScaleRejected {
|
|
102
|
+
files?: string[];
|
|
103
|
+
/** Why the inferred set was not accepted as a scale, for example "no common step". */
|
|
104
|
+
reasons: string[];
|
|
105
|
+
source: AuditScaleSource;
|
|
106
|
+
values: Array<number | string>;
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
export interface AuditScale {
|
|
100
110
|
/** Files the scale was derived from (token sources or scanned stylesheets). Empty for explicit, default and fallback. */
|
|
101
111
|
files: string[];
|
|
112
|
+
/** Present when `source` is "fallback" because an inferred scale failed the plausibility check. */
|
|
113
|
+
rejected?: AuditScaleRejected | null;
|
|
102
114
|
source: AuditScaleSource;
|
|
103
115
|
tokenCount: number;
|
|
104
116
|
values: Array<number | string>;
|
|
@@ -137,6 +149,7 @@ export interface AuditContractReport {
|
|
|
137
149
|
files: string[];
|
|
138
150
|
offScaleProperties?: Record<string, number>;
|
|
139
151
|
offScaleValues?: unknown;
|
|
152
|
+
rejected: AuditScaleRejected | null;
|
|
140
153
|
source: AuditScaleSource;
|
|
141
154
|
tokenOpportunities?: unknown;
|
|
142
155
|
values: Array<number | string> | null;
|