@josueavalosjim/taste-check 0.8.0 → 0.10.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
@@ -63,8 +63,9 @@ which is fine for advice and disqualifying for a build gate. So the measured
63
63
  half here gates, the judged half does not, and the tool will not let you
64
64
  confuse one for the other by accident.
65
65
 
66
- It also ships no design rules of its own. Not a palette, not a class list, not
67
- a contrast floor, not a checklist. You supply all of it.
66
+ It also enforces no rule it wrote. Not a palette, not a class list, not a
67
+ contrast floor. It will scaffold you a starter checklist, and that file lands
68
+ in your repo: what runs is the copy you edited, never its own.
68
69
 
69
70
  ## What counts as a failure
70
71
 
@@ -284,11 +285,13 @@ told what changed, answer every line, prefer unsure to a guess, do not be
284
285
  agreeable, and name the specific thing you are looking at. That part is method
285
286
  and it is the same for everyone.
286
287
 
287
- The checklist is yours. taste-check ships none, and there is a test asserting
288
- the framing mentions no design vocabulary at all, because a rule that arrives
289
- inside a tool is somebody else's taste with the tool's authority behind it.
290
- Checklist lines are list items in your file; a heading or a paragraph is prose
291
- and is not judged.
288
+ The checklist is yours, and there is a test asserting the framing mentions no
289
+ design vocabulary at all, because a rule that arrives at the moment of judgment
290
+ carries the tool's authority rather than its author's. Checklist lines are list
291
+ items in your file; a heading or a paragraph is prose and is not judged.
292
+
293
+ Starting from nothing is its own problem, so there is a scaffold and a linter
294
+ for the lines you write. Both are in the next section.
292
295
 
293
296
  ### Letting an agent carry the call
294
297
 
@@ -336,12 +339,71 @@ exits 1 whatever `failOn` says:
336
339
  Without that split, "the judge did not run" and "the judge found nothing" print
337
340
  the same thing.
338
341
 
342
+ ## Starting a checklist
343
+
344
+ An empty file is the hardest part of the judge, so:
345
+
346
+ ```bash
347
+ taste-check checklist --new
348
+ ```
349
+
350
+ It writes a starter into your repo. Six lines, and every one is about whether
351
+ the screen is broken rather than whether it is good, because that is the only
352
+ kind of line that is not somebody's taste. It refuses to overwrite a file that
353
+ exists, since the moment it lands it is yours.
354
+
355
+ That is a scaffold and not a default, and the difference is the whole reason it
356
+ is allowed to exist here. A default judges every user who never opened the file
357
+ against opinions they did not choose. A scaffold is a file you edited and
358
+ committed, and taste-check never reads its own copy. eslint is the same shape:
359
+ it ships rules and no default configuration, and `--init` writes you one.
360
+
361
+ ```bash
362
+ taste-check checklist --lint
363
+ ```
364
+
365
+ This checks your lines and never their content. It has no opinion about what
366
+ you ask for, only about whether asking it produces a verdict:
367
+
368
+ ```
369
+ checklist, 3 to look at
370
+ unfalsifiable design-checklist.md:12 "Does it feel premium and modern"
371
+ "premium", "modern", "feel" describes a feeling rather than
372
+ something visible. A judge answers unsure, every run.
373
+ not-in-a-still design-checklist.md:13 "Is the hover animation smooth"
374
+ "animation", "hover" cannot be seen in a screenshot.
375
+ measurable design-checklist.md:14 "Body copy hits at least 4.5:1"
376
+ this asks for a number. Put it in a contrast pair instead.
377
+ ```
378
+
379
+ It also flags a line asking two things at once, since one verdict cannot answer
380
+ both. "The primary action reads as the primary action" passes: it is an opinion,
381
+ a strong one, and none of the linter's business, because it can be settled by
382
+ looking.
383
+
339
384
  ## Config
340
385
 
341
386
  Point your editor at `schema/config.schema.json` for completion and inline
