@josueavalosjim/taste-check 0.11.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
@@ -637,12 +637,16 @@ publisher over OIDC, so no npm token is stored in the repository and every
637
637
  release carries a provenance attestation. For a minor or major bump, run
638
638
  `npm version minor` or `npm version major` and `git push --follow-tags`.
639
639
 
640
- 171 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
641
641
  moment earlier and demand it gets caught: a token darkened below its floor, an
642
642
  unapproved class added to a clean file, a class buried in a template literal
643
643
  hole, a fabricated token hidden in a ternary, a file pattern pointed at
644
644
  nothing.
645
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.
649
+
646
650
  ## License
647
651
 
648
652
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.11.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",
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/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/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/tokens.mjs CHANGED
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { readFileSync } from 'node:fs';
31
31
  import { lineAt, parseDeclarations } from './css.mjs';
32
- import { expand, label } from './files.mjs';
32
+ import { expandEach, label } from './files.mjs';
33
33
 
34
34
  /**
35
35
  * The name is `[\w-]+` for the same reason `resolveValue` in css.mjs uses it:
@@ -72,10 +72,17 @@ export function runTokens(config, cwd) {
72
72
  const { declaredIn, files: patterns, allow = [], allowPrefixes = [] } = config;
73
73
 
74
74
  const quoted = (list) => list.map((p) => `"${p}"`).join(', ');
75
- const tokenFiles = expand(declaredIn, cwd);
76
- const files = expand(patterns, cwd);
75
+ const declared_ = expandEach(declaredIn, cwd);
76
+ const scanned_ = expandEach(patterns, cwd);
77
+ const tokenFiles = declared_.files;
78
+ const files = scanned_.files;
77
79
 
78
- if (!tokenFiles.length) problems.push(`no token files matched ${quoted(declaredIn)}`);
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)}`);
79
86
  const declared = declaredNames(tokenFiles);
80
87
  if (tokenFiles.length && !declared.size) {
81
88
  problems.push(
@@ -83,7 +90,7 @@ export function runTokens(config, cwd) {
83
90
  `but none of them declares a custom property. Every reference would read as undefined.`,
84
91
  );
85
92
  }
86
- if (!files.length) problems.push(`no markup files matched ${quoted(patterns)}`);
93
+ if (!files.length && !scanned_.empty.length) problems.push(`no markup files matched ${quoted(patterns)}`);
87
94
 
88
95
  // Without a trustworthy declared set there is nothing to compare against,
89
96
  // and reporting every reference in the codebase as undefined would bury the
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { readFileSync } from 'node:fs';
23
23
  import { lineAt } from './css.mjs';
24
- import { expand, label } from './files.mjs';
24
+ import { expandEach, label } from './files.mjs';
25
25
 
26
26
  /** Literal value shapes worth flagging when they are not on the list. */
27
27
  const VALUE_SHAPES = [
@@ -61,10 +61,18 @@ export function* openTags(source, names) {
61
61
  }
62
62
  }
63
63
 
64
- /** 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
+ */
65
73
  export function literalAttr(attrs, attr) {
66
- const m = attrs.match(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*"([^"]*)"`));
67
- 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;
68
76
  }
69
77
 
70
78
  /** The braced expression for `attr`, brace-balanced, or null. */
@@ -190,9 +198,11 @@ export function runTreatments(config, cwd) {
190
198
  approvedValues = [],
191
199
  } = config;
192
200
 
193
- 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}"`);
194
204
  if (!files.length) {
195
- 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(', ')}`);
196
206
  return { name: 'treatments', failures, problems, summary: '' };
197
207
  }
198
208