paperlint 13.6.0 → 14.0.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/dist/skills/build-label-page/scripts/label-page-core.d.ts +62 -20
- package/dist/skills/build-label-page/scripts/rule-descriptions.d.ts +6 -1
- package/package.json +1 -1
- package/skills/assess-novelty/scripts/check_novelty.py +7 -2
- package/skills/assess-novelty/scripts/check_novelty_search.test.ts +279 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/novelty.json +6 -3
- package/skills/assess-novelty/scripts/novelty_search.py +87 -0
- package/skills/build-label-page/SKILL.md +5 -4
- package/skills/build-label-page/SKILL.md.spec.ts +4 -3
- package/skills/build-label-page/references/checklist.md +6 -6
- package/skills/build-label-page/references/closed-choice-generator.md +38 -16
- package/skills/build-label-page/scripts/label-page-browser.test.ts +109 -14
- package/skills/build-label-page/scripts/label-page-core.js +119 -36
- package/skills/build-label-page/scripts/label-page-core.test.ts +192 -35
- package/skills/build-label-page/scripts/label-page-core.ts +204 -97
- package/skills/build-label-page/scripts/label-page-html.test.ts +41 -56
- package/skills/build-label-page/scripts/label-page.js +11 -6
- package/skills/build-label-page/scripts/label-page.ts +11 -6
- package/skills/build-label-page/scripts/page-template.html +45 -19
- package/skills/build-label-page/scripts/rule-descriptions.js +48 -30
- package/skills/build-label-page/scripts/rule-descriptions.test.ts +58 -12
- package/skills/build-label-page/scripts/rule-descriptions.ts +56 -33
|
@@ -81,11 +81,11 @@ the text of an allowed field says.
|
|
|
81
81
|
|
|
82
82
|
- Restrict the items to the languages and tools the labeller knows (`labeller_knows`). Items of
|
|
83
83
|
other groups are not put on the page and get no id.
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
the labeller does not know well.
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
- When the study filters on it, ask one question per group, "how well do you know this language /
|
|
85
|
+
tool" (well / somewhat / not really), with `"familiarity": true` in `items.json`: the study can
|
|
86
|
+
then report the labels with and without the groups the labeller does not know well. Left out, no
|
|
87
|
+
page asks it: on an approval page the question is about a drawn unit, not about repositories the
|
|
88
|
+
author has seen, and "how well do you know the projects of this group" there means nothing.
|
|
89
89
|
- The per-item judgement "clear-cut or a real boundary case" is the labeller's. The study design
|
|
90
90
|
does not decide it in advance and does not pre-fill it.
|
|
91
91
|
|
|
@@ -94,7 +94,7 @@ answer is the only way to find it afterwards. And a boundary case is boundary be
|
|
|
94
94
|
person hesitates; a designer who tags it in advance has measured the design, not the item.
|
|
95
95
|
|
|
96
96
|
**Check.** The build prints how many items were left out; the export has a familiarity value for
|
|
97
|
-
every group, or the page was built
|
|
97
|
+
every group, or the page was built without `"familiarity": true` and the export has no groups.
|
|
98
98
|
|
|
99
99
|
## 6. Export into the repository right away
|
|
100
100
|
|
|
@@ -33,24 +33,45 @@ length.
|
|
|
33
33
|
required `example`: a real case of the study, in a line, that takes that answer. `icon` (an
|
|
34
34
|
emoji) is optional. Two labels that read the same, or two equal values, stop the build.
|
|
35
35
|
- **`practice`**: 3 or more real cases whose answer you are sure of, each with `expected` (one answer
|
|
36
|
-
per question) and a one-line `why`. Never an item that is also on the page to be labelled.
|
|
36
|
+
per question) and a one-line `why`. Never an item that is also on the page to be labelled. A
|
|
37
|
+
practice card carries the same fields as the real cards it rehearses (`rules`, `unit`,
|
|
38
|
+
`readings`, `file`).
|
|
37
39
|
- **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
|
|
38
40
|
the earlier round are kept but not counted.
|
|
39
41
|
- **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
|
|
40
|
-
`code`, `context`, `rules`, `unit`. Nothing else: the allow-list is fixed in
|
|
41
|
-
is taken out of the item, not added to the list to make a
|
|
42
|
+
`code`, `context`, `rules`, `unit`, `readings`, `file`. Nothing else: the allow-list is fixed in
|
|
43
|
+
the generator, and a field outside it is taken out of the item, not added to the list to make a
|
|
44
|
+
build pass.
|
|
45
|
+
- **`kind`** (required): what the page asks, and so which card fields every item and practice card
|
|
46
|
+
must carry. A card without one fails the schema, naming the field and why the kind needs it:
|
|
47
|
+
|
|
48
|
+
| kind | the labeller decides | required on every card |
|
|
49
|
+
| ---------- | ---------------------------------------------------- | -------------------------------------------------- |
|
|
50
|
+
| `approval` | keep or drop a drawn requirement | `unit` (the line, the requirement marked), `rules` |
|
|
51
|
+
| `witness` | why two readings of a requirement disagree on a file | `rules`, `readings`, `file` |
|
|
52
|
+
| `labels` | a gold label, a blind review | nothing beyond the item |
|
|
53
|
+
|
|
54
|
+
Nothing reads `context`: it stays free text. A rule, a line or two readings written there show
|
|
55
|
+
none of what their fields show, so on an `approval` or `witness` page they go in the fields.
|
|
56
|
+
|
|
42
57
|
- **`rules`**: `[{linter, id}]` (`linter` is `eslint` or `ruff`) on an item read as a linter rule.
|
|
43
|
-
The card shows "
|
|
44
|
-
lazily…",
|
|
45
|
-
does" paragraph, or the ESLint rule's
|
|
46
|
-
installed where `items.json` is
|
|
58
|
+
The card's "Rule" row shows "ruff `TID253` (flake8-tidy-imports): Checks for module-level
|
|
59
|
+
imports that should instead be imported lazily…", taken at build time from the linter itself:
|
|
60
|
+
`ruff rule <id>`'s "What it does" paragraph and family, or the ESLint rule's
|
|
61
|
+
`meta.docs.description` from the ESLint and plugins installed where `items.json` is (a plugin
|
|
62
|
+
rule's own name may hold a slash: `n/no-unsupported-features/node-builtins`). The build refuses a rule the linter cannot describe; a
|
|
47
63
|
description is never typed into `items.json`.
|
|
48
|
-
- **`unit`**: `{id, text, part, of}` on an item
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
- **`unit`**: `{id, text, mark, part, of}` on an item drawn from a source line: the whole line,
|
|
65
|
+
the requirement's own words in it (`mark`, shown highlighted), and which requirement of the line
|
|
66
|
+
it is ("TypeScript only (no JavaScript); top-level await is allowed" is two). The card's "Line"
|
|
67
|
+
row shows the line with the mark, and "part 1 of 2" when the line holds several. A drawn sample
|
|
68
|
+
may hold one part of a line and not the others; the build refuses a `mark` that is not in the
|
|
69
|
+
line, and parts of one line that disagree (another text or `of`, a part repeated, split across
|
|
70
|
+
groups), naming the item.
|
|
71
|
+
- **`readings`**: `{a, b}` on an item that compares two readings of one requirement: two rows,
|
|
72
|
+
"Reading A" on a solid accent rule and "Reading B" on a dashed one, in the same place on every
|
|
73
|
+
card. **`file`**: the witness file (a path, a link, what each reading did with it), on its own
|
|
74
|
+
"File" row. `id` names one occurrence of the line (a file and line
|
|
54
75
|
number, say) and is never shown: two files holding the same words are two lines, each judged in
|
|
55
76
|
full. Splitting the line is the study's job, before the page.
|
|
56
77
|
- **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
|
|
@@ -89,9 +110,10 @@ drop, the drop carrying `ask_why`.
|
|
|
89
110
|
}
|
|
90
111
|
```
|
|
91
112
|
|
|
92
|
-
The page sets `"
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
The page sets `"kind": "approval"`, so every card carries its `unit` and `rules`, and leaves
|
|
114
|
+
`familiarity` out: the author is judging drawn units, not repositories they have
|
|
115
|
+
seen, so no per-group "how well do you know it" question is asked. Only a page that sets
|
|
116
|
+
`"familiarity": true` asks it (and a `familiarity_question` without it is refused).
|
|
95
117
|
|
|
96
118
|
The items are the drawn units verbatim, in a language the author reads. Units in other languages
|
|
97
119
|
are taken out before the draw, as a population rule the study's `plan.json` states ("English-language
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* cannot show: focus, typing, and a page that is left without the box ever losing focus.
|
|
5
5
|
*/
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
type Browser,
|
|
9
|
+
chromium,
|
|
10
|
+
type Locator,
|
|
11
|
+
type Page,
|
|
12
|
+
} from "playwright-core";
|
|
13
|
+
import { afterAll, test } from "vitest";
|
|
9
14
|
import { z } from "zod";
|
|
10
15
|
import { chromiumPath } from "../../../test/chromium.ts";
|
|
11
16
|
import {
|
|
@@ -66,6 +71,20 @@ async function reasonStored(page: Page, text: string): Promise<void> {
|
|
|
66
71
|
assert.equal(await storedReason(page), text);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/**
|
|
75
|
+
* One Chromium for the whole file, each test in a context of its own (`newPage` opens one): a launch
|
|
76
|
+
* per test cost about 5 s apiece on the macOS runner, and the file's wall time grew with every test.
|
|
77
|
+
*/
|
|
78
|
+
let launched: Browser | undefined;
|
|
79
|
+
const sharedBrowser = async (): Promise<Browser> =>
|
|
80
|
+
(launched ??= await chromium.launch({
|
|
81
|
+
executablePath: chromiumPath(),
|
|
82
|
+
args: ["--no-sandbox"],
|
|
83
|
+
}));
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await launched?.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
69
88
|
/** A browser test launches Chromium and types: far above vitest's 5 s default on a slow runner. */
|
|
70
89
|
const BROWSER_TEST_MS = 60_000;
|
|
71
90
|
|
|
@@ -76,12 +95,9 @@ async function onApprovalPage(
|
|
|
76
95
|
filter: "all" | "next" = "all",
|
|
77
96
|
): Promise<void> {
|
|
78
97
|
const { page: html } = build(setup(name, approval()));
|
|
79
|
-
const browser = await
|
|
80
|
-
|
|
81
|
-
args: ["--no-sandbox"],
|
|
82
|
-
});
|
|
98
|
+
const browser = await sharedBrowser();
|
|
99
|
+
const page = await browser.newPage();
|
|
83
100
|
try {
|
|
84
|
-
const page = await browser.newPage();
|
|
85
101
|
await page.addInitScript(STORE(PRACTICE_DONE, echoMs));
|
|
86
102
|
await page.goto("about:blank");
|
|
87
103
|
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
@@ -90,7 +106,7 @@ async function onApprovalPage(
|
|
|
90
106
|
await page.getByRole("button", { name: /Drop/ }).first().click();
|
|
91
107
|
await steps(page);
|
|
92
108
|
} finally {
|
|
93
|
-
await
|
|
109
|
+
await page.context().close();
|
|
94
110
|
}
|
|
95
111
|
}
|
|
96
112
|
|
|
@@ -163,12 +179,9 @@ test(
|
|
|
163
179
|
"a Keep needs no reason and writes none: the page stays quiet",
|
|
164
180
|
async () => {
|
|
165
181
|
const { page: html } = build(setup("browser-keep", approval()));
|
|
166
|
-
const browser = await
|
|
167
|
-
|
|
168
|
-
args: ["--no-sandbox"],
|
|
169
|
-
});
|
|
182
|
+
const browser = await sharedBrowser();
|
|
183
|
+
const page = await browser.newPage();
|
|
170
184
|
try {
|
|
171
|
-
const page = await browser.newPage();
|
|
172
185
|
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
173
186
|
await page.goto("about:blank");
|
|
174
187
|
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
@@ -180,7 +193,89 @@ test(
|
|
|
180
193
|
[{}],
|
|
181
194
|
);
|
|
182
195
|
} finally {
|
|
183
|
-
await
|
|
196
|
+
await page.context().close();
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
BROWSER_TEST_MS,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/** A two-reading card with a long URL and compact JSON: what pushed the page sideways on a phone. */
|
|
203
|
+
const URL_LONG = `https://github.com/a/b/blob/${"0".repeat(40)}/src/a/long/path/to/witness.ts`;
|
|
204
|
+
const WITNESS = {
|
|
205
|
+
source_id: "s1",
|
|
206
|
+
group: "python",
|
|
207
|
+
body: "Warn for unused variables, except those prefixed with `_`",
|
|
208
|
+
unit: {
|
|
209
|
+
id: "L",
|
|
210
|
+
text: "Warn for unused variables",
|
|
211
|
+
mark: "unused",
|
|
212
|
+
part: 1,
|
|
213
|
+
of: 1,
|
|
214
|
+
},
|
|
215
|
+
rules: [{ linter: "eslint", id: "no-console" }],
|
|
216
|
+
readings: {
|
|
217
|
+
a: `[{"argsIgnorePattern":"^_","varsIgnorePattern":"^_","caughtErrors":"all"}]`,
|
|
218
|
+
b: "[]",
|
|
219
|
+
},
|
|
220
|
+
file: URL_LONG,
|
|
221
|
+
context: URL_LONG,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** Where the line, the rule, reading A, reading B and the question sit on a card, top to bottom. */
|
|
225
|
+
const blocksOf = async (card: Locator) =>
|
|
226
|
+
Promise.all(
|
|
227
|
+
[
|
|
228
|
+
".quote",
|
|
229
|
+
".row:has(.lbl:text-is('Rule'))",
|
|
230
|
+
".reading.a",
|
|
231
|
+
".reading.b",
|
|
232
|
+
".ask",
|
|
233
|
+
].map(async (sel) => card.locator(sel).first().boundingBox()),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
test(
|
|
237
|
+
"🔴 at phone width the card reads by position: line, rule, reading A above reading B, then the question; nothing pushes the page sideways",
|
|
238
|
+
async () => {
|
|
239
|
+
const { page: html } = build(
|
|
240
|
+
setup("browser-layout", approval({ items: [WITNESS] })),
|
|
241
|
+
);
|
|
242
|
+
const page = await (
|
|
243
|
+
await sharedBrowser()
|
|
244
|
+
).newPage({ viewport: { width: 400, height: 900 } });
|
|
245
|
+
try {
|
|
246
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
247
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
248
|
+
const card = page.locator("article.item").last();
|
|
249
|
+
const boxes = await blocksOf(card);
|
|
250
|
+
const [line, rule, a, b, ask] = boxes;
|
|
251
|
+
assert.ok(line && rule && a && b && ask, "every block is on the card");
|
|
252
|
+
// Guards: each block below the one before, A and B in one column, a gap before the question.
|
|
253
|
+
assert.deepEqual(
|
|
254
|
+
boxes
|
|
255
|
+
.slice(1)
|
|
256
|
+
.map(
|
|
257
|
+
(x, i) =>
|
|
258
|
+
(x?.y ?? 0) >= (boxes[i]?.y ?? 0) + (boxes[i]?.height ?? 0),
|
|
259
|
+
),
|
|
260
|
+
[true, true, true, true],
|
|
261
|
+
);
|
|
262
|
+
assert.equal(a.x, b.x);
|
|
263
|
+
assert.ok(ask.y - (b.y + b.height) >= 16, "space before the question");
|
|
264
|
+
assert.deepEqual(
|
|
265
|
+
await card
|
|
266
|
+
.locator(".reading")
|
|
267
|
+
.evaluateAll((els) =>
|
|
268
|
+
els.map((e) => getComputedStyle(e).borderLeftStyle),
|
|
269
|
+
),
|
|
270
|
+
["solid", "dashed"],
|
|
271
|
+
);
|
|
272
|
+
// Guards: a long URL or compact JSON wraps; the page itself never scrolls sideways.
|
|
273
|
+
assert.equal(
|
|
274
|
+
await page.evaluate(() => document.documentElement.scrollWidth),
|
|
275
|
+
400,
|
|
276
|
+
);
|
|
277
|
+
} finally {
|
|
278
|
+
await page.context().close();
|
|
184
279
|
}
|
|
185
280
|
},
|
|
186
281
|
BROWSER_TEST_MS,
|
|
@@ -8,7 +8,7 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { describeRule } from "./rule-descriptions.js";
|
|
11
|
+
import { describeRule, } from "./rule-descriptions.js";
|
|
12
12
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
13
13
|
export class Refusal extends Error {
|
|
14
14
|
exitCode;
|
|
@@ -88,18 +88,22 @@ const readJson = (path) => {
|
|
|
88
88
|
// stops the build: a blind page fails by what leaked in, and a field nobody listed is exactly the
|
|
89
89
|
// one nobody thought of. A NEW FIELD IS ADDED HERE ON PURPOSE, after asking "does this tell the
|
|
90
90
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
91
|
-
|
|
91
|
+
// prettier-ignore
|
|
92
|
+
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"];
|
|
92
93
|
const PRIVATE_FIELDS = ["source_id"];
|
|
93
94
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
94
95
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
95
96
|
const RULES_FIELD = "rules";
|
|
96
|
-
//
|
|
97
|
-
// the
|
|
97
|
+
// The source line an item was drawn from, with its requirement marked (`mark`, words of the line)
|
|
98
|
+
// and which part of the line it is. Shown on the card; the line is text the labeller may read.
|
|
98
99
|
const UNIT_FIELD = "unit";
|
|
100
|
+
// Two readings of one requirement, shown as rows A and B (#374).
|
|
101
|
+
const READINGS_FIELD = "readings";
|
|
99
102
|
const ALLOWED_FIELDS = [
|
|
100
103
|
...PAGE_FIELDS,
|
|
101
104
|
RULES_FIELD,
|
|
102
105
|
UNIT_FIELD,
|
|
106
|
+
READINGS_FIELD,
|
|
103
107
|
...PRIVATE_FIELDS,
|
|
104
108
|
];
|
|
105
109
|
const FAMILIARITY = [
|
|
@@ -128,9 +132,14 @@ const RESERVED_NAMES = [
|
|
|
128
132
|
const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
|
|
129
133
|
const SOURCES = ["page", "chat"];
|
|
130
134
|
const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
131
|
-
const string = (required = true, pattern) => pattern === undefined
|
|
135
|
+
const string = (required = true, pattern, why) => pattern === undefined
|
|
132
136
|
? { kind: "string", required }
|
|
133
|
-
: {
|
|
137
|
+
: {
|
|
138
|
+
kind: "string",
|
|
139
|
+
required,
|
|
140
|
+
pattern,
|
|
141
|
+
...(why === undefined ? {} : { why }),
|
|
142
|
+
};
|
|
134
143
|
const integer = (required = true) => ({
|
|
135
144
|
kind: "integer",
|
|
136
145
|
required,
|
|
@@ -153,13 +162,56 @@ const mapping = (values, keyPattern, min = 0) => ({
|
|
|
153
162
|
values,
|
|
154
163
|
});
|
|
155
164
|
const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
[RULES_FIELD]: array(obj({
|
|
165
|
+
// What an item and a practice item both may carry beyond text: a practice card looks like the real
|
|
166
|
+
// cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
|
|
167
|
+
const CARD_FIELDS = {
|
|
168
|
+
[RULES_FIELD]: array(obj({
|
|
169
|
+
linter: string(true, "eslint|ruff", "eslint or ruff"),
|
|
170
|
+
id: string(),
|
|
171
|
+
}), false, 1),
|
|
160
172
|
[UNIT_FIELD]: obj(
|
|
161
173
|
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
162
|
-
{
|
|
174
|
+
{
|
|
175
|
+
id: string(),
|
|
176
|
+
text: string(),
|
|
177
|
+
mark: string(),
|
|
178
|
+
part: integer(),
|
|
179
|
+
of: integer(),
|
|
180
|
+
}, true, false),
|
|
181
|
+
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
182
|
+
file: string(false),
|
|
183
|
+
};
|
|
184
|
+
const KINDS = {
|
|
185
|
+
// keep or drop a drawn requirement: the line it came from, marked, and the rule read for it
|
|
186
|
+
approval: {
|
|
187
|
+
[UNIT_FIELD]: "an approval card shows the line its requirement was drawn from, the requirement marked",
|
|
188
|
+
[RULES_FIELD]: "an approval card shows the rule read for it, with the linter's own description",
|
|
189
|
+
},
|
|
190
|
+
// why two readings of one requirement disagree on a file
|
|
191
|
+
witness: {
|
|
192
|
+
[RULES_FIELD]: "a witness card shows the rule both readings configure, with the linter's description",
|
|
193
|
+
[READINGS_FIELD]: "a witness card shows the two readings, one row each",
|
|
194
|
+
file: "a witness card shows the file the readings disagree on, on its own row",
|
|
195
|
+
},
|
|
196
|
+
// gold labels, a blind review: the card is the item, nothing more is required
|
|
197
|
+
labels: {},
|
|
198
|
+
};
|
|
199
|
+
const KIND_NAMES = Object.keys(KINDS).join(", ");
|
|
200
|
+
/** The card fields of a page of `kind`: those it needs, required and saying why. */
|
|
201
|
+
const cardFields = (kind) => Object.fromEntries(Object.entries(CARD_FIELDS).map(([name, node]) => {
|
|
202
|
+
const why = KINDS[kind][name];
|
|
203
|
+
return [
|
|
204
|
+
name,
|
|
205
|
+
why === undefined ? node : { ...node, required: true, why },
|
|
206
|
+
];
|
|
207
|
+
}));
|
|
208
|
+
const itemSchema = (kind) => obj({
|
|
209
|
+
source_id: string(),
|
|
210
|
+
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
|
|
211
|
+
f,
|
|
212
|
+
string(f === "group" || f === "body"),
|
|
213
|
+
])),
|
|
214
|
+
...cardFields(kind),
|
|
163
215
|
}, false);
|
|
164
216
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
165
217
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
@@ -171,11 +223,12 @@ const OPTION_SCHEMA = obj({
|
|
|
171
223
|
icon: string(false),
|
|
172
224
|
ask_why: string(false),
|
|
173
225
|
});
|
|
174
|
-
const
|
|
175
|
-
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group").map((f) => [
|
|
226
|
+
const practiceSchema = (kind) => obj({
|
|
227
|
+
...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
|
|
176
228
|
f,
|
|
177
229
|
string(f === "body"),
|
|
178
230
|
])),
|
|
231
|
+
...cardFields(kind),
|
|
179
232
|
expected: mapping(string(), NAME_PATTERN, 1),
|
|
180
233
|
why: string(),
|
|
181
234
|
});
|
|
@@ -186,22 +239,25 @@ const QUESTION_SCHEMA = obj({
|
|
|
186
239
|
short: string(false),
|
|
187
240
|
options: array(OPTION_SCHEMA, true, 2),
|
|
188
241
|
});
|
|
189
|
-
|
|
242
|
+
const configSchema = (kind) => obj({
|
|
190
243
|
study: string(true, NAME_PATTERN), // becomes a file name
|
|
244
|
+
kind: string(true, Object.keys(KINDS).join("|"), `one of ${KIND_NAMES}`),
|
|
191
245
|
title: string(),
|
|
192
246
|
intro: string(false),
|
|
193
247
|
seed: integer(),
|
|
194
248
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
195
249
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
196
|
-
//
|
|
197
|
-
//
|
|
250
|
+
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
251
|
+
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
198
252
|
familiarity: boolean(false),
|
|
199
253
|
familiarity_question: string(false),
|
|
200
254
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
201
|
-
practice: array(
|
|
255
|
+
practice: array(practiceSchema(kind), true, MIN_PRACTICE),
|
|
202
256
|
practice_round: integer(false),
|
|
203
|
-
items: array(
|
|
257
|
+
items: array(itemSchema(kind), true, 1),
|
|
204
258
|
});
|
|
259
|
+
/** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
|
|
260
|
+
export const CONFIG_SCHEMA = configSchema("labels");
|
|
205
261
|
// ── checking a value against the schema ──────────────────────────────────────────────────────
|
|
206
262
|
const at = (path, name) => path ? `${path}.${name}` : name;
|
|
207
263
|
const fullMatch = (pattern, s) => new RegExp(`^(?:${pattern})$`, "u").test(s);
|
|
@@ -211,7 +267,7 @@ function checkString(value, node, where) {
|
|
|
211
267
|
fail(`${where}: must be a non-empty string, got ${repr(value)}`);
|
|
212
268
|
}
|
|
213
269
|
else if (node.pattern !== undefined && !fullMatch(node.pattern, value)) {
|
|
214
|
-
fail(`${where}: ${repr(value)} must be ${ID_RULE}`);
|
|
270
|
+
fail(`${where}: ${repr(value)} must be ${node.why ?? ID_RULE}`);
|
|
215
271
|
}
|
|
216
272
|
}
|
|
217
273
|
const checkCount = (n, min, where) => {
|
|
@@ -243,7 +299,7 @@ function checkObject(value, node, path, where) {
|
|
|
243
299
|
if (has(value, name))
|
|
244
300
|
checkValue(value[name] ?? null, sub, at(path, name));
|
|
245
301
|
else if (sub.required)
|
|
246
|
-
fail(`${at(path, name)}: is required and missing`);
|
|
302
|
+
fail(`${at(path, name)}: is required and missing${sub.why === undefined ? "" : ` — ${sub.why}`}`);
|
|
247
303
|
}
|
|
248
304
|
const unknown = Object.keys(value).find((name) => !has(node.fields, name));
|
|
249
305
|
if (node.closed && unknown !== undefined) {
|
|
@@ -298,7 +354,7 @@ const allowedValues = (questions, asksFamiliarity) => ({
|
|
|
298
354
|
// Empty when the page asks no familiarity: the export then expects no group document at all.
|
|
299
355
|
familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
|
|
300
356
|
});
|
|
301
|
-
const asksFamiliarity = (cfg) => cfg.familiarity
|
|
357
|
+
const asksFamiliarity = (cfg) => cfg.familiarity === true;
|
|
302
358
|
/** Two values that read the same once case and surrounding space are gone. */
|
|
303
359
|
const readsAs = (s) => s.trim().toLowerCase();
|
|
304
360
|
/** The first entry whose reading repeats an earlier one, with the index of that earlier one. */
|
|
@@ -363,7 +419,7 @@ function checkPractice(cfg) {
|
|
|
363
419
|
function checkConfig(cfg) {
|
|
364
420
|
checkGroups(cfg);
|
|
365
421
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
366
|
-
fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is
|
|
422
|
+
fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
|
|
367
423
|
}
|
|
368
424
|
cfg.questions.forEach((q, qn) => {
|
|
369
425
|
checkQuestion(q, qn, cfg.questions.slice(0, qn).map((x) => x.id));
|
|
@@ -388,25 +444,36 @@ function checkPart({ item, unit, n }, parts) {
|
|
|
388
444
|
fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
|
|
389
445
|
if (unit.of !== first.unit.of)
|
|
390
446
|
fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
|
|
391
|
-
if (unit.part < 1 || unit.part > unit.of)
|
|
392
|
-
fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
|
|
393
447
|
const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
|
|
394
448
|
if (earlier !== undefined)
|
|
395
449
|
fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
|
|
396
450
|
if (item.group !== first.item.group)
|
|
397
451
|
fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
|
|
398
452
|
}
|
|
453
|
+
/** One unit on its own: its part in range, and its mark the requirement's words in the line. */
|
|
454
|
+
function checkUnit(unit, at) {
|
|
455
|
+
if (unit === undefined)
|
|
456
|
+
return;
|
|
457
|
+
if (unit.part < 1 || unit.part > unit.of)
|
|
458
|
+
fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
|
|
459
|
+
if (!unit.text.includes(unit.mark))
|
|
460
|
+
fail(`${at}.unit.mark: ${repr(unit.mark)} is not in the line ${repr(unit.text)} — mark the requirement with the line's own words`);
|
|
461
|
+
}
|
|
399
462
|
function checkUnits(cfg) {
|
|
400
|
-
|
|
463
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
464
|
+
checkUnit(card.unit, at);
|
|
465
|
+
});
|
|
466
|
+
for (const parts of linesOf(cfg).values()) {
|
|
401
467
|
parts.forEach((p) => {
|
|
402
468
|
checkPart(p, parts);
|
|
403
469
|
});
|
|
404
|
-
const [first] = parts;
|
|
405
|
-
const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
|
|
406
|
-
if (missing !== undefined)
|
|
407
|
-
fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
|
|
408
470
|
}
|
|
409
471
|
}
|
|
472
|
+
/** Every card of the config, items then practice, by path. */
|
|
473
|
+
const cardsOf = (cfg) => [
|
|
474
|
+
...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
|
|
475
|
+
...cfg.practice.map((p, n) => [`practice[${String(n)}]`, p]),
|
|
476
|
+
];
|
|
410
477
|
function checkItemFields(cfg) {
|
|
411
478
|
const sids = [];
|
|
412
479
|
cfg.items.forEach((item, n) => {
|
|
@@ -427,6 +494,7 @@ function checkItemFields(cfg) {
|
|
|
427
494
|
const visibleTexts = (item) => [
|
|
428
495
|
...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
|
|
429
496
|
[UNIT_FIELD, item.unit?.text ?? ""],
|
|
497
|
+
[READINGS_FIELD, `${item.readings?.a ?? ""} ${item.readings?.b ?? ""}`],
|
|
430
498
|
];
|
|
431
499
|
const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
432
500
|
/**
|
|
@@ -491,28 +559,30 @@ const shownFields = (item) => ({
|
|
|
491
559
|
: {
|
|
492
560
|
[UNIT_FIELD]: {
|
|
493
561
|
text: item.unit.text,
|
|
562
|
+
mark: item.unit.mark,
|
|
494
563
|
part: item.unit.part,
|
|
495
564
|
of: item.unit.of,
|
|
496
565
|
},
|
|
497
566
|
}),
|
|
567
|
+
...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
|
|
498
568
|
});
|
|
499
569
|
const ruleKey = (r) => `${r.linter} ${r.id}`;
|
|
500
570
|
/** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
|
|
501
571
|
function describeRules(cfg, fromDir) {
|
|
502
|
-
return new Map(cfg.
|
|
572
|
+
return new Map(cardsOf(cfg).flatMap(([at, card]) => (card.rules ?? []).map((r, k) => {
|
|
503
573
|
const text = describeRule(r, fromDir);
|
|
504
574
|
return text === undefined
|
|
505
|
-
? fail(
|
|
575
|
+
? fail(`${at}.rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`)
|
|
506
576
|
: [ruleKey(r), text];
|
|
507
577
|
})));
|
|
508
578
|
}
|
|
509
|
-
const rulesShown = (
|
|
579
|
+
const rulesShown = (card, described) => card.rules === undefined
|
|
510
580
|
? {}
|
|
511
581
|
: {
|
|
512
|
-
[RULES_FIELD]:
|
|
582
|
+
[RULES_FIELD]: card.rules.map((r) => ({
|
|
513
583
|
linter: r.linter,
|
|
514
584
|
id: r.id,
|
|
515
|
-
|
|
585
|
+
...described.get(ruleKey(r)),
|
|
516
586
|
})),
|
|
517
587
|
};
|
|
518
588
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
@@ -528,6 +598,14 @@ function itemsOnPage(cfg, keyPath) {
|
|
|
528
598
|
return { items: sorted, ids, entries, idOf };
|
|
529
599
|
}
|
|
530
600
|
const practiceRound = (cfg) => cfg.practice_round ?? 1;
|
|
601
|
+
/** The practice as its cards show it, with the answers the page checks against. */
|
|
602
|
+
const practiceShown = (cfg, described) => practiceOf(cfg).map((p) => ({
|
|
603
|
+
id: p.id,
|
|
604
|
+
expected: p.expected,
|
|
605
|
+
why: p.why,
|
|
606
|
+
...shownFields(p),
|
|
607
|
+
...rulesShown(p, described),
|
|
608
|
+
}));
|
|
531
609
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
532
610
|
function payloadOf(cfg, page, described) {
|
|
533
611
|
const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
|
|
@@ -541,7 +619,10 @@ function payloadOf(cfg, page, described) {
|
|
|
541
619
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
542
620
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
543
621
|
questions: cfg.questions,
|
|
544
|
-
practice: {
|
|
622
|
+
practice: {
|
|
623
|
+
round: practiceRound(cfg),
|
|
624
|
+
items: practiceShown(cfg, described),
|
|
625
|
+
},
|
|
545
626
|
items: page.items.map((i) => ({
|
|
546
627
|
id: page.idOf(i),
|
|
547
628
|
...shownFields(i),
|
|
@@ -610,6 +691,8 @@ function keyOf(cfg, page, earlier) {
|
|
|
610
691
|
export function prepareBuild(configPath, outDir) {
|
|
611
692
|
const cfg = readJson(configPath);
|
|
612
693
|
assertConfig(cfg);
|
|
694
|
+
// Once its `kind` is known to be one, the fields that kind needs on every card.
|
|
695
|
+
checkValue(cfg, configSchema(cfg.kind), "");
|
|
613
696
|
checkConfig(cfg);
|
|
614
697
|
checkItemFields(cfg);
|
|
615
698
|
checkUnits(cfg);
|
|
@@ -774,7 +857,7 @@ function checkNoFamiliarity(dump, allowed) {
|
|
|
774
857
|
const groups = isObject(dump["groups"]) ? dump["groups"] : {};
|
|
775
858
|
const [first] = Object.keys(groups).sort(byText);
|
|
776
859
|
if (allowed.familiarity.length === 0 && first !== undefined) {
|
|
777
|
-
fail(`groups/${first}: familiarity is off for this page (items.json \`familiarity:
|
|
860
|
+
fail(`groups/${first}: familiarity is off for this page (items.json does not set \`"familiarity": true\`), so no group document is expected — take it out of the dump`);
|
|
778
861
|
}
|
|
779
862
|
}
|
|
780
863
|
/** The key's `allowed` lists, or a refusal naming a key made by an older build. */
|