@josueavalosjim/taste-check 0.5.2 → 0.7.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
@@ -290,6 +290,35 @@ inside a tool is somebody else's taste with the tool's authority behind it.
290
290
  Checklist lines are list items in your file; a heading or a paragraph is prose
291
291
  and is not judged.
292
292
 
293
+ ### Letting an agent carry the call
294
+
295
+ A shell command is one way to reach a model and a poor one for an agent, which
296
+ is already a model and can spawn a genuinely fresh context of its own instead
297
+ of shelling out to a second copy of itself. So the judge splits in two:
298
+
299
+ ```bash
300
+ taste-check judge --emit # the prompt, and nothing else happens
301
+ taste-check judge --verdict reply.json # or - for stdin
302
+ ```
303
+
304
+ `--emit` prints the prompt and stops. It still refuses when there are no
305
+ screenshots or no checklist, because a prompt for nothing gets a confident
306
+ verdict about nothing. `--verdict` checks the reply against the checklist
307
+ exactly as the shell route does: every line answered once, no invented lines,
308
+ valid verdicts. Taking the agent route does not buy a softer grading.
309
+
310
+ `judge.command` is optional when you use this. There is a skill for it:
311
+
312
+ ```bash
313
+ taste-check judge --skill # prints the path to SKILL.md
314
+ ```
315
+
316
+ Copy it wherever your agent keeps skills. It carries the mechanism, which is
317
+ that the agent must not be the judge: it is reading the session that built the
318
+ thing, and the reasoning that justified each choice is still sitting there
319
+ ready to justify it again. It carries no design rules, for the same reason
320
+ nothing else here does.
321
+
293
322
  ### What the exit code means here
294
323
 
295
324
  A verdict is an opinion, so a `fail` prints as a note and the command exits 0.
@@ -328,7 +357,7 @@ from anywhere.
328
357
  | `judge.checklist` | Your checklist file. List items are judged, prose is not. |
329
358
  | `judge.shots` | Screenshots to hand the judge. Matching nothing is a failure. |
330
359
  | `judge.shotCommand` | Optional command run first to produce those screenshots. |
331
- | `judge.command` | The model command. Prompt on stdin, image paths as arguments. |
360
+ | `judge.command` | The model command. Prompt on stdin, image paths as arguments. Optional if an agent carries the call. |
332
361
  | `judge.failOn` | `"never"` (default) or `"fail"`. Whether a verdict blocks. |
333
362
  | `runtime.url` | The page to measure. A `file://` URL works. |
334
363
  | `runtime.endpoint` | An existing CDP endpoint. Given one, taste-check connects rather than launching, and never closes a browser it did not start. |
@@ -343,13 +372,52 @@ taste-check judge [options] Ask a fresh-eyes judge about your screenshots
343
372
 
344
373
  -c, --config <path> Config file (default: tastecheck.config.json)
345
374
  --only <name> Run one check: contrast or treatments
346
- --json Machine-readable output
375
+ --format <kind> text (default), json, or sarif
376
+ --json Alias for --format json
347
377
  --version Print the version