342
387
  docs. Paths inside a config resolve against the config file, so it can be run
343
388
  from anywhere.
344
389
 
390
+ Unknown keys are rejected, because a misspelled key that silently does nothing
391
+ is a config that looks like it is working. `$comment` is the exception: it is
392
+ allowed wherever an object is, ignored, and takes a string or an array of them.
393
+ A config you are expected to live with for years should let you write down why
394
+ it is the way it is.
395
+
396
+ ```json
397
+ {
398
+ "$comment": "Guards the ramp itself. The browser suite only covers tokens some selector reaches.",
399
+ "contrast": {
400
+ "pairs": [
401
+ { "$comment": "Documented at 6.13:1 in tokens.css.", "fg": "--ink-muted", "bg": "--bg", "min": 4.5 }
402
+ ]
403
+ }
404
+ }
405
+ ```
406
+
345
407
  | Key | Meaning |
346
408
  | --- | --- |
347
409
  | `contrast.tokens` | CSS files holding the custom properties. Later files override earlier ones. |
@@ -438,8 +500,9 @@ the whole of your problem, it is the better fit.
438
500
 
439
501
  **Checklist Design and similar agent skills.** They arrive with a hundred or
440
502
  more published checklists and review conversationally. If you want good
441
- opinions supplied, take theirs. taste-check supplies none on purpose and runs
442
- in CI with an exit code instead.
503
+ opinions supplied, take theirs, and they are good. taste-check scaffolds six
504
+ lines about whether a screen is broken and expects the rest to be yours, which
505
+ is more work and a different bargain.
443
506
 
444
507
  What is left, and the reason this exists: nothing above draws a line between
445
508
  the part that can gate a build and the part that cannot. The linters have no
@@ -7,10 +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';
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
12
13
 
13
14
  import { load } from '../src/config.mjs';
15
+ import { STARTER, lintChecklist } from '../src/checklist.mjs';
16
+ import { checklistEntries } from '../src/judge.mjs';
14
17
  import { gradeVerdict, judge, prepareJudge, run, runtime } from '../src/index.mjs';
15
18
  import { failed, toJson, toText } from '../src/report.mjs';
16
19
  import { toSarif } from '../src/sarif.mjs';
