stylelint-plugin-rhythmguard 3.10.2 → 3.12.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 (44) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +5 -1
  3. package/package.json +1 -1
  4. package/src/audit/args.js +22 -2
  5. package/src/audit/baseline.js +184 -27
  6. package/src/audit/codemod.js +39 -14
  7. package/src/audit/config.js +25 -31
  8. package/src/audit/contract.js +96 -10
  9. package/src/audit/render-github.js +4 -0
  10. package/src/audit/render-html.js +7 -0
  11. package/src/audit/render-markdown.js +27 -0
  12. package/src/audit/render-text.js +23 -1
  13. package/src/audit/report.js +60 -17
  14. package/src/audit/scan/files.js +154 -27
  15. package/src/audit/scan/stylesheets.js +87 -34
  16. package/src/audit/scan/templates.js +22 -7
  17. package/src/audit/token-chains.js +18 -7
  18. package/src/cli/audit.js +7 -3
  19. package/src/cli/doctor.js +48 -7
  20. package/src/cli/fix.js +56 -15
  21. package/src/cli/init.js +136 -23
  22. package/src/cli/quickstart.js +34 -8
  23. package/src/core/fs-cache.js +6 -0
  24. package/src/core/length.js +15 -0
  25. package/src/core/options.js +8 -1
  26. package/src/core/scale-inference.js +99 -68
  27. package/src/core/tailwind-class-analysis.js +26 -16
  28. package/src/core/tailwind-motion-analysis.js +12 -3
  29. package/src/core/token-index.js +76 -24
  30. package/src/core/token-map.js +23 -10
  31. package/src/core/token-source-entries.js +64 -0
  32. package/src/core/token-sources.js +361 -69
  33. package/src/core/value-nodes.js +280 -54
  34. package/src/eslint/literal-spans.js +319 -0
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
  36. package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
  37. package/src/rules/no-offscale-transform/index.js +35 -14
  38. package/src/rules/prefer-token/index.js +72 -48
  39. package/src/rules/report.js +3 -1
  40. package/src/rules/use-motion-scale/index.js +68 -5
  41. package/src/rules/use-scale/index.js +54 -39
  42. package/types/__checks__/eslint-config.ts +37 -0
  43. package/types/audit.d.ts +10 -2
  44. package/types/shared.d.ts +21 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,83 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.12.0] - 2026-10-18