348
378
  ```
349
379
 
350
380
  Exit code is 1 if any check fails, 0 if every check ran and passed. The judge
351
381
  plays by the rules in its own section above.
352
382
 
383
+ ## SARIF
384
+
385
+ ```bash
386
+ taste-check --format sarif > taste-check.sarif
387
+ ```
388
+
389
+ Works on any of the three commands, and drops findings into a code scanning tab
390
+ instead of a log nobody opens.
391
+
392
+ ```yaml
393
+ - run: npx taste-check --format sarif > taste-check.sarif
394
+ continue-on-error: true
395
+ - uses: github/codeql-action/upload-sarif@v3
396
+ with:
397
+ sarif_file: taste-check.sarif
398
+ ```
399
+
400
+ `continue-on-error` because the step exits 1 when something fails, and you
401
+ still want the findings uploaded when it does.
402
+
403
+ Two things make this worth having over a log.
404
+
405
+ **Every finding points at the line you would edit.** A class points at the
406
+ markup. A contrast failure points at the line in your token file where the
407
+ foreground is declared, which is the half of a pair you usually end up moving.
408
+ A judge verdict points at the line in your checklist. Findings with no file
409
+ behind them, like a browser that would not start, point at the config, because
410
+ that is where the rule was written.
411
+
412
+ **Fingerprints are keyed to the content of the line, not its number.** Add an
413
+ import at the top of a file and every finding below it stays the same alert
414
+ rather than closing and reopening as a new one. Without that a code scanning
415
+ tab fills with churn and people stop reading it.
416
+
417
+ Levels follow the same rule the exit code does. Everything that gates the build
418
+ is an `error`. A judge verdict is a `note`, or a `warning` if you set
419
+ `failOn`, because it is an opinion either way.
420
+
353
421
  ## Where it sits next to other tools
354
422
 
355
423
  This is a small tool with a narrow claim, and several of these are better than
@@ -414,11 +482,6 @@ Not built. Written down so the shape is clear.
414
482
 
415
483
  **YAML configs**, once there is a reason to take on a parser.
416
484
 
417
- **SARIF output**, so findings land in a code scanning tab rather than only in
418
- a log.
419
-
420
- **A way to run the judge from an agent skill**, not only from a shell.
421
-
422
485
  **`lab()` and `lch()`**, which need the D50 white point and a chromatic
423
486
  adaptation step that `oklch()` does not. Completeness rather than reach, so
424
487
  it sits behind the others. Worth doing the same way when it happens: derive it,
@@ -7,9 +7,13 @@
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
+ import { fileURLToPath } from 'node:url';
12
+
10
13
  import { load } from '../src/config.mjs';
11
- import { judge, run, runtime } from '../src/index.mjs';
14
+ import { gradeVerdict, judge, prepareJudge, run, runtime } from '../src/index.mjs';
12
15
  import { failed, toJson, toText } from '../src/report.mjs';
16
+ import { toSarif } from '../src/sarif.mjs';
13
17
 
14
18
  const USAGE = `taste-check
15
19
 
@@ -20,17 +24,35 @@ const USAGE = `taste-check
20
24
  Options:
21
25
  -c, --config <path> Config file (default: tastecheck.config.json)
22
26
  --only <name> Run one check: contrast or treatments
23
- --json Machine-readable output
27
+ --emit judge only: print the prompt and stop, for an agent
28
+ to carry to a model itself
29
+ --verdict <path> judge only: grade a reply from a file, or - for stdin
30
+ --skill Print the path to the bundled agent skill
31
+ --format <kind> text (default), json, or sarif
32
+ --json Alias for --format json
24
33
  -h, --help This
25
34
  --version Print the version
26
35
 
27
36
  Exit code is 1 if any check fails, 0 if every check ran and passed.
28
37
 
38
+ --format sarif writes SARIF 2.1.0 on stdout, for a code scanning tab:
39
+
40
+ taste-check --format sarif > taste-check.sarif
41
+
42
+ Every finding points at the line you would edit to change it. A class points
43
+ at the markup, a contrast failure at the line in your token file where the
44
+ foreground is declared, a judge verdict at the line in your checklist.
45
+
29
46
  runtime is a separate command because it needs a browser and a server that
30
47
  is already up. It measures what is actually painted, compositing every
31
48
  background layer behind an element rather than stopping at the first opaque
32
49
  one, and it can put the page into a state first.
33
50
 
51
+ An agent can carry the model call instead of a shell command. --emit prints
52
+ the prompt and the images; the agent asks a fresh context and pipes the JSON
53
+ back to --verdict -, which checks it against the checklist the same way. Run
54
+ taste-check judge --skill for the bundled skill that wires this up.
55
+
34
56
  The judge is a separate command because it runs a model, and a model's
35
57
  verdict is not reproducible. Its verdicts print as notes and do not affect
36
58
  the exit code unless judge.failOn is set to "fail". Whether the judge ran
@@ -38,7 +60,15 @@ at all is a different question: no screenshots, a command that failed, or
38
60
  a reply that skipped a checklist line all exit 1 either way.`;
