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.
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/package.json +23 -1
- package/src/audit/args.js +23 -4
- package/src/audit/baseline.js +15 -2
- package/src/audit/codemod.js +56 -5
- package/src/audit/config.js +2 -1
- package/src/audit/contract.js +3 -0
- package/src/audit/decisions.js +45 -16
- package/src/audit/render-markdown.js +22 -21
- package/src/audit/render-text.js +1 -0
- package/src/audit/render-utils.js +28 -1
- package/src/audit/report.js +14 -2
- package/src/audit/scan/files.js +139 -30
- package/src/audit/token-chains.js +4 -1
- package/src/audit/token-ladders.js +23 -2
- package/src/cli/doctor.js +28 -3
- package/src/cli/fix.js +7 -6
- package/src/cli/index.js +2 -2
- package/src/cli/init.js +41 -5
- package/src/cli/quickstart.js +46 -8
- package/src/configs/embed.js +2 -0
- package/src/configs/motion.js +1 -0
- package/src/configs/recommended.js +1 -0
- package/src/configs/strict.js +1 -0
- package/src/configs/tailwind.js +1 -0
- package/src/core/decisions.js +2 -1
- package/src/core/json.js +25 -0
- package/src/core/length.js +11 -0
- package/src/core/options.js +39 -20
- package/src/core/scale-inference.js +78 -23
- package/src/core/tailwind-class-analysis.js +3 -2
- package/src/core/token-map.js +2 -1
- package/src/core/token-sources.js +2 -1
- package/src/eslint/index.js +14 -8
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
- package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
- package/src/rules/no-offscale-transform/index.js +58 -53
- package/src/rules/prefer-token/index.js +31 -46
- package/src/rules/use-motion-scale/index.js +14 -1
- package/src/rules/use-scale/index.js +75 -102
- package/src/rules/validate.js +13 -4
- package/types/__checks__/consumer.ts +20 -1
- package/types/__checks__/eslint-config.ts +8 -0
- package/types/__checks__/shipped-configs.ts +96 -0
- package/types/audit.d.ts +101 -16
- package/types/index.d.ts +15 -0
- 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
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/src/audit/baseline.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
}
|
package/src/audit/codemod.js
CHANGED
|
@@ -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:
|
|
69
|
+
column: position.column,
|
|
69
70
|
from: node.value,
|
|
70
|
-
line:
|
|
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
|
|
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,
|
package/src/audit/config.js
CHANGED
|
@@ -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 =
|
|
47
|
+
config = readJsonFile(resolvedPath);
|
|
47
48
|
} catch (err) {
|
|
48
49
|
throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`, { cause: err });
|
|
49
50
|
}
|
package/src/audit/contract.js
CHANGED
|
@@ -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,
|
package/src/audit/decisions.js
CHANGED
|
@@ -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
|
|
49
|
-
* and the decision so far (`undecided` for
|
|
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
|
|
57
|
-
const
|
|
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
|
|
68
|
-
|
|
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
|
|
77
|
-
decided.add(
|
|
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
|
|
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
|
|
89
|
-
if (decided.has(
|
|
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:
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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) =>
|
|
142
|
-
lines.push(`|
|
|
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
|
|
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) =>
|
|
171
|
-
lines.push(
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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(`|
|
|
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:
|
|
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(`|
|
|
317
|
+
lines.push(`| ${tableCode(value)} | ${count} |`);
|
|
317
318
|
}
|
|
318
319
|
lines.push('');
|
|
319
320
|
}
|