@josueavalosjim/taste-check 0.5.2 → 0.6.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
@@ -343,13 +343,52 @@ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
343
343
 
344
344
  -c, --config <path> Config file (default: tastecheck.config.json)
345
345
  --only <name> Run one check: contrast or treatments
346
- --json Machine-readable output
346
+ --format <kind> text (default), json, or sarif
347
+ --json Alias for --format json
347
348
  --version Print the version
348
349
  ```
349
350
 
350
351
  Exit code is 1 if any check fails, 0 if every check ran and passed. The judge
351
352
  plays by the rules in its own section above.
352
353
 
354
+ ## SARIF
355
+
356
+ ```bash
357
+ taste-check --format sarif > taste-check.sarif
358
+ ```
359
+
360
+ Works on any of the three commands, and drops findings into a code scanning tab
361
+ instead of a log nobody opens.
362
+
363
+ ```yaml
364
+ - run: npx taste-check --format sarif > taste-check.sarif
365
+ continue-on-error: true
366
+ - uses: github/codeql-action/upload-sarif@v3
367
+ with:
368
+ sarif_file: taste-check.sarif
369
+ ```
370
+
371
+ `continue-on-error` because the step exits 1 when something fails, and you
372
+ still want the findings uploaded when it does.
373
+
374
+ Two things make this worth having over a log.
375
+
376
+ **Every finding points at the line you would edit.** A class points at the
377
+ markup. A contrast failure points at the line in your token file where the
378
+ foreground is declared, which is the half of a pair you usually end up moving.
379
+ A judge verdict points at the line in your checklist. Findings with no file
380
+ behind them, like a browser that would not start, point at the config, because
381
+ that is where the rule was written.
382
+
383
+ **Fingerprints are keyed to the content of the line, not its number.** Add an
384
+ import at the top of a file and every finding below it stays the same alert
385
+ rather than closing and reopening as a new one. Without that a code scanning
386
+ tab fills with churn and people stop reading it.
387
+
388
+ Levels follow the same rule the exit code does. Everything that gates the build
389
+ is an `error`. A judge verdict is a `note`, or a `warning` if you set
390
+ `failOn`, because it is an opinion either way.
391
+
353
392
  ## Where it sits next to other tools
354
393
 
355
394
  This is a small tool with a narrow claim, and several of these are better than
@@ -414,9 +453,6 @@ Not built. Written down so the shape is clear.
414
453
 
415
454
  **YAML configs**, once there is a reason to take on a parser.
416
455
 
417
- **SARIF output**, so findings land in a code scanning tab rather than only in
418
- a log.
419
-
420
456
  **A way to run the judge from an agent skill**, not only from a shell.
421
457
 
422
458
  **`lab()` and `lch()`**, which need the D50 white point and a chromatic
@@ -7,9 +7,12 @@
7
7
  * exist, or scopes a pair to a theme that is not defined is a failure here,
8
8
  * not a quiet skip.
9
9
  */
10
+ import { readFileSync } from 'node:fs';
11
+
10
12
  import { load } from '../src/config.mjs';
11
13
  import { judge, run, runtime } from '../src/index.mjs';
12
14
  import { failed, toJson, toText } from '../src/report.mjs';
15
+ import { toSarif } from '../src/sarif.mjs';
13
16
 
14
17
  const USAGE = `taste-check
15
18
 
@@ -20,12 +23,21 @@ const USAGE = `taste-check
20
23
  Options:
21
24
  -c, --config <path> Config file (default: tastecheck.config.json)
22
25
  --only <name> Run one check: contrast or treatments
23
- --json Machine-readable output
26
+ --format <kind> text (default), json, or sarif
27
+ --json Alias for --format json
24
28
  -h, --help This
25
29
  --version Print the version
26
30
 
27
31
  Exit code is 1 if any check fails, 0 if every check ran and passed.
28
32
 
33
+ --format sarif writes SARIF 2.1.0 on stdout, for a code scanning tab:
34
+
35
+ taste-check --format sarif > taste-check.sarif
36
+
37
+ Every finding points at the line you would edit to change it. A class points
38
+ at the markup, a contrast failure at the line in your token file where the
39
+ foreground is declared, a judge verdict at the line in your checklist.
40
+
29
41
  runtime is a separate command because it needs a browser and a server that
30
42
  is already up. It measures what is actually painted, compositing every
31
43
  background layer behind an element rather than stopping at the first opaque
@@ -38,7 +50,7 @@ at all is a different question: no screenshots, a command that failed, or
38
50
  a reply that skipped a checklist line all exit 1 either way.`;