@@ -20,10 +23,15 @@ const USAGE = `taste-check
20
23
  taste-check [options] Run the deterministic checks over your files
21
24
  taste-check runtime [options] Measure contrast on a rendered page
22
25
  taste-check judge [options] Ask a fresh-eyes judge about your screenshots
26
+ taste-check checklist --new Write a starter checklist you then own
27
+ taste-check checklist --lint Check your checklist lines can be answered
23
28
 
24
29
  Options:
25
30
  -c, --config <path> Config file (default: tastecheck.config.json)
26
31
  --only <name> Run one check: contrast or treatments
32
+ --new [path] checklist only: scaffold a starter, refusing to
33
+ overwrite one that exists
34
+ --lint checklist only: report lines a judge cannot answer
27
35
  --emit judge only: print the prompt and stop, for an agent
28
36
  to carry to a model itself
29
37
  --verdict <path> judge only: grade a reply from a file, or - for stdin
@@ -48,6 +56,16 @@ is already up. It measures what is actually painted, compositing every
48
56
  background layer behind an element rather than stopping at the first opaque
49
57
  one, and it can put the page into a state first.
50
58
 
59
+ checklist --new scaffolds; it does not default. The file it writes is yours,
60
+ and taste-check never reads its own copy of it. The six lines in it are about
61
+ whether a screen is broken rather than whether it is good, because that is the
62
+ only kind of line that is not somebody's taste.
63
+
64
+ checklist --lint is about the form of your lines and never their content. It
65
+ reports a line a judge will answer "unsure" to every run, a line asking two
66
+ things at once, a line about something a still image cannot show, and a line
67
+ asking for a number that belongs in a contrast pair instead.
68
+
51
69
  An agent can carry the model call instead of a shell command. --emit prints
52
70
  the prompt and the images; the agent asks a fresh context and pipes the JSON
53
71
  back to --verdict -, which checks it against the checklist the same way. Run
@@ -68,10 +86,12 @@ function parseArgs(argv) {
68
86
  emit: false,
69
87
  verdict: null,
70
88
  skill: false,
89
+ lint: false,
90
+ scaffold: null,
71
91
  };
72
92
  // One positional, and only in first position, so a stray argument is an
73
93
  // error rather than something silently ignored.
74
- if (argv[0] === 'judge' || argv[0] === 'runtime') {
94
+ if (argv[0] === 'judge' || argv[0] === 'runtime' || argv[0] === 'checklist') {
75
95
  options.command = argv[0];
76
96
  argv = argv.slice(1);
77
97
  }
@@ -96,7 +116,12 @@ function parseArgs(argv) {
96
116
  throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
97
117
  }
98
118
  } else if (arg === '--json') options.format = 'json';
99
- else if (arg === '--emit') options.emit = true;
119
+ else if (arg === '--lint') options.lint = true;
120
+ else if (arg === '--new') {
121
+ // The path is optional, so peek rather than demanding a value.
122
+ const value = argv[i + 1];
123
+ options.scaffold = value !== undefined && !value.startsWith('-') ? (i += 1, value) : true;
124
+ } else if (arg === '--emit') options.emit = true;
100
125
  else if (arg === '--skill') options.skill = true;
101
126
  else if (arg === '--verdict') options.verdict = next();
102
127
  else if (arg === '--format') {
@@ -138,6 +163,26 @@ if (options.skill) {
138
163
  process.exit(0);
139
164
  }
140
165
 
166
+ // checklist --new is the one command that has to work before a config exists,
167
+ // because not having anything yet is the situation it is for.
168
+ if (options.command === 'checklist' && options.scaffold) {
169
+ const named = typeof options.scaffold === 'string' ? options.scaffold : null;
170
+ const fromConfig = (() => {
171
+ const loadedForPath = load(options.config);
172
+ return loadedForPath.ok ? loadedForPath.config.judge?.checklist : null;
173
+ })();
174
+ const target = resolve(named ?? fromConfig ?? 'design-checklist.md');
175
+ if (existsSync(target)) {
176
+ die(`${target} already exists. It is yours now, so this will not overwrite it.`);
177
+ }
178
+ writeFileSync(target, STARTER);
179
+ const lines = checklistEntries(STARTER).length;
180
+ console.log(`wrote ${target}`);
181
+ console.log(`${lines} lines, all of them about whether a screen is broken. The ones worth`);
182
+ console.log('having are the ones you add. Run taste-check checklist --lint after editing.');
183
+ process.exit(0);
184
+ }
185
+
141
186
  for (const flag of ['emit', 'verdict']) {
142
187
  if (options[flag] && options.command !== 'judge') {
143
188
  die(`--${flag} applies to \`taste-check judge\`, not to ${options.command}`);
