@josueavalosjim/taste-check 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -6
- package/bin/taste-check.mjs +4 -4
- package/package.json +1 -1
- package/schema/config.schema.json +61 -0
- package/src/config.mjs +22 -2
- package/src/contrast.mjs +8 -3
- package/src/css.mjs +30 -10
- package/src/files.mjs +24 -5
- package/src/index.mjs +5 -0
- package/src/judge.mjs +16 -2
- package/src/report.mjs +4 -0
- package/src/runtime.mjs +12 -4
- package/src/sarif.mjs +4 -2
- package/src/tokens.mjs +135 -0
- package/src/treatments.mjs +18 -9
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Design review in CI, with a line down the middle of it.
|
|
4
4
|
|
|
5
5
|
Some of design review is measurable. Contrast ratios, values that should have
|
|
6
|
-
been tokens, class names nobody approved.
|
|
7
|
-
your build on them, either from your token
|
|
8
|
-
real browser.
|
|
6
|
+
been tokens, tokens that were never real, class names nobody approved.
|
|
7
|
+
taste-check measures those and fails your build on them, either from your token
|
|
8
|
+
file or from a page it renders in a real browser.
|
|
9
9
|
|
|
10
10
|
The rest is judgment, and it cannot be measured. For that it sends your
|
|
11
11
|
screenshots and your checklist to a model, and reports what comes back as an
|
|
@@ -32,6 +32,9 @@ contrast ok, 10 pairs across 2 themes
|
|
|
32
32
|
treatments FAILED
|
|
33
33
|
FAIL src/Promo.jsx:7 class "promo-huge" on <div> is not approved
|
|
34
34
|
FAIL src/Promo.jsx:8 inline value "#ff0055" on <a> is a one-off. Use a token, or add it to approvedValues.
|
|
35
|
+
|
|
36
|
+
tokens FAILED
|
|
37
|
+
FAIL src/Promo.jsx:8 "--color-primary-500" is referenced here but declared in no token file. Check the name, or add it to allow.
|
|
35
38
|
```
|
|
36
39
|
|
|
37
40
|
```bash
|
|
@@ -72,7 +75,9 @@ in your repo: what runs is the copy you edited, never its own.
|
|
|
72
75
|
Each of these exits 1 rather than passing quietly:
|
|
73
76
|
|
|
74
77
|
- a pair naming a token that does not exist
|
|
78
|
+
- markup referencing a token that is declared nowhere
|
|
75
79
|
- a file pattern matching no files
|
|
80
|
+
- a token file that declares no custom properties at all
|
|
76
81
|
- a theme whose scopes resolve no tokens
|
|
77
82
|
- a single scope inside a theme that selects nothing, even when the theme has
|
|
78
83
|
tokens from its other scopes
|
|
@@ -182,6 +187,55 @@ direction to be wrong in.
|
|
|
182
187
|
Template literal holes are read into rather than blanked, so a class written
|
|
183
188
|
inside `` `card ${on ? 'card--on' : ''}` `` is seen.
|
|
184
189
|
|
|
190
|
+
## The tokens check
|
|
191
|
+
|
|
192
|
+
Give it the stylesheet that declares your tokens and the markup that uses them.
|
|
193
|
+
It reports every `var(--x)` in your markup where `--x` is declared nowhere.
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"tokens": {
|
|
198
|
+
"declaredIn": ["src/styles/tokens.css"],
|
|
199
|
+
"files": ["src/**/*.jsx"],
|
|
200
|
+
"allow": ["--scroll-progress"],
|
|
201
|
+
"allowPrefixes": ["--radix-"]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The failure it is built for is a token name that was never real:
|
|
207
|
+
`var(--color-primary-500)` in a system that says `--brand-action-bg`. A name
|
|
208
|
+
like that is not a typo. It is what gets written when the code is produced
|
|
209
|
+
without the token file open, which is most of what a model does, and it looks
|
|
210
|
+
correct in review because it looks like a token.
|
|
211
|
+
|
|
212
|
+
**It scans markup, not stylesheets, and that is deliberate.** See
|
|
213
|
+
[where it sits](#where-it-sits-next-to-other-tools). Stylelint already owns the
|
|
214
|
+
CSS side and does it better.
|
|
215
|
+
|
|
216
|
+
For markup the scan is the whole file rather than the tags, because `var(--`
|
|
217
|
+
means one thing everywhere it appears. So a reference in an object declared
|
|
218
|
+
above the `return`, in a styled-components template, or in a constant in
|
|
219
|
+
another module is found, and nesting comes free: both names in
|
|
220
|
+
`var(--a, var(--b))` are checked, not only the outer one.
|
|
221
|
+
|
|
222
|
+
`allow` and `allowPrefixes` are the escape hatch, and you will need them. A
|
|
223
|
+
custom property set from JavaScript with `setProperty`, or one owned by a
|
|
224
|
+
library's stylesheet, is legitimately absent from your token file.
|
|
225
|
+
|
|
226
|
+
Two things it does not treat as failures. A bare `--x` written where you meant
|
|
227
|
+
`var(--x)` is stylelint's `custom-property-no-missing-var-function`, not this.
|
|
228
|
+
And a run that finds no references at all is reported in the summary rather
|
|
229
|
+
than failed, because a codebase that keeps its styling in stylesheets has none
|
|
230
|
+
and is not broken:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
tokens ok, 1 token file, 84 declared, 41 files scanned, 0 references
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
If that zero is a surprise, your `files` pattern is pointed at the wrong tree.
|
|
237
|
+
A pattern matching no files at all is a failure, as everywhere else here.
|
|
238
|
+
|
|
185
239
|
## Measuring the rendered page
|
|
186
240
|
|
|
187
241
|
The contrast check above reads your token file, which tells you what a colour
|
|
@@ -416,6 +470,10 @@ it is the way it is.
|
|
|
416
470
|
| `treatments.approvedClasses` | Every class allowed to appear. |
|
|
417
471
|
| `treatments.allowPrefixes` | Prefixes that are always allowed, as a deliberate escape hatch. |
|
|
418
472
|
| `treatments.approvedValues` | Literal values allowed inside inline styles. |
|
|
473
|
+
| `tokens.declaredIn` | Stylesheets declaring the tokens. Every custom property in them counts, in any scope. |
|
|
474
|
+
| `tokens.files` | Markup to scan for `var()` references. |
|
|
475
|
+
| `tokens.allow` | Names that need no declaration, for ones set from JavaScript. Must start with `--`. |
|
|
476
|
+
| `tokens.allowPrefixes` | Name prefixes that are always allowed, for a family a library owns. |
|
|
419
477
|
| `judge.checklist` | Your checklist file. List items are judged, prose is not. |
|
|
420
478
|
| `judge.shots` | Screenshots to hand the judge. Matching nothing is a failure. |
|
|
421
479
|
| `judge.shotCommand` | Optional command run first to produce those screenshots. |
|
|
@@ -433,7 +491,7 @@ taste-check runtime [options] Measure contrast on a rendered page
|
|
|
433
491
|
taste-check judge [options] Ask a fresh-eyes judge about your screenshots
|
|
434
492
|
|
|
435
493
|
-c, --config <path> Config file (default: tastecheck.config.json)
|
|
436
|
-
--only <name> Run one check: contrast or
|
|
494
|
+
--only <name> Run one check: contrast, treatments or tokens
|
|
437
495
|
--format <kind> text (default), json, or sarif
|
|
438
496
|
--json Alias for --format json
|
|
439
497
|
--version Print the version
|
|
@@ -493,6 +551,16 @@ tool, run axe.
|
|
|
493
551
|
**stylelint, eslint.** General code quality, with an enormous rule ecosystem.
|
|
494
552
|
Nothing here replaces them.
|
|
495
553
|
|
|
554
|
+
**stylelint for tokens in CSS specifically.** Its core
|
|
555
|
+
`no-unknown-custom-properties` reports a `var()` naming a custom property
|
|
556
|
+
nothing declares, and its `referenceFiles` option covers the case that matters,
|
|
557
|
+
where the tokens live in one file and the uses live in another. That is the
|
|
558
|
+
same check taste-check runs, done first-party, so run stylelint on your
|
|
559
|
+
stylesheets. taste-check only scans markup, where stylelint does not go: its
|
|
560
|
+
CSS-in-JS syntax is deprecated, and a `style={{ color: open ? 'var(--a)' :
|
|
561
|
+
'var(--b)' }}` prop is a JavaScript object literal that no CSS parser reads at
|
|
562
|
+
all.
|
|
563
|
+
|
|
496
564
|
**@lapidist/design-lint.** More thorough than taste-check on the token and
|
|
497
565
|
component side: it parses properly rather than scanning, knows about
|
|
498
566
|
frameworks, autofixes, and manages deprecations. If enforcing tokens in code is
|
|
@@ -536,6 +604,11 @@ within one channel unit.
|
|
|
536
604
|
them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
|
|
537
605
|
you want.
|
|
538
606
|
|
|
607
|
+
**It does not lint your stylesheets.** The tokens check reads your CSS to learn
|
|
608
|
+
what is declared and then never looks at it again. A `var()` in a `.css` file
|
|
609
|
+
naming nothing is not reported here. That is stylelint's
|
|
610
|
+
`no-unknown-custom-properties`, named above.
|
|
611
|
+
|
|
539
612
|
**Component indirection is invisible.** Classes applied by a helper
|
|
540
613
|
function, a `clsx` call importing names from elsewhere, or CSS-in-JS are
|
|
541
614
|
invisible to it.
|
|
@@ -564,10 +637,15 @@ publisher over OIDC, so no npm token is stored in the repository and every
|
|
|
564
637
|
release carries a provenance attestation. For a minor or major bump, run
|
|
565
638
|
`npm version minor` or `npm version major` and `git push --follow-tags`.
|
|
566
639
|
|
|
567
|
-
|
|
640
|
+
183 tests. Most of them plant a violation into a fixture that was passing a
|
|
568
641
|
moment earlier and demand it gets caught: a token darkened below its floor, an
|
|
569
642
|
unapproved class added to a clean file, a class buried in a template literal
|
|
570
|
-
hole, a file pattern pointed at
|
|
643
|
+
hole, a fabricated token hidden in a ternary, a file pattern pointed at
|
|
644
|
+
nothing.
|
|
645
|
+
|
|
646
|
+
The count is not the point and is easy to inflate. What the suite is actually
|
|
647
|
+
worth is measured by breaking the source on purpose and checking the right
|
|
648
|
+
test goes red, which is where the 0.12.0 fixes came from.
|
|
571
649
|
|
|
572
650
|
## License
|
|
573
651
|
|
package/bin/taste-check.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const USAGE = `taste-check
|
|
|
28
28
|
|
|
29
29
|
Options:
|
|
30
30
|
-c, --config <path> Config file (default: tastecheck.config.json)
|
|
31
|
-
--only <name> Run one check: contrast or
|
|
31
|
+
--only <name> Run one check: contrast, treatments or tokens
|
|
32
32
|
--new [path] checklist only: scaffold a starter, refusing to
|
|
33
33
|
overwrite one that exists
|
|
34
34
|
--lint checklist only: report lines a judge cannot answer
|
|
@@ -112,8 +112,8 @@ function parseArgs(argv) {
|
|
|
112
112
|
else if (arg === '-c' || arg === '--config') options.config = next();
|
|
113
113
|
else if (arg === '--only') {
|
|
114
114
|
options.only = next();
|
|
115
|
-
if (
|
|
116
|
-
throw new Error(`--only takes "contrast" or "
|
|
115
|
+
if (!['contrast', 'treatments', 'tokens'].includes(options.only)) {
|
|
116
|
+
throw new Error(`--only takes "contrast", "treatments" or "tokens", not "${options.only}"`);
|
|
117
117
|
}
|
|
118
118
|
} else if (arg === '--json') options.format = 'json';
|
|
119
119
|
else if (arg === '--lint') options.lint = true;
|
|
@@ -268,7 +268,7 @@ else if (options.command === 'runtime') results = await runtime(loaded.config, l
|
|
|
268
268
|
else results = run(loaded.config, loaded.dir, { only: options.only });
|
|
269
269
|
|
|
270
270
|
if (!results.length) {
|
|
271
|
-
die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or
|
|
271
|
+
die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast, treatments or tokens'} check.`);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
if (options.format === 'sarif') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@josueavalosjim/taste-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"treatments"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"required": [
|
|
22
|
+
"tokens"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
20
25
|
{
|
|
21
26
|
"required": [
|
|
22
27
|
"runtime"
|
|
@@ -260,6 +265,62 @@
|
|
|
260
265
|
}
|
|
261
266
|
}
|
|
262
267
|
},
|
|
268
|
+
"tokens": {
|
|
269
|
+
"type": "object",
|
|
270
|
+
"description": "Custom properties referenced by var() in markup, against the tokens the system declares.",
|
|
271
|
+
"additionalProperties": false,
|
|
272
|
+
"required": [
|
|
273
|
+
"declaredIn",
|
|
274
|
+
"files"
|
|
275
|
+
],
|
|
276
|
+
"properties": {
|
|
277
|
+
"declaredIn": {
|
|
278
|
+
"type": "array",
|
|
279
|
+
"minItems": 1,
|
|
280
|
+
"description": "Stylesheets that declare the tokens. Every custom property declared anywhere in them counts, in any scope or at-rule.",
|
|
281
|
+
"items": {
|
|
282
|
+
"type": "string"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"files": {
|
|
286
|
+
"type": "array",
|
|
287
|
+
"minItems": 1,
|
|
288
|
+
"description": "Markup to scan for var() references. A pattern matching nothing is a failure, never a clean run.",
|
|
289
|
+
"items": {
|
|
290
|
+
"type": "string"
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
"allow": {
|
|
294
|
+
"type": "array",
|
|
295
|
+
"description": "Custom property names that are fine to reference without being declared, for ones set from JavaScript or by a third-party stylesheet.",
|
|
296
|
+
"items": {
|
|
297
|
+
"type": "string",
|
|
298
|
+
"pattern": "^--"
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
"allowPrefixes": {
|
|
302
|
+
"type": "array",
|
|
303
|
+
"description": "Name prefixes that are always allowed, for a whole family a library owns.",
|
|
304
|
+
"items": {
|
|
305
|
+
"type": "string"
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"$comment": {
|
|
309
|
+
"oneOf": [
|
|
310
|
+
{
|
|
311
|
+
"type": "string"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"type": "array",
|
|
315
|
+
"items": {
|
|
316
|
+
"type": "string"
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
],
|
|
320
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
263
324
|
"judge": {
|
|
264
325
|
"type": "object",
|
|
265
326
|
"description": "A fresh-eyes review of your screenshots against your checklist, run by a model command you supply. Run with `taste-check judge`. Verdicts are advisory unless failOn says otherwise.",
|
package/src/config.mjs
CHANGED
|
@@ -151,6 +151,19 @@ function validateTreatments(treatments, errors) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
function validateTokens(tokens, errors) {
|
|
155
|
+
rejectUnknown(tokens, ['declaredIn', 'files', 'allow', 'allowPrefixes'], 'tokens', errors);
|
|
156
|
+
stringArray(tokens.declaredIn, 'tokens.declaredIn', errors);
|
|
157
|
+
stringArray(tokens.files, 'tokens.files', errors);
|
|
158
|
+
stringArray(tokens.allow, 'tokens.allow', errors, { required: false });
|
|
159
|
+
stringArray(tokens.allowPrefixes, 'tokens.allowPrefixes', errors, { required: false });
|
|
160
|
+
for (const name of tokens.allow ?? []) {
|
|
161
|
+
if (typeof name === 'string' && !name.startsWith('--')) {
|
|
162
|
+
errors.push(`tokens.allow has "${name}", which is not a custom property name. Names start with "--".`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
function validateJudge(judge, errors) {
|
|
155
168
|
rejectUnknown(judge, ['checklist', 'shots', 'shotCommand', 'command', 'failOn'], 'judge', errors);
|
|
156
169
|
if (typeof judge.checklist !== 'string' || !judge.checklist) {
|
|
@@ -246,15 +259,18 @@ function validateRuntime(runtime, errors) {
|
|
|
246
259
|
export function validate(config) {
|
|
247
260
|
const errors = [];
|
|
248
261
|
if (!isPlainObject(config)) return ['the config must be a JSON object'];
|
|
249
|
-
rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge', 'runtime'], 'the config', errors);
|
|
262
|
+
rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'tokens', 'judge', 'runtime'], 'the config', errors);
|
|
250
263
|
|
|
251
264
|
if (
|
|
252
265
|
config.contrast === undefined &&
|
|
253
266
|
config.treatments === undefined &&
|
|
267
|
+
config.tokens === undefined &&
|
|
254
268
|
config.judge === undefined &&
|
|
255
269
|
config.runtime === undefined
|
|
256
270
|
) {
|
|
257
|
-
errors.push(
|
|
271
|
+
errors.push(
|
|
272
|
+
'the config must define at least one of "contrast", "treatments", "tokens", "runtime" or "judge"',
|
|
273
|
+
);
|
|
258
274
|
}
|
|
259
275
|
if (config.contrast !== undefined) {
|
|
260
276
|
if (isPlainObject(config.contrast)) validateContrast(config.contrast, errors);
|
|
@@ -264,6 +280,10 @@ export function validate(config) {
|
|
|
264
280
|
if (isPlainObject(config.treatments)) validateTreatments(config.treatments, errors);
|
|
265
281
|
else errors.push('treatments must be an object');
|
|
266
282
|
}
|
|
283
|
+
if (config.tokens !== undefined) {
|
|
284
|
+
if (isPlainObject(config.tokens)) validateTokens(config.tokens, errors);
|
|
285
|
+
else errors.push('tokens must be an object');
|
|
286
|
+
}
|
|
267
287
|
if (config.judge !== undefined) {
|
|
268
288
|
if (isPlainObject(config.judge)) validateJudge(config.judge, errors);
|
|
269
289
|
else errors.push('judge must be an object');
|
package/src/contrast.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { readFileSync } from 'node:fs';
|
|
20
20
|
import { contrastRatio, isOpaque, parseColor } from './color.mjs';
|
|
21
21
|
import { lineAt, parseDeclarations, resolveScopes, resolveValue, unmatchedScopes } from './css.mjs';
|
|
22
|
-
import {
|
|
22
|
+
import { expandEach, label } from './files.mjs';
|
|
23
23
|
|
|
24
24
|
/** A token name, or a literal colour, resolved to rgba for one theme. */
|
|
25
25
|
function side(spec, table, theme) {
|
|
@@ -40,9 +40,14 @@ export function runContrast(config, cwd) {
|
|
|
40
40
|
const problems = [];
|
|
41
41
|
const { tokens, themes, pairs } = config;
|
|
42
42
|
|
|
43
|
-
const files =
|
|
43
|
+
const { files, empty: deadPatterns } = expandEach(tokens, cwd);
|
|
44
|
+
// Per pattern. A dead one beside a live one leaves the union non-empty, so
|
|
45
|
+
// a renamed token file stops being read without anything saying so.
|
|
46
|
+
for (const p of deadPatterns) problems.push(`no token files matched "${p}"`);
|
|
44
47
|
if (!files.length) {
|
|
45
|
-
|
|
48
|
+
if (!deadPatterns.length) {
|
|
49
|
+
problems.push(`no token files matched ${tokens.map((t) => `"${t}"`).join(', ')}`);
|
|
50
|
+
}
|
|
46
51
|
return { name: 'contrast', samples, problems, summary: '' };
|
|
47
52
|
}
|
|
48
53
|
|
package/src/css.mjs
CHANGED
|
@@ -39,11 +39,6 @@
|
|
|
39
39
|
*/
|
|
40
40
|
const CONDITIONAL = /^@(media|supports|container|scope|document)\b/;
|
|
41
41
|
|
|
42
|
-
/** Blank out comments, keeping every offset and newline so lines stay true. */
|
|
43
|
-
function blankComments(css) {
|
|
44
|
-
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
42
|
const normalize = (selector) => selector.replace(/\s+/g, ' ').replace(/'/g, '"').trim();
|
|
48
43
|
|
|
49
44
|
/**
|
|
@@ -51,16 +46,25 @@ const normalize = (selector) => selector.replace(/\s+/g, ' ').replace(/'/g, '"')
|
|
|
51
46
|
* under and the at-rules it is nested inside.
|
|
52
47
|
*/
|
|
53
48
|
export function parseDeclarations(css) {
|
|
54
|
-
const source =
|
|
49
|
+
const source = css;
|
|
55
50
|
const decls = [];
|
|
56
51
|
const stack = [];
|
|
57
52
|
let buffer = '';
|
|
58
53
|
let bufferStart = 0;
|
|
54
|
+
/* Offset of the first non-whitespace character in the buffer, recorded as
|
|
55
|
+
it is read rather than derived afterwards by trimming. Deriving it worked
|
|
56
|
+
only while comments were blanked to spaces up front: once they are stepped
|
|
57
|
+
over instead, the source still holds the comment text, so trimming stops
|
|
58
|
+
at the "/" and every declaration under a comment reported the comment's
|
|
59
|
+
line as its own. */
|
|
60
|
+
let ink = -1;
|
|
59
61
|
let quote = null;
|
|
60
62
|
|
|
61
|
-
const flush = (
|
|
63
|
+
const flush = () => {
|
|
62
64
|
const text = buffer.trim();
|
|
63
65
|
buffer = '';
|
|
66
|
+
const at = ink;
|
|
67
|
+
ink = -1;
|
|
64
68
|
if (!text) return;
|
|
65
69
|
const colon = text.indexOf(':');
|
|
66
70
|
if (colon === -1) return;
|
|
@@ -77,13 +81,14 @@ export function parseDeclarations(css) {
|
|
|
77
81
|
value,
|
|
78
82
|
selector,
|
|
79
83
|
atRules: stack.filter((s) => s.startsWith('@')),
|
|
80
|
-
index:
|
|
84
|
+
index: at === -1 ? bufferStart : at,
|
|
81
85
|
});
|
|
82
86
|
};
|
|
83
87
|
|
|
84
88
|
for (let i = 0; i < source.length; i += 1) {
|
|
85
89
|
const c = source[i];
|
|
86
90
|
if (quote) {
|
|
91
|
+
if (ink === -1) ink = i;
|
|
87
92
|
buffer += c;
|
|
88
93
|
if (c === '\\') {
|
|
89
94
|
buffer += source[i + 1] ?? '';
|
|
@@ -91,29 +96,44 @@ export function parseDeclarations(css) {
|
|
|
91
96
|
} else if (c === quote) quote = null;
|
|
92
97
|
continue;
|
|
93
98
|
}
|
|
99
|
+
/* Comments are skipped here rather than blanked before the walk.
|
|
100
|
+
Blanking first cannot see quotes, so a "/*" inside one string value and
|
|
101
|
+
a "*\/" inside another erased every declaration between them. A theme
|
|
102
|
+
override lost that way does not fail: resolveScopes falls back to the
|
|
103
|
+
base value and the dark theme gets measured against light numbers, which
|
|
104
|
+
is the exact miss this file exists to catch. Offsets stay true because
|
|
105
|
+
nothing is rewritten, only stepped over. */
|
|
106
|
+
if (c === '/' && source[i + 1] === '*') {
|
|
107
|
+
const end = source.indexOf('*\/', i + 2);
|
|
108
|
+
i = end === -1 ? source.length : end + 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
94
111
|
if (c === '"' || c === "'") {
|
|
95
112
|
quote = c;
|
|
113
|
+
if (ink === -1) ink = i;
|
|
96
114
|
buffer += c;
|
|
97
115
|
continue;
|
|
98
116
|
}
|
|
99
117
|
if (c === '{') {
|
|
100
118
|
stack.push(buffer.trim().replace(/\s+/g, ' '));
|
|
101
119
|
buffer = '';
|
|
120
|
+
ink = -1;
|
|
102
121
|
bufferStart = i + 1;
|
|
103
122
|
continue;
|
|
104
123
|
}
|
|
105
124
|
if (c === '}') {
|
|
106
|
-
flush(
|
|
125
|
+
flush();
|
|
107
126
|
stack.pop();
|
|
108
127
|
bufferStart = i + 1;
|
|
109
128
|
continue;
|
|
110
129
|
}
|
|
111
130
|
if (c === ';') {
|
|
112
|
-
flush(
|
|
131
|
+
flush();
|
|
113
132
|
bufferStart = i + 1;
|
|
114
133
|
continue;
|
|
115
134
|
}
|
|
116
135
|
if (!buffer) bufferStart = i;
|
|
136
|
+
if (ink === -1 && c.trim()) ink = i;
|
|
117
137
|
buffer += c;
|
|
118
138
|
}
|
|
119
139
|
return decls;
|
package/src/files.mjs
CHANGED
|
@@ -56,25 +56,44 @@ function walk(dir, out) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Absolute paths matching the patterns,
|
|
60
|
-
*
|
|
59
|
+
* Absolute paths matching the patterns, plus the patterns that matched
|
|
60
|
+
* nothing.
|
|
61
|
+
*
|
|
62
|
+
* The misses are returned rather than inferred from an empty result, because
|
|
63
|
+
* a list is checked as a union. Two patterns, one naming src and one naming
|
|
64
|
+
* components, with components renamed: the src half still returns every file
|
|
65
|
+
* it matched, so the union is not empty and that whole second tree silently
|
|
66
|
+
* stops being checked. This file's own header promises a typo is reported
|
|
67
|
+
* where it happened, and only a per-pattern answer keeps that promise.
|
|
61
68
|
*/
|
|
62
|
-
export function
|
|
69
|
+
export function expandEach(patterns, cwd) {
|
|
63
70
|
const found = new Set();
|
|
71
|
+
const empty = [];
|
|
64
72
|
for (const pattern of patterns) {
|
|
73
|
+
const before = found.size;
|
|
65
74
|
const absolute = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
|
|
66
75
|
if (!/[*?]/.test(pattern)) {
|
|
67
76
|
if (existsSync(absolute) && statSync(absolute).isFile()) found.add(absolute);
|
|
77
|
+
else empty.push(pattern);
|
|
68
78
|
continue;
|
|
69
79
|
}
|
|
70
80
|
const base = resolve(cwd, baseOf(pattern));
|
|
71
|
-
if (!existsSync(base) || !statSync(base).isDirectory())
|
|
81
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) {
|
|
82
|
+
empty.push(pattern);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
72
85
|
const test = toRegExp(isAbsolute(pattern) ? pattern : resolve(cwd, pattern).split(sep).join('/'));
|
|
73
86
|
for (const file of walk(base, [])) {
|
|
74
87
|
if (test.test(file.split(sep).join('/'))) found.add(file);
|
|
75
88
|
}
|
|
89
|
+
if (found.size === before) empty.push(pattern);
|
|
76
90
|
}
|
|
77
|
-
return [...found].sort();
|
|
91
|
+
return { files: [...found].sort(), empty };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Just the paths, for callers with nothing useful to say about a miss. */
|
|
95
|
+
export function expand(patterns, cwd) {
|
|
96
|
+
return expandEach(patterns, cwd).files;
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
/** A path as you would paste it into an editor. */
|
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { runContrast } from './contrast.mjs';
|
|
6
6
|
export { runTreatments } from './treatments.mjs';
|
|
7
|
+
export { runTokens, varRefs } from './tokens.mjs';
|
|
7
8
|
export { runJudge, prepareJudge, gradeVerdict, buildPrompt, checklistLines, checklistEntries, extractJson } from './judge.mjs';
|
|
8
9
|
export { runRuntime } from './runtime.mjs';
|
|
9
10
|
export { connect, findBrowser } from './cdp.mjs';
|
|
@@ -15,6 +16,7 @@ export { openTags, classesOf } from './treatments.mjs';
|
|
|
15
16
|
|
|
16
17
|
import { runContrast } from './contrast.mjs';
|
|
17
18
|
import { runTreatments } from './treatments.mjs';
|
|
19
|
+
import { runTokens } from './tokens.mjs';
|
|
18
20
|
import { runJudge } from './judge.mjs';
|
|
19
21
|
import { runRuntime } from './runtime.mjs';
|
|
20
22
|
|
|
@@ -34,6 +36,9 @@ export function run(config, cwd, { only = null } = {}) {
|
|
|
34
36
|
if (config.treatments && (!only || only === 'treatments')) {
|
|
35
37
|
results.push(runTreatments(config.treatments, cwd));
|
|
36
38
|
}
|
|
39
|
+
if (config.tokens && (!only || only === 'tokens')) {
|
|
40
|
+
results.push(runTokens(config.tokens, cwd));
|
|
41
|
+
}
|
|
37
42
|
return results;
|
|
38
43
|
}
|
|
39
44
|
|
package/src/judge.mjs
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { execFileSync } from 'node:child_process';
|
|
42
42
|
import { readFileSync } from 'node:fs';
|
|
43
43
|
import { resolve } from 'node:path';
|
|
44
|
-
import {
|
|
44
|
+
import { expandEach, label } from './files.mjs';
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* The instructions wrapped around the user's checklist.
|
|
@@ -182,7 +182,21 @@ export function prepareJudge(config, cwd) {
|
|
|
182
182
|
if (!made.ok) return { ok: false, result: empty([made.reason], failOn) };
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
const images =
|
|
185
|
+
const { files: images, empty: deadShots } = expandEach(shots, cwd);
|
|
186
|
+
/* A dead shot pattern is the same failure as no shots at all, one screen at
|
|
187
|
+
a time: the judge answers about the frames it was given and says nothing
|
|
188
|
+
about the one nobody handed it. */
|
|
189
|
+
if (deadShots.length) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
result: empty(
|
|
193
|
+
deadShots.map(
|
|
194
|
+
(p) => `no screenshots matched "${p}". A screen the judge never saw cannot pass.`,
|
|
195
|
+
),
|
|
196
|
+
failOn,
|
|
197
|
+
),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
186
200
|
if (!images.length) {
|
|
187
201
|
return {
|
|
188
202
|
ok: false,
|
package/src/report.mjs
CHANGED
|
@@ -54,6 +54,10 @@ function judgeLines(result) {
|
|
|
54
54
|
export const linesFor = (result) => {
|
|
55
55
|
if (result.name === 'contrast' || result.name === 'runtime') return contrastLines(result);
|
|
56
56
|
if (result.name === 'judge') return judgeLines(result);
|
|
57
|
+
// tokens and treatments both report {file, line, message} failures, so they
|
|
58
|
+
// render the same way. Named rather than left to the fallthrough: a check
|
|
59
|
+
// that renders by accident is a check that stops rendering by accident.
|
|
60
|
+
if (result.name === 'tokens') return treatmentLines(result);
|
|
57
61
|
return treatmentLines(result);
|
|
58
62
|
};
|
|
59
63
|
|
package/src/runtime.mjs
CHANGED
|
@@ -38,11 +38,19 @@ const MEASURE = `(targets) => targets.map(({ selector, prop, againstParent }) =>
|
|
|
38
38
|
if (!el) return { error: 'matched no element' };
|
|
39
39
|
if (!el.getClientRects().length) return { error: 'matched an element that is not rendered' };
|
|
40
40
|
const cs = getComputedStyle(el);
|
|
41
|
-
|
|
41
|
+
// Read either spelling. A config saying border-top-color is the natural
|
|
42
|
+
// thing to write and the validator accepts it, so it must not fall through
|
|
43
|
+
// to an undefined lookup.
|
|
44
|
+
const fg = prop.includes('-') ? cs.getPropertyValue(prop) : cs[prop];
|
|
42
45
|
if (!fg) return { error: 'has no ' + prop };
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
if (/^border/i.test(prop)) {
|
|
47
|
+
/* Both spellings again. Stripping only the camelCase affixes turned
|
|
48
|
+
border-top-color into "-top-color" and then read borderTopWidth as
|
|
49
|
+
border-top-colorWidth, which is undefined, so every dash-case edge
|
|
50
|
+
reported "no width, so no edge is drawn" over a real border. */
|
|
51
|
+
const side = prop.replace(/^border-?/i, '').replace(/-?color$/i, '');
|
|
52
|
+
const dashed = side ? side.replace(/([A-Z])/g, '-$1').replace(/^-/, '').toLowerCase() : 'top';
|
|
53
|
+
const width = parseFloat(cs.getPropertyValue('border-' + dashed + '-width'));
|
|
46
54
|
if (!width) return { error: 'has a ' + prop + ' but no width, so no edge is drawn' };
|
|
47
55
|
}
|
|
48
56
|
const layers = [];
|
package/src/sarif.mjs
CHANGED
|
@@ -32,6 +32,8 @@ const RULES = [
|
|
|
32
32
|
['treatments/unapproved-class', 'error', 'A class name that is not on the approved list.'],
|
|
33
33
|
['treatments/one-off-value', 'error', 'A literal colour or length hardcoded into an inline style.'],
|
|
34
34
|
['treatments/unscannable', 'error', 'No markup matched, so nothing was checked.'],
|
|
35
|
+
['tokens/undefined', 'error', 'Markup references a custom property that no token file declares.'],
|
|
36
|
+
['tokens/unscannable', 'error', 'The tokens check could not run: no token file, no markup, or a token file that declares nothing.'],
|
|
35
37
|
['runtime/below-floor', 'error', 'A target on the rendered page is under the ratio it was given.'],
|
|
36
38
|
['runtime/unmeasurable', 'error', 'A target could not be measured: no element, nothing rendered, an edge with no width, or no browser.'],
|
|
37
39
|
['judge/verdict', 'note', 'A checklist line the judge did not pass. Advisory: a model verdict is not reproducible.'],
|
|
@@ -118,7 +120,7 @@ function resultsFor(check, ctx) {
|
|
|
118
120
|
return out;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
if (check.name === 'treatments') {
|
|
123
|
+
if (check.name === 'treatments' || check.name === 'tokens') {
|
|
122
124
|
for (const failure of check.failures ?? []) {
|
|
123
125
|
out.push({
|
|
124
126
|
ruleId: failure.rule,
|
|
@@ -130,7 +132,7 @@ function resultsFor(check, ctx) {
|
|
|
130
132
|
}
|
|
131
133
|
for (const problem of check.problems ?? []) {
|
|
132
134
|
out.push({
|
|
133
|
-
ruleId:
|
|
135
|
+
ruleId: `${check.name}/unscannable`,
|
|
134
136
|
key: problem,
|
|
135
137
|
level: 'error',
|
|
136
138
|
message: { text: problem },
|
package/src/tokens.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The undefined-token check: every `var(--x)` in markup, against the tokens
|
|
3
|
+
* the system actually declares.
|
|
4
|
+
*
|
|
5
|
+
* ── Why markup and not stylesheets ──────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Stylelint's core `no-unknown-custom-properties` already does this for CSS,
|
|
8
|
+
* and its `referenceFiles` option covers the case that matters, where tokens
|
|
9
|
+
* are declared in one file and consumed in another. Rebuilding that would be
|
|
10
|
+
* a worse copy of a first-party rule.
|
|
11
|
+
*
|
|
12
|
+
* What nothing covers is markup. Stylelint's CSS-in-JS syntax is deprecated,
|
|
13
|
+
* and `style={{ color: open ? 'var(--a)' : 'var(--b)' }}` is not CSS-in-JS
|
|
14
|
+
* anyway: it is a JavaScript object literal that no CSS parser reads. That is
|
|
15
|
+
* the gap, and it is the one a model writing plausible off-system code falls
|
|
16
|
+
* into, reaching for `--color-primary-500` when the system says `--brand-ink`.
|
|
17
|
+
*
|
|
18
|
+
* ── Why a plain scan and not the tag walker ─────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* `treatments.mjs` walks tags because a class name is only a class name in an
|
|
21
|
+
* attribute, and a bare word elsewhere is noise. `var(--` is not like that.
|
|
22
|
+
* The string is unambiguous wherever it appears, so scanning the whole file
|
|
23
|
+
* costs nothing in false positives and picks up references the tag walker
|
|
24
|
+
* cannot see: a styled-components template, an object of styles declared above
|
|
25
|
+
* the return, a constant in a helper module.
|
|
26
|
+
*
|
|
27
|
+
* Nesting comes free. `var(--a, var(--b))` matches twice, so both names are
|
|
28
|
+
* checked rather than only the outer one.
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { lineAt, parseDeclarations } from './css.mjs';
|
|
32
|
+
import { expandEach, label } from './files.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The name is `[\w-]+` for the same reason `resolveValue` in css.mjs uses it:
|
|
36
|
+
* escapes and non-ASCII in a custom property name are legal and vanishingly
|
|
37
|
+
* rare, and inventing a fuller grammar here would only disagree with the
|
|
38
|
+
* resolver next door.
|
|
39
|
+
*/
|
|
40
|
+
const VAR_REF = /var\(\s*(--[\w-]+)\s*(,?)/g;
|
|
41
|
+
|
|
42
|
+
const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every custom property referenced through `var()`, with the offset of the
|
|
46
|
+
* name and whether the reference carries a fallback.
|
|
47
|
+
*
|
|
48
|
+
* A bare `--x` written outside a `var()` is deliberately not a reference. That
|
|
49
|
+
* is a different mistake, and stylelint's `custom-property-no-missing-var-function`
|
|
50
|
+
* is the rule for it.
|
|
51
|
+
*/
|
|
52
|
+
export function varRefs(source) {
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const m of source.matchAll(VAR_REF)) {
|
|
55
|
+
found.push({ name: m[1], index: m.index + m[0].indexOf(m[1]), fallback: m[2] === ',' });
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every custom property name declared anywhere in the token files. */
|
|
61
|
+
function declaredNames(files) {
|
|
62
|
+
const names = new Set();
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
for (const decl of parseDeclarations(readFileSync(file, 'utf8'))) names.add(decl.prop);
|
|
65
|
+
}
|
|
66
|
+
return names;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function runTokens(config, cwd) {
|
|
70
|
+
const failures = [];
|
|
71
|
+
const problems = [];
|
|
72
|
+
const { declaredIn, files: patterns, allow = [], allowPrefixes = [] } = config;
|
|
73
|
+
|
|
74
|
+
const quoted = (list) => list.map((p) => `"${p}"`).join(', ');
|
|
75
|
+
const declared_ = expandEach(declaredIn, cwd);
|
|
76
|
+
const scanned_ = expandEach(patterns, cwd);
|
|
77
|
+
const tokenFiles = declared_.files;
|
|
78
|
+
const files = scanned_.files;
|
|
79
|
+
|
|
80
|
+
/* Per pattern, not per list. A list is a union, so one dead pattern beside
|
|
81
|
+
a live one leaves the union non-empty and that tree stops being checked
|
|
82
|
+
in silence. */
|
|
83
|
+
for (const p of declared_.empty) problems.push(`no token files matched "${p}"`);
|
|
84
|
+
for (const p of scanned_.empty) problems.push(`no markup files matched "${p}"`);
|
|
85
|
+
if (!tokenFiles.length && !declared_.empty.length) problems.push(`no token files matched ${quoted(declaredIn)}`);
|
|
86
|
+
const declared = declaredNames(tokenFiles);
|
|
87
|
+
if (tokenFiles.length && !declared.size) {
|
|
88
|
+
problems.push(
|
|
89
|
+
`${count(tokenFiles.length, 'token file')} matched ${quoted(declaredIn)}, ` +
|
|
90
|
+
`but none of them declares a custom property. Every reference would read as undefined.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!files.length && !scanned_.empty.length) problems.push(`no markup files matched ${quoted(patterns)}`);
|
|
94
|
+
|
|
95
|
+
// Without a trustworthy declared set there is nothing to compare against,
|
|
96
|
+
// and reporting every reference in the codebase as undefined would bury the
|
|
97
|
+
// one line that says why.
|
|
98
|
+
if (problems.length) return { name: 'tokens', failures, problems, summary: '' };
|
|
99
|
+
|
|
100
|
+
const allowed = new Set(allow);
|
|
101
|
+
let references = 0;
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const source = readFileSync(file, 'utf8');
|
|
105
|
+
for (const { name, index, fallback } of varRefs(source)) {
|
|
106
|
+
references += 1;
|
|
107
|
+
if (declared.has(name)) continue;
|
|
108
|
+
if (allowed.has(name)) continue;
|
|
109
|
+
if (allowPrefixes.some((p) => name.startsWith(p))) continue;
|
|
110
|
+
failures.push({
|
|
111
|
+
rule: 'tokens/undefined',
|
|
112
|
+
file: label(file, cwd),
|
|
113
|
+
line: lineAt(source, index),
|
|
114
|
+
subject: name,
|
|
115
|
+
message:
|
|
116
|
+
`"${name}" is referenced here but declared in no token file.` +
|
|
117
|
+
(fallback ? ' It has a fallback, so it degrades rather than breaks.' : '') +
|
|
118
|
+
' Check the name, or add it to allow.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// `references` is in the summary rather than a failure on purpose. A zero
|
|
124
|
+
// can mean a glob pointing at the wrong tree, but it can equally mean a
|
|
125
|
+
// codebase that keeps its styling in stylesheets, which is not a fault. The
|
|
126
|
+
// wrong-glob case that can be told apart is the one above: no files matched.
|
|
127
|
+
return {
|
|
128
|
+
name: 'tokens',
|
|
129
|
+
failures,
|
|
130
|
+
problems,
|
|
131
|
+
summary:
|
|
132
|
+
`${count(tokenFiles.length, 'token file')}, ${declared.size} declared, ` +
|
|
133
|
+
`${count(files.length, 'file')} scanned, ${count(references, 'reference')}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
package/src/treatments.mjs
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* positive is a conversation while a false negative is the bug shipping.
|
|
21
21
|
*/
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
|
-
import {
|
|
23
|
+
import { lineAt } from './css.mjs';
|
|
24
|
+
import { expandEach, label } from './files.mjs';
|
|
24
25
|
|
|
25
26
|
/** Literal value shapes worth flagging when they are not on the list. */
|
|
26
27
|
const VALUE_SHAPES = [
|
|
@@ -60,10 +61,18 @@ export function* openTags(source, names) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The value of `attr` when it is a plain string literal, else null.
|
|
66
|
+
*
|
|
67
|
+
* Both quote styles. Matching only double quotes meant class='a b' and
|
|
68
|
+
* style='...' hit neither this nor the expression path, so the element was
|
|
69
|
+
* read as carrying no classes and no values: a file full of unapproved
|
|
70
|
+
* classes reported clean and exited 0. Single quotes are the house style in
|
|
71
|
+
* a lot of HTML, and `files` accepts .html, so this was not a corner.
|
|
72
|
+
*/
|
|
64
73
|
export function literalAttr(attrs, attr) {
|
|
65
|
-
const m = attrs.match(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*
|
|
66
|
-
return m ? { text: m[
|
|
74
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*(["'])([\\s\\S]*?)\\1`));
|
|
75
|
+
return m ? { text: m[2], at: m.index + m[0].indexOf(m[1]) + 1 } : null;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
/** The braced expression for `attr`, brace-balanced, or null. */
|
|
@@ -178,8 +187,6 @@ function inlineValues(attrs) {
|
|
|
178
187
|
return found;
|
|
179
188
|
}
|
|
180
189
|
|
|
181
|
-
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
|
182
|
-
|
|
183
190
|
export function runTreatments(config, cwd) {
|
|
184
191
|
const failures = [];
|
|
185
192
|
const problems = [];
|
|
@@ -191,9 +198,11 @@ export function runTreatments(config, cwd) {
|
|
|
191
198
|
approvedValues = [],
|
|
192
199
|
} = config;
|
|
193
200
|
|
|
194
|
-
const files =
|
|
201
|
+
const { files, empty } = expandEach(patterns, cwd);
|
|
202
|
+
// Per pattern: a dead one beside a live one leaves the union non-empty.
|
|
203
|
+
for (const p of empty) problems.push(`no markup files matched "${p}"`);
|
|
195
204
|
if (!files.length) {
|
|
196
|
-
problems.push(`no markup files matched ${patterns.map((p) => `"${p}"`).join(', ')}`);
|
|
205
|
+
if (!empty.length) problems.push(`no markup files matched ${patterns.map((p) => `"${p}"`).join(', ')}`);
|
|
197
206
|
return { name: 'treatments', failures, problems, summary: '' };
|
|
198
207
|
}
|
|
199
208
|
|
|
@@ -202,7 +211,7 @@ export function runTreatments(config, cwd) {
|
|
|
202
211
|
|
|
203
212
|
for (const file of files) {
|
|
204
213
|
const source = readFileSync(file, 'utf8');
|
|
205
|
-
const where = (at) => ({ file: label(file, cwd), line:
|
|
214
|
+
const where = (at) => ({ file: label(file, cwd), line: lineAt(source, at) });
|
|
206
215
|
|
|
207
216
|
for (const tag of openTags(source, elements)) {
|
|
208
217
|
for (const { name, at } of classesOf(tag.attrs)) {
|