39
51
 
40
52
  function parseArgs(argv) {
41
- const options = { config: 'tastecheck.config.json', only: null, json: false, command: 'check' };
53
+ const options = { config: 'tastecheck.config.json', only: null, format: 'text', command: 'check' };
42
54
  // One positional, and only in first position, so a stray argument is an
43
55
  // error rather than something silently ignored.
44
56
  if (argv[0] === 'judge' || argv[0] === 'runtime') {
@@ -61,7 +73,13 @@ function parseArgs(argv) {
61
73
  if (options.only !== 'contrast' && options.only !== 'treatments') {
62
74
  throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
63
75
  }
64
- } else if (arg === '--json') options.json = true;
76
+ } else if (arg === '--json') options.format = 'json';
77
+ else if (arg === '--format') {
78
+ options.format = next();
79
+ if (!['text', 'json', 'sarif'].includes(options.format)) {
80
+ throw new Error(`--format takes text, json or sarif, not "${options.format}"`);
81
+ }
82
+ }
65
83
  else if (!arg.startsWith('-')) throw new Error(`unknown command "${arg}"`);
66
84
  else throw new Error(`unknown option "${arg}"`);
67
85
  }
@@ -85,11 +103,7 @@ if (options.help) {
85
103
  process.exit(0);
86
104
  }
87
105
  if (options.version) {
88
- const { version } = JSON.parse(
89
- await import('node:fs').then((fs) =>
90
- fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
91
- ),
92
- );
106
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
93
107
  console.log(version);
94
108
  process.exit(0);
95
109
  }
@@ -118,5 +132,12 @@ if (!results.length) {
118
132
  die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or treatments'} check.`);
119
133
  }
120
134
 
121
- console.log(options.json ? toJson(results) : toText(results));
135
+ if (options.format === 'sarif') {
136
+ const { version } = JSON.parse(
137
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
138
+ );
139
+ console.log(toSarif(results, { version, configFile: options.config, configDir: loaded.dir }));
140
+ } else {
141
+ console.log(options.format === 'json' ? toJson(results) : toText(results));
142
+ }
122
143
  process.exit(failed(results) ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.5.2",
3
+ "version": "0.6.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
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { readFileSync } from 'node:fs';
20
20
  import { contrastRatio, isOpaque, parseColor } from './color.mjs';
21
- import { parseDeclarations, resolveScopes, resolveValue, unmatchedScopes } from './css.mjs';
21
+ import { lineAt, parseDeclarations, resolveScopes, resolveValue, unmatchedScopes } from './css.mjs';
22
22
  import { expand, label } from './files.mjs';
23
23
 
24
24
  /** A token name, or a literal colour, resolved to rgba for one theme. */
@@ -28,7 +28,7 @@ function side(spec, table, theme) {
28
28
  if (!resolved.ok) return { ok: false, reason: `theme "${theme}": ${resolved.reason}` };
29
29
  const color = parseColor(resolved.value);
30
30
  if (!color.ok) return { ok: false, reason: `theme "${theme}": ${spec} is ${color.reason}` };
31
- return { ok: true, rgba: color.rgba };
31
+ return { ok: true, rgba: color.rgba, at: resolved.decl };
32
32
  }
33
33
  const color = parseColor(spec);
34
34
  if (!color.ok) return { ok: false, reason: `theme "${theme}": ${color.reason}` };
@@ -49,7 +49,14 @@ export function runContrast(config, cwd) {
49
49
  // One declaration list across every token file, in the order they were
50
50
  // listed, so a later file overriding an earlier one behaves like a later
51
51
  // @import would.
52
- const decls = files.flatMap((file) => parseDeclarations(readFileSync(file, 'utf8')));
52
+ // Each declaration remembers where it was written. A contrast failure is
53
+ // otherwise a number with nowhere to go, and the line you would edit to fix
54
+ // it is the line the token is declared on.
55
+ const decls = files.flatMap((file) => {
56
+ const source = readFileSync(file, 'utf8');
57
+ const where = { file: label(file, cwd), };
58
+ return parseDeclarations(source).map((d) => ({ ...d, ...where, line: lineAt(source, d.index) }));
59
+ });
53
60
 
54
61
  for (const theme of themes) {
55
62
  const table = resolveScopes(decls, theme.scopes);
@@ -111,6 +118,9 @@ export function runContrast(config, cwd) {
111
118
  ratio,
112
119
  min: pair.min,
113
120
  pass: ratio >= pair.min,
121
+ // Point at the foreground: it is the half a contrast failure is
122
+ // usually fixed by moving.
123
+ at: fg.at ? { file: fg.at.file, line: fg.at.line } : null,
114
124
  });
115
125
  }
116
126
  }