@@ -155,11 +200,42 @@ if (!loaded.ok) {
155
200
  if (options.command !== 'check' && options.only) {
156
201
  die(`--only applies to the deterministic checks, not to ${options.command}`);
157
202
  }
203
+ if (options.lint && options.command !== 'checklist') {
204
+ die('--lint applies to `taste-check checklist`, not to ' + options.command);
205
+ }
158
206
 
159
- if (options.command !== 'check' && !loaded.config[options.command]) {
207
+ // checklist is the exception: its file lives under judge.checklist rather than
208
+ // in a block of its own, and it says so itself below.
209
+ if (options.command !== 'check' && options.command !== 'checklist' && !loaded.config[options.command]) {
160
210
  die(`${options.config} defines no "${options.command}" block.`);
161
211
  }
162
212
 
213
+ if (options.command === 'checklist') {
214
+ if (!options.lint) die('taste-check checklist takes --new or --lint');
215
+ const path = loaded.config.judge?.checklist;
216
+ if (!path) die(`${options.config} defines no judge.checklist to lint.`);
217
+ let entries;
218
+ try {
219
+ entries = checklistEntries(readFileSync(resolve(loaded.dir, path), 'utf8'));
220
+ } catch {
221
+ die(`cannot read the checklist at ${path}`);
222
+ }
223
+ if (!entries.length) {
224
+ die(`${path} has no checklist lines in it. Lines are list items ("- ..." or "1. ...").`);
225
+ }
226
+ const findings = lintChecklist(entries);
227
+ if (!findings.length) {
228
+ console.log(`checklist ok, ${entries.length} ${entries.length === 1 ? 'line' : 'lines'} a judge can answer`);
229
+ process.exit(0);
230
+ }
231
+ console.log(`checklist, ${findings.length} to look at`);
232
+ for (const f of findings) {
233
+ console.log(` ${f.kind.padEnd(16)}${path}:${f.line} ${JSON.stringify(f.text)}`);
234
+ console.log(` ${' '.repeat(16)}${f.detail}`);
235
+ }
236
+ process.exit(1);
237
+ }
238
+
163
239
  // --emit stops before any model is involved, so it has no findings to report
164
240
  // and no exit code to earn. It still refuses to hand back a prompt when the
165
241
  // screenshots or the checklist are missing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -89,11 +89,39 @@
89
89
  "atRule": {
90
90
  "type": "string",
91
91
  "description": "Opt into declarations nested in a matching at-rule prelude, for example \"prefers-color-scheme: dark\". Conditional at-rules (@media, @supports, @container, @scope) are ignored without this. Grouping at-rules like @layer are always transparent, so you only name one to narrow a scope to it."
92
+ },
93
+ "$comment": {
94
+ "oneOf": [
95
+ {
96
+ "type": "string"
97
+ },
98
+ {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "string"
102
+ }
103
+ }
104
+ ],
105
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
92
106
  }
93
107
  }
94
108
  }
95
109
  ]
96
110
  }
111
+ },
112
+ "$comment": {
113
+ "oneOf": [
114
+ {
115
+ "type": "string"
116
+ },
117
+ {
118
+ "type": "array",
119
+ "items": {
120
+ "type": "string"
121
+ }
122
+ }
123
+ ],
124
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
97
125
  }
98
126
  }
99
127
  }
@@ -137,9 +165,37 @@
137
165
  "items": {
138
166
  "type": "string"
139
167
  }
168
+ },
169
+ "$comment": {
170
+ "oneOf": [
171
+ {
172
+ "type": "string"
173
+ },
174
+ {
175
+ "type": "array",
176
+ "items": {
177
+ "type": "string"
178
+ }
179
+ }
180
+ ],
181
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
140
182
  }
141
183
  }
142
184
  }
185
+ },
186
+ "$comment": {
187
+ "oneOf": [
188
+ {
189
+ "type": "string"
190
+ },
191
+ {
192
+ "type": "array",
193
+ "items": {
194
+ "type": "string"
195
+ }
196
+ }
197
+ ],
198
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
143
199
  }
144
200
  }
145
201
  },
@@ -187,6 +243,20 @@
187
243
  "items": {
188
244
  "type": "string"
189
245
  }
246
+ },
247
+ "$comment": {
248
+ "oneOf": [
249
+ {
250
+ "type": "string"
251
+ },
252
+ {
253
+ "type": "array",
254
+ "items": {
255
+ "type": "string"
256
+ }
257
+ }
258
+ ],
259
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
190
260
  }
191
261
  }
192
262
  },
@@ -227,6 +297,20 @@
227
297
  ],
228
298
  "default": "never",
229
299
  "description": "Whether a \"fail\" verdict affects the exit code. Defaults to never: a model's verdict is not reproducible, so it does not gate a build unless you decide it should. Independent of this, a judge that could not run always exits 1."
