stylelint-plugin-rhythmguard 3.0.0 → 3.2.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 +23 -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 +8 -6
- package/src/audit/args.js +26 -3
- package/src/audit/contract.js +5 -0
- package/src/audit/render-badge.js +54 -0
- package/src/audit/render-html.js +1 -0
- package/src/audit/render-markdown.js +4 -3
- package/src/audit/render-text.js +4 -3
- package/src/audit/scan.js +73 -0
- 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 -0
- package/types/audit.d.ts +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.2.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `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))
|
|
14
|
+
- `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))
|
|
15
|
+
- `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))
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
## [3.1.0] - 2026-09-06
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Audit findings for CSS declarations carry the declaration's `property`, recovered from the source at the warning position (`null` inside at-rules). Off-scale findings are counted by property in `offScaleProperties`, exposed as `contracts.scale.offScaleProperties`, rendered as a histogram in text, Markdown and HTML output and as a `Top properties` line in the quickstart. Baseline keys and benchmark snapshots are unchanged. ([#64](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/64))
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `rhythmguard audit --help` describes what `--scale auto` reads and in which order, SCSS scanning through `postcss-scss`, and the value, property and file breakdowns.
|
|
30
|
+
- CI and release workflows no longer run the full suite on the Stylelint 16.0.0 floor as an allowed-to-fail step; that step produced an error annotation on every green run. The blocking floor suite is unchanged. Actions `checkout` and `setup-node` moved to v5.
|
|
31
|
+
|
|
9
32
|
## [3.0.0] - 2026-09-06
|
|
10
33
|
|
|
11
34
|
Upgrade notes: [`docs/MIGRATING_TO_3.md`](./docs/MIGRATING_TO_3.md).
|
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=5" 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.2.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,9 @@
|
|
|
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"
|
|
117
120
|
},
|
|
118
121
|
"repository": {
|
|
119
122
|
"type": "git",
|
|
@@ -125,8 +128,8 @@
|
|
|
125
128
|
"email": "hello@petrilahdelma.com"
|
|
126
129
|
},
|
|
127
130
|
"peerDependencies": {
|
|
128
|
-
"stylelint": "^16.0.0 || ^17.0.0",
|
|
129
131
|
"postcss-scss": "^4.0.0",
|
|
132
|
+
"stylelint": "^16.0.0 || ^17.0.0",
|
|
130
133
|
"stylelint-config-tailwindcss": "^1.0.1",
|
|
131
134
|
"stylelint-plugin-logical-css": "^2.0.2"
|
|
132
135
|
},
|
|
@@ -145,11 +148,10 @@
|
|
|
145
148
|
"known-css-properties": "^0.37.0"
|
|
146
149
|
},
|
|
147
150
|
"devDependencies": {
|
|
148
|
-
"@eslint/js": "^9.
|
|
151
|
+
"@eslint/js": "^9.39.5",
|
|
149
152
|
"@types/node": "^22.20.1",
|
|
150
153
|
"c8": "^10.1.3",
|
|
151
|
-
"eslint": "^9.
|
|
152
|
-
"geist": "^1.7.0",
|
|
154
|
+
"eslint": "^9.39.5",
|
|
153
155
|
"postcss-scss": "^4.0.9",
|
|
154
156
|
"postcss-value-parser": "^4.2.0",
|
|
155
157
|
"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)
|
|
@@ -37,8 +40,14 @@ Options:
|
|
|
37
40
|
--token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
|
|
38
41
|
--token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
|
|
39
42
|
--scale <values|auto> Comma-separated scale values (default: 0,4,8,12,16,24,32);
|
|
40
|
-
auto infers the scale from token sources, then
|
|
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
|
|
41
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.
|
|
42
51
|
`;
|
|
43
52
|
|
|
44
53
|
function parseArgs(argv) {
|
|
@@ -277,6 +286,16 @@ function parseArgs(argv) {
|
|
|
277
286
|
continue;
|
|
278
287
|
}
|
|
279
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
|
+
|
|
280
299
|
if (arg === '--format') {
|
|
281
300
|
parsed.format = String(argv[++index] || '').toLowerCase();
|
|
282
301
|
continue;
|
|
@@ -323,6 +342,10 @@ function parseArgs(argv) {
|
|
|
323
342
|
throw new Error(`Invalid format "${parsed.format}". Expected text, json, json-v1, markdown, html, or github.`);
|
|
324
343
|
}
|
|
325
344
|
|
|
345
|
+
if (!BADGE_METRICS.has(parsed.badgeMetric)) {
|
|
346
|
+
throw new Error(`Invalid badge metric "${parsed.badgeMetric}". Expected drift or findings.`);
|
|
347
|
+
}
|
|
348
|
+
|
|
326
349
|
if (parsed.since && parsed.staged) {
|
|
327
350
|
throw new Error('Use either --since or --staged, not both.');
|
|
328
351
|
}
|
package/src/audit/contract.js
CHANGED
|
@@ -268,6 +268,9 @@ function buildReport({
|
|
|
268
268
|
const offScaleValues = countByValue(cssFindings
|
|
269
269
|
.filter((finding) => finding.type === 'off-scale' && finding.value)
|
|
270
270
|
.map((finding) => finding.value));
|
|
271
|
+
const offScaleProperties = countByValue(cssFindings
|
|
272
|
+
.filter((finding) => finding.type === 'off-scale' && finding.property)
|
|
273
|
+
.map((finding) => finding.property));
|
|
271
274
|
const tokenOpportunities = countByValue(cssFindings
|
|
272
275
|
.filter((finding) => finding.type === 'token-opportunity' && finding.value)
|
|
273
276
|
.map((finding) => finding.value));
|
|
@@ -319,6 +322,7 @@ function buildReport({
|
|
|
319
322
|
findings: motionFindings.length,
|
|
320
323
|
values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
|
|
321
324
|
},
|
|
325
|
+
offScaleProperties: Object.fromEntries(sortCountMap(offScaleProperties).slice(0, 10)),
|
|
322
326
|
offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
|
|
323
327
|
scale: scale || null,
|
|
324
328
|
scaleCleanliness,
|
|
@@ -388,6 +392,7 @@ function toAuditContractReport(report) {
|
|
|
388
392
|
scale: {
|
|
389
393
|
cleanliness: report.scaleCleanliness,
|
|
390
394
|
files: report.scale ? report.scale.files : [],
|
|
395
|
+
offScaleProperties: report.offScaleProperties || {},
|
|
391
396
|
offScaleValues: report.offScaleValues,
|
|
392
397
|
source: report.scale ? report.scale.source : 'default',
|
|
393
398
|
tokenOpportunities: report.tokenOpportunities,
|
|
@@ -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
|
+
};
|
package/src/audit/render-html.js
CHANGED
|
@@ -34,6 +34,7 @@ function renderHtml(report) {
|
|
|
34
34
|
metricHtml('CSS files', report.cssFilesScanned),
|
|
35
35
|
metricHtml('Template files', report.templateFilesScanned),
|
|
36
36
|
'</section>',
|
|
37
|
+
renderHtmlTable('CSS Off-Scale Properties', ['Property', 'Count'], Object.entries(report.offScaleProperties || {})),
|
|
37
38
|
renderHtmlTable('Top Affected Files', ['File', 'Findings'], report.topAffectedFiles.map(({ file, count }) => [file, count])),
|
|
38
39
|
renderHtmlTable('Token Contract Sources', ['File', 'Format', 'Tokens'], report.tokenContract.sources.map((source) => [
|
|
39
40
|
source.file,
|
|
@@ -40,6 +40,7 @@ function renderMarkdown(report) {
|
|
|
40
40
|
lines.push('');
|
|
41
41
|
|
|
42
42
|
appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
|
|
43
|
+
appendMarkdownCounts(lines, 'CSS Off-Scale Properties', report.offScaleProperties, 'Property');
|
|
43
44
|
appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
|
|
44
45
|
appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
|
|
45
46
|
appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
|
|
@@ -204,8 +205,8 @@ function appendBaselineMarkdown(lines, report) {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
function appendMarkdownCounts(lines, title, counts) {
|
|
208
|
-
const entries = sortCountMap(counts);
|
|
208
|
+
function appendMarkdownCounts(lines, title, counts, label = 'Value') {
|
|
209
|
+
const entries = sortCountMap(counts || {});
|
|
209
210
|
|
|
210
211
|
if (entries.length === 0) {
|
|
211
212
|
return;
|
|
@@ -213,7 +214,7 @@ function appendMarkdownCounts(lines, title, counts) {
|
|
|
213
214
|
|
|
214
215
|
lines.push(`## ${title}`);
|
|
215
216
|
lines.push('');
|
|
216
|
-
lines.push(
|
|
217
|
+
lines.push(`| ${label} | Count |`);
|
|
217
218
|
lines.push('| --- | ---: |');
|
|
218
219
|
for (const [value, count] of entries) {
|
|
219
220
|
lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
|
package/src/audit/render-text.js
CHANGED
|
@@ -33,6 +33,7 @@ function renderText(report) {
|
|
|
33
33
|
lines.push('');
|
|
34
34
|
|
|
35
35
|
appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
|
|
36
|
+
appendHistogram(lines, 'CSS OFF-SCALE PROPERTIES', report.offScaleProperties);
|
|
36
37
|
appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
|
|
37
38
|
appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
|
|
38
39
|
appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
|
|
@@ -145,9 +146,9 @@ function appendBaselineText(lines, report) {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
function appendHistogram(lines, title, counts) {
|
|
149
|
-
const entries = sortCountMap(counts);
|
|
150
|
-
const total = sumCounts(counts);
|
|
149
|
+
function appendHistogram(lines, title, counts = {}) {
|
|
150
|
+
const entries = sortCountMap(counts || {});
|
|
151
|
+
const total = sumCounts(counts || {});
|
|
151
152
|
|
|
152
153
|
if (entries.length === 0) {
|
|
153
154
|
return;
|
package/src/audit/scan.js
CHANGED
|
@@ -342,10 +342,12 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
342
342
|
|
|
343
343
|
function collectCssFindings(fileResults) {
|
|
344
344
|
const findings = [];
|
|
345
|
+
const sources = new Map();
|
|
345
346
|
|
|
346
347
|
for (const fileResult of fileResults) {
|
|
347
348
|
for (const warning of fileResult.warnings || []) {
|
|
348
349
|
const text = warning.text || '';
|
|
350
|
+
const source = readSourceOnce(sources, fileResult.source);
|
|
349
351
|
const offScaleMatch = text.match(
|
|
350
352
|
/Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
|
|
351
353
|
);
|
|
@@ -363,6 +365,9 @@ function collectCssFindings(fileResults) {
|
|
|
363
365
|
column: warning.column || 1,
|
|
364
366
|
file: formatPath(fileResult.source),
|
|
365
367
|
line: warning.line || 1,
|
|
368
|
+
property: source === null
|
|
369
|
+
? null
|
|
370
|
+
: findDeclarationProperty(source, warning.line || 1, warning.column || 1),
|
|
366
371
|
rule: warning.rule || 'rhythmguard',
|
|
367
372
|
text,
|
|
368
373
|
type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
|
|
@@ -379,6 +384,73 @@ function collectCssFindings(fileResults) {
|
|
|
379
384
|
return findings;
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
function readSourceOnce(cache, filePath) {
|
|
388
|
+
if (!filePath) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
if (!cache.has(filePath)) {
|
|
392
|
+
try {
|
|
393
|
+
cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
|
|
394
|
+
} catch {
|
|
395
|
+
cache.set(filePath, null);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return cache.get(filePath);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const DECLARATION_BOUNDARY = new Set([';', '{', '}']);
|
|
402
|
+
const DECLARATION_HEAD_PATTERN = /^\s*(?:(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*)*(--[\w-]+|[a-zA-Z][\w-]*)\s*:/;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Stylelint warnings carry a position but not the declaration node. Recover the
|
|
406
|
+
* property by walking from the warning position back to the previous declaration
|
|
407
|
+
* boundary and reading the `property:` head, skipping block and Sass line comments.
|
|
408
|
+
* Sass interpolation is blanked first so `#{...}` braces do not act as boundaries. Returns null when the position is not
|
|
409
|
+
* inside a declaration, for example inside an at-rule.
|
|
410
|
+
*/
|
|
411
|
+
function findDeclarationProperty(source, line, column) {
|
|
412
|
+
const offset = positionToOffset(source, line, column);
|
|
413
|
+
if (offset === null) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const text = source.replace(/#\{[^}]*\}/g, (match) => ' '.repeat(match.length));
|
|
418
|
+
let start = offset;
|
|
419
|
+
while (start > 0 && !DECLARATION_BOUNDARY.has(text[start - 1])) {
|
|
420
|
+
start -= 1;
|
|
421
|
+
}
|
|
422
|
+
const backward = text.slice(start, offset).match(DECLARATION_HEAD_PATTERN);
|
|
423
|
+
if (backward) {
|
|
424
|
+
return backward[1];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let end = offset;
|
|
428
|
+
while (end < text.length && !DECLARATION_BOUNDARY.has(text[end])) {
|
|
429
|
+
end += 1;
|
|
430
|
+
}
|
|
431
|
+
const forward = text.slice(offset, end).match(DECLARATION_HEAD_PATTERN);
|
|
432
|
+
return forward ? forward[1] : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function positionToOffset(source, line, column) {
|
|
436
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
let offset = 0;
|
|
440
|
+
let currentLine = 1;
|
|
441
|
+
while (currentLine < line) {
|
|
442
|
+
const newline = source.indexOf('\n', offset);
|
|
443
|
+
if (newline === -1) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
offset = newline + 1;
|
|
447
|
+
currentLine += 1;
|
|
448
|
+
}
|
|
449
|
+
const lineEnd = source.indexOf('\n', offset);
|
|
450
|
+
const lineLength = (lineEnd === -1 ? source.length : lineEnd) - offset;
|
|
451
|
+
return offset + Math.min(Math.max((column || 1) - 1, 0), lineLength);
|
|
452
|
+
}
|
|
453
|
+
|
|
382
454
|
function getCssFindingValue({
|
|
383
455
|
motionDurationMatch,
|
|
384
456
|
motionEasingMatch,
|
|
@@ -581,6 +653,7 @@ module.exports = {
|
|
|
581
653
|
collectTailwindMotionFindings,
|
|
582
654
|
createIgnoreMatchers,
|
|
583
655
|
escapeRegExp,
|
|
656
|
+
findDeclarationProperty,
|
|
584
657
|
findStringLiterals,
|
|
585
658
|
getCssFindingType,
|
|
586
659
|
getCssFindingValue,
|
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
|
@@ -177,6 +177,10 @@ async function run() {
|
|
|
177
177
|
if (topValues.length > 0) {
|
|
178
178
|
out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
|
|
179
179
|
}
|
|
180
|
+
const topProperties = topEntries(report.offScaleProperties);
|
|
181
|
+
if (topProperties.length > 0) {
|
|
182
|
+
out.push(` Top properties ${topProperties.map(([property, count]) => `${property} ×${count}`).join(', ')}`);
|
|
183
|
+
}
|
|
180
184
|
const topFiles = (report.topAffectedFiles || []).slice(0, 3);
|
|
181
185
|
if (topFiles.length > 0) {
|
|
182
186
|
out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
|
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;
|
|
@@ -77,7 +79,8 @@ export interface AuditFinding {
|
|
|
77
79
|
key?: string;
|
|
78
80
|
line?: number;
|
|
79
81
|
message: string;
|
|
80
|
-
property
|
|
82
|
+
/** CSS property of the declaration, recovered from the source. Null when the position is not inside a declaration. CSS findings only. */
|
|
83
|
+
property?: string | null;
|
|
81
84
|
rule?: string;
|
|
82
85
|
type: string;
|
|
83
86
|
value?: string;
|
|
@@ -113,6 +116,10 @@ export interface AuditReport {
|
|
|
113
116
|
motion: AuditFinding[];
|
|
114
117
|
tailwind: AuditFinding[];
|
|
115
118
|
};
|
|
119
|
+
/** Off-scale CSS findings counted by property, top ten. */
|
|
120
|
+
offScaleProperties?: Record<string, number>;
|
|
121
|
+
/** Off-scale CSS findings counted by value, top ten. */
|
|
122
|
+
offScaleValues?: Record<string, number>;
|
|
116
123
|
scanned: AuditScanned;
|
|
117
124
|
summary: AuditSummary;
|
|
118
125
|
[key: string]: unknown;
|
|
@@ -130,6 +137,7 @@ export interface AuditContractReport {
|
|
|
130
137
|
scale: {
|
|
131
138
|
cleanliness?: unknown;
|
|
132
139
|
files: string[];
|
|
140
|
+
offScaleProperties?: Record<string, number>;
|
|
133
141
|
offScaleValues?: unknown;
|
|
134
142
|
source: AuditScaleSource;
|
|
135
143
|
tokenOpportunities?: unknown;
|