stylelint-plugin-rhythmguard 3.12.0 → 3.13.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 (48) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -1
  4. package/src/audit/args.js +23 -4
  5. package/src/audit/baseline.js +15 -2
  6. package/src/audit/codemod.js +56 -5
  7. package/src/audit/config.js +2 -1
  8. package/src/audit/contract.js +3 -0
  9. package/src/audit/decisions.js +45 -16
  10. package/src/audit/render-markdown.js +22 -21
  11. package/src/audit/render-text.js +1 -0
  12. package/src/audit/render-utils.js +28 -1
  13. package/src/audit/report.js +14 -2
  14. package/src/audit/scan/files.js +139 -30
  15. package/src/audit/token-chains.js +4 -1
  16. package/src/audit/token-ladders.js +23 -2
  17. package/src/cli/doctor.js +28 -3
  18. package/src/cli/fix.js +7 -6
  19. package/src/cli/index.js +2 -2
  20. package/src/cli/init.js +41 -5
  21. package/src/cli/quickstart.js +46 -8
  22. package/src/configs/embed.js +2 -0
  23. package/src/configs/motion.js +1 -0
  24. package/src/configs/recommended.js +1 -0
  25. package/src/configs/strict.js +1 -0
  26. package/src/configs/tailwind.js +1 -0
  27. package/src/core/decisions.js +2 -1
  28. package/src/core/json.js +25 -0
  29. package/src/core/length.js +11 -0
  30. package/src/core/options.js +39 -20
  31. package/src/core/scale-inference.js +78 -23
  32. package/src/core/tailwind-class-analysis.js +3 -2
  33. package/src/core/token-map.js +2 -1
  34. package/src/core/token-sources.js +2 -1
  35. package/src/eslint/index.js +14 -8
  36. package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
  37. package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
  38. package/src/rules/no-offscale-transform/index.js +58 -53
  39. package/src/rules/prefer-token/index.js +31 -46
  40. package/src/rules/use-motion-scale/index.js +14 -1
  41. package/src/rules/use-scale/index.js +75 -102
  42. package/src/rules/validate.js +13 -4
  43. package/types/__checks__/consumer.ts +20 -1
  44. package/types/__checks__/eslint-config.ts +8 -0
  45. package/types/__checks__/shipped-configs.ts +96 -0
  46. package/types/audit.d.ts +101 -16
  47. package/types/index.d.ts +15 -0
  48. package/types/shared.d.ts +126 -20