39
61
 
40
62
  function parseArgs(argv) {
41
- const options = { config: 'tastecheck.config.json', only: null, json: false, command: 'check' };
63
+ const options = {
64
+ config: 'tastecheck.config.json',
65
+ only: null,
66
+ format: 'text',
67
+ command: 'check',
68
+ emit: false,
69
+ verdict: null,
70
+ skill: false,
71
+ };
42
72
  // One positional, and only in first position, so a stray argument is an
43
73
  // error rather than something silently ignored.
44
74
  if (argv[0] === 'judge' || argv[0] === 'runtime') {
@@ -49,7 +79,11 @@ function parseArgs(argv) {
49
79
  const arg = argv[i];
50
80
  const next = () => {
51
81
  const value = argv[i + 1];
52
- if (value === undefined || value.startsWith('-')) throw new Error(`${arg} needs a value`);
82
+ // A bare "-" is a value, not a flag: it is the conventional name for
83
+ // stdin and --verdict takes it.
84
+ if (value === undefined || (value !== '-' && value.startsWith('-'))) {
85
+ throw new Error(`${arg} needs a value`);
86
+ }
53
87
  i += 1;
54
88
  return value;
55
89
  };
@@ -61,8 +95,16 @@ function parseArgs(argv) {
61
95
  if (options.only !== 'contrast' && options.only !== 'treatments') {
62
96
  throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
63
97
  }
64
- } else if (arg === '--json') options.json = true;
65
- else if (!arg.startsWith('-')) throw new Error(`unknown command "${arg}"`);
98
+ } else if (arg === '--json') options.format = 'json';
99
+ else if (arg === '--emit') options.emit = true;
100
+ else if (arg === '--skill') options.skill = true;
101
+ else if (arg === '--verdict') options.verdict = next();
102
+ else if (arg === '--format') {
103
+ options.format = next();
104
+ if (!['text', 'json', 'sarif'].includes(options.format)) {
105
+ throw new Error(`--format takes text, json or sarif, not "${options.format}"`);
106
+ }
107
+ } else if (!arg.startsWith('-')) throw new Error(`unknown command "${arg}"`);
66
108
  else throw new Error(`unknown option "${arg}"`);
67
109
  }
68
110
  return options;
@@ -85,15 +127,24 @@ if (options.help) {
85
127
  process.exit(0);
86
128
  }
87
129
  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
- );
130
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
93
131
  console.log(version);
94
132
  process.exit(0);
95
133
  }
96
134
 
135
+ // Printing the bundled skill needs no config: it is the same file every time.
136
+ if (options.skill) {
137
+ console.log(fileURLToPath(new URL('../skills/taste-check-judge/SKILL.md', import.meta.url)));
138
+ process.exit(0);
139
+ }
140
+
141
+ for (const flag of ['emit', 'verdict']) {
142
+ if (options[flag] && options.command !== 'judge') {
143
+ die(`--${flag} applies to \`taste-check judge\`, not to ${options.command}`);
144
+ }
145
+ }
146
+ if (options.emit && options.verdict) die('--emit prints a prompt and --verdict grades a reply, so not both');
147
+
97
148
  const loaded = load(options.config);