300
+ },
301
+ "$comment": {
302
+ "oneOf": [
303
+ {
304
+ "type": "string"
305
+ },
306
+ {
307
+ "type": "array",
308
+ "items": {
309
+ "type": "string"
310
+ }
311
+ }
312
+ ],
313
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
230
314
  }
231
315
  }
232
316
  },
@@ -282,6 +366,20 @@
282
366
  "waitFor": {
283
367
  "type": "string",
284
368
  "description": "A selector to wait for before measuring."
369
+ },
370
+ "$comment": {
371
+ "oneOf": [
372
+ {
373
+ "type": "string"
374
+ },
375
+ {
376
+ "type": "array",
377
+ "items": {
378
+ "type": "string"
379
+ }
380
+ }
381
+ ],
382
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
285
383
  }
286
384
  }
287
385
  }
@@ -318,11 +416,53 @@
318
416
  "againstParent": {
319
417
  "type": "boolean",
320
418
  "description": "Measure against what is behind the element rather than its own background. Use when the thing being measured is a fill: measuring a fill against itself scores 1.00 and means nothing."
419
+ },
420
+ "$comment": {
421
+ "oneOf": [
422
+ {
423
+ "type": "string"
424
+ },
425
+ {
426
+ "type": "array",
427
+ "items": {
428
+ "type": "string"
429
+ }
430
+ }
431
+ ],
432
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
321
433
  }
322
434
  }
323
435
  }
436
+ },
437
+ "$comment": {
438
+ "oneOf": [
439
+ {
440
+ "type": "string"
441
+ },
442
+ {
443
+ "type": "array",
444
+ "items": {
445
+ "type": "string"
446
+ }
447
+ }
448
+ ],
449
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
324
450
  }
325
451
  }
452
+ },
453
+ "$comment": {
454
+ "oneOf": [
455
+ {
456
+ "type": "string"
457
+ },
458
+ {
459
+ "type": "array",
460
+ "items": {
461
+ "type": "string"
462
+ }
463
+ }
464
+ ],
465
+ "description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
326
466
  }
327
467
  }
328
468
  }
@@ -78,5 +78,11 @@ old screenshots is not a verdict on the new ones.
78
78
  ## What this skill does not contain
79
79
 
