@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 +70 -7
- package/bin/taste-check.mjs +97 -13
- package/package.json +2 -1
- package/skills/taste-check-judge/SKILL.md +82 -0
- package/src/config.mjs +7 -5
- package/src/contrast.mjs +13 -3
- package/src/index.mjs +1 -1
- package/src/judge.mjs +111 -42
- package/src/report.mjs +1 -1
- package/src/sarif.mjs +236 -0
- package/src/treatments.mjs +14 -5
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
|
|
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,
|
package/bin/taste-check.mjs
CHANGED
|
@@ -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
|
-
--
|
|
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 = {
|
|
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
|
-
|
|
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.
|
|
65
|
-
else if (
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
196
|
+
let entries;
|
|
173
197
|
try {
|
|
174
|
-
|
|
198
|
+
entries = checklistEntries(readFileSync(resolve(cwd, checklistPath), 'utf8'));
|
|
175
199
|
} catch {
|
|
176
|
-
|
|
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 (!
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
241
|
+
const parsed = extractJson(reply);
|
|
194
242
|
if (!parsed.ok) {
|
|
195
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
} (${
|
|
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((
|
|
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 };
|
package/src/treatments.mjs
CHANGED
|
@@ -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) =>
|
|
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(
|
|
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
|
-
|
|
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
|
}
|