@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 +40 -4
- package/bin/taste-check.mjs +30 -9
- package/package.json +1 -1
- package/src/contrast.mjs +13 -3
- package/src/judge.mjs +21 -7
- package/src/report.mjs +1 -1
- package/src/sarif.mjs +236 -0
- package/src/treatments.mjs +14 -5
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
|
|
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
|
package/bin/taste-check.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -169,13 +176,15 @@ export function runJudge(config, cwd) {
|
|
|
169
176
|
return { name: 'judge', findings, problems, failOn, summary: '' };
|
|
170
177
|
}
|
|
171
178
|
|
|
172
|
-
let
|
|
179
|
+
let entries;
|
|
173
180
|
try {
|
|
174
|
-
|
|
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({
|
|
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((
|
|
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
|
}
|