80
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.
81
+ if it is empty then this reports nothing and that is correct.
82
+
83
+ `taste-check checklist --new` will scaffold a starter into their repo, six
84
+ lines about whether a screen is broken rather than whether it is good. Once it
85
+ is written it is theirs, and what gets judged is whatever they edited it into.
86
+ Do not add lines to it yourself, and do not judge against a rule that is not in
87
+ it. A rule that arrives at the moment of judgment, from the tool or from you,
88
+ is somebody else's taste wearing borrowed authority.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Starting a checklist, and checking that the lines in one can be answered.
3
+ *
4
+ * Neither of these says what to value. The starter contains only lines about
5
+ * whether a screen is broken, which is not taste, and the linter is about the
6
+ * form of a line rather than its content.
7
+ *
8
+ * ── Why a starter at all, given this ships no design rules ──────────────
9
+ *
10
+ * There is a real difference between a tool that defaults to a checklist and a
11
+ * tool that writes one into your repo once. A default arrives with the tool's
12
+ * authority at the moment of judgment: every user who never opened the file is
13
+ * being judged against opinions they did not choose. A scaffold is a file you
14
+ * own, edited and committed by you, and what runs is yours. The tool never
15
+ * reads its own copy.
16
+ *
17
+ * eslint is the precedent. It ships rules and no default configuration, and
18
+ * `--init` writes you one. Nobody thinks eslint has opinions about their code
19
+ * until they enable some.
20
+ */
21
+
22
+ /**
23
+ * The starter.
24
+ *
25
+ * Six lines, and every one of them is about whether the screen is broken
26
+ * rather than whether it is good. That is deliberate and the header says so:
27
+ * these are the ones that are not anybody's taste, which means the lines worth
28
+ * having are the ones you add.
29
+ */
30
+ export const STARTER = `# Design checklist
31
+
32
+ Lines here are judged one at a time against a screenshot, and each gets one
33
+ verdict: pass, fail, or unsure.
34
+
35
+ Everything below is about whether the screen is broken. None of it is taste,
36
+ which is the only reason it could ship inside a tool. The lines worth having
37
+ are the ones you add, and they should be the specific things your work gets
38
+ wrong, in your words.
39
+
40
+ Two things make a line answerable. It has to be settleable by looking, so
41
+ "nothing is cut off" works where "looks expensive" comes back unsure every
42
+ run. And it has to ask one thing, because a line joining two claims cannot be
43
+ answered by a single verdict.
44
+
45
+ Anything you can measure belongs in a contrast pair or a runtime target
46
+ instead, where the answer is a number rather than an opinion.
47
+
48
+ Note that only list items are judged. Everything above this is prose and is
49
+ ignored, so there is room here to write down why these lines and not others.
50
+
51
+ Run \`taste-check checklist --lint\` after editing.
52
+
53
+ - Nothing important is cut off at an edge of the frame
54
+ - Every piece of text is legible against what is directly behind it
55
+ - Nothing overlaps something it was not meant to overlap
56
+ - Nothing is obviously misaligned with the things beside it
57
+ - Nothing is still showing a loading, empty, or placeholder state
58
+ - Nothing appears twice that was meant to appear once
59
+ `;
60
+
61
+ /** Words that describe a feeling rather than something visible in an image. */
62
+ const UNFALSIFIABLE = [
63
+ 'premium', 'modern', 'clean', 'professional', 'polished', 'elegant',
64
+ 'beautiful', 'delightful', 'intuitive', 'slick', 'crisp', 'tasteful',
65
+ 'cohesive', 'harmonious', 'balanced', 'pleasing', 'appealing', 'nice',
66
+ 'good', 'bad', 'ugly', 'feel', 'feels', 'vibe', 'aesthetic',
67
+ ];
68
+
69
+ /** Things a still image cannot show. */
70
+ const NOT_IN_A_STILL = [
71
+ 'animation', 'animate', 'transition', 'hover', 'scroll', 'scrolling',
72
+ 'load time', 'performance', 'sound', 'audio', 'click', 'tap', 'drag',
73
+ 'keyboard', 'focus ring', 'video plays',
74
+ ];
75
+
76
+ const MEASURABLE = /\b\d+(\.\d+)?\s*(:\s*1|px|rem|em|%|pt)\b|\bcontrast ratio\b|\b\d+:\d+\b/i;
77
+ const COMPOUND = /\b(and|or)\b/i;
78
+
79
+ /**
80
+ * Check a single line for whether a judge could answer it.
81
+ *
82
+ * Never about content. A line can ask for anything at all; these findings are
83
+ * about whether asking it produces a verdict or a shrug.
84
+ */
85
+ export function lintLine(text) {
86
+ const findings = [];
87
+ const lower = text.toLowerCase();
88
+ const words = text.trim().split(/\s+/);
89
+
90
+ const vague = UNFALSIFIABLE.filter((w) => new RegExp(`\\b${w}\\b`, 'i').test(lower));
91
+ if (vague.length) {
92
+ findings.push({
93
+ kind: 'unfalsifiable',
94
+ detail:
95
+ `"${vague.join('", "')}" describes a feeling rather than something visible. ` +
96
+ `A judge answers unsure, every run.`,
97
+ });
98
+ }
99
+
100
+ const unseen = NOT_IN_A_STILL.filter((w) => lower.includes(w));
101
+ if (unseen.length) {
102
+ findings.push({
103
+ kind: 'not-in-a-still',
104
+ detail: `"${unseen.join('", "')}" cannot be seen in a screenshot. The judge only gets images.`,
105
+ });
106
+ }
107
+
108
+ if (MEASURABLE.test(text)) {
109
+ findings.push({
110
+ kind: 'measurable',
111
+ detail:
112
+ 'this asks for a number. Put it in a contrast pair or a runtime target, ' +
113
+ 'where the answer is measured rather than judged.',
114
+ });
115
+ }
116
+
117
+ // Only compound when both halves look like claims, so "black and white" or
118
+ // "cut off at the top or bottom" are left alone.
119
+ if (COMPOUND.test(text)) {
120
+ // Three words a side, so "black and white" and "the top or bottom" are
121
+ // left alone while two real claims are caught.
122
+ const halves = text.split(/\b(?:and|or)\b/i).map((h) => h.trim().split(/\s+/).filter(Boolean).length);
123
+ if (halves.length > 1 && halves.every((n) => n >= 3)) {
124
+ findings.push({
125
+ kind: 'compound',
126
+ detail: 'two claims in one line. One verdict cannot answer both, so split it.',
127
+ });
128
+ }
129
+ }
130
+
131
+ if (words.length < 4) {
132
+ findings.push({ kind: 'too-short', detail: 'too short to say what is being asked.' });
133
+ }
134
+
135
+ return findings;
136
+ }
137
+
138
+ /** Lint every line of a checklist, keeping the line numbers. */
139
+ export function lintChecklist(entries) {
140
+ const findings = [];
141
+ for (const entry of entries) {
142
+ for (const finding of lintLine(entry.text)) {
143
+ findings.push({ ...finding, line: entry.line, text: entry.text });
144
+ }
145
+ }
146
+ return findings;
147
+ }
package/src/config.mjs CHANGED
@@ -27,8 +27,28 @@ function stringArray(value, where, errors, { required = true } = {}) {
27
27
  return value;
28
28
  }