package/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.13.0] - 2026-10-18
10
+
11
+ ### Fixed
12
+
13
+ - `checkMathBounds` checks bounds inside translations. `use-scale`, `prefer-token` and `no-offscale-transform` skipped it for `transform`, so `transform: translateX(clamp(13px, 2vw, 17px))` gave no finding while `padding: clamp(13px, 2vw, 17px)` reported both bounds; `no-offscale-transform` accepted the option but ignored it everywhere, the `translate` properties included. The `clamp()` minimum and maximum and the length arguments of `min()` and `max()` are now checked, named and fixed in translate functions and in `translate`, `translate-x`, `translate-y` and `translate-z` as anywhere else. Only configs that set `checkMathBounds: true` see new reports. ([#272](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/272))
14
+ - Option shapes that turned a rule into a silent no-op are invalid options now. `scale: ["auto"]`, an empty `scale` or `customScale`, an empty list or an unknown preset in `scales` or `propertyScales` (`{ spacing: [] }`, `{ padding: { preset: "nope" } }`), and `use-motion-scale`'s `durationScale: 100`, `durationScale: []` or `durationUnits: "s"` were accepted and then checked nothing or fell back to the defaults; each is now an invalid option warning, which fails the Stylelint run as a misspelled option does. `"auto"` stays valid as the whole `scale`. A preset name as a `propertyScales` value (`{ padding: "rhythmic-8" }`), typed `scaleOrPreset` in the docs, was rejected and is accepted now. `audit.scales` in `.rhythmguardrc.json` uses the same check, so an empty list or unknown preset there is refused with `scales must map ...`. ([#273](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/273))
15
+ - `use-scale` and `no-offscale-transform` say why a negative value or a percentage is reported. With `allowNegative: false` or `allowPercentages: false` the message read `Use scale values (nearest: n/a).`, although `-8px` is on the scale. It now reads `Negative values are disabled for this rule.` or `Percentages are disabled for this rule.`, as `tailwind-class-use-scale` already does; see Messages. What is reported is unchanged. ([#274](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/274))
16
+ - `prefer-token` finds a px `tokenMap` key for a converted length. The key was built from the raw conversion, so `padding: 1.1rem` at `baseFontSize: 14` looked up `15.400000000000002px`, missed `"15.4px": "var(--space-x)"` and read `No known token holds this value`. The key is now formatted like every other length, so the value reads `Use var(--space-x).` and is fixed to it. This changes the message and adds a fix for such values. ([#275](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/275))
17
+ - The `use-motion-scale` docs print the rule's messages. They showed `Use motion scale values` and `Use an easing token.`, while the rule reports `Use duration scale values (nearest: 150ms or 200ms).` and `Use motion tokens for easing decisions.`; the docs now match, and a test lints the documented example against them. The messages are unchanged. ([#276](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/276))
18
+ - A file saved with a UTF-8 byte order mark is read like one without. A JSON token source with a BOM failed with `Unable to parse token source ...: Unexpected token`, a `.rhythmguardrc.json` with one was ignored by the lint (`.rhythmguardrc.json could not be read`) and stopped the audit with `Invalid Rhythmguard config`, and a baseline with one stopped `--since-baseline`. A leading BOM is now stripped before parsing token sources (JSON and CSS), `.rhythmguardrc.json` in the lint, the audit and `rhythmguard doctor`, `tokenMapFile`, and baselines. ([#291](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/291))
19
+ - A bare `--space` is a spacing base only when the other spacing tokens do not form a scale on their own. `:root { --space: 1rem; --space-s: .5rem; --space-m: 1rem; --space-l: 2rem; --space-xl: 3rem }` with `scale: "auto"` gave multiples of 16px up to 1536px, so 40px and 56px passed while 12px was reported against 16px steps; that ladder is now the scale and `--space` one step on it. A project that declares `--space: 0.25rem` and writes each step as `calc(var(--space) * N)`, as Discourse does, keeps the multiples, since its steps are not plain lengths. `--spacing` (Tailwind v4) is always a base. ([#293](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/293))
20
+ - Audit token ladders compare a ladder of negative steps by magnitude. `--space-negative-1: -4px` to `--space-negative-3: -16px` was reported as two `inverted` problems; a ladder whose every step is negative is now compared by magnitude, and a ladder that crosses zero keeps its signed order. ([#294](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/294))
21
+ - Audit token chains no longer count shadow tokens as spacing tokens. `inset` in Tailwind v4's `--inset-shadow-*` made them spacing-named, so a Tailwind `theme.css` added three `non-length` chains and read "1 of 4 spacing tokens resolve". Names containing `shadow` are now left out of the chains. ([#295](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/295))
22
+ - An explicit `tokenPattern` now selects the tokens `scale: "auto"` reads from token files, as it already did for `fixWith: "token"`. With `{ scale: "auto", tokenPattern: "^--size-", scaleSources: ["sizes.css"] }` and `--size-1` to `--size-4` in that file, inference found nothing and fell back to `rhythmic-4`, while fixes wrote the `--size-*` tokens. The pattern is now applied to `scaleSources`, `.rhythmguardrc.json` token sources and installed token packages on top of the spacing names, unless a source sets its own `tokenPattern`, so inference and fixes read the same tokens. This changes the inferred scale for configs that set both `scale: "auto"` and `tokenPattern` when a token file has tokens the pattern matches but the spacing names do not. ([#296](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/296))
23
+ - The docs list the `scale: "auto"` sources in the order the code reads them: `tailwindConfigPath` comes before installed token packages. `docs/rules/use-scale.md` and `docs/FOR_CONFIG_AUTHORS.md` had them the other way round. The order itself is unchanged. ([#297](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/297))
24
+ - `tailwind-class-use-scale` keeps the sign of a double negative. In `-m-[-13px]` the two signs cancel and Tailwind writes `margin: 13px`, but the rule treated either sign as making the value negative, so the fix wrote `-m-3` (-12px) and the message named `-m-3` or `-m-4`. The value is now negative only when exactly one of the two signs is there, so `-m-[-13px]` becomes `m-3`; with `spacingUnit: false` it still becomes `-m-[-12px]`, and with `allowNegative: false` a double negative is checked like any positive value instead of being reported as negative. The audit's class-string findings change the same way. ([#277](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/277))
25
+ - `eslint --fix-type` applies the Rhythmguard fixes. Neither ESLint rule declared `meta.type`, and ESLint skips the fixes of a rule without one whenever `--fix-type` is given, so `--fix-type problem,suggestion,layout` fixed nothing. Both rules are now `type: "suggestion"`. ([#278](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/278))
26
+ - The ESLint `configs.recommended` works as a flat config of its own. It had only `rules`, so `export default [rhythmguard.configs.recommended]` failed with `could not find plugin "rhythmguard-tailwind"`. It now also has `plugins: { "rhythmguard-tailwind": plugin }`; spreading `configs.recommended.rules` into a block that registers the plugin works as before. ([#279](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/279))
27
+ - The Stylelint option types accept the package's own configs. `RhythmguardRuleOptions` lacked most options, so typing the shipped `embed` config failed on `severity` and `motion` on `durationScale`. The types now have every option each rule accepts, as `RhythmguardUseScaleOptions`, `RhythmguardPreferTokenOptions`, `RhythmguardTransformRuleOptions` and `RhythmguardMotionRuleOptions` (with `RhythmguardRuleOptions` covering all three length rules), Stylelint's own `severity`, `message`, `disableFix`, `reportDisables` and `url`, and the `[true]` form. `rules` in `RhythmguardStylelintConfig` types each Rhythmguard rule with its own options. `includeMathFunctions`, which no rule accepts, is gone from the types; use `enforceInsideMathFunctions`. Every shipped config now declares its type and is checked by `npm run typecheck`. ([#280](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/280))
28
+ - Smaller type and playground mismatches. `getScalePreset` and `getCommunityScaleMetadata` are typed as returning `null` for an unknown name, which is what they return. The subpath types (`stylelint-plugin-rhythmguard/eslint`, `/audit`, `/presets`, `/configs/*`, `/rules/*`, `/cli`) resolve under `moduleResolution: "node"` through `typesVersions`, and `eslint-plugin-rhythmguard`'s types import the package root, so they no longer fail with `TS2307`. The playground's `tailwind` config includes the `strict` config it extends, so it runs `use-scale` and `no-offscale-transform` as Stylelint does, not only `prefer-token`. ([#281](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/281))
29
+ - The audit API types match the report. `AuditSummary` declared `cleanliness`, `filesWithFindings` and `findingCount`, `AuditFinding` declared `message`, `AuditBaselineComparison` declared `resolvedFindings`, and `config` was typed as a string; the report has `scaleCleanliness`, `filesWithIssues`, `totalFindings`, `text`, `resolvedFindingsCount` and `config: { file }`. The types now describe the report as `createAuditReport` returns it, `AuditOptions` marks `format`, `outputPath`, `maxFindings`, `minCleanliness`, `failOnNewDrift` and `badgeMetric` as CLI only (`createAuditReport` never read them), and `loadAuditConfig` has its real return type. `createAuditReport({ scale: undefined })` used the undefined over the default scale and inferred one from the scanned CSS; a key set to `undefined` now counts as omitted, so the default or the config file value applies. ([#284](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/284))
30
+ - Numeric audit flags refuse empty, whitespace and non-decimal values. `--min-cleanliness=` (an unset `$THRESHOLD` in CI) gated at 0% and passed, `--max-findings=` became 0, and `--max-findings 0x10` became 16, because each value went through `Number()`. `--max-findings`, `--min-cleanliness`, `--token-candidate-min-count` and `--base-font-size` (also on `rhythmguard fix`) now take plain decimal digits only, with a fraction where one makes sense, and exit 1 with their existing message otherwise. In `.rhythmguardrc.json` a JSON number or a decimal string is accepted as before; `null` and `true`, which read as 0 and 1, are refused. ([#282](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/282))
31
+ - `audit.scales` reaches `--plan` and the reported scale. With `{"scales":{"spacing":[0,5,10],"typography":[12,14,16]}}`, `--plan` proposed `margin: 8px` with `"nearest": ["8px","8px"]` and `font-size: 15px` with `["12px","16px"]`, taken from the project scale, and `contracts.scale.values` still read `0, 4, 8, ..., 32`. A `spacing` list or preset is now the audit's spacing scale (`source: "explicit"`), as it already was for `use-scale`; `--plan` takes each value's nearest steps from the scale it was checked against, gives a value found in groups with different scales one entry per scale, and leaves `nearest` empty for a group set to `"auto"`. Token opportunities, Tailwind class strings and token chains now read the `spacing` entry too, so this changes findings for configs that set `audit.scales.spacing`. ([#283](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/283))
32
+ - Markdown reports keep their tables and code spans intact. A scale source file named `a|b.css` split the `Scale source` row, and a file named ``we`ird.css`` broke out of its code span, since a backslash escapes nothing inside a code span. The scale source row is escaped now, and code spans use a fence one backtick longer than any run in the value; token names, chain terminals, ladder steps and the directory line get the same treatment. ([#285](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/285))
33
+ - `init --help`, `doctor --help` and `quickstart --help` print their usage. `echo y | rhythmguard init --help` wrote `.stylelintrc.json`, doctor ran its checks, quickstart audited, and a typo such as `init --agnets claude` fell through to the interactive init. Every command now handles `--help` and `-h` and refuses an option it does not know before doing anything; `init --agents` with no value still means `all`. ([#286](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/286))
34
+ - The audit commands quickstart suggests reproduce its result. Quickstart audits with `--scale auto` and the token files it finds, but suggested `npx rhythmguard audit . --format markdown`, which used the default scale: with a `tokens.json` scale of `0, 4, 8, 12, 20`, quickstart reported `16px` and the suggested audit and baseline reported `20px`. The suggested commands now carry the same flags, for example `npx rhythmguard audit . --scale auto --token-source ./tokens.json --format markdown`. ([#287](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/287))
35
+ - `rhythmguard fix` checks its target and reports where each literal is. `fix ./nope` and `fix ./a.css` ended in a raw `binding.readdir` stack trace; they now exit 1 with the audit's `Directory not found: ./nope` and `Not a directory: ./a.css`. A literal in a value that spans lines was listed at the declaration's line with a column past its end (`2:24` for a `10px` on line 4); positions are now counted through the value. ([#288](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/288))
36
+ - The audit follows symbolic links. A linked directory or stylesheet was skipped without a word (`cssFiles: 1` and no findings for `src/linked -> ../real` and `src/l.css -> ../real/r.css`), in a full run and with `--staged`. Links that resolve inside the audited directory are now followed, a link back up the tree is not walked twice, and a file reached by more than one path is scanned once, under its own path when the walk reaches it directly. Security: a link that resolves outside the directory (`../../other-project`, `/etc`) is never followed, in a full run, with `--staged` and with `--since`, and is counted as `Links not followed` in text and Markdown output (`scanned.linksOutsideRoot` in the report); `rhythmguard fix` walks the same way and never writes through a symbolic link, so `fix --write` cannot rewrite a file outside the directory it was given. This adds findings for projects that link stylesheets into the audited directory. ([#289](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/289))
37
+ - A corrupt baseline names its file. `--since-baseline` with an invalid `.rhythmguard-baseline.json` failed with `Expected property name or '}' in JSON at position 1` alone; the error now reads `Invalid baseline file .rhythmguard-baseline.json: <the JSON error>`, also when a `--staged` or `--since` run merges into it. ([#290](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/290))
38
+
39
+ ### Messages
40
+
41
+ - `use-scale`: with `allowNegative: false` a negative value reads `Unexpected value "-8px". Negative values are disabled for this rule.`, and with `allowPercentages: false` a percentage reads `Unexpected value "5%". Percentages are disabled for this rule.`, where both read `Unexpected off-scale value "...". Use scale values (nearest: n/a).` A `clamp()`, `min()` or `max()` bound names its position: `Unexpected clamp() minimum "-8px". Negative values are disabled for this rule.` ([#274](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/274))
42
+ - `no-offscale-transform`: the same cases read `Unexpected transform translation value "-8px". Negative values are disabled for this rule.` and `Unexpected transform translation value "13%". Percentages are disabled for this rule.` instead of `Use scale values (nearest: n/a).` A bound checked by `checkMathBounds` names its position, as in `use-scale`: `Unexpected transform translation clamp() minimum "13px". Use scale values (nearest: 12px or 16px).`, with the px value after a rem or em bound. ([#274](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/274), [#272](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/272))
43
+ - Audit: a baseline that is not valid JSON reads `Invalid baseline file <path>: <the JSON error>`. ([#290](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/290))
44
+ - `rhythmguard init`, `doctor` and `quickstart`: `--help` prints `Usage: rhythmguard init [options]`, `Usage: rhythmguard doctor` or `Usage: rhythmguard [quickstart]` with the options; an unknown option reads `Unknown option: <option>` followed by the usage, and `init --agents=` reads `Missing value for --agents.` ([#286](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/286))
45
+ - `rhythmguard fix`: a missing target reads `Directory not found: <path>` and a file `Not a directory: <path>`, as in the audit. ([#288](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/288))
46
+ - Audit: text output adds `Links not followed <n> (they point outside the scanned directory)` and Markdown `| Symbolic links not followed (they point outside the scanned directory) | <n> |` when a symbolic link resolves outside the scanned directory. `rhythmguard fix` lists `skipped <file>: symbolic link to a path outside the directory; not followed` and `skipped <file>: reached through a symbolic link; rewrite the file it points to instead`. ([#289](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/289))
47
+ - Quickstart: the `Next` section puts each note on its own line above its command (`PR-ready report:` then `npx rhythmguard audit . --scale auto ... --format markdown`), and the baseline note reads `Then gate only new drift in CI, with the same flags:`. ([#287](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/287))
48
+
9
49
  ## [3.12.0] - 2026-10-18
10
50
 
11
51
  ### Fixed
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=3120)](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=3130)](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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.12.0",
3
+ "version": "3.13.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"
@@ -21,6 +21,28 @@
21
21
  "type": "commonjs",
22
22
  "main": "src/index.js",
23
23
  "types": "./types/index.d.ts",
24
+ "typesVersions": {
25
+ "*": {
26
+ "audit": [
27
+ "./types/audit.d.ts"
28
+ ],
29
+ "cli": [
30
+ "./types/cli.d.ts"
31
+ ],
32
+ "configs/*": [
33
+ "./types/config.d.ts"
34
+ ],
35
+ "eslint": [
36
+ "./types/eslint.d.ts"
37
+ ],
38
+ "presets": [
39
+ "./types/presets.d.ts"
40
+ ],
41
+ "rules/*": [
42
+ "./types/rule.d.ts"
43
+ ]
44
+ }
45
+ },
24
46
  "exports": {
25
47
  ".": {
26
48
  "types": "./types/index.d.ts",
package/src/audit/args.js CHANGED
@@ -164,8 +164,27 @@ function parsePathOption(raw, optionName) {
164
164
  return String(raw);
165
165
  }
166
166
 
167
+ /**
168
+ * A number typed on the command line or written in .rhythmguardrc.json. A
169
+ * JSON number is taken as is; a string must be plain decimal digits, with a
170
+ * fraction only where one is allowed. `Number()` alone would read an empty
171
+ * value (an unset `$THRESHOLD` in CI) as 0 and `0x10` as 16 (#282).
172
+ */
173
+ const DECIMAL_INTEGER = /^\d+$/;
174
+ const DECIMAL_NUMBER = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
175
+
176
+ function parseDecimal(raw, pattern) {
177
+ if (typeof raw === 'number') {
178
+ return raw;
179
+ }
180
+ if (typeof raw === 'string' && pattern.test(raw)) {
181
+ return Number(raw);
182
+ }
183
+ return Number.NaN;
184
+ }
185
+
167
186
  function parseNonNegativeInteger(raw, optionName) {
168
- const value = Number(raw);
187
+ const value = parseDecimal(raw, DECIMAL_INTEGER);
169
188
  if (!Number.isInteger(value) || value < 0) {
170
189
  throw new Error(`${optionName} must be a non-negative integer.`);
171
190
  }
@@ -174,7 +193,7 @@ function parseNonNegativeInteger(raw, optionName) {
174
193
  }
175
194
 
176
195
  function parsePositiveInteger(raw, optionName) {
177
- const value = Number(raw);
196
+ const value = parseDecimal(raw, DECIMAL_INTEGER);
178
197
  if (!Number.isInteger(value) || value <= 0) {
179
198
  throw new Error(`${optionName} must be a positive integer.`);
180
199
  }
@@ -183,7 +202,7 @@ function parsePositiveInteger(raw, optionName) {
183
202
  }
184
203
 
185
204
  function parsePercentage(raw, optionName) {
186
- const value = Number(raw);
205
+ const value = parseDecimal(raw, DECIMAL_NUMBER);
187
206
  if (!Number.isFinite(value) || value < 0 || value > 100) {
188
207
  throw new Error(`${optionName} must be a number between 0 and 100.`);
189
208
  }
@@ -267,7 +286,7 @@ function validateScale(scale, optionName = '--scale', raw = scale) {
267
286
  }
268
287
 
269
288
  function parseBaseFontSize(raw) {
270
- const value = Number(raw);
289
+ const value = parseDecimal(raw, DECIMAL_NUMBER);
271
290
  if (!Number.isFinite(value) || value <= 0) {
272
291
  throw new Error('--base-font-size must be a positive number.');
273
292
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { readJsonFile } = require('../core/json');
5
6
  const { exactScaleCleanliness } = require('./contract');
6
7
  const { findGitTopLevel } = require('./scan/files');
7
8
  const { formatPath } = require('./shared');
@@ -79,7 +80,7 @@ function applyBaselineComparison(report, baselinePath, scopeInput) {
79
80
  throw new Error(`Baseline file not found: ${baselinePath}`);
80
81
  }
81
82
 
82
- const baseline = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
83
+ const baseline = readBaselineFile(resolvedPath, baselinePath);
83
84
  const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : [];
84
85
  const scope = createBaselineScope(report, scopeInput);
85
86
  const currentFindings = getAllFindings(report);
@@ -113,6 +114,18 @@ function applyBaselineComparison(report, baselinePath, scopeInput) {
113
114
  report.summary.resolvedFindings = resolvedFindings.length;
114
115
  }
115
116
 
117
+ /**
118
+ * A baseline file as JSON. A parse error names the file, so a corrupt
119
+ * baseline is not reported as a bare `JSON.parse` message (#290).
120
+ */
121
+ function readBaselineFile(resolvedPath, baselinePath) {
122
+ try {
123
+ return readJsonFile(resolvedPath);
124
+ } catch (err) {
125
+ throw new Error(`Invalid baseline file ${baselinePath}: ${err.message}`, { cause: err });
126
+ }
127
+ }
128
+
116
129
  function writeBaseline(report, baselinePath, scopeInput) {
117
130
  const resolvedPath = path.resolve(process.cwd(), baselinePath);
118
131
  const scope = createBaselineScope(report, scopeInput);
@@ -125,7 +138,7 @@ function writeBaseline(report, baselinePath, scopeInput) {
125
138
  // entries for the files it audited and keeps every other entry, so a scoped
126
139
  // run never shrinks the baseline.
127
140
  if (scope.scoped && fs.existsSync(resolvedPath)) {
128
- const existing = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
141
+ const existing = readBaselineFile(resolvedPath, baselinePath);
129
142
  if (!isContentKeyed(existing)) {
130
143
  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
144
  }
@@ -9,7 +9,7 @@ const { numbersEqual, parseLengthToken, toPx } = require('../core/length');
9
9
  const { negateReplacement } = require('../core/token-index');
10
10
  const { isTokenFunction, propertyMatches, walkRootValueNodes } = require('../core/value-nodes');
11
11
  const { formatPath } = require('./shared');
12
- const { isCssFile, isScssFile, walkFiles } = require('./scan/files');
12
+ const { isCssFile, isPathInside, isScssFile, isTemplateFile, walkFiles } = require('./scan/files');
13
13
  const { resolveScssSyntax } = require('./scan/stylesheets');
14
14
 
15
15
  /**
@@ -64,10 +64,11 @@ function replaceLength(root, { baseFontSize = 16, properties = null, px, replace
64
64
  return false;
65
65
  }
66
66
  const to = length.number < 0 ? negateReplacement(match.replacement) : match.replacement;
67
+ const position = positionInDeclaration(decl, declarationValueOffset(decl) + node.sourceIndex);
67
68
  changes.push({
68
- column: (decl.source && decl.source.start ? decl.source.start.column : 1) + declarationValueOffset(decl) + node.sourceIndex,
69
+ column: position.column,
69
70
  from: node.value,
70
- line: decl.source && decl.source.start ? decl.source.start.line : 1,
71
+ line: position.line,
71
72
  property: decl.prop,
72
73
  to,
73
74
  });
@@ -89,6 +90,22 @@ function declarationValueOffset(decl) {
89
90
  return decl.prop.length + between.length;
90
91
  }
91
92
 
93
+ /**
94
+ * The line and column of `index`, an offset into the declaration as written
95
+ * (property, separator, value), counted from where the declaration starts. A
96
+ * value that spans lines puts later literals on later lines (#288).
97
+ */
98
+ function positionInDeclaration(decl, index) {
99
+ const start = decl.source && decl.source.start ? decl.source.start : { column: 1, line: 1 };
100
+ const raws = decl.raws || {};
101
+ const text = `${decl.prop}${typeof raws.between === 'string' ? raws.between : ': '}${decl.value}`.slice(0, index);
102
+ const lastBreak = text.lastIndexOf('\n');
103
+ const breaks = text.split('\n').length - 1;
104
+ return breaks === 0
105
+ ? { column: start.column + index, line: start.line }
106
+ : { column: index - lastBreak, line: start.line + breaks };
107
+ }
108
+
92
109
  /**
93
110
  * Parse with the syntax that will write the file back. SCSS goes through
94
111
  * postcss-scss's own parse and stringify: `postcss.parse` ignores a `syntax`
@@ -119,10 +136,26 @@ function parseStylesheet(filePath, source) {
119
136
  */
120
137
  function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties = null, px, replacement, replacements, write = false }) {
121
138
  const rootDir = path.resolve(process.cwd(), dir);
122
- const files = walkFiles(rootDir, ignorePatterns).cssFiles.filter(isCssFile).sort();
139
+ const walked = walkFiles(rootDir, ignorePatterns);
140
+ const files = walked.cssFiles.filter(isCssFile).sort();
123
141
  const results = [];
124
- const skipped = [];
142
+ const skipped = walked.linksOutsideRoot
143
+ .filter((link) => !isTemplateFile(link))
144
+ .map((link) => ({ file: formatPath(path.join(rootDir, link)), reason: 'symbolic link to a path outside the directory; not followed' }));
145
+ const realRoot = fs.realpathSync(rootDir);
146
+ const rewritten = new Set();
125
147
  for (const filePath of files) {
148
+ // Never write through a symbolic link: a file is rewritten only at its own
149
+ // path inside the directory, and only once (#289).
150
+ const realPath = writableRealPath(filePath, rootDir, realRoot);
151
+ if (!realPath) {
152
+ skipped.push({ file: formatPath(filePath), reason: 'reached through a symbolic link; rewrite the file it points to instead' });
153
+ continue;
154
+ }
155
+ if (rewritten.has(realPath)) {
156
+ continue;
157
+ }
158
+ rewritten.add(realPath);
126
159
  const source = fs.readFileSync(filePath, 'utf8');
127
160
  let parsed;
128
161
  try {
@@ -148,6 +181,24 @@ function runCodemod({ baseFontSize = 16, dir, ignorePatterns = [], properties =
148
181
  return { results, skipped };
149
182
  }
150
183
 
184
+ /**
185
+ * The real path of `filePath` when it is the file's own path: no symbolic link
186
+ * between `rootDir` and the file, and the file inside `rootDir`. Null otherwise.
187
+ */
188
+ function writableRealPath(filePath, rootDir, realRoot) {
189
+ let realPath;
190
+ try {
191
+ if (fs.lstatSync(filePath).isSymbolicLink()) {
192
+ return null;
193
+ }
194
+ realPath = fs.realpathSync(filePath);
195
+ } catch {
196
+ return null;
197
+ }
198
+ const expected = path.join(realRoot, path.relative(rootDir, filePath));
199
+ return realPath === expected && isPathInside(realRoot, realPath) ? realPath : null;
200
+ }
201
+
151
202
  module.exports = {
152
203
  replaceLength,
153
204
  runCodemod,
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { normalizeDecisions } = require('../core/decisions');
6
+ const { readJsonFile } = require('../core/json');
6
7
  const { SCALED_GROUPS, isGroupScalesMap } = require('../core/options');
7
8
  const { normalizeTokenKind } = require('../core/token-sources');
8
9
  const { normalizeTokenSourceEntry } = require('../core/token-source-entries');
@@ -43,7 +44,7 @@ function loadAuditConfig(parsed) {
43
44
 
44
45
  let config;
45
46
  try {
46
- config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
47
+ config = readJsonFile(resolvedPath);
47
48
  } catch (err) {
48
49
  throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`, { cause: err });
49
50
  }
@@ -232,6 +232,7 @@ function buildReport({
232
232
  dir,
233
233
  externalTokenDefinitions,
234
234
  includeMotion,
235
+ linksOutsideRoot = [],
235
236
  motionFindings,
236
237
  parseErrorFindings = [],
237
238
  readSource,
@@ -323,6 +324,8 @@ function buildReport({
323
324
  stylelintIgnored,
324
325
  templateFiles: templateFiles.length,
325
326
  totalFiles,
327
+ // Symbolic links that resolve outside the scanned directory: not followed (#289).
328
+ ...(linksOutsideRoot.length > 0 ? { linksOutsideRoot: linksOutsideRoot.length } : {}),
326
329
  },
327
330
  summary: {
328
331
  cssWarnings: cssFindings.length,
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { propertyKindOf } = require('../core/css-vocabulary');
3
4
  const { decisionFor } = require('../core/decisions');
4
- const { formatLength, nearestScaleValues, parseLengthToken, toPx } = require('../core/length');
5
+ const { formatLength, nearestScaleValues, normalizeScale, parseLengthToken, toPx } = require('../core/length');
5
6
 
6
7
  /**
7
8
  * Decisions applied to audit findings, and the plan that proposes them.
@@ -43,18 +44,43 @@ function applyDecisions({ baseFontSize, cssFindings, decisions, tailwindFindings
43
44
  return { cssFindings: nextCss, suppressed, summary: { ...summary, suppressed }, tailwindFindings: nextTailwind };
44
45
  }
45
46
 
47
+ /**
48
+ * Which scale a finding was checked against, as use-scale picks it: the
49
+ * `audit.scales` entry for the property's group, else the project scale. The
50
+ * spacing group's entry is already the project scale (report.js). `key` names
51
+ * the group when it has a scale of its own; `scalePx` is null for a group set
52
+ * to "auto", whose scale the rule infers and the audit cannot see (#283).
53
+ */
54
+ function createFindingScaleResolver({ baseFontSize, groupScales, scale }) {
55
+ const projectScale = { key: '', scalePx: Array.isArray(scale) ? normalizeScale(scale, baseFontSize) : [] };
56
+ const byGroup = new Map();
57
+ for (const [group, entry] of Object.entries(groupScales || {})) {
58
+ if (group === 'spacing' || !entry) continue;
59
+ byGroup.set(group, { key: group, scalePx: Array.isArray(entry.scale) ? normalizeScale(entry.scale, baseFontSize) : null });
60
+ }
61
+ return (finding) => {
62
+ if (finding.token || !finding.property) return projectScale;
63
+ return byGroup.get(propertyKindOf(finding.property)) || projectScale;
64
+ };
65
+ }
66
+
46
67
  /**
47
68
  * One proposed entry per distinct off-scale px value: the most common spelling,
48
- * how often it occurs, the properties it sits on, the two nearest scale steps,
49
- * and the decision so far (`undecided` for anything nobody has looked at).
69
+ * how often it occurs, the properties it sits on, the two nearest steps of the
70
+ * scale it was checked against, and the decision so far (`undecided` for
71
+ * anything nobody has looked at). A value found in groups with different
72
+ * scales (`15px` as a font size and as a padding) gets one entry per scale.
50
73
  */
51
- function buildDecisionPlan({ baseFontSize, decisions, offScaleFindings, scale }) {
74
+ function buildDecisionPlan({ baseFontSize, decisions, groupScales = {}, offScaleFindings, scale }) {
75
+ const scaleOf = createFindingScaleResolver({ baseFontSize, groupScales, scale });
76
+ const projectScale = scaleOf({});
52
77
  const groups = new Map();
53
78
  for (const finding of offScaleFindings) {
54
79
  const px = findingPx(finding, baseFontSize);
55
80
  if (px === null) continue;
56
- const key = String(px);
57
- const group = groups.get(key) || { count: 0, properties: new Map(), px, spellings: new Map() };
81
+ const checkedAgainst = scaleOf(finding);
82
+ const key = `${px}|${checkedAgainst.key}`;
83
+ const group = groups.get(key) || { count: 0, properties: new Map(), px, scale: checkedAgainst, spellings: new Map() };
58
84
  group.count += 1;
59
85
  const spelling = (finding.value || finding.rawValue).trim();
60
86
  group.spellings.set(spelling, (group.spellings.get(spelling) || 0) + 1);
@@ -64,35 +90,38 @@ function buildDecisionPlan({ baseFontSize, decisions, offScaleFindings, scale })
64
90
  }
65
91
 
66
92
  const mostCommon = (map) => [...map.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
67
- const scalePx = Array.isArray(scale) ? scale.map(Number).filter(Number.isFinite) : [];
68
- const nearestFor = (px) => {
69
- const nearest = nearestScaleValues(px, scalePx);
93
+ const nearestFor = (px, scalePx) => {
94
+ const nearest = scalePx ? nearestScaleValues(px, scalePx) : null;
70
95
  return nearest ? [formatLength(nearest.lower, 'px'), formatLength(nearest.upper, 'px')] : [];
71
96
  };
97
+ const groupsForPx = (px) => [...groups.values()].filter((group) => group.px === px);
72
98
 
73
99
  const entries = [];
74
100
  const decided = new Set();
75
101
  for (const decision of decisions || []) {
76
- const group = groups.get(String(decision.px));
77
- decided.add(String(decision.px));
102
+ const found = groupsForPx(decision.px);
103
+ decided.add(decision.px);
104
+ // A decision covers the value on every property it names; its nearest steps
105
+ // come from the one scale its findings were checked against, if just one.
106
+ const scales = [...new Set(found.map((group) => group.scale))];
78
107
  entries.push({
79
108
  value: decision.value,
80
109
  decision: decision.decision,
81
110
  ...(decision.as ? { as: decision.as } : {}),
82
111
  ...(decision.reason ? { reason: decision.reason } : {}),
83
112
  ...(decision.properties ? { properties: decision.properties } : {}),
84
- count: group ? group.count : 0,
85
- nearest: nearestFor(decision.px),
113
+ count: found.reduce((sum, group) => sum + group.count, 0),
114
+ nearest: nearestFor(decision.px, (scales.length === 1 ? scales[0] : projectScale).scalePx),
86
115
  });
87
116
  }
88
- for (const [key, group] of groups) {
89
- if (decided.has(key)) continue;
117
+ for (const group of groups.values()) {
118
+ if (decided.has(group.px)) continue;
90
119
  entries.push({
91
120
  value: mostCommon(group.spellings)[0],
92
121
  decision: 'undecided',
93
122
  count: group.count,
94
123
  properties: mostCommon(group.properties).slice(0, 3),
95
- nearest: nearestFor(group.px),
124
+ nearest: nearestFor(group.px, group.scale.scalePx),
96
125
  });
97
126
  }
98
127
  entries.sort((a, b) => b.count - a.count || String(a.value).localeCompare(String(b.value)));
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { sortCountMap } = require('./contract');
4
- const { escapeMarkdown } = require('./render-utils');
4
+ const { escapeMarkdown, markdownCode, tableCode } = require('./render-utils');
5
5
 
6
6
  function describeScaleSource(scale) {
7
7
  if (scale.rejected) {
@@ -17,7 +17,7 @@ function renderMarkdown(report) {
17
17
  '# Rhythmguard Design-System Audit',
18
18
  '',
19
19
  ...(report.baseline ? [`**Since baseline:** ${report.baseline.resolvedFindingsCount} resolved, ${report.baseline.newFindingsCount} new.`, ''] : []),
20
- `Directory: \`${report.directory}\``,
20
+ `Directory: ${markdownCode(report.directory)}`,
21
21
  '',
22
22
  '## Summary',
23
23
  '',
@@ -28,6 +28,7 @@ function renderMarkdown(report) {
28
28
  ...(report.scanned && report.scanned.scssFiles > 0 ? [`| SCSS files scanned | ${report.scanned.scssFiles - report.scanned.scssSkipped} |`] : []),
29
29
  ...(report.scanned && report.scanned.scssSkipped > 0 ? [`| SCSS files skipped (install postcss-scss) | ${report.scanned.scssSkipped} |`] : []),
30
30
  ...(report.scanned && report.scanned.stylelintIgnored > 0 ? [`| Stylesheets ignored by .stylelintignore (not audited) | ${report.scanned.stylelintIgnored} |`] : []),
31
+ ...(report.scanned && report.scanned.linksOutsideRoot > 0 ? [`| Symbolic links not followed (they point outside the scanned directory) | ${report.scanned.linksOutsideRoot} |`] : []),
31
32
  ...(parseErrors(report).length > 0 ? [`| Stylesheets that failed to parse (not audited) | ${parseErrors(report).length} |`] : []),
32
33
  `| Files with issues | ${report.filesWithIssues} |`,
33
34
  `| Total findings | ${report.totalWarnings} |`,
@@ -36,7 +37,7 @@ function renderMarkdown(report) {
36
37
 
37
38
  if (report.scale) {
38
39
  lines.push(`| Scale | ${report.scale.values.join(', ')} |`);
39
- lines.push(`| Scale source | ${describeScaleSource(report.scale)} |`);
40
+ lines.push(`| Scale source | ${escapeMarkdown(describeScaleSource(report.scale))} |`);
40
41
  }
41
42
 
42
43
  if (report.decisions) {
@@ -66,7 +67,7 @@ function renderMarkdown(report) {
66
67
  lines.push('| File | Findings |');
67
68
  lines.push('| --- | ---: |');
68
69
  for (const { file, count } of report.topAffectedFiles) {
69
- lines.push(`| \`${escapeMarkdown(file)}\` | ${count} |`);
70
+ lines.push(`| ${tableCode(file)} | ${count} |`);
70
71
  }
71
72
  lines.push('');
72
73
  }
@@ -77,7 +78,7 @@ function renderMarkdown(report) {
77
78
  lines.push('| File | Class | Suggested class |');
78
79
  lines.push('| --- | --- | --- |');
79
80
  for (const finding of report.findings.tailwind.slice(0, 10)) {
80
- lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` | \`${escapeMarkdown(finding.token)}\` | \`${escapeMarkdown(finding.fixedToken || 'n/a')}\` |`);
81
+ lines.push(`| ${tableCode(`${finding.file}:${finding.line}`)} | ${tableCode(finding.token)} | ${tableCode(finding.fixedToken || 'n/a')} |`);
81
82
  }
82
83
  lines.push('');
83
84
  }
@@ -115,7 +116,7 @@ function appendParseErrorsMarkdown(lines, report) {
115
116
  lines.push('| File | Error |');
116
117
  lines.push('| --- | --- |');
117
118
  for (const finding of errors) {
118
- lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}:${finding.column}`)}\` | ${escapeMarkdown(finding.text)} |`);
119
+ lines.push(`| ${tableCode(`${finding.file}:${finding.line}:${finding.column}`)} | ${escapeMarkdown(finding.text)} |`);
119
120
  }
120
121
  lines.push('');
121
122
  }
@@ -138,8 +139,8 @@ function appendTokenChainsMarkdown(lines, chains) {
138
139
  for (const entry of chains.entries.slice(0, 50)) {
139
140
  const detail = entry.reason
140
141
  ? entry.reason
141
- : entry.terminals.map((terminal) => `\`${terminal}\``).join(', ');
142
- lines.push(`| \`${escapeMarkdown(entry.token)}\` | ${entry.outcome} | ${detail}${entry.via.length > 0 ? ` via ${entry.via.map((name) => `\`${name}\``).join(' → ')}` : ''} |`);
142
+ : entry.terminals.map((terminal) => tableCode(terminal)).join(', ');
143
+ lines.push(`| ${tableCode(entry.token)} | ${entry.outcome} | ${detail}${entry.via.length > 0 ? ` via ${entry.via.map((name) => tableCode(name)).join(' → ')}` : ''} |`);
143
144
  }
144
145
  lines.push('');
145
146
  }
@@ -164,16 +165,16 @@ function appendTokenLaddersMarkdown(lines, ladders) {
164
165
  const flagged = new Map();
165
166
  for (const problem of entry.problems) {
166
167
  const where = problem.end === 'both' ? '' : ` at ${problem.end}`;
167
- const note = `${LADDER_PROBLEM_TEXT[problem.type]}${where} with \`${problem.tokens[0]}\``;
168
+ const note = `${LADDER_PROBLEM_TEXT[problem.type]}${where} with ${tableCode(problem.tokens[0])}`;
168
169
  flagged.set(problem.tokens[1], [...(flagged.get(problem.tokens[1]) || []), note]);
169
170
  }
170
- const aliasNote = entry.aliases ? ` (also aliased as ${entry.aliases.map((alias) => `\`${escapeMarkdown(alias)}\``).join(', ')})` : '';
171
- lines.push(`**\`${escapeMarkdown(entry.prefix)}\`**${aliasNote}`);
171
+ const aliasNote = entry.aliases ? ` (also aliased as ${entry.aliases.map((alias) => markdownCode(alias)).join(', ')})` : '';
172
+ lines.push(`**${markdownCode(entry.prefix)}**${aliasNote}`);
172
173
  lines.push('');
173
174
  lines.push('| Token | Min | Max | Problem |');
174
175
  lines.push('| --- | --- | --- | --- |');
175
176
  for (const step of entry.steps) {
176
- lines.push(`| \`${escapeMarkdown(step.token)}\` | ${step.min || 'fluid'} | ${step.max || 'fluid'} | ${(flagged.get(step.token) || []).join('; ')} |`);
177
+ lines.push(`| ${tableCode(step.token)} | ${step.min || 'fluid'} | ${step.max || 'fluid'} | ${(flagged.get(step.token) || []).join('; ')} |`);
177
178
  }
178
179
  lines.push('');
179
180
  }
@@ -208,7 +209,7 @@ function appendTokenContractMarkdown(lines, tokenContract) {
208
209
  lines.push('| File | Format | Tokens | Warnings |');
209
210
  lines.push('| --- | --- | ---: | --- |');
210
211
  for (const source of sources.slice(0, 10)) {
211
- lines.push(`| \`${escapeMarkdown(source.file)}\` | \`${source.format}\` | ${source.tokenCount} | \`${escapeMarkdown(source.warnings.join('; ') || 'none')}\` |`);
212
+ lines.push(`| ${tableCode(source.file)} | ${tableCode(source.format)} | ${source.tokenCount} | ${tableCode(source.warnings.join('; ') || 'none')} |`);
212
213
  }
213
214
  lines.push('');
214
215
  }
@@ -219,7 +220,7 @@ function appendTokenContractMarkdown(lines, tokenContract) {
219
220
  lines.push('| Token | Files |');
220
221
  lines.push('| --- | --- |');
221
222
  for (const entry of missingTokens.slice(0, 10)) {
222
- lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
223
+ lines.push(`| ${tableCode(entry.token)} | ${tableCode(entry.files.join(', '))} |`);
223
224
  }
224
225
  lines.push('');
225
226
  }
@@ -230,7 +231,7 @@ function appendTokenContractMarkdown(lines, tokenContract) {
230
231
  lines.push('| Token | Value |');
231
232
  lines.push('| --- | --- |');
232
233
  for (const entry of unusedTokens.slice(0, 10)) {
233
- lines.push(`| \`${escapeMarkdown(entry.token)}\` | \`${escapeMarkdown((entry.values || []).join(', ') || 'n/a')}\` |`);
234
+ lines.push(`| ${tableCode(entry.token)} | ${tableCode((entry.values || []).join(', ') || 'n/a')} |`);
234
235
  }
235
236
  lines.push('');
236
237
  }
@@ -241,7 +242,7 @@ function appendTokenContractMarkdown(lines, tokenContract) {
241
242
  lines.push('| Value | Count | Files |');
242
243
  lines.push('| --- | ---: | --- |');
243
244
  for (const entry of rawValueCandidates.slice(0, 10)) {
244
- lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.files.join(', '))}\` |`);
245
+ lines.push(`| ${tableCode(entry.value)} | ${entry.count} | ${tableCode(entry.files.join(', '))} |`);
245
246
  }
246
247
  lines.push('');
247
248
  }
@@ -252,7 +253,7 @@ function appendTokenContractMarkdown(lines, tokenContract) {
252
253
  lines.push('| Value | Count | Tokens |');
253
254
  lines.push('| --- | ---: | --- |');
254
255
  for (const entry of rawValueMatches.slice(0, 10)) {
255
- lines.push(`| \`${escapeMarkdown(entry.value)}\` | ${entry.count} | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
256
+ lines.push(`| ${tableCode(entry.value)} | ${entry.count} | ${tableCode(entry.tokens.join(', '))} |`);
256
257
  }
257
258
  lines.push('');
258
259
  }
@@ -263,7 +264,7 @@ function appendTokenContractMarkdown(lines, tokenContract) {
263
264
  lines.push('| Value | Tokens |');
264
265
  lines.push('| --- | --- |');
265
266
  for (const entry of conflictingTokens.slice(0, 10)) {
266
- lines.push(`| \`${escapeMarkdown(entry.value)}\` | \`${escapeMarkdown(entry.tokens.join(', '))}\` |`);
267
+ lines.push(`| ${tableCode(entry.value)} | ${tableCode(entry.tokens.join(', '))} |`);
267
268
  }
268
269
  lines.push('');
269
270
  }
@@ -290,13 +291,13 @@ function appendBaselineMarkdown(lines, report) {
290
291
  lines.push('| New finding | Location |');
291
292
  lines.push('| --- | --- |');
292
293
  for (const finding of report.baseline.newFindings.slice(0, 10)) {
293
- lines.push(`| \`${escapeMarkdown(finding.text)}\` | \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` |`);
294
+ lines.push(`| ${tableCode(finding.text)} | ${tableCode(`${finding.file}:${finding.line}`)} |`);
294
295
  }
295
296
  lines.push('');
296
297
  }
297
298
 
298
299
  if (report.baselineWritten) {
299
- lines.push(`Baseline written: \`${escapeMarkdown(report.baselineWritten.file)}\``);
300
+ lines.push(`Baseline written: ${markdownCode(report.baselineWritten.file)}`);
300
301
  lines.push('');
301
302
  }
302
303
  }
@@ -313,7 +314,7 @@ function appendMarkdownCounts(lines, title, counts, label = 'Value') {
313
314
  lines.push(`| ${label} | Count |`);
314
315
  lines.push('| --- | ---: |');
315
316
  for (const [value, count] of entries) {
316
- lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
317
+ lines.push(`| ${tableCode(value)} | ${count} |`);
317
318
  }
318
319
  lines.push('');
319
320
  }