package/src/judge.mjs CHANGED
@@ -86,10 +86,17 @@ CHECKLIST:
86
86
  * returns a verdict on your explanatory paragraph.
87
87
  */
88
88
  export function checklistLines(text) {
89
- return text
90
- .split('\n')
91
- .filter((l) => /^\s*(?:[-*+]|\d+[.)])\s+\S/.test(l))
92
- .map((l) => l.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim());
89
+ return checklistEntries(text).map((e) => e.text);
90
+ }
91
+
92
+ /** The same, keeping the line each one was written on so a finding can point at it. */
93
+ export function checklistEntries(text) {
94
+ const entries = [];
95
+ text.split('\n').forEach((raw, i) => {
96
+ if (!/^\s*(?:[-*+]|\d+[.)])\s+\S/.test(raw)) return;
97
+ entries.push({ text: raw.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim(), line: i + 1 });
98
+ });
99
+ return entries;
93
100
  }
94
101
 
95
102
  /**
@@ -169,13 +176,15 @@ export function runJudge(config, cwd) {
169
176
  return { name: 'judge', findings, problems, failOn, summary: '' };
170
177
  }
171
178
 
172
- let lines;
179
+ let entries;
173
180
  try {
174
- lines = checklistLines(readFileSync(resolve(cwd, checklistPath), 'utf8'));
181
+ entries = checklistEntries(readFileSync(resolve(cwd, checklistPath), 'utf8'));
175
182
  } catch {
176
183
  problems.push(`cannot read the checklist at ${checklistPath}`);
177
184
  return { name: 'judge', findings, problems, failOn, summary: '' };
178
185
  }
186
+ const lines = entries.map((e) => e.text);
187
+ const lineNumbers = new Map(entries.map((e) => [e.text, e.line]));
179
188
  if (!lines.length) {
180
189
  problems.push(
181
190
  `${checklistPath} has no checklist lines in it. Lines to judge are list ` +
@@ -219,7 +228,12 @@ export function runJudge(config, cwd) {
219
228
  continue;
220
229
  }
221
230
  answered.add(f.line);
222
- findings.push({ line: f.line, verdict: f.verdict, why: (f.why ?? '').trim() });
231
+ findings.push({
232
+ line: f.line,
233
+ verdict: f.verdict,
234
+ why: (f.why ?? '').trim(),
235
+ at: { file: checklistPath, line: lineNumbers.get(f.line) },
236
+ });
223
237
  }
224
238
  for (const line of lines) {
225
239
  if (!answered.has(line)) problems.push(`the judge did not answer "${line}"`);
package/src/report.mjs CHANGED
@@ -28,7 +28,7 @@ function contrastLines(result) {
28
28
 
29
29
  function treatmentLines(result) {
30
30
  return [
31
- ...result.failures.map((text) => ({ level: 'fail', text })),
31
+ ...result.failures.map((f) => ({ level: 'fail', text: `${f.file}:${f.line} ${f.message}` })),
32
32
  ...result.problems.map((text) => ({ level: 'error', text })),
33
33
  ];
34
34
  }
package/src/sarif.mjs ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * SARIF 2.1.0 output, so findings land in a code scanning tab instead of only
3
+ * in a log nobody opens.
4
+ *
5
+ * The part worth getting right is locations. A format conversion that emits
6
+ * every finding against the config file is technically valid SARIF and useless
7
+ * in practice: the annotations all pile onto one line and none of them say
8
+ * where the problem is.
9
+ *
10
+ * So every finding points at the line you would edit to change it. An
11
+ * unapproved class points at the markup. A contrast failure points at the line
12
+ * in the token file where the foreground is declared, which is the half of a
13
+ * pair you usually end up moving. A judge verdict points at the line in your
14
+ * checklist. Where a finding genuinely has no file behind it, it points at the
15
+ * config, because that is where the rule was written.
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import { readFileSync } from 'node:fs';
19
+ import { relative, resolve, sep } from 'node:path';
20
+
21
+ import { linesFor } from './report.mjs';
22
+
23
+ const HELP = 'https://github.com/josueavalosjim/taste-check#readme';
24
+
25
+ /**
26
+ * Levels are the same judgement the exit code makes. A judge verdict is
27
+ * advisory, so it is a note; everything that gates the build is an error.
28
+ */
29
+ const RULES = [
30
+ ['contrast/below-floor', 'error', 'A declared pair is under the ratio it was given.'],
31
+ ['contrast/unmeasurable', 'error', 'A pair could not be measured: a missing token, a dead scope, a translucent background, or a colour that would not parse.'],
32
+ ['treatments/unapproved-class', 'error', 'A class name that is not on the approved list.'],
33
+ ['treatments/one-off-value', 'error', 'A literal colour or length hardcoded into an inline style.'],
34
+ ['treatments/unscannable', 'error', 'No markup matched, so nothing was checked.'],
35
+ ['runtime/below-floor', 'error', 'A target on the rendered page is under the ratio it was given.'],
36
+ ['runtime/unmeasurable', 'error', 'A target could not be measured: no element, nothing rendered, an edge with no width, or no browser.'],
37
+ ['judge/verdict', 'note', 'A checklist line the judge did not pass. Advisory: a model verdict is not reproducible.'],
38
+ ['judge/did-not-run', 'error', 'The judge could not run, which is a fact rather than an opinion.'],
39
+ ];
40
+
41
+ const index = new Map(RULES.map(([id], i) => [id, i]));
42
+
43
+ /**
44
+ * SARIF URIs are relative to the run's root, which for code scanning is the
45
+ * repository. Paths inside a config are relative to the config file, so they
46
+ * have to be re-rooted here. Getting this wrong does not fail validation, it
47
+ * just hangs every annotation on a path that does not exist.
48
+ */
49
+ function uriFor(file, { configDir, root }) {
50
+ const absolute = resolve(configDir, file);
51
+ return relative(root, absolute).split(sep).join('/');
52
+ }
53
+
54
+ /**
55
+ * A fingerprint keyed to the content of the line rather than to its number.
56
+ *
57
+ * This is what keeps an alert the same alert when the file shifts. Without it
58
+ * every finding below an added import reads as a new problem and an old one
59
+ * closed, which turns the code scanning tab into churn and trains people to
60
+ * ignore it. Hashing the line's text rather than its position is what survives
61
+ * the shift. The rule id and a key naming what the finding is about are in
62
+ * there too, because two one-off values on the same line are two findings and
63
+ * a fingerprint that collides makes them one. The key is an identity, not the
64
+ * message: rewording a message must not close an alert and open a new one.
65
+ */
66
+ function fingerprint(uri, line, ruleId, key, ctx) {
67
+ let text = '';
68
+ try {
69
+ text = (readFileSync(resolve(ctx.root, uri), 'utf8').split('\n')[line - 1] ?? '').trim();
70
+ } catch {
71
+ /* a file we cannot read still gets a stable fingerprint from its path */
72
+ }
73
+ return {
74
+ primaryLocationLineHash: createHash('sha256')
75
+ .update(`${ruleId}\u0000${uri}\u0000${text}\u0000${key}`)
76
+ .digest('hex')
77
+ .slice(0, 32),
78
+ };
79
+ }
80
+
81
+ const location = (file, ctx, line) => ({
82
+ physicalLocation: {
83
+ artifactLocation: { uri: uriFor(file, ctx), uriBaseId: '%SRCROOT%' },
84
+ // A region with no line is invalid, and line 1 is the honest fallback for
85
+ // "this file, we cannot be more specific".
86
+ region: { startLine: Math.max(1, line ?? 1) },
87
+ },
88
+ });
89
+
90
+ function resultsFor(check, ctx) {
91
+ const here = location(ctx.configFile, { ...ctx, configDir: ctx.root }, 1);
92
+ const out = [];
93
+
94
+ if (check.name === 'contrast' || check.name === 'runtime') {
95
+ for (const sample of check.samples ?? []) {
96
+ if (sample.pass) continue;
97
+ out.push({
98
+ ruleId: `${check.name}/below-floor`,
99
+ key: `${sample.fg}|${sample.bg}|${sample.theme}`,
100
+ level: 'error',
101
+ message: {
102
+ text:
103
+ `${sample.ratio.toFixed(2)}:1 against a floor of ${sample.min} for ${sample.fg} on ` +
104
+ `${sample.bg} in ${sample.theme}${sample.note ? `. ${sample.note}` : ''}`,
105
+ },
106
+ locations: [sample.at ? location(sample.at.file, ctx, sample.at.line) : here],
107
+ });
108
+ }
109
+ for (const problem of check.problems ?? []) {
110
+ out.push({
111
+ ruleId: `${check.name}/unmeasurable`,
112
+ key: problem,
113
+ level: 'error',
114
+ message: { text: problem },
115
+ locations: [here],
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ if (check.name === 'treatments') {
122
+ for (const failure of check.failures ?? []) {
123
+ out.push({
124
+ ruleId: failure.rule,
125
+ key: `${failure.subject ?? failure.message}`,
126
+ level: 'error',
127
+ message: { text: failure.message },
128
+ locations: [location(failure.file, ctx, failure.line)],
129
+ });
130
+ }
131
+ for (const problem of check.problems ?? []) {
132
+ out.push({
133
+ ruleId: 'treatments/unscannable',
134
+ key: problem,
135
+ level: 'error',
136
+ message: { text: problem },
137
+ locations: [here],
138
+ });
139
+ }
140
+ return out;
141
+ }
142
+
143
+ if (check.name === 'judge') {
144
+ for (const finding of check.findings ?? []) {
145
+ if (finding.verdict === 'pass') continue;
146
+ out.push({
147
+ ruleId: 'judge/verdict',
148
+ key: finding.line,
149
+ // Mirrors the exit code: advisory unless the config opted into blocking.
150
+ level: check.failOn === 'fail' && finding.verdict === 'fail' ? 'warning' : 'note',
151
+ message: { text: `${finding.verdict}: ${finding.line}${finding.why ? `. ${finding.why}` : ''}` },
152
+ locations: [finding.at ? location(finding.at.file, ctx, finding.at.line) : here],
153
+ });
154
+ }
155
+ for (const problem of check.problems ?? []) {
156
+ out.push({
157
+ ruleId: 'judge/did-not-run',
158
+ key: problem,
159
+ level: 'error',
160
+ message: { text: problem },
161
+ locations: [here],
162
+ });
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** Attach a fingerprint to each result, derived from where it points. */
169
+ function withFingerprints(found, ctx) {
170
+ return found.map((r) => {
171
+ const place = r.locations[0].physicalLocation;
172
+ const { key, ...rest } = r;
173
+ return {
174
+ ...rest,
175
+ partialFingerprints: fingerprint(
176
+ place.artifactLocation.uri,
177
+ place.region.startLine,
178
+ r.ruleId,
179
+ r.key ?? r.message.text,
180
+ ctx,
181
+ ),
182
+ };
183
+ });
184
+ }
185
+
186
+ export function toSarif(results, { version, configFile, configDir, root = process.cwd() }) {
187
+ const ctx = {
188
+ configFile: configFile ?? 'tastecheck.config.json',
189
+ configDir: configDir ?? root,
190
+ root,
191
+ };
192
+ const found = results.flatMap((check) => resultsFor(check, ctx));
193
+ // Only the rules that actually fired, so the tab is not padded with rules
194
+ // this run had no opinion about.
195
+ const fired = [...new Set(found.map((r) => r.ruleId))];
196
+
197
+ return JSON.stringify(
198
+ {
199
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
200
+ version: '2.1.0',
201
+ runs: [
202
+ {
203
+ tool: {
204
+ driver: {
205
+ name: 'taste-check',
206
+ version,
207
+ informationUri: HELP,
208
+ rules: fired.map((id) => {
209
+ const [, level, description] = RULES[index.get(id)];
210
+ return {
211
+ id,
212
+ name: id.replace(/[/-](.)/g, (_, c) => c.toUpperCase()),
213
+ shortDescription: { text: description },
214
+ helpUri: HELP,
215
+ defaultConfiguration: { level },
216
+ };
217
+ }),
218
+ },
219
+ },
220
+ results: withFingerprints(found, ctx).map((r) => ({
221
+ ...r,
222
+ ruleIndex: fired.indexOf(r.ruleId),
223
+ })),
224
+ },
225
+ ],
226
+ },
227
+ null,
228
+ 2,
229
+ );
230
+ }
231
+
232
+ /** Every rule this tool can emit, for the docs and for the tests to check. */
233
+ export const ruleIds = () => RULES.map(([id]) => id);
234
+
235
+ // Re-exported so a caller does not have to know that a note is not a failure.
236
+ export { linesFor };
@@ -202,20 +202,29 @@ export function runTreatments(config, cwd) {
202
202
 
203
203
  for (const file of files) {
204
204
  const source = readFileSync(file, 'utf8');
205
- const where = (at) => `${label(file, cwd)}:${lineOf(source, at)}`;
205
+ const where = (at) => ({ file: label(file, cwd), line: lineOf(source, at) });
206
206
 
207
207
  for (const tag of openTags(source, elements)) {
208
208
  for (const { name, at } of classesOf(tag.attrs)) {
209
209
  if (approved.has(name)) continue;
210
210
  if (allowPrefixes.some((p) => name.startsWith(p))) continue;
211
- failures.push(`${where(tag.start + at)} class "${name}" on <${tag.name}> is not approved`);
211
+ failures.push({
212
+ rule: 'treatments/unapproved-class',
213
+ ...where(tag.start + at),
214
+ subject: name,
215
+ message: `class "${name}" on <${tag.name}> is not approved`,
216
+ });
212
217
  }
213
218
  for (const { text, at } of inlineValues(tag.attrs)) {
214
219
  if (allowedValues.has(text.toLowerCase())) continue;
215
- failures.push(
216
- `${where(tag.start + at)} inline value "${text}" on <${tag.name}> is a one-off. ` +
220
+ failures.push({
221
+ rule: 'treatments/one-off-value',
222
+ ...where(tag.start + at),
223
+ subject: text,
224
+ message:
225
+ `inline value "${text}" on <${tag.name}> is a one-off. ` +
217
226
  `Use a token, or add it to approvedValues.`,
218
- );
227
+ });
219
228
  }
220
229
  }
221
230
  }