29
29
 
30
+ /**
31
+ * Allowed anywhere an object is, and ignored.
32
+ *
33
+ * The validator rejects keys it does not know, which is right: a misspelled
34
+ * key that silently does nothing is a config that looks like it is working.
35
+ * The cost is that a config had nowhere to say why it exists. Per-pair labels
36
+ * carry the reasoning for a pair, and nothing carried the reasoning for the
37
+ * file. A format people are expected to live with for years should let them
38
+ * write that down.
39
+ */
40
+ const COMMENT = '$comment';
41
+
30
42
  function rejectUnknown(object, allowed, where, errors) {
31
43
  for (const key of Object.keys(object)) {
44
+ if (key === COMMENT) {
45
+ const value = object[key];
46
+ const ok =
47
+ typeof value === 'string' ||
48
+ (Array.isArray(value) && value.every((v) => typeof v === 'string'));
49
+ if (!ok) errors.push(`${where}.${COMMENT} must be a string, or an array of them`);
50
+ continue;
51
+ }
32
52
  if (!allowed.includes(key)) {
33
53
  errors.push(`${where} has an unknown key "${key}". Allowed: ${allowed.join(', ')}.`);
34
54
  }
package/src/judge.mjs CHANGED
@@ -18,9 +18,12 @@
18
18
  * every line, prefer unsure to a guess, quote what you actually see. That part
19
19
  * is method and it is portable.
20
20
  *
21
- * The CHECKLIST is yours. taste-check contains no design rules and never will,
22
- * because a shipped checklist is just somebody else's taste wearing the
23
- * authority of a tool.
21
+ * The CHECKLIST is yours. taste-check enforces no rule it wrote: what runs is
22
+ * the file in your repo. `checklist --new` will scaffold you a starting point,
23
+ * and the distinction is not a dodge, it is the difference between a default
24
+ * and a scaffold. A default judges every user who never opened the file
25
+ * against opinions they did not choose. A scaffold is a file you edited and
26
+ * committed, and this never reads its own copy of it.
24
27
  *
25
28
  * ── Where the determinism line falls ────────────────────────────────────
26
29
  *