10
+
11
+ ### Fixed
12
+
13
+ - `use-motion-scale` no longer snaps times inside `calc()`, `clamp()`, `min()` and `max()`. They are terms of an expression, so `transition-delay: calc(var(--i) * 30ms)` was reported and fixed to `calc(var(--i) * 0ms)`, removing the stagger, and `calc(var(--index) * 50ms + 100ms)` became `* 75ms`. They are now skipped, as `use-scale` skips them by default; times outside the math function in the same value are still checked. This removes reports for times inside math functions. ([#232](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/232))
14
+ - Negative delays are no longer reported as invalid. `use-motion-scale` rejected every negative time with `Unexpected negative motion duration "-100ms".`, including `transition-delay: -100ms` and the `-0.5s` delay in `animation: spin 1s -0.5s infinite`, which are valid CSS that start the motion partway through; `tailwind-class-use-motion-scale` did the same for `delay-[-100ms]`. A negative is now rejected only for a duration: `*-duration`, `duration-[...]`, and the first time of each layer in a `transition` or `animation` shorthand (not when a function such as `var()` comes before it, since that may be the duration). A negative delay is checked against the scale by its size and a fix keeps the sign, so `-130ms` is reported and becomes `-150ms`, while `-100ms` passes. ([#236](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/236))
15
+ - `tailwind-class-use-motion-scale` no longer flags token easings. Every `ease-[...]` was reported, including `ease-[var(--ease-out)]` and `ease-[--my-ease]`, the tokens its message asks for. Only raw curves (`cubic-bezier(`, `linear(`, `steps(`) are reported now, as in `use-motion-scale`; the same applies to `rhythmguard audit --include-motion`. This removes reports for `ease-[...]` values that are not raw curves. ([#245](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/245))
16
+ - `no-offscale-transform` accepts its documented `tokenPattern` and `tokenFunctions` options. Both were missing from its option schema, so `{ tokenPattern: "^--gap-" }` failed with `Invalid option name "tokenPattern"` and turned the rule off, and `fixWith: "token"` could only write `--space-*` tokens. Both are accepted now; `tokenPattern` picks the tokens `fixWith: "token"` writes, an invalid pattern is reported on the root instead of throwing, and a translation tokenized through one of `tokenFunctions` is skipped as in `use-scale`. With the defaults this skips the fallback of `var(--space-*, ...)`, so `translateX(var(--space-2, 13px))` is no longer reported. ([#237](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/237))
17
+ - `no-offscale-transform` reports what `allowNegative: false` and `allowPercentages: false` forbid. Both branches returned without a report, so `allowNegative: false` made `translateX(-8px)` pass instead of failing, and `translateX(13%)` passed with `allowPercentages: false`. They are now reported without a fix, as in `use-scale`: `Unexpected transform translation value "-8px". Use scale values (nearest: n/a).` Only configs that set either option to `false` see new reports. ([#238](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/238))
18
+ - The audit finds Tailwind classes in string literals nested inside another literal. After a literal closed, the scanner continued after it, so ``className={`flex ${on ? 'p-[13px]' : 'm-[15px]'}`}``, Vue `:class="{ 'p-[13px]': active }"` and `:class="[active ? 'px-[13px]' : '']"` gave no findings and Svelte `class="m-[15px] {on ? 'gap-[13px]' : ''}"` found only `m-[15px]`, while the ESLint rule reported them. Each literal's value is now scanned again for literals in the other quote kinds, which still reads every character at most three times. This changes reports: projects with such classes get new findings, and a baseline written by 3.11.0 counts them as new drift. ([#254](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/254))
19
+ - `rhythmguard doctor` agrees with the audit on `.rhythmguardrc.json`. It failed a decisions-only file with `audit config missing`, which the audit reads fine, and passed files the audit rejects, such as `{"audit":{"minCleanliness":150}}` or a decision with value `abc`. It now validates the `audit` section with the audit's own loader (which makes the section optional and checks every value, token source and the ignore file) and the `decisions` section with the same code the audit and the rules use, and reports each section's error as a failure. ([#255](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/255))
20
+ - `rhythmguard fix --decided` applies every snap decision once, to the original values. Each decision ran over the files the previous one had rewritten, so decisions `10px -> 14px` and `14px -> 16px` turned `padding: 10px` into `16px` on `--write`, while the dry run, reading the files unchanged, showed `14px`. All snaps now run in one pass: a value one snap writes is never snapped again, and the dry run lists exactly what `--write` writes. ([#256](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/256))
21
+ - `rhythmguard init` writes the config Stylelint loads. It always wrote `.stylelintrc.json`, which Stylelint never reads when `package.json` has a `stylelint` key or a `.stylelintrc` exists, so init reported `Wrote .stylelintrc.json` and nothing changed. Init now finds the config with Stylelint's search order (cosmiconfig's: `package.json`, `.stylelintrc`, `.stylelintrc.{json,yaml,yml,js,ts,cjs,mjs}`, `.config/stylelintrc*`, `stylelint.config.{js,ts,cjs,mjs}`). A JSON `.stylelintrc` or `.stylelintrc.json` is rewritten in place after the same prompts; for a `package.json` key, a YAML file or a JS or TS config, init writes nothing and prints the lines to add, in that file's syntax. ([#257](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/257))
22
+ - The token source cache no longer grows with every save. Parsed token sources were cached under a key that included each file's mtime and never evicted, so an editor or watch mode that re-linted after each edit kept every old parse: a 20k-token file edited 60 times took the heap from 4MB to 1152MB. The cache is now keyed by the source paths, keeps the mtimes in the entry and replaces it when a file changes; the same 60 edits stay at about 23MB. ([#264](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/264))
23
+ - The `scale: "auto"` fallback note names the token source that failed. A missing `scaleSources` file or one that does not parse read `No spacing tokens were found for scale "auto"; using preset "rhythmic-4".`, the same as a project without tokens. The note now adds the reason, for example `Token source not found: does-not-exist.css.` This changes the note for configs whose token sources fail; see Messages. ([#270](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/270))
24
+ - An unknown `format` in a token source no longer aborts the Stylelint run. In `scaleSources` it is now an invalid option warning; in `.rhythmguardrc.json` (`{"audit":{"tokenSources":[{"path":"tokens.css","format":"scss"}]}}`) the entry is skipped and the `scale: "auto"` fallback note names it, as it does for an rc file that is not valid JSON. The audit still stops on such an entry with `Invalid token source format "scss". ...`. An rc entry the audit rejects (no `path`, an empty `tokenPattern`) is now skipped by the lint too, where it used to be read with the pattern ignored. ([#265](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/265))
25
+ - Lint and audit read the same files from `.rhythmguardrc.json`. The lint honoured `baseDir` and `file` in `audit.tokenSources` entries; the audit ignored `baseDir` and refused `file` with `token source objects must include a path.` Both now read entries through one normaliser: `file` is another name for `path`, and `baseDir` resolves from the config file's directory. This changes the audit's scale for configs that set `baseDir`. ([#271](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/271))
26
+ - `scales: { spacing: "auto" }` next to an explicit `scale` uses `scale` when no spacing tokens are found, as the docs say. It used the `rhythmic-4` fallback, so with `scale: [0, 5, 10, 15, 20]` a `padding: 5px` was reported as `Use spacing scale values (nearest: 4px or 8px)`. This changes findings for configs without spacing tokens that set it. ([#261](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/261))
27
+ - Typography `"auto"` no longer reads Tailwind's sub-tokens such as `--text-sm--line-height: 20px` as font sizes, which put line heights on the type scale. Names with a second `--` after the prefix are left out. This changes findings for Tailwind v4 projects that set `scales: { typography: "auto" }`. ([#262](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/262))
28
+ - Audit token chains resolve through installed token packages and through tokens of any kind in token sources. Chains were resolved against token sources filtered to the spacing kind, without packages, so `--button-padding: var(--space-2)` with `@radix-ui/themes` installed, or `--card-gap: var(--size-8)` with `--size-8` in a token source, read `unresolved`. Those tokens are now looked up; they are not counted as chains of their own, so totals change only where a chain used to be unresolved. ([#259](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/259))
29
+ - `--token-kind` no longer changes the audit's spacing scale. With `--scale auto --token-kind radius`, spacing was checked against the radius tokens (`[0, 3, 6, 10, 14]`); token sources are now read once more for spacing tokens to build the scale, and `--token-kind` shapes the token contract only. ([#260](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/260))
30
+ - `tailwind-class-use-scale` with `allowNegative: false` reports the negative class form. Only a negative value inside the brackets counted, so `-m-[13px]` was reported as off-scale and fixed to `-m-3`, and `-m-[16px]` was not reported at all. `-m-[16px]` and `m-[-16px]` now both read `Negative values are disabled for this rule.`, with no fix. With `allowNegative: false`, negative classes that are on the scale are now reported; the default (`true`) reports nothing new. The audit's class-string scan uses the same analysis. ([#241](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/241))
31
+ - `tailwind-class-use-scale` names and writes utility classes for the configured root size. `spacingUnit` was 4px whatever `baseFontSize` said, so at `baseFontSize: 10` `p-[1.3rem]` was fixed to `p-3`, which is 7.5px at a 10px root. When `spacingUnit` is not set it is now `baseFontSize / 4` (Tailwind's `0.25rem` step, still 4px at the default 16), so that value is fixed to `p-[1.2rem]` and a scale of 2.5px multiples gets classes. `em` values keep the arbitrary form and their unit (`p-[1.1em]` becomes `p-[1em]`, not `p-4`), because a utility class is root-relative and an `em` value follows the element's font size. Configs with the default 16px root and px or rem values get the same fixes as before; an explicit `spacingUnit` is used as given. ([#247](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/247))
32
+ - `tailwind-class-use-scale` checks `class` attributes in Vue and Svelte templates. It visited only `Literal` and `TemplateElement`, so with vue-eslint-parser the `<template>` (a separate tree) was never seen, and with svelte-eslint-parser the static text of `class="..."` (a `SvelteLiteral`) was skipped, while the audit counted both. The rule now walks the Vue template through `parserServices.defineTemplateBodyVisitor` when the parser provides it, checking static `class` attributes and the strings in bound classes, and visits `SvelteLiteral` in `class` attributes. Both are fixed in place, with HTML entities kept as written. No new dependency: the code uses the parsers' node shapes only when they are present. ([#244](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/244))
33
+ - The ESLint plugin's TypeScript declarations are assignable to ESLint's `ESLint.Plugin` and `Linter.Config`, so a typed `eslint.config.ts` with `defineConfig` from `eslint/config` compiles. `configs.recommended.rules` allowed any `number` and rules returned `Record<string, unknown>`; severities are now `0 | 1 | 2 | "off" | "warn" | "error"` and `create` returns listener functions, with a typed `meta`. A typecheck fixture registers both `eslint-plugin-rhythmguard` and `stylelint-plugin-rhythmguard/eslint` through `defineConfig`. ([#242](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/242))
34
+ - The ESLint config samples lint JSX. The samples in the READMEs, `FRAMEWORKS.md`, `TAILWIND.md`, `COMPARISON.md`, the rule docs and the snippet quickstart prints had no `files` (so `npx eslint .` skipped `.jsx` and exited 0) or no JSX parsing (`Parsing error: Unexpected token <`). Each now sets `files: ['**/*.{js,jsx,mjs,cjs}']` and `languageOptions.parserOptions.ecmaFeatures.jsx`, and the package README has an "Other file types" note for TypeScript, Vue, Svelte and Astro parsers. A contract test runs every sample as an `eslint.config.js` against a `.jsx` file. ([#243](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/243))
35
+ - Quickstart and the docs no longer call the audit's fallback scale `rhythmic-4`. The audit and the ESLint rule fall back to `0, 4, 8, 12, 16, 24, 32`, while `rhythmic-4` goes on to 40, 48 and 64, so quickstart reported `margin: 40px` as off-scale and then suggested `scale: "auto"`, which falls back to `rhythmic-4` in Stylelint and passes it. On a fallback, quickstart now suggests the explicit scale it used, so the pasted config reports what quickstart reported, and the note reads `the audit's default scale is being used`. `AUDIT.md`, the ESLint rule docs and the benchmark write-ups name the actual default. The default scales themselves are unchanged; one default for the audit, the ESLint rule and the Stylelint rules changes reports and is planned for 4.0. ([#246](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/246))
36
+ - A CSS token file keeps the scope of each custom property. `scaleSources`, `--token-source` and token-package files dropped it, so `.chip { --chip-spacing: 3px }` in `tokens.css` joined the `:root` scale (`[0,3,4,8,16,24]`) and `padding: 3px` was not reported, although the same CSS in the linted stylesheet left it out. Component-local variables in a token file are now left out whenever the root tokens form a scale, as in the stylesheet. This can change the inferred scale, and so the findings, for a token file that declares component variables. ([#258](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/258))
37
+ - A CSS token file with an unquoted `url(https://...)`, such as `@import url(https://fonts.example.com/x.css);` before `:root`, yielded no tokens and no warning, because `//` was read as a line comment in every file and the rest of the line was skipped. `//` now starts a comment only outside plain `.css` files, and never right after `:` or `(`, so `https://` and `url(//cdn...)` stay values in SCSS too. Applies to token sources, the audit's scan of stylesheets and token chains. ([#263](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/263))
38
+ - A Sass variable declared inside a block no longer defines the scale. `$spacer: 8px` followed by `.compact { $spacer: 2px; }` made `$spacers` evaluate against 2px, so the scale became `[0,1,2,4,6]` and `padding: 16px` was reported. Scale inference now reads only top-level declarations and `!global` ones, in token files and in the linted SCSS file; declarations in rules, mixins, `@media` and `@if` blocks, mixin parameters and `@use ... with (...)` settings are left out. Several top-level declarations on one line are now all read; before, only the first was. Values in the linted file are still evaluated as before. This can change the inferred scale for Sass sources with block-local spacing variables. ([#266](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/266)) On the quiet benchmark this uncovers three findings that a block-local variable had hidden: `$link-underline-thickness * -1` (-3px) in govuk-frontend, and `$_header-item-padding` (12px) and `$_header-item-padding - 1px` (11px) in nhsuk-frontend, whose own scale has no 12px step.
39
+ - DTCG and Style Dictionary references resolve. A semantic token such as `"spacing.sm": { "$value": "{dimension.200}" }` or `"value": "{size.base.value}"` kept the reference text, so a DTCG file of aliases over primitives fell back to the default scale and a Style Dictionary alias was dropped from it. References are now followed within the file, through tokens of any name, before the spacing filter; a reference to a missing token or a cycle keeps its text and still counts as no length. ([#267](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/267))
40
+ - DTCG 2025.10 dimension objects are read. `"$value": { "value": 4, "unit": "px" }` was skipped, so a token file in the stable format yielded no tokens and the scale fell back without saying why. px and rem dimensions are now read (`4px`, `0.75rem`), references resolve through them, and a dimension in another unit is skipped with a warning that names the token. ([#163](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/163))
41
+ - A non-zero token value without a unit is no longer read as px. `--space-ratio: 1.5` and `--spacing-scale: 3` put 1.5px and 3px on an inferred scale, and token chains reported them as `off-scale` with terminal `1.5px`; a DTCG `"$value": 1.5` or a Style Dictionary `"value": 4` did the same. They are now ratios, not lengths: left out of the scale and `non-length` in token chains. A unitless `0` is still a length, and flat JSON numbers (`{ "space-1": 4 }`) are still read as px. ([#268](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/268))
42
+ - A comment or a trailing `!important` in a custom property value no longer drops the token. `--space-1: 4px !important` and `--space-1: 4px /* 0.25rem */` were stored as `4px !important` and `4px /* 0.25rem */`, which do not parse, so the token was lost and the scale could fall back. The value is now read without comments and without the trailing `!important`, and a `;` inside a string or a comment no longer ends it. ([#269](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/269))
43
+ - A comment inside a value is kept by the fix, and the warning points at the literal. PostCSS strips comments from `decl.value`, so `padding: 8px /* keep */ 13px` was reported at the start of the value and fixed to `padding: 8px 12px` with the comment gone; the same happened in `clamp()` and in Sass values, where a `// note` between terms was lost too. The rules now read the value as written, comments included, and splice each fix into it, so the result is `padding: 8px /* keep */ 12px` and lengths inside a Sass `//` comment are never read as values. Applies to `use-scale`, `prefer-token`, `no-offscale-transform` and `use-motion-scale`. ([#233](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/233))
44
+ - A literal operand of a Sass expression that does not resolve is reported but no longer rewritten. With `$gap` unknown, `margin: $gap * 13px` was fixed to `$gap * 12px`, which changes what the expression computes. A literal next to `*`, `/`, `+`, `-` or `%`, or inside a Sass module function such as `math.div(13px, $n)`, in a value containing `$` is now reported without a fix, by `use-scale` and `prefer-token`. A literal that is a term of its own (`13px $gutter`) is still fixed. ([#234](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/234))
45
+ - Typography tokens are no longer offered for spacing properties. `--text-*`, `--fs-*`, `--letter-spacing-*` and `--word-spacing-*` (also prefixed, as `--mantine-letter-spacing-md`) declared no kind, so `padding: 13px` with `fixWith: "token"` was fixed to `var(--text-sm)` and `prefer-token` offered `var(--letter-spacing-wide)` for a margin. They are now typography tokens and serve only `font-size`, `line-height`, `letter-spacing` and `word-spacing`. A Tailwind sub-key such as `--text-sm--line-height` is a typography token too. ([#239](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/239))
46
+ - A raw length in the fallback of a token `var()` is checked. A `var()` whose name matched `tokenPattern` was skipped whole, so `padding: var(--space-1, 7px)` passed while `var(--nope, 13px)` was reported. Only the name is skipped now; the fallback is walked like any value, also when nested (`var(--a, var(--b, 7px))`). This adds reports: `use-scale` and `no-offscale-transform` report an off-scale fallback (`translateX(var(--space-2, 13px))`), and `prefer-token` asks for a token in a fallback (`var(--brand-gap, 8px)` becomes `var(--brand-gap, var(--space-2))`). A fallback the enclosing token already holds (`var(--space-2, 8px)` with `--space-2: 8px`) passes `prefer-token`, and no fix ever writes the token a fallback falls back from. ([#240](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/240))
47
+ - `--staged` audits the staged content. It took the file list from the index but read each file from disk, so a commit carrying `padding: 13px` passed `--max-findings 0` when the working copy had moved on to `color: blue`, and drift that existed only in the working copy failed a clean commit. The staged blobs are now read from the index in one `git cat-file --batch` call and linted as they will be committed, for stylesheets and template files alike, including a staged file that is no longer in the working tree. `--since` still reads the working tree. ([#248](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/248))
48
+ - Git-scoped runs no longer corrupt baselines. With `--staged` or `--since`, `--since-baseline` counted every baseline finding outside the changed files as resolved (`2 resolved, 0 new` for findings in a file that was never scanned), and `--write-baseline` replaced the baseline with the changed files' findings alone, so the next full run failed `--fail-on-new-drift` with `0 resolved, 2 new`. A scoped comparison now counts resolved findings only in the files it scanned, and a scoped write replaces the entries for those files and keeps the rest. A scoped write into a `formatVersion: 1` baseline stops with an error asking for one full `--write-baseline` first, since those entries cannot be merged reliably. ([#249](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/249))
49
+ - Stylesheets the audit did not lint no longer count as clean scanned files. `.scss` files skipped because postcss-scss is not installed and files excluded by the project's `.stylelintignore` were in `CSS files scanned` and in the cleanliness denominator, so a project whose only drift sat in skipped files reported 100%. They are now left out of both, and a baseline finding in such a file is neither resolved nor new; the quickstart prints them as `Skipped 2 not audited (2 SCSS without postcss-scss)`. This changes `scanned.cssFiles`, `scanned.totalFiles` and `scaleCleanliness` for projects with skipped or ignored stylesheets. ([#250](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/250))
50
+ - `--min-cleanliness 100` fails when any file has a finding. Cleanliness was rounded to the nearest percent, so 1 file with drift among 201 reported 100%, passed the gate and showed a 0% drift badge. The gate now compares the exact value and the reported value is rounded down: that run reads 99%, exits 1, and the badge shows 1%. Reported cleanliness can be one point lower than before. ([#251](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/251))
51
+ - Baseline keys no longer depend on the working directory. They named each file relative to the directory the command ran from, so a baseline written by `audit web/src` from the repository root reported `2 resolved, 2 new` and failed `--fail-on-new-drift` when read by `audit ./src` from `web`, with no code change. Baselines now carry `formatVersion: 3` and name files relative to the git top level, or to the scanned directory outside a repository. Baselines written by 3.11.0 still compare: from the directory they were written in as before, and from another one when their recorded `directory` ends the scanned path. They are upgraded on the next `--write-baseline`. ([#252](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/252))
52
+ - A stylesheet with a syntax error is reported as a parse error instead of an off-scale finding. Stylelint runs no rule on a file it cannot parse, and its `CssSyntaxError` warning was read as `{"type":"off-scale","value":null}`, counted in total findings, cleanliness and the badge, with nothing saying the file was not audited. It is now a `parse-error` entry in `findings.parseErrors` with a `summary.parseErrors` count, left out of findings, off-scale counts and cleanliness, listed in the text, Markdown, HTML and quickstart output and annotated with `::error` in `--format github`. The `--schema` output and `types/audit.d.ts` describe the new fields. A file that fails to parse no longer adds to `totalFindings`, so `--max-findings` can pass where it failed before. ([#253](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/253))
53
+
54
+ ### Messages
55
+
56
+ - `rhythmguard doctor`: `✗ .rhythmguardrc.json audit config missing` is gone. An invalid `audit` section reads `✗ .rhythmguardrc.json audit config invalid: <the audit's error>`; the decisions section reads `✓ .rhythmguardrc.json decisions valid (2 decisions)`, `✗ .rhythmguardrc.json decisions invalid: <the error>` or `- decisions check skipped (not configured)`. ([#255](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/255))
57
+ - `rhythmguard init`: the write prompt names the file it writes (`Write .stylelintrc? (y/n)` for a JSON `.stylelintrc`; unchanged for `.stylelintrc.json`). When Stylelint loads a config init cannot rewrite, init prints `⚠ Stylelint loads <file> before .stylelintrc.json, so it would never read a .stylelintrc.json.` (or `⚠ Stylelint loads <file>; a new .stylelintrc.json would replace it and leave it unused.`), the lines to add, and `Nothing was written.` ([#257](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/257))
58
+ - `use-scale` with `scale: "auto"`: when a token source fails, the fallback note goes on to say why, `No spacing tokens were found for scale "auto"; using preset "rhythmic-4". Token source not found: does-not-exist.css.`, with `Unable to parse token source tokens.json: ...`, `.rhythmguardrc.json: Invalid token source format "scss". ...` or `.rhythmguardrc.json could not be read: ...` for the other causes. The rejected-scale note (`The spacing tokens found do not form a spacing scale ...`) gets the same addition. The note is unchanged when every source reads. ([#270](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/270), [#265](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/265))
59
+ - `tailwind-class-use-scale`: an `em` value, or a value at a `baseFontSize` other than 16 whose nearest steps are not quarter steps of `baseFontSize / 4`, now gets the px form of the message, `Use scale values (nearest: 16px or 24px).`, where it named classes such as `Use "p-4" (16px) or "p-6" (24px).` With `allowNegative: false`, `-m-[13px]` reads `Negative values are disabled for this rule.` ([#241](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/241), [#247](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/247))
60
+ - Quickstart: `the rhythmic-4 preset is being used.` now reads `the audit's default scale is being used.`, both for no tokens and for rejected tokens. ([#246](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/246))
61
+ - New token source warning for a DTCG dimension in a unit other than px or rem: `Token source tokens.json: skipped --spacing-print (dimension unit "pt" is not px or rem).` ([#163](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/163))
62
+ - Audit text output: when nothing was found but some stylesheets failed to parse, the closing line reads `! No findings in the stylesheets that parsed; 1 could not be parsed and were not audited.` instead of `✓ No issues found. Your design-system rhythm is clean.` New lines: `Parse errors 1 (not audited)` in the summary and a `PARSE ERRORS` section; in Markdown a `Stylesheets that failed to parse (not audited)` row, a `## Parse Errors` table and the next action `Fix the parse errors above: those stylesheets were not audited.`; in the quickstart `Skipped` and `Parse errors` lines. ([#250](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/250), [#253](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/253))
63
+ - A scoped `--write-baseline` into a `formatVersion: 1` baseline fails with `Cannot update <file> from a --staged or --since run: it was written with formatVersion 1, keyed by line. Run --write-baseline once without --staged or --since to upgrade it.` ([#249](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/249))
64
+
65
+ ## [3.11.0] - 2026-10-18
66
+
67
+ ### Fixed
68
+
69
+ - ESLint `--fix` on `tailwind-class-use-scale` and `tailwind-class-use-motion-scale` no longer rewrites the rest of the string. The fix was built from the decoded value with only the quote re-escaped, so `'p-[13px]\n'` became a string with a real newline that no longer parsed, `\\201C` became an octal escape, `\\` lost its escape and a JSX `&quot;` became `\"`. A fix now replaces only the class token in the source text, and everything else in the literal stays as written. A class with an escape inside it (`p-[13\x70x]`) is still reported but not fixed. Template literals are checked on their decoded text, so a class next to an escape such as `` `p-[13px]\n` `` is now reported; they are still not fixed. ([#225](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/225))
70
+ - `rhythmguard fix` parsed SCSS with the plain CSS parser (`postcss.parse` ignores a `syntax` option), so it rewrote values inside `//` comments (`// old: .card { padding: 10px; }` became `padding: 8px`) and skipped valid SCSS such as a rule with `// note` inside it as `could not parse`. SCSS is now read and written with postcss-scss's own `parse` and `stringify`: comments stay byte-identical and the declarations are fixed. Without postcss-scss an `.scss` file is skipped with `postcss-scss is not installed; install it to rewrite SCSS`. ([#229](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/229))
71
+ - `rhythmguard fix` ignored `.rhythmguardignore` and `audit.ignore`, so `--write` rewrote files the audit never scans: with `legacy` and `vendor/**` ignored, the audit scanned 1 file and `fix` reported `3 replacements in 3 files`. It now loads ignores through the same code as the audit (the ignore file, `audit.ignore`, `audit.ignorePath`) and takes the audit's `--ignore` and `--ignore-path`; generated and vendored directories such as `node_modules` and `dist` stay skipped. Dry run and `--write` list the same files. A file `--decided` cannot read is named once instead of once per decision. ([#230](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/230))
72
+ - `maxFindings` and `minCleanliness` in `.rhythmguardrc.json` now fail the audit. The config was merged into the report but the exit gates read only the command line, so `{"audit":{"maxFindings":0}}` printed findings and exited 0. The gates now read the merged options; a `--max-findings` or `--min-cleanliness` flag still overrides the config, and `--no-config` still ignores it. ([#226](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/226))
73
+ - An invalid `--scale` such as `0;4;8`, `4,abc`, `NaN` or `-4,4,8` is refused with `Invalid --scale value "0;4;8": "0;4;8" is not a non-negative number or length.` and exit 1. Before, Stylelint rejected the option, ran no rule, and the audit reported `No issues found` with every gate passing. `audit.scale` in the config is checked the same way. The audit also stops with Stylelint's own text whenever it rejects any rule option, so no other bad option can switch the audit off. ([#227](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/227))
74
+ - `--staged` and `--since` scan the changed files from any directory. Git printed paths relative to the repository root, which were resolved against the current directory, so a run from a package directory found no files and exited 0; and names with non-ASCII characters came back quoted and were dropped. Git now runs in the scanned directory with `-z`, and paths resolve against the repository top level, so `src/ä b.css` and `src/sp ace.css` are scanned. ([#228](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/228))
75
+ - Negative tokens no longer flip the sign of a fix. Tokens were indexed by their absolute value, so `--space-neg-2: -8px` was offered for `padding: 9px` (writing `var(--space-neg-2)`, which is -8px) and negated for `margin: -9px` (writing `calc(-1 * var(--space-neg-2))`, which is +8px). Token values are now indexed with their sign: a negative token serves only a negative value and is written as is, a positive token is negated for a negative value as before, and a positive value never gets a negative token. Applies to `use-scale` and `no-offscale-transform` with `fixWith: "token"` and to `prefer-token`, including `tokenMapFromCssCustomProperties`, where the last of `--space-2: 8px` and `--space-neg-2: -8px` used to win for both signs. ([#221](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/221))
76
+ - A token with more than one value is no longer written by a fix. `--space-3: 12px` at the root and 20px under `@media (min-width: 768px)`, or `--space-2` redefined under `[data-density=compact]`, was indexed once per value, so `padding: 20px` became `var(--space-3)`, which is 12px on mobile. A token whose definitions resolve to more than one px value (or to a value that is not a plain length) is now left out of the token index and of `tokenMapFromCssCustomProperties`, and the fix writes the literal. The same value in two units (`12px` and `0.75rem`) is still one value. ([#231](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/231))
77
+ - `unitStrategy: "exact"` with `fixWith: "token"` finds the token in the literal's unit. The snapped value, in rem for a rem literal, was compared with the tokens' px values, so `padding: 1.1rem` against a `1rem` step wrote `var(--space-a)` (`0.0625rem`, 1px) instead of `var(--space-1)` (`1rem`). The lookup now converts the snapped value to px first. Applies to `use-scale` and `no-offscale-transform`. ([#223](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/223))
78
+ - An invalid `tokenPattern` (`"["`) is reported again instead of aborting the Stylelint run. `use-scale` and `prefer-token` reported it without a node, which Stylelint rejects with an error about position arguments; the report is now made on the root, once per file, and the file is linted with the default pattern as the option docs describe. ([#222](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/222))
79
+ - Autofix converges when `baseFontSize` is not 16. A fix writes rem and em with four decimals, so `0.7rem` at base 18 became `0.6667rem` (12.0006px), and the next run reported that value as off scale because the on-scale check allowed only 0.0001px. A rem or em value is now on the scale when it is within the rounding of four decimals at the base font size (0.00005 of the base, plus the old tolerance); px values are compared as before. Fix then re-lint is clean at base 10, 12, 14, 18 and 20 in `use-scale`, `no-offscale-transform`, `prefer-token`'s numeric scale check and the ESLint Tailwind rule. ([#224](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/224))
80
+
81
+ ### Messages
82
+
83
+ - `use-scale` and `no-offscale-transform` with `fixWith: "token"`: when a negative token is written as is for a negative literal, the note gives the signed value, `var(--space-neg-2) holds -8px.` A positive token negated for a negative literal still reads `var(--space-3) holds 12px.` ([#221](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/221))
84
+ - `prefer-token`: a negative literal that a negative token holds now reads `Use var(--space-neg-2).`, where it read `Use calc(-1 * var(--space-neg-2)).`; a positive literal is no longer offered a negative token. A token that holds more than one value is no longer named, so such a value reads `No known token holds this value; use the nearest token or add one.` ([#221](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/221), [#231](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/231))
85
+
9
86
  ## [3.10.2] - 2026-10-15
10
87
 
11
88
  ### Messages
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  Nobody chose 13px. Rhythmguard catches off-scale spacing in CSS and Tailwind class strings, tells you the nearest steps on your scale, and snaps to them or to your tokens when you ask.
8
8
 
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/petrilahdelma/stylelint-plugin-rhythmguard/ci.yml?branch=main&label=ci)](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
10
- [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3102)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
10
+ [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3120)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/stylelint-plugin-rhythmguard.svg)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-white.svg)](./LICENSE)
13
13
 
@@ -59,12 +59,16 @@ import rhythmguard from 'eslint-plugin-rhythmguard';
59
59
 
60
60
  export default [
61
61
  {
62
+ files: ['**/*.{js,jsx,mjs,cjs}'],
63
+ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
62
64
  plugins: { 'rhythmguard-tailwind': rhythmguard },
63
65
  rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['error', { scale: [0, 4, 8, 12, 16, 24, 32] }] },
64
66
  },
65
67
  ];
66
68
  ```
67
69
 
70
+ `files` and `ecmaFeatures.jsx` are what make ESLint read `.jsx`; without them it lints no JSX at all. TypeScript, Vue, Svelte and Astro files need their parser (`typescript-eslint`, `vue-eslint-parser`, `svelte-eslint-parser`, `astro-eslint-parser`) in a block of their own: see [Other file types](packages/eslint-plugin-rhythmguard#other-file-types).
71
+
68
72
  ## Rules
69
73
 
70
74
  | Rule | What it reports | Fix |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.10.2",
3
+ "version": "3.12.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"
package/src/audit/args.js CHANGED
@@ -4,6 +4,7 @@ const {
4
4
  normalizeTokenKind,
5
5
  normalizeTokenSourceFormat,
6
6
  } = require('../core/token-sources');
7
+ const { isScaleEntry } = require('../core/options');
7
8
  const { BADGE_METRICS } = require('./render-badge');
8
9
  const {
9
10
  VALID_FORMATS,
@@ -223,9 +224,9 @@ function parseTokenSourcePaths(raw) {
223
224
  }
224
225
 
225
226
 
226
- function parseScale(raw) {
227
+ function parseScale(raw, optionName = '--scale') {
227
228
  if (!raw) {
228
- throw new Error('Missing value for --scale.');
229
+ throw new Error(`Missing value for ${optionName}.`);
229
230
  }
230
231
 
231
232
  if (raw.trim().toLowerCase() === 'auto') {
@@ -240,10 +241,28 @@ function parseScale(raw) {
240
241
  return Number.isFinite(numeric) && !/[a-z%]/i.test(part) ? numeric : part;
241
242
  });
242
243
 
244
+ return validateScale(scale, optionName, raw);
245
+ }
246
+
247
+ /**
248
+ * A scale reaches Stylelint as the use-scale `scale` option, and Stylelint
249
+ * answers an invalid entry with an option warning and no findings at all. Check
250
+ * every entry the way the rule does, so a typo fails here, by name.
251
+ */
252
+ function validateScale(scale, optionName = '--scale', raw = scale) {
243
253
  if (scale.length === 0) {
244
254
  throw new Error('Scale must include at least one value.');
245
255
  }
246
256
 
257
+ const invalid = scale.find((entry) => !isScaleEntry(entry));
258
+ if (invalid !== undefined) {
259
+ const shown = Array.isArray(raw) ? JSON.stringify(raw) : `"${raw}"`;
260
+ throw new Error(
261
+ `Invalid ${optionName} value ${shown}: "${invalid}" is not a non-negative number or length. `
262
+ + 'Use comma-separated values such as 0,4,8,12,16, or auto.',
263
+ );
264
+ }
265
+
247
266
  return scale;
248
267
  }
249
268
 
@@ -268,4 +287,5 @@ module.exports = {
268
287
  parsePositiveInteger,
269
288
  parseScale,
270
289
  parseTokenSourcePaths,
290
+ validateScale,
271
291
  };
@@ -2,6 +2,8 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { exactScaleCleanliness } = require('./contract');
6
+ const { findGitTopLevel } = require('./scan/files');
5
7
  const { formatPath } = require('./shared');
6
8
 
7
9
  /**
@@ -9,12 +11,69 @@ const { formatPath } = require('./shared');
9
11
  * file, property (or Tailwind class), value, and the occurrence index among
10
12
  * identical findings in that file. Inserting a comment above a declaration no
11
13
  * longer turns it into one "resolved" and one "new"; adding a second identical
12
- * off-scale declaration still counts as new. Version 1 baselines keyed by line
13
- * and column are still compared with the key they were written with.
14
+ * off-scale declaration still counts as new.
15
+ *
16
+ * Version 3 names the file relative to a root that does not move with the
17
+ * working directory: the git top level when the scanned directory is inside a
18
+ * repository, otherwise the scanned directory itself. Version 2 baselines
19
+ * (3.x up to 3.11.0) named it relative to the directory the command ran from;
20
+ * they are read by translating each file to the version 3 form. Version 1
21
+ * baselines keyed by line and column are still compared with the key they
22
+ * were written with.
14
23
  */
15
- const BASELINE_FORMAT_VERSION = 2;
24
+ const BASELINE_FORMAT_VERSION = 3;
16
25
 
17
- function applyBaselineComparison(report, baselinePath) {
26
+ const KEY_SEPARATOR = '\u001f';
27
+
28
+ /**
29
+ * What the run looked at, so a baseline is only judged on files the audit
30
+ * actually read. `scoped` is true for --staged and --since runs.
31
+ */
32
+ function createBaselineScope(report, scope = {}) {
33
+ const scanRoot = path.resolve(scope.scanRoot || report.directory || '.');
34
+ let realScanRoot = scanRoot;
35
+ try {
36
+ realScanRoot = fs.realpathSync(scanRoot);
37
+ } catch {
38
+ // The directory was audited, so it exists; keep the given form if not.
39
+ }
40
+ const keyRoot = findGitTopLevel(scanRoot) || realScanRoot;
41
+
42
+ // Name a file relative to the key root. Paths under the scanned directory
43
+ // go through its real path, the form git reports the top level in.
44
+ const toKeyFile = (absolutePath) => {
45
+ const insideScan = path.relative(scanRoot, absolutePath);
46
+ const realPath = insideScan.startsWith('..') || path.isAbsolute(insideScan)
47
+ ? absolutePath
48
+ : path.join(realScanRoot, insideScan);
49
+ return path.relative(keyRoot, realPath).split(path.sep).join('/');
50
+ };
51
+ const findingKeyFile = (finding) => toKeyFile(path.resolve(process.cwd(), String(finding.file || '')));
52
+ const toKeyFiles = (files) => new Set((files || []).map((file) => toKeyFile(path.resolve(file))));
53
+
54
+ return {
55
+ auditedFiles: toKeyFiles(scope.auditedFiles),
56
+ findingKeyFile,
57
+ scanRoot,
58
+ scoped: scope.scoped === true,
59
+ toKeyFile,
60
+ unauditedFiles: toKeyFiles(scope.unauditedFiles),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * A baseline finding that is no longer reported counts as resolved only when
66
+ * the run audited its file. A --staged or --since run reads the changed files
67
+ * alone, and no run reads a stylesheet that was skipped, ignored or failed to
68
+ * parse, so findings there are neither resolved nor new.
69
+ */
70
+ function countsAsResolved(scope, keyFile) {
71
+ return scope.scoped
72
+ ? scope.auditedFiles.has(keyFile)
73
+ : !scope.unauditedFiles.has(keyFile);
74
+ }
75
+
76
+ function applyBaselineComparison(report, baselinePath, scopeInput) {
18
77
  const resolvedPath = path.resolve(process.cwd(), baselinePath);
19
78
  if (!fs.existsSync(resolvedPath)) {
20
79
  throw new Error(`Baseline file not found: ${baselinePath}`);
@@ -22,15 +81,26 @@ function applyBaselineComparison(report, baselinePath) {
22
81
 
23
82
  const baseline = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
24
83
  const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : [];
25
- const legacy = baseline.formatVersion !== BASELINE_FORMAT_VERSION;
84
+ const scope = createBaselineScope(report, scopeInput);
26
85
  const currentFindings = getAllFindings(report);
27
- const currentKeys = legacy
28
- ? new Map(currentFindings.map((finding) => [finding, createLegacyFindingKey(finding)]))
29
- : assignFindingKeys(currentFindings);
30
- const baselineKeys = new Set(baselineFindings.map((finding) => finding.key || createLegacyFindingKey(finding)));
86
+
87
+ let currentKeys;
88
+ let entries;
89
+ if (isContentKeyed(baseline)) {
90
+ currentKeys = assignFindingKeys(currentFindings, scope.findingKeyFile);
91
+ entries = normalizeBaselineEntries(baseline, scope);
92
+ } else {
93
+ currentKeys = new Map(currentFindings.map((finding) => [finding, createLegacyFindingKey(finding)]));
94
+ entries = baselineFindings.map((finding) => ({
95
+ file: scope.findingKeyFile(finding),
96
+ key: finding.key || createLegacyFindingKey(finding),
97
+ }));
98
+ }
99
+
100
+ const baselineKeys = new Set(entries.map((entry) => entry.key));
31
101
  const currentKeySet = new Set(currentKeys.values());
32
102
  const newFindings = currentFindings.filter((finding) => !baselineKeys.has(currentKeys.get(finding)));
33
- const resolvedFindings = baselineFindings.filter((finding) => !currentKeySet.has(finding.key || createLegacyFindingKey(finding)));
103
+ const resolvedFindings = entries.filter((entry) => !currentKeySet.has(entry.key) && countsAsResolved(scope, entry.file));
34
104
 
35
105
  report.baseline = {
36
106
  baselineFindings: baselineFindings.length,
@@ -43,31 +113,110 @@ function applyBaselineComparison(report, baselinePath) {
43
113
  report.summary.resolvedFindings = resolvedFindings.length;
44
114
  }
45
115
 
46
- function writeBaseline(report, baselinePath) {
116
+ function writeBaseline(report, baselinePath, scopeInput) {
47
117
  const resolvedPath = path.resolve(process.cwd(), baselinePath);
118
+ const scope = createBaselineScope(report, scopeInput);
48
119
  const findings = getAllFindings(report);
49
- const keys = assignFindingKeys(findings);
120
+ const keys = assignFindingKeys(findings, scope.findingKeyFile);
121
+ let records = findings.map((finding) => toBaselineFinding(finding, keys.get(finding), scope.findingKeyFile(finding)));
122
+ let scaleCleanliness = report.scaleCleanliness;
123
+
124
+ // A --staged or --since run saw only the changed files. It replaces the
125
+ // entries for the files it audited and keeps every other entry, so a scoped
126
+ // run never shrinks the baseline.
127
+ if (scope.scoped && fs.existsSync(resolvedPath)) {
128
+ const existing = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
129
+ if (!isContentKeyed(existing)) {
130
+ throw new Error(`Cannot update ${baselinePath} from a --staged or --since run: it was written with formatVersion 1, keyed by line. Run --write-baseline once without --staged or --since to upgrade it.`);
131
+ }
132
+ const kept = normalizeBaselineEntries(existing, scope)
133
+ .filter((entry) => !scope.auditedFiles.has(entry.file))
134
+ .map((entry) => ({ ...entry.record, file: entry.file, key: entry.key }));
135
+ records = [...kept, ...records].sort((a, b) =>
136
+ String(a.file).localeCompare(String(b.file)) || (a.line || 0) - (b.line || 0) || (a.column || 0) - (b.column || 0));
137
+ if (existing.summary && Number.isFinite(existing.summary.scaleCleanliness)) {
138
+ scaleCleanliness = existing.summary.scaleCleanliness;
139
+ }
140
+ }
141
+
50
142
  fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
51
143
  fs.writeFileSync(
52
144
  resolvedPath,
53
145
  `${JSON.stringify({
54
146
  createdAt: new Date().toISOString(),
55
147
  directory: report.directory,
56
- findings: findings.map((finding) => toBaselineFinding(finding, keys.get(finding))),
148
+ findings: records,
57
149
  formatVersion: BASELINE_FORMAT_VERSION,
58
150
  summary: {
59
- scaleCleanliness: report.scaleCleanliness,
60
- totalFindings: report.totalWarnings,
151
+ scaleCleanliness,
152
+ totalFindings: records.length,
61
153
  },
62
154
  }, null, 2)}\n`,
63
155
  );
64
156
 
65
157
  report.baselineWritten = {
66
158
  file: formatPath(resolvedPath),
67
- findings: report.totalWarnings,
159
+ findings: records.length,
68
160
  };
69
161
  }
70
162
 
163
+ /** Version 2 and later key findings by content; version 1 (or none) by line. */
164
+ function isContentKeyed(baseline) {
165
+ return Number.isInteger(baseline.formatVersion) && baseline.formatVersion >= 2;
166
+ }
167
+
168
+ /**
169
+ * Baseline entries with their file and key in the current form. Version 3
170
+ * entries are already in it. A version 2 file and key are relative to the
171
+ * directory the baseline was written from, which `legacyWriterDirectory` works
172
+ * out; the key keeps its rule, type, property, value and occurrence index and
173
+ * only the file part is rewritten.
174
+ */
175
+ function normalizeBaselineEntries(baseline, scope) {
176
+ const findings = Array.isArray(baseline.findings) ? baseline.findings : [];
177
+ if (baseline.formatVersion >= BASELINE_FORMAT_VERSION) {
178
+ return findings.map((record) => ({ file: String(record.file || ''), key: record.key, record }));
179
+ }
180
+
181
+ const writerDirectory = legacyWriterDirectory(baseline.directory, scope.scanRoot);
182
+ return findings.map((record) => {
183
+ const file = scope.toKeyFile(path.resolve(writerDirectory, String(record.file || '')));
184
+ const parts = typeof record.key === 'string' ? record.key.split(KEY_SEPARATOR) : [];
185
+ const key = parts.length === 6
186
+ ? [...parts.slice(0, 2), file, ...parts.slice(3)].join(KEY_SEPARATOR)
187
+ : `${findingIdentity(record, file)}${KEY_SEPARATOR}0`;
188
+ return { file, key, record };
189
+ });
190
+ }
191
+
192
+ /**
193
+ * The directory a version 2 baseline was written from. Its files are relative
194
+ * to that directory, and `directory` records the audited directory as it was
195
+ * typed there. When that directory, read from here, is the one being audited,
196
+ * the baseline was written from here (the only case 3.11.0 matched). When it is
197
+ * not, but the audited directory ends with it (`web/src` written from the
198
+ * parent, audited as `./src` from `web`), the baseline was written from the
199
+ * directory that many levels up. Anything else is read as written from here.
200
+ */
201
+ function legacyWriterDirectory(directory, scanRoot) {
202
+ const cwd = process.cwd();
203
+ if (typeof directory !== 'string' || directory === '' || path.isAbsolute(directory)) {
204
+ return cwd;
205
+ }
206
+ if (path.resolve(cwd, directory) === scanRoot) {
207
+ return cwd;
208
+ }
209
+ const segments = directory.split(/[\\/]+/).filter((segment) => segment !== '' && segment !== '.');
210
+ if (segments.length === 0 || segments.includes('..')) {
211
+ return cwd;
212
+ }
213
+ const scanSegments = scanRoot.split(path.sep);
214
+ if (scanSegments.slice(-segments.length).join('/') !== segments.join('/')) {
215
+ return cwd;
216
+ }
217
+ return path.resolve(scanRoot, ...segments.map(() => '..'));
218
+ }
219
+
71
220
  function getAllFindings(report) {
72
221
  return [
73
222
  ...report.findings.css,
@@ -76,10 +225,10 @@ function getAllFindings(report) {
76
225
  ];
77
226
  }
78
227
 
79
- function toBaselineFinding(finding, key) {
228
+ function toBaselineFinding(finding, key, file = finding.file) {
80
229
  return {
81
230
  column: finding.column,
82
- file: finding.file,
231
+ file,
83
232
  key,
84
233
  line: finding.line,
85
234
  property: finding.property || undefined,
@@ -91,27 +240,31 @@ function toBaselineFinding(finding, key) {
91
240
  };
92
241
  }
93
242
 
94
- function findingIdentity(finding) {
243
+ function findingIdentity(finding, file = finding.file) {
95
244
  return [
96
245
  finding.rule || '',
97
246
  finding.type || '',
98
- finding.file || '',
247
+ file || '',
99
248
  finding.property || finding.token || '',
100
249
  finding.value || finding.rawValue || '',
101
- ].join('\u001f');
250
+ ].join(KEY_SEPARATOR);
102
251
  }
103
252
 
104
- /** Content keys for a set of findings: identity plus the occurrence index in source order. */
105
- function assignFindingKeys(findings) {
253
+ /**
254
+ * Content keys for a set of findings: identity plus the occurrence index in
255
+ * source order. `fileOf` names each finding's file in the key (the version 3
256
+ * root-relative form when called from the audit).
257
+ */
258
+ function assignFindingKeys(findings, fileOf = (finding) => finding.file) {
106
259
  const ordered = [...findings].sort((a, b) =>
107
260
  String(a.file).localeCompare(String(b.file)) || (a.line || 0) - (b.line || 0) || (a.column || 0) - (b.column || 0));
108
261
  const seen = new Map();
109
262
  const keys = new Map();
110
263
  for (const finding of ordered) {
111
- const identity = findingIdentity(finding);
264
+ const identity = findingIdentity(finding, fileOf(finding));
112
265
  const occurrence = seen.get(identity) || 0;
113
266
  seen.set(identity, occurrence + 1);
114
- keys.set(finding, `${identity}\u001f${occurrence}`);
267
+ keys.set(finding, `${identity}${KEY_SEPARATOR}${occurrence}`);
115
268
  }
116
269
  return keys;
117
270
  }
@@ -126,7 +279,7 @@ function createLegacyFindingKey(finding) {
126
279
  finding.column || '',
127
280
  finding.value || finding.rawValue || finding.token || '',
128
281
  finding.text || '',
129
- ].join('\u001f');
282
+ ].join(KEY_SEPARATOR);
130
283
  }
131
284
 
132
285
  function getAuditFailures(report, parsed) {
@@ -136,7 +289,9 @@ function getAuditFailures(report, parsed) {
136
289
  failures.push(`total findings ${report.totalWarnings} exceeds --max-findings ${parsed.maxFindings}`);
137
290
  }
138
291
 
139
- if (parsed.minCleanliness !== null && report.scaleCleanliness < parsed.minCleanliness) {
292
+ // The gate reads the unrounded value: 199 clean files of 200 is 99.5%, which
293
+ // must not pass --min-cleanliness 100.
294
+ if (parsed.minCleanliness !== null && exactScaleCleanliness(report) < parsed.minCleanliness) {
140
295
  failures.push(`scale cleanliness ${report.scaleCleanliness}% is below --min-cleanliness ${parsed.minCleanliness}%`);
141
296
  }
142
297
 
@@ -151,8 +306,10 @@ module.exports = {
151
306
  BASELINE_FORMAT_VERSION,
152
307
  applyBaselineComparison,
153
308
  assignFindingKeys,
309
+ createBaselineScope,
154
310
  createLegacyFindingKey,
155
311
  getAllFindings,
156
312
  getAuditFailures,
313
+ legacyWriterDirectory,
157
314
  writeBaseline,
158
315
  };