@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 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. taste-check measures those and fails
7
- your build on them, either from your token file or from a page it renders in a
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 treatments
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
- 34 tests. Most of them plant a violation into a fixture that was passing a
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 nothing.
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
 
@@ -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 treatments
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 (options.only !== 'contrast' && options.only !== 'treatments') {
116
- throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
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 treatments'} check.`);
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.10.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('the config must define at least one of "contrast", "treatments", "runtime" or "judge"');
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 { expand, label } from './files.mjs';
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 = expand(tokens, cwd);
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
- problems.push(`no token files matched ${tokens.map((t) => `"${t}"`).join(', ')}`);
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 = blankComments(css);
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 = (end) => {
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: bufferStart + (source.slice(bufferStart, end).length - source.slice(bufferStart, end).trimStart().length),
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(i);
125
+ flush();
107
126
  stack.pop();
108
127
  bufferStart = i + 1;
109
128
  continue;
110
129
  }
111
130
  if (c === ';') {
112
- flush(i);
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, relative to `cwd`, sorted and
60
- * de-duplicated so a run is reproducible and a report is diffable.
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 expand(patterns, cwd) {
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()) continue;
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 { expand, label } from './files.mjs';
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 = expand(shots, cwd);
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
- const fg = cs[prop];
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 (prop.startsWith('border')) {
44
- const side = prop.replace(/^border|Color$/g, '');
45
- const width = parseFloat(cs['border' + (side || 'Top') + 'Width']);
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: 'treatments/unscannable',
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
+ }
@@ -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 { expand, label } from './files.mjs';
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
- /** The value of `attr` when it is a plain string literal, else null. */
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[1], at: m.index + m[0].indexOf('"') + 1 } : null;
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 = expand(patterns, cwd);
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: lineOf(source, at) });
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)) {