98
149
  if (!loaded.ok) {
99
150
  console.error(`taste-check: ${options.config} could not be used:\n`);
@@ -109,8 +160,34 @@ if (options.command !== 'check' && !loaded.config[options.command]) {
109
160
  die(`${options.config} defines no "${options.command}" block.`);
110
161
  }
111
162
 
163
+ // --emit stops before any model is involved, so it has no findings to report
164
+ // and no exit code to earn. It still refuses to hand back a prompt when the
165
+ // screenshots or the checklist are missing.
166
+ if (options.emit) {
167
+ const prepared = prepareJudge(loaded.config.judge, loaded.dir);
168
+ if (!prepared.ok) {
169
+ console.log(toText([prepared.result]));
170
+ process.exit(1);
171
+ }
172
+ console.log(prepared.prompt);
173
+ process.exit(0);
174
+ }
175
+
112
176
  let results;
113
- if (options.command === 'judge') results = judge(loaded.config, loaded.dir);
177
+ if (options.verdict) {
178
+ const prepared = prepareJudge(loaded.config.judge, loaded.dir);
179
+ if (!prepared.ok) {
180
+ console.log(toText([prepared.result]));
181
+ process.exit(1);
182
+ }
183
+ let reply;
184
+ try {
185
+ reply = readFileSync(options.verdict === '-' ? 0 : options.verdict, 'utf8');
186
+ } catch {
187
+ die(`cannot read the verdict from ${options.verdict === '-' ? 'stdin' : options.verdict}`);
188
+ }
189
+ results = [gradeVerdict(reply, prepared, loaded.config.judge)];
190
+ } else if (options.command === 'judge') results = judge(loaded.config, loaded.dir);
114
191
  else if (options.command === 'runtime') results = await runtime(loaded.config, loaded.dir);
115
192
  else results = run(loaded.config, loaded.dir, { only: options.only });
116
193
 
@@ -118,5 +195,12 @@ if (!results.length) {
118
195
  die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or treatments'} check.`);
119
196
  }
120
197
 
121
- console.log(options.json ? toJson(results) : toText(results));
198
+ if (options.format === 'sarif') {
199
+ const { version } = JSON.parse(
200
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
201
+ );
202
+ console.log(toSarif(results, { version, configFile: options.config, configDir: loaded.dir }));
203
+ } else {
204
+ console.log(options.format === 'json' ? toJson(results) : toText(results));
205
+ }
122
206
  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.7.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",
@@ -34,6 +34,7 @@
34
34
  "bin",
35
35
  "src",
36
36
  "schema",
37
+ "skills",
37
38
  "README.md",
38
39
  "LICENSE"
39
40
  ],
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: taste-check-judge
3
+ description: Run the taste-check fresh-eyes judge on a design, using a fresh subagent as the judge rather than a shell command. Use after building or changing a screen, when asked to review or critique a design, or on any request to run the taste gate. Requires a tastecheck.config.json with a judge block.
4
+ ---
5
+
6
+ # taste-check judge
7
+
8
+ You are the transport, not the judge.
9
+
10
+ `taste-check` builds the prompt and checks the answer. You carry the prompt to a
11
+ model and carry the reply back. The model you carry it to must not be you.
12
+
13
+ ## Why not you
14
+
15
+ You are reading this inside a session that has context: what was built, what it
16
+ was for, which tradeoffs were made and why. That context is exactly what
17
+ disqualifies you from judging the result. The reasoning that justified each
18
+ choice is still sitting here ready to justify it again, and a review that
19
+ reaches for it is not a review.
20
+
21
+ So the judge is a separate agent with none of it. That is the whole mechanism,
22
+ and skipping it turns this into self-assessment with extra steps.
23
+
24
+ ## Steps
25
+
26
+ **1. Produce the screenshots.** However this project does it. If the config has
27
+ a `shotCommand`, the next step runs it for you.
28
+
29
+ **2. Get the prompt.**
30
+
31
+ ```
32
+ npx taste-check judge --emit
33
+ ```
34
+
35
+ It prints the prompt, which already contains the framing and the user's
36
+ checklist. It exits 1 and prints nothing usable if there are no screenshots or
37
+ no checklist, which is deliberate: a judge with nothing to look at must not
38
+ produce a verdict.
39
+
40
+ **3. Ask a fresh agent.** Spawn a new general-purpose agent on the strongest
41
+ reasoning tier available. Give it:
42
+
43
+ - the prompt from step 2, verbatim
44
+ - the screenshot files it names
45
+
46
+ Give it nothing else. No summary of what changed. No statement of intent. No
47
+ prior conversation. Do not mention which lines you expect to fail, do not say
48
+ what you already fixed, and do not add a checklist item of your own. Every one
49
+ of those turns the verdict into your opinion with a second signature on it.
50
+
51
+ Ask it to reply with the JSON the prompt specifies and nothing else.
52
+
53
+ **4. Grade the reply.**
54
+
55
+ ```
56
+ npx taste-check judge --verdict reply.json
57
+ ```
58
+
59
+ or pipe it on stdin with `-`. This checks the reply against the checklist:
60
+ every line answered exactly once, no invented lines, valid verdicts. A judge
61
+ that quietly drops the hardest line is the failure this catches.
62
+
63
+ Exit code 1 means the judge could not run, or a verdict blocked under
64
+ `failOn: "fail"`. Verdicts are otherwise advisory and print as notes.
65
+
66
+ ## Reporting back
67
+
68
+ Report what the judge said, including the passes. Do not soften a `fail` and do
69
+ not quietly drop an `unsure`.
70
+
71
+ If you disagree with a verdict, say so as a disagreement and leave the verdict
72
+ standing. "The judge flagged X, I think it is wrong because Y" is useful. Not
73
+ mentioning X is not.
74
+
75
+ Fix what you can, then run the whole thing again from step 1. A verdict on the
76
+ old screenshots is not a verdict on the new ones.
77
+
78
+ ## What this skill does not contain
79
+
80
+ Any design rules. The checklist is the user's file, named in their config, and
81
+ if it is empty then this reports nothing and that is correct. A checklist that
82
+ arrived with a tool is somebody else's taste wearing the tool's authority.
package/src/config.mjs CHANGED
@@ -133,14 +133,16 @@ function validateTreatments(treatments, errors) {
133
133
 
134
134
  function validateJudge(judge, errors) {
135
135
  rejectUnknown(judge, ['checklist', 'shots', 'shotCommand', 'command', 'failOn'], 'judge', errors);
136
- for (const key of ['checklist', 'command']) {
137
- if (typeof judge[key] !== 'string' || !judge[key]) {
136
+ if (typeof judge.checklist !== 'string' || !judge.checklist) {
137
+ errors.push('judge.checklist must be a non-empty string');
138
+ }
139
+ // command is optional: an agent carrying the call with --emit and --verdict
140
+ // never needs one, and demanding a placeholder would be theatre.
141
+ for (const key of ['command', 'shotCommand']) {
142
+ if (judge[key] !== undefined && (typeof judge[key] !== 'string' || !judge[key])) {
138
143
  errors.push(`judge.${key} must be a non-empty string`);
139
144
  }
140
145
  }
141
- if (judge.shotCommand !== undefined && (typeof judge.shotCommand !== 'string' || !judge.shotCommand)) {
142
- errors.push('judge.shotCommand must be a non-empty string');
143
- }
144
146
  stringArray(judge.shots, 'judge.shots', errors);
145
147
  if (judge.failOn !== undefined && judge.failOn !== 'never' && judge.failOn !== 'fail') {
146
148
  errors.push(
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/index.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export { runContrast } from './contrast.mjs';
6
6
  export { runTreatments } from './treatments.mjs';
7
- export { runJudge, buildPrompt, checklistLines, extractJson } from './judge.mjs';
7
+ export { runJudge, prepareJudge, gradeVerdict, buildPrompt, checklistLines, checklistEntries, extractJson } from './judge.mjs';
8
8
  export { runRuntime } from './runtime.mjs';
9
9
  export { connect, findBrowser } from './cdp.mjs';
10
10
  export { load, validate } from './config.mjs';
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
  /**
@@ -147,62 +154,98 @@ export function extractJson(stdout) {
147
154
 
148
155
  const VERDICTS = new Set(['pass', 'fail', 'unsure']);
149
156
 
150
- export function runJudge(config, cwd) {
151
- const findings = [];
152
- const problems = [];
153
- const { checklist: checklistPath, shots = [], shotCommand, command, failOn = 'never' } = config;
157
+ const empty = (problems, failOn) => ({ name: 'judge', findings: [], problems, failOn, summary: '' });
158
+
159
+ /**
160
+ * Everything the judge needs before a model is involved: the screenshots, the
161
+ * checklist, and the prompt built from them.
162
+ *
163
+ * Split out so the model call does not have to be a subprocess. A shell
164
+ * command is one way to reach a model and a poor one for an agent, which is
165
+ * already a model and can spawn a genuinely fresh context of its own rather
166
+ * than shelling out to a second copy of itself. This half prepares the call,
167
+ * `gradeVerdict` below checks the answer, and in between the transport is
168
+ * somebody else's problem.
169
+ *
170
+ * The preconditions are enforced here rather than at grading time. Handing
171
+ * back a prompt for zero screenshots would produce a confident verdict about
172
+ * nothing.
173
+ */
174
+ export function prepareJudge(config, cwd) {
175
+ const { checklist: checklistPath, shots = [], shotCommand, failOn = 'never' } = config;
154
176
 
155
177
  if (shotCommand) {
156
178
  const made = runCommand(shotCommand, [], '', cwd);
157
- if (!made.ok) {
158
- problems.push(made.reason);
159
- return { name: 'judge', findings, problems, failOn, summary: '' };
160
- }
179
+ if (!made.ok) return { ok: false, result: empty([made.reason], failOn) };
161
180
  }
162
181
 
163
182
  const images = expand(shots, cwd);
164
183
  if (!images.length) {
165
- problems.push(
166
- `no screenshots matched ${shots.map((s) => `"${s}"`).join(', ')}. ` +
167
- `A judge with nothing to look at cannot fail, so it does not get to pass either.`,
168
- );
169
- return { name: 'judge', findings, problems, failOn, summary: '' };
184
+ return {
185
+ ok: false,
186
+ result: empty(
187
+ [
188
+ `no screenshots matched ${shots.map((s) => `"${s}"`).join(', ')}. ` +
189
+ `A judge with nothing to look at cannot fail, so it does not get to pass either.`,
190
+ ],
191
+ failOn,
192
+ ),
193
+ };
170
194
  }
171
195
 
172
- let lines;
196
+ let entries;
173
197
  try {
174
- lines = checklistLines(readFileSync(resolve(cwd, checklistPath), 'utf8'));
198
+ entries = checklistEntries(readFileSync(resolve(cwd, checklistPath), 'utf8'));
175
199
  } catch {
176
- problems.push(`cannot read the checklist at ${checklistPath}`);
177
- return { name: 'judge', findings, problems, failOn, summary: '' };
200
+ return { ok: false, result: empty([`cannot read the checklist at ${checklistPath}`], failOn) };
178
201
  }
179
- if (!lines.length) {
180
- problems.push(
181
- `${checklistPath} has no checklist lines in it. Lines to judge are list ` +
182
- `items ("- ..." or "1. ..."); everything else is treated as prose.`,
183
- );
184
- return { name: 'judge', findings, problems, failOn, summary: '' };
202
+ if (!entries.length) {
203
+ return {
204
+ ok: false,
205
+ result: empty(
206
+ [
207
+ `${checklistPath} has no checklist lines in it. Lines to judge are list ` +
208
+ `items ("- ..." or "1. ..."); everything else is treated as prose.`,
209
+ ],
210
+ failOn,
211
+ ),
212
+ };
185
213
  }
186
214
 
187
- const reply = runCommand(command, images, buildPrompt(lines, images.map((i) => label(i, cwd))), cwd);
188
- if (!reply.ok) {
189
- problems.push(reply.reason);
190
- return { name: 'judge', findings, problems, failOn, summary: '' };
191
- }
215
+ const relative = images.map((i) => label(i, cwd));
216
+ return {
217
+ ok: true,
218
+ entries,
219
+ images,
220
+ relativeImages: relative,
221
+ prompt: buildPrompt(entries.map((e) => e.text), relative),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Check a reply against the checklist it was supposed to answer.
227
+ *
228
+ * This is the half that makes the whole thing trustworthy, and it does not
229
+ * care where the reply came from. A judge that quietly drops the hardest line
230
+ * is the failure mode to guard: every remaining verdict says pass, and the one
231
+ * nobody answered is the one that mattered.
232
+ */
233
+ export function gradeVerdict(reply, prepared, config) {
234
+ const { checklist: checklistPath, failOn = 'never' } = config;
235
+ const findings = [];
236
+ const problems = [];
237
+ const { entries, images } = prepared;
238
+ const lines = entries.map((e) => e.text);
239
+ const lineNumbers = new Map(entries.map((e) => [e.text, e.line]));
192
240
 
193
- const parsed = extractJson(reply.stdout);
241
+ const parsed = extractJson(reply);
194
242
  if (!parsed.ok) {
195
- problems.push(`${parsed.reason}. The judge must reply with the documented JSON shape.`);
196
- return { name: 'judge', findings, problems, failOn, summary: '' };
243
+ return empty([`${parsed.reason}. The judge must reply with the documented JSON shape.`], failOn);
197
244
  }
198
245
  if (!Array.isArray(parsed.value.findings)) {
199
- problems.push('the reply has no "findings" array');
200
- return { name: 'judge', findings, problems, failOn, summary: '' };
246
+ return empty(['the reply has no "findings" array'], failOn);
201
247
  }
202
248
 
203
- // Cross-check both directions. A judge that quietly drops the hardest line
204
- // is the failure mode to guard: the remaining verdicts all say pass, and
205
- // the line nobody answered is the one that mattered.
206
249
  const wanted = new Set(lines);
207
250
  const answered = new Set();
208
251
  for (const f of parsed.value.findings) {
@@ -219,7 +262,12 @@ export function runJudge(config, cwd) {
219
262
  continue;
220
263
  }
221
264
  answered.add(f.line);
222
- findings.push({ line: f.line, verdict: f.verdict, why: (f.why ?? '').trim() });
265
+ findings.push({
266
+ line: f.line,
267
+ verdict: f.verdict,
268
+ why: (f.why ?? '').trim(),
269
+ at: { file: checklistPath, line: lineNumbers.get(f.line) },
270
+ });
223
271
  }
224
272
  for (const line of lines) {
225
273
  if (!answered.has(line)) problems.push(`the judge did not answer "${line}"`);
@@ -232,6 +280,27 @@ export function runJudge(config, cwd) {
232
280
  failOn,
233
281
  summary: `${lines.length} ${lines.length === 1 ? 'line' : 'lines'} against ${images.length} ${
234
282
  images.length === 1 ? 'screenshot' : 'screenshots'
235
- } (${images.map((i) => label(i, cwd)).join(', ')})`,
283
+ } (${prepared.relativeImages.join(', ')})`,
236
284
  };
237
285
  }
286
+
287
+ /** The whole thing, with a configured command as the transport. */
288
+ export function runJudge(config, cwd) {
289
+ const { command, failOn = 'never' } = config;
290
+ const prepared = prepareJudge(config, cwd);
291
+ if (!prepared.ok) return prepared.result;
292
+
293
+ if (!command) {
294
+ return empty(
295
+ [
296
+ 'judge.command is not set, so there is nothing to ask. Set it, or use ' +
297
+ '`taste-check judge --emit` and `--verdict` to let an agent carry the call.',
298
+ ],
299
+ failOn,
300
+ );
301
+ }
302
+
303
+ const reply = runCommand(command, prepared.images, prepared.prompt, cwd);
304
+ if (!reply.ok) return empty([reply.reason], failOn);
305
+ return gradeVerdict(reply.stdout, prepared, config);
306
+ }
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
  }