paperlint 13.3.2 → 13.5.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 +16 -1
- package/package.json +1 -1
- package/skills/assess-novelty/scripts/candidate_page.test.ts +1 -32
- package/skills/build-label-page/SKILL.md +3 -2
- package/skills/build-label-page/SKILL.md.spec.ts +2 -1
- package/skills/build-label-page/references/checklist.md +4 -2
- package/skills/build-label-page/references/closed-choice-generator.md +21 -4
- package/skills/build-label-page/scripts/label-page-browser.test.ts +187 -0
- package/skills/build-label-page/scripts/label-page-core.js +108 -16
- package/skills/build-label-page/scripts/label-page-core.test.ts +206 -3
- package/skills/build-label-page/scripts/label-page-core.ts +167 -19
- package/skills/build-label-page/scripts/label-page-html.test.ts +60 -0
- package/skills/build-label-page/scripts/label-page.js +6 -1
- package/skills/build-label-page/scripts/label-page.ts +6 -1
- package/skills/build-label-page/scripts/page-template.html +75 -11
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ITEMS,
|
|
15
15
|
PRACTICE,
|
|
16
16
|
QUESTIONS,
|
|
17
|
+
WORK,
|
|
17
18
|
approval,
|
|
18
19
|
build,
|
|
19
20
|
config,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
/** A node of the declared config schema, as `example-generator.py schema` prints it. */
|
|
27
28
|
const Node: z.ZodType<SchemaNode> = z.lazy(() =>
|
|
28
29
|
z.object({
|
|
29
|
-
kind: z.enum(["string", "integer", "map", "array", "object"]),
|
|
30
|
+
kind: z.enum(["string", "integer", "boolean", "map", "array", "object"]),
|
|
30
31
|
required: z.boolean(),
|
|
31
32
|
pattern: z.string().optional(),
|
|
32
33
|
key_pattern: z.string().optional(),
|
|
@@ -39,7 +40,7 @@ const Node: z.ZodType<SchemaNode> = z.lazy(() =>
|
|
|
39
40
|
);
|
|
40
41
|
|
|
41
42
|
interface SchemaNode {
|
|
42
|
-
readonly kind: "string" | "integer" | "map" | "array" | "object";
|
|
43
|
+
readonly kind: "string" | "integer" | "boolean" | "map" | "array" | "object";
|
|
43
44
|
readonly required: boolean;
|
|
44
45
|
readonly pattern?: string | undefined;
|
|
45
46
|
readonly key_pattern?: string | undefined;
|
|
@@ -77,9 +78,18 @@ const FULL: DocObject = (() => {
|
|
|
77
78
|
QUESTIONS[1],
|
|
78
79
|
],
|
|
79
80
|
labeller_knows: ["python", "go", "rust"],
|
|
81
|
+
familiarity: true,
|
|
80
82
|
familiarity_question: "How well do you know {group}?",
|
|
81
83
|
items: ITEMS.map((i, n) =>
|
|
82
|
-
n === 0
|
|
84
|
+
n === 0
|
|
85
|
+
? {
|
|
86
|
+
...i,
|
|
87
|
+
title: "T",
|
|
88
|
+
code: "c = 1",
|
|
89
|
+
context: "ctx",
|
|
90
|
+
unit: { id: "L0", text: "One line.", part: 1, of: 1 },
|
|
91
|
+
}
|
|
92
|
+
: i,
|
|
83
93
|
),
|
|
84
94
|
}),
|
|
85
95
|
);
|
|
@@ -175,6 +185,7 @@ function spoil(path: string, bad: Doc | undefined): DocObject {
|
|
|
175
185
|
const WRONG_TYPES: Readonly<Record<SchemaNode["kind"], readonly Doc[]>> = {
|
|
176
186
|
string: [null, 1, true, ["x"], {}, "", " "],
|
|
177
187
|
integer: [null, "7", true, 7.5, []],
|
|
188
|
+
boolean: [null, "false", 0, [], {}],
|
|
178
189
|
map: [null, "x", ["x"], 1],
|
|
179
190
|
array: [null, "x", {}, 1],
|
|
180
191
|
object: [null, "x", ["x"], 1],
|
|
@@ -238,6 +249,7 @@ const DOCUMENTED_FIELDS = [
|
|
|
238
249
|
"seed",
|
|
239
250
|
"groups",
|
|
240
251
|
"labeller_knows",
|
|
252
|
+
"familiarity",
|
|
241
253
|
"familiarity_question",
|
|
242
254
|
"questions",
|
|
243
255
|
"items",
|
|
@@ -1162,3 +1174,194 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
|
|
|
1162
1174
|
assert.equal(r.status, 0, r.out);
|
|
1163
1175
|
assert.match(r.out, /practice round 1: 3 of 3 matched/);
|
|
1164
1176
|
});
|
|
1177
|
+
|
|
1178
|
+
/** Items 0 and 3 (both Rust) as the two requirements of one line. */
|
|
1179
|
+
const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
1180
|
+
const withUnit = (
|
|
1181
|
+
parts: readonly (readonly [n: number, unit: Record<string, unknown>])[],
|
|
1182
|
+
): Record<string, unknown> =>
|
|
1183
|
+
config({
|
|
1184
|
+
items: ITEMS.map((item, n) => {
|
|
1185
|
+
const u = parts.find(([at]) => at === n);
|
|
1186
|
+
return u === undefined ? item : { ...item, unit: u[1] };
|
|
1187
|
+
}),
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
/** A line whose parts do not add up, and the refusal that names it. */
|
|
1191
|
+
const UNIT_REFUSALS: readonly (readonly [
|
|
1192
|
+
string,
|
|
1193
|
+
Record<string, unknown>,
|
|
1194
|
+
string,
|
|
1195
|
+
])[] = [
|
|
1196
|
+
[
|
|
1197
|
+
"unit-missing-part",
|
|
1198
|
+
withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
|
|
1199
|
+
`items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
|
|
1200
|
+
],
|
|
1201
|
+
[
|
|
1202
|
+
"unit-part-twice",
|
|
1203
|
+
withUnit([
|
|
1204
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1205
|
+
[3, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1206
|
+
]),
|
|
1207
|
+
"items[3].unit.part: 1 repeats items[0].unit.part",
|
|
1208
|
+
],
|
|
1209
|
+
[
|
|
1210
|
+
"unit-of-differs",
|
|
1211
|
+
withUnit([
|
|
1212
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1213
|
+
[3, { id: "L1", text: LINE, part: 2, of: 3 }],
|
|
1214
|
+
]),
|
|
1215
|
+
"items[3].unit.of: 3, but items[0].unit.of says 2 for the same line",
|
|
1216
|
+
],
|
|
1217
|
+
[
|
|
1218
|
+
"unit-part-range",
|
|
1219
|
+
withUnit([[0, { id: "L1", text: LINE, part: 3, of: 2 }]]),
|
|
1220
|
+
"items[0].unit.part: 3 is not between 1 and 2",
|
|
1221
|
+
],
|
|
1222
|
+
[
|
|
1223
|
+
"unit-groups-differ",
|
|
1224
|
+
withUnit([
|
|
1225
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1226
|
+
[1, { id: "L1", text: LINE, part: 2, of: 2 }],
|
|
1227
|
+
]),
|
|
1228
|
+
"items[1].group: 'python', but items[0] of the same line is in 'rust'",
|
|
1229
|
+
],
|
|
1230
|
+
[
|
|
1231
|
+
"unit-text-differs",
|
|
1232
|
+
withUnit([
|
|
1233
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1234
|
+
[3, { id: "L1", text: "Another line.", part: 2, of: 2 }],
|
|
1235
|
+
]),
|
|
1236
|
+
"items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
|
|
1237
|
+
],
|
|
1238
|
+
[
|
|
1239
|
+
"unit-halves-of-two-occurrences",
|
|
1240
|
+
withUnit([
|
|
1241
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1242
|
+
[3, { id: "L2", text: LINE, part: 2, of: 2 }],
|
|
1243
|
+
]),
|
|
1244
|
+
"items[0].unit: part 2 of 2 of line 'L1' is not among the items",
|
|
1245
|
+
],
|
|
1246
|
+
[
|
|
1247
|
+
"unit-leaks-source",
|
|
1248
|
+
withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
|
|
1249
|
+
"the source id 'case-1003' appears in the visible `unit` of item case-1000",
|
|
1250
|
+
],
|
|
1251
|
+
];
|
|
1252
|
+
|
|
1253
|
+
test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
|
|
1254
|
+
for (const [name, cfg, message] of UNIT_REFUSALS) {
|
|
1255
|
+
const dir = setup(name, cfg);
|
|
1256
|
+
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
1257
|
+
assert.equal(r.status, 1, `${name}\n${r.out}`);
|
|
1258
|
+
assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test("a line whose every requirement is an item builds, and each item carries its part and the whole line", () => {
|
|
1263
|
+
const dir = setup(
|
|
1264
|
+
"unit-ok",
|
|
1265
|
+
withUnit([
|
|
1266
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1267
|
+
[3, { id: "L1", text: LINE, part: 2, of: 2 }],
|
|
1268
|
+
]),
|
|
1269
|
+
);
|
|
1270
|
+
const { page } = build(dir);
|
|
1271
|
+
const json =
|
|
1272
|
+
/<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
|
|
1273
|
+
page,
|
|
1274
|
+
)?.[1];
|
|
1275
|
+
assert.ok(json !== undefined);
|
|
1276
|
+
const units = z
|
|
1277
|
+
.object({
|
|
1278
|
+
items: z.array(
|
|
1279
|
+
z.object({
|
|
1280
|
+
unit: z
|
|
1281
|
+
.object({ text: z.string(), part: z.number(), of: z.number() })
|
|
1282
|
+
.optional(),
|
|
1283
|
+
}),
|
|
1284
|
+
),
|
|
1285
|
+
})
|
|
1286
|
+
.parse(JSON.parse(json.replace(/<\\\//g, "</")))
|
|
1287
|
+
.items.flatMap((i) => (i.unit ? [i.unit] : []))
|
|
1288
|
+
.sort((a, b) => a.part - b.part);
|
|
1289
|
+
// what the page carries: the part and the line, never the line's private id
|
|
1290
|
+
assert.deepEqual(units, [
|
|
1291
|
+
{ text: LINE, part: 1, of: 2 },
|
|
1292
|
+
{ text: LINE, part: 2, of: 2 },
|
|
1293
|
+
]);
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
test("🔴 the same line in two places is two lines: four items, two ids, it builds, and the id stays off the page", () => {
|
|
1297
|
+
const dir = setup(
|
|
1298
|
+
"unit-same-text",
|
|
1299
|
+
withUnit([
|
|
1300
|
+
[0, { id: "a.md:3", text: LINE, part: 1, of: 2 }],
|
|
1301
|
+
[3, { id: "a.md:3", text: LINE, part: 2, of: 2 }],
|
|
1302
|
+
[6, { id: "b.md:9", text: LINE, part: 1, of: 2 }],
|
|
1303
|
+
[9, { id: "b.md:9", text: LINE, part: 2, of: 2 }],
|
|
1304
|
+
]),
|
|
1305
|
+
);
|
|
1306
|
+
const { page } = build(dir);
|
|
1307
|
+
assert.ok(!page.includes("a.md:3"), "the line's id is private");
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
|
|
1311
|
+
const dir = setup("familiarity-off", approval({ familiarity: false }));
|
|
1312
|
+
const { key } = build(dir);
|
|
1313
|
+
assert.deepEqual(key.allowed.familiarity, []);
|
|
1314
|
+
const [a] = Object.keys(key.ids);
|
|
1315
|
+
assert.ok(a !== undefined);
|
|
1316
|
+
writeFileSync(
|
|
1317
|
+
join(dir, "dump.json"),
|
|
1318
|
+
JSON.stringify({
|
|
1319
|
+
answers: { [a]: { answers: { keep: "keep" }, source: "page" } },
|
|
1320
|
+
groups: {},
|
|
1321
|
+
}),
|
|
1322
|
+
);
|
|
1323
|
+
const r = exportAt(dir);
|
|
1324
|
+
assert.equal(r.status, 0, r.out);
|
|
1325
|
+
assert.doesNotMatch(r.out, /no familiarity answer|invalid group/);
|
|
1326
|
+
assert.deepEqual(Exported.parse(JSON.parse(labelsFile(dir))).groups, {});
|
|
1327
|
+
writeFileSync(
|
|
1328
|
+
join(dir, "dump.json"),
|
|
1329
|
+
JSON.stringify({
|
|
1330
|
+
answers: {},
|
|
1331
|
+
groups: { python: { familiarity: "well", source: "chat" } },
|
|
1332
|
+
}),
|
|
1333
|
+
);
|
|
1334
|
+
const refused = exportAt(dir);
|
|
1335
|
+
assert.notEqual(refused.status, 0);
|
|
1336
|
+
assert.match(refused.out, /groups\/python: .*familiarity is off/);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test("🔴 familiarity off with a familiarity_question is a contradiction the build refuses", () => {
|
|
1340
|
+
const r = run([
|
|
1341
|
+
"build",
|
|
1342
|
+
join(
|
|
1343
|
+
setup(
|
|
1344
|
+
"familiarity-contradiction",
|
|
1345
|
+
approval({ familiarity: false, familiarity_question: "How well?" }),
|
|
1346
|
+
),
|
|
1347
|
+
"items.json",
|
|
1348
|
+
),
|
|
1349
|
+
join(WORK, "contradiction-out"),
|
|
1350
|
+
]);
|
|
1351
|
+
assert.notEqual(r.status, 0);
|
|
1352
|
+
assert.match(
|
|
1353
|
+
r.out,
|
|
1354
|
+
/REFUSED: familiarity_question: .*`familiarity` is false/,
|
|
1355
|
+
);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
|
|
1359
|
+
const dir = setup("familiarity-default", approval());
|
|
1360
|
+
const { key } = build(dir);
|
|
1361
|
+
assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
|
|
1362
|
+
writeFileSync(
|
|
1363
|
+
join(dir, "dump.json"),
|
|
1364
|
+
JSON.stringify({ answers: {}, groups: {} }),
|
|
1365
|
+
);
|
|
1366
|
+
assert.match(exportAt(dir).out, /group python: no familiarity answer/);
|
|
1367
|
+
});
|
|
@@ -94,7 +94,14 @@ const readJson = (path: string): Json => {
|
|
|
94
94
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
95
95
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
|
|
96
96
|
const PRIVATE_FIELDS = ["source_id"] as const;
|
|
97
|
-
|
|
97
|
+
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
98
|
+
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
99
|
+
const UNIT_FIELD = "unit";
|
|
100
|
+
const ALLOWED_FIELDS: readonly string[] = [
|
|
101
|
+
...PAGE_FIELDS,
|
|
102
|
+
UNIT_FIELD,
|
|
103
|
+
...PRIVATE_FIELDS,
|
|
104
|
+
];
|
|
98
105
|
|
|
99
106
|
const FAMILIARITY = [
|
|
100
107
|
{ value: "well", label: "Well" },
|
|
@@ -132,6 +139,7 @@ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
|
132
139
|
type SchemaNode =
|
|
133
140
|
| { kind: "string"; required: boolean; pattern?: string }
|
|
134
141
|
| { kind: "integer"; required: boolean }
|
|
142
|
+
| { kind: "boolean"; required: boolean }
|
|
135
143
|
| { kind: "array"; required: boolean; min: number; items: SchemaNode }
|
|
136
144
|
| {
|
|
137
145
|
kind: "map";
|
|
@@ -155,6 +163,10 @@ const integer = (required = true): SchemaNode => ({
|
|
|
155
163
|
kind: "integer",
|
|
156
164
|
required,
|
|
157
165
|
});
|
|
166
|
+
const boolean = (required = true): SchemaNode => ({
|
|
167
|
+
kind: "boolean",
|
|
168
|
+
required,
|
|
169
|
+
});
|
|
158
170
|
const array = (items: SchemaNode, required = true, min = 0): SchemaNode => ({
|
|
159
171
|
kind: "array",
|
|
160
172
|
required,
|
|
@@ -175,7 +187,8 @@ const mapping = (
|
|
|
175
187
|
const obj = (
|
|
176
188
|
fields: Readonly<Record<string, SchemaNode>>,
|
|
177
189
|
closed = true,
|
|
178
|
-
|
|
190
|
+
required = true,
|
|
191
|
+
): SchemaNode => ({ kind: "object", required, closed, fields });
|
|
179
192
|
|
|
180
193
|
const ITEM_SCHEMA = obj(
|
|
181
194
|
{
|
|
@@ -183,6 +196,12 @@ const ITEM_SCHEMA = obj(
|
|
|
183
196
|
...Object.fromEntries(
|
|
184
197
|
PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
|
|
185
198
|
),
|
|
199
|
+
[UNIT_FIELD]: obj(
|
|
200
|
+
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
201
|
+
{ id: string(), text: string(), part: integer(), of: integer() },
|
|
202
|
+
true,
|
|
203
|
+
false,
|
|
204
|
+
),
|
|
186
205
|
},
|
|
187
206
|
false, // an unlisted item field is the leak guard's refusal, with its own message
|
|
188
207
|
);
|
|
@@ -220,6 +239,9 @@ export const CONFIG_SCHEMA = obj({
|
|
|
220
239
|
seed: integer(),
|
|
221
240
|
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
222
241
|
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
242
|
+
// false: no "how well do you know {group}" question (an approval page: the author has never seen
|
|
243
|
+
// these repositories, so its answer means nothing). Left out, every group is asked (rule 5).
|
|
244
|
+
familiarity: boolean(false),
|
|
223
245
|
familiarity_question: string(false),
|
|
224
246
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
225
247
|
practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
|
|
@@ -296,6 +318,10 @@ function checkObject(
|
|
|
296
318
|
);
|
|
297
319
|
}
|
|
298
320
|
}
|
|
321
|
+
function checkBoolean(value: Json, where: string): void {
|
|
322
|
+
if (typeof value !== "boolean")
|
|
323
|
+
fail(`${where}: must be true or false, got ${repr(value)}`);
|
|
324
|
+
}
|
|
299
325
|
/** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
|
|
300
326
|
function checkValue(value: Json, node: SchemaNode, path: string): void {
|
|
301
327
|
const where = path || "items.json";
|
|
@@ -308,6 +334,9 @@ function checkValue(value: Json, node: SchemaNode, path: string): void {
|
|
|
308
334
|
fail(`${where}: must be an integer, got ${repr(value)}`);
|
|
309
335
|
}
|
|
310
336
|
break;
|
|
337
|
+
case "boolean":
|
|
338
|
+
checkBoolean(value, where);
|
|
339
|
+
break;
|
|
311
340
|
case "array":
|
|
312
341
|
checkArray(value, node, path, where);
|
|
313
342
|
break;
|
|
@@ -333,9 +362,19 @@ interface Question {
|
|
|
333
362
|
readonly short?: string;
|
|
334
363
|
readonly options: readonly Option[];
|
|
335
364
|
}
|
|
336
|
-
|
|
365
|
+
interface Unit {
|
|
366
|
+
readonly id: string;
|
|
367
|
+
readonly text: string;
|
|
368
|
+
readonly part: number;
|
|
369
|
+
readonly of: number;
|
|
370
|
+
}
|
|
371
|
+
type Item = {
|
|
372
|
+
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
373
|
+
} & {
|
|
337
374
|
readonly source_id: string;
|
|
338
375
|
readonly group: string;
|
|
376
|
+
readonly body: string;
|
|
377
|
+
readonly unit?: Unit;
|
|
339
378
|
};
|
|
340
379
|
interface Practice {
|
|
341
380
|
readonly title?: string;
|
|
@@ -352,6 +391,7 @@ interface Config {
|
|
|
352
391
|
readonly seed: number;
|
|
353
392
|
readonly groups: Readonly<Record<string, string>>;
|
|
354
393
|
readonly labeller_knows?: readonly string[];
|
|
394
|
+
readonly familiarity?: boolean;
|
|
355
395
|
readonly familiarity_question?: string;
|
|
356
396
|
readonly questions: readonly Question[];
|
|
357
397
|
readonly practice: readonly Practice[];
|
|
@@ -368,7 +408,10 @@ function assertConfig(value: Json): asserts value is Json & Config {
|
|
|
368
408
|
* The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
|
|
369
409
|
* stored value counts as an answer on the page exactly when it counts in the export.
|
|
370
410
|
*/
|
|
371
|
-
const allowedValues = (
|
|
411
|
+
const allowedValues = (
|
|
412
|
+
questions: readonly Question[],
|
|
413
|
+
asksFamiliarity: boolean,
|
|
414
|
+
): JsonObject => ({
|
|
372
415
|
questions: Object.fromEntries(
|
|
373
416
|
questions.map((q) => [q.id, q.options.map((o) => o.value)]),
|
|
374
417
|
),
|
|
@@ -379,8 +422,10 @@ const allowedValues = (questions: readonly Question[]): JsonObject => ({
|
|
|
379
422
|
q.options.filter((o) => o.ask_why !== undefined).map((o) => o.value),
|
|
380
423
|
]),
|
|
381
424
|
),
|
|
382
|
-
familiarity:
|
|
425
|
+
// Empty when the page asks no familiarity: the export then expects no group document at all.
|
|
426
|
+
familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
|
|
383
427
|
});
|
|
428
|
+
const asksFamiliarity = (cfg: Config): boolean => cfg.familiarity !== false;
|
|
384
429
|
|
|
385
430
|
/** Two values that read the same once case and surrounding space are gone. */
|
|
386
431
|
const readsAs = (s: string): string => s.trim().toLowerCase();
|
|
@@ -464,6 +509,11 @@ function checkPractice(cfg: Config): void {
|
|
|
464
509
|
}
|
|
465
510
|
function checkConfig(cfg: Config): void {
|
|
466
511
|
checkGroups(cfg);
|
|
512
|
+
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
513
|
+
fail(
|
|
514
|
+
`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is false — remove one of the two`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
467
517
|
cfg.questions.forEach((q, qn) => {
|
|
468
518
|
checkQuestion(
|
|
469
519
|
q,
|
|
@@ -481,6 +531,69 @@ const practiceOf = (
|
|
|
481
531
|
cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
|
|
482
532
|
|
|
483
533
|
// ── the items: the leak guard, and the references to the config ───────────────────────────────
|
|
534
|
+
/**
|
|
535
|
+
* A line with several requirements is split upstream into one item per requirement, each carrying
|
|
536
|
+
* `unit: {text, part, of}`. Every part of a line must be an item, once, in one group (so
|
|
537
|
+
* `labeller_knows` keeps or drops the whole line): a lost part is a requirement nobody judged.
|
|
538
|
+
*/
|
|
539
|
+
interface Part {
|
|
540
|
+
readonly item: Item;
|
|
541
|
+
readonly unit: Unit;
|
|
542
|
+
readonly n: number;
|
|
543
|
+
}
|
|
544
|
+
type Parts = readonly [Part, ...Part[]];
|
|
545
|
+
/** The items of each line, in items.json order; a line's list is never empty. */
|
|
546
|
+
const linesOf = (cfg: Config): ReadonlyMap<string, Parts> =>
|
|
547
|
+
cfg.items.reduce((byLine, item, n) => {
|
|
548
|
+
if (item.unit === undefined) return byLine;
|
|
549
|
+
const part: Part = { item, unit: item.unit, n };
|
|
550
|
+
const seen = byLine.get(item.unit.id);
|
|
551
|
+
return byLine.set(
|
|
552
|
+
item.unit.id,
|
|
553
|
+
seen ? [seen[0], ...seen.slice(1), part] : [part],
|
|
554
|
+
);
|
|
555
|
+
}, new Map<string, Parts>());
|
|
556
|
+
/** One part against the first part of its line: same count, in range, not repeated, same group. */
|
|
557
|
+
function checkPart({ item, unit, n }: Part, parts: Parts): void {
|
|
558
|
+
const [first] = parts;
|
|
559
|
+
const at = `items[${String(n)}]`;
|
|
560
|
+
if (unit.text !== first.unit.text)
|
|
561
|
+
fail(
|
|
562
|
+
`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`,
|
|
563
|
+
);
|
|
564
|
+
if (unit.of !== first.unit.of)
|
|
565
|
+
fail(
|
|
566
|
+
`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`,
|
|
567
|
+
);
|
|
568
|
+
if (unit.part < 1 || unit.part > unit.of)
|
|
569
|
+
fail(
|
|
570
|
+
`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
|
|
571
|
+
);
|
|
572
|
+
const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
|
|
573
|
+
if (earlier !== undefined)
|
|
574
|
+
fail(
|
|
575
|
+
`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`,
|
|
576
|
+
);
|
|
577
|
+
if (item.group !== first.item.group)
|
|
578
|
+
fail(
|
|
579
|
+
`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
function checkUnits(cfg: Config): void {
|
|
583
|
+
for (const [line, parts] of linesOf(cfg)) {
|
|
584
|
+
parts.forEach((p) => {
|
|
585
|
+
checkPart(p, parts);
|
|
586
|
+
});
|
|
587
|
+
const [first] = parts;
|
|
588
|
+
const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find(
|
|
589
|
+
(k) => !parts.some((p) => p.unit.part === k),
|
|
590
|
+
);
|
|
591
|
+
if (missing !== undefined)
|
|
592
|
+
fail(
|
|
593
|
+
`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
484
597
|
function checkItemFields(cfg: Config): void {
|
|
485
598
|
const sids: string[] = [];
|
|
486
599
|
cfg.items.forEach((item, n) => {
|
|
@@ -503,6 +616,11 @@ function checkItemFields(cfg: Config): void {
|
|
|
503
616
|
);
|
|
504
617
|
});
|
|
505
618
|
}
|
|
619
|
+
/** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
|
|
620
|
+
const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
|
|
621
|
+
...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
|
|
622
|
+
[UNIT_FIELD, item.unit?.text ?? ""],
|
|
623
|
+
];
|
|
506
624
|
const escapeRegExp = (s: string): string =>
|
|
507
625
|
s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
508
626
|
/**
|
|
@@ -519,7 +637,7 @@ function checkNoSourceShown(cfg: Config): void {
|
|
|
519
637
|
`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`,
|
|
520
638
|
"u",
|
|
521
639
|
);
|
|
522
|
-
const field =
|
|
640
|
+
const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
|
|
523
641
|
if (field !== undefined) {
|
|
524
642
|
fail(
|
|
525
643
|
`the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`,
|
|
@@ -586,13 +704,27 @@ function assignIds(
|
|
|
586
704
|
}
|
|
587
705
|
|
|
588
706
|
// ── a build, short of the page ──────────────────────────────────────────────────────────────────
|
|
589
|
-
|
|
590
|
-
|
|
707
|
+
/** A unit as the page shows it: the part and the line, without the line's private id. */
|
|
708
|
+
type ShownUnit = Omit<Unit, "id">;
|
|
709
|
+
const shownFields = (
|
|
710
|
+
item: Item,
|
|
711
|
+
): Readonly<Record<string, string | ShownUnit>> => ({
|
|
712
|
+
...Object.fromEntries(
|
|
591
713
|
PAGE_FIELDS.flatMap((f) => {
|
|
592
714
|
const v = item[f];
|
|
593
715
|
return v === undefined ? [] : [[f, v]];
|
|
594
716
|
}),
|
|
595
|
-
)
|
|
717
|
+
),
|
|
718
|
+
...(item.unit === undefined
|
|
719
|
+
? {}
|
|
720
|
+
: {
|
|
721
|
+
[UNIT_FIELD]: {
|
|
722
|
+
text: item.unit.text,
|
|
723
|
+
part: item.unit.part,
|
|
724
|
+
of: item.unit.of,
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
});
|
|
596
728
|
|
|
597
729
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
598
730
|
function itemsOnPage(cfg: Config, keyPath: string) {
|
|
@@ -618,10 +750,11 @@ function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
|
|
|
618
750
|
title: cfg.title,
|
|
619
751
|
intro: cfg.intro ?? "",
|
|
620
752
|
groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
|
|
621
|
-
familiarity_question:
|
|
622
|
-
cfg.familiarity_question ?? "How well do you know {group}?"
|
|
623
|
-
|
|
624
|
-
|
|
753
|
+
familiarity_question: asksFamiliarity(cfg)
|
|
754
|
+
? (cfg.familiarity_question ?? "How well do you know {group}?")
|
|
755
|
+
: "",
|
|
756
|
+
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
757
|
+
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
625
758
|
questions: cfg.questions,
|
|
626
759
|
practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
|
|
627
760
|
items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
|
|
@@ -675,7 +808,7 @@ function keyOf(
|
|
|
675
808
|
questions: cfg.questions.map((q) => q.id),
|
|
676
809
|
// What counts as an answer: the same lists the page was given (`allowedValues`). `export`
|
|
677
810
|
// checks every stored answer and familiarity against them, so the build's options decide.
|
|
678
|
-
allowed: allowedValues(cfg.questions),
|
|
811
|
+
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
679
812
|
// The practice is scored against this at export; its answers never go into the labels.
|
|
680
813
|
practice: {
|
|
681
814
|
round: practiceRound(cfg),
|
|
@@ -702,6 +835,7 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
702
835
|
assertConfig(cfg);
|
|
703
836
|
checkConfig(cfg);
|
|
704
837
|
checkItemFields(cfg);
|
|
838
|
+
checkUnits(cfg);
|
|
705
839
|
checkNoSourceShown(cfg);
|
|
706
840
|
mkdirSync(outDir, { recursive: true });
|
|
707
841
|
const keyPath = join(outDir, "key.json");
|
|
@@ -931,6 +1065,17 @@ function checkDump(dump: Json, key: Key): asserts dump is JsonObject {
|
|
|
931
1065
|
}
|
|
932
1066
|
}
|
|
933
1067
|
|
|
1068
|
+
/** A page built with `familiarity: false` asks no group anything: a group document came from elsewhere. */
|
|
1069
|
+
function checkNoFamiliarity(dump: JsonObject, allowed: Allowed): void {
|
|
1070
|
+
const groups = isObject(dump["groups"]) ? dump["groups"] : {};
|
|
1071
|
+
const [first] = Object.keys(groups).sort(byText);
|
|
1072
|
+
if (allowed.familiarity.length === 0 && first !== undefined) {
|
|
1073
|
+
fail(
|
|
1074
|
+
`groups/${first}: familiarity is off for this page (items.json \`familiarity: false\`), so no group document is expected — take it out of the dump`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
934
1079
|
// ── export ────────────────────────────────────────────────────────────────────────────────────
|
|
935
1080
|
interface Allowed {
|
|
936
1081
|
readonly questions: Readonly<Record<string, readonly string[]>>;
|
|
@@ -1165,11 +1310,13 @@ function labelsOf(dump: JsonObject, ctx: Ctx, day: string) {
|
|
|
1165
1310
|
const items = onPage.map(([nid, meta]) =>
|
|
1166
1311
|
labelOf(nid, meta, docOf(dump["answers"], nid), ctx),
|
|
1167
1312
|
);
|
|
1168
|
-
const groups =
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1313
|
+
const groups = ctx.allowed.familiarity.length
|
|
1314
|
+
? groupsOf(
|
|
1315
|
+
[...new Set(onPage.map(([, m]) => m.group))],
|
|
1316
|
+
dump["groups"],
|
|
1317
|
+
ctx.allowed,
|
|
1318
|
+
)
|
|
1319
|
+
: {};
|
|
1173
1320
|
return {
|
|
1174
1321
|
study: ctx.key.study,
|
|
1175
1322
|
exported: day,
|
|
@@ -1197,6 +1344,7 @@ export function exportLabels(
|
|
|
1197
1344
|
const allowed = key.allowed;
|
|
1198
1345
|
assertAllowed(allowed, key, keyPath);
|
|
1199
1346
|
checkDump(dump, key);
|
|
1347
|
+
checkNoFamiliarity(dump, allowed);
|
|
1200
1348
|
const out = labelsOf(dump, { key, allowed }, day);
|
|
1201
1349
|
mkdirSync(outDir, { recursive: true });
|
|
1202
1350
|
const path = join(outDir, `${key.study}-labels-${day}.json`);
|
|
@@ -411,3 +411,63 @@ test("🔴 without the store the page is read-only, and the real items stay read
|
|
|
411
411
|
assert.match(shown, /cannot be saved in this view/);
|
|
412
412
|
assert.match(shown, /G001/, "a real item is shown read-only");
|
|
413
413
|
});
|
|
414
|
+
|
|
415
|
+
test("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
|
|
416
|
+
const line = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
417
|
+
const { page } = build(
|
|
418
|
+
setup(
|
|
419
|
+
"unit-card",
|
|
420
|
+
config({
|
|
421
|
+
items: ITEMS.map((item, n) =>
|
|
422
|
+
n === 0
|
|
423
|
+
? {
|
|
424
|
+
...item,
|
|
425
|
+
body: "TypeScript only (no JavaScript)",
|
|
426
|
+
unit: { id: "L1", text: line, part: 1, of: 2 },
|
|
427
|
+
}
|
|
428
|
+
: n === 3
|
|
429
|
+
? {
|
|
430
|
+
...item,
|
|
431
|
+
body: "top-level await is allowed",
|
|
432
|
+
unit: { id: "L1", text: line, part: 2, of: 2 },
|
|
433
|
+
}
|
|
434
|
+
: item,
|
|
435
|
+
),
|
|
436
|
+
}),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
const shown = await showPage(page, {}, {});
|
|
440
|
+
assert.match(
|
|
441
|
+
shown,
|
|
442
|
+
/From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
|
|
443
|
+
shown,
|
|
444
|
+
);
|
|
445
|
+
assert.match(shown, /From the line \(requirement 2 of 2\):/);
|
|
446
|
+
assert.equal(
|
|
447
|
+
occurrences(shown, "From the line"),
|
|
448
|
+
2,
|
|
449
|
+
"only the two parts carry the line",
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("🔴 familiarity off: no group asks how well it is known, and a group is done when its items are", async () => {
|
|
454
|
+
const { page, key } = build(
|
|
455
|
+
setup("familiarity-off-page", approval({ familiarity: false })),
|
|
456
|
+
);
|
|
457
|
+
const practice = Object.fromEntries(
|
|
458
|
+
APPROVAL_PRACTICE.map((p, n) => [
|
|
459
|
+
`r1-P${String(n + 1)}`,
|
|
460
|
+
{ answers: p.expected, source: "page" },
|
|
461
|
+
]),
|
|
462
|
+
);
|
|
463
|
+
const answers = Object.fromEntries(
|
|
464
|
+
Object.keys(key.ids).map((id) => [
|
|
465
|
+
id,
|
|
466
|
+
{ answers: { keep: "keep" }, source: "page" },
|
|
467
|
+
]),
|
|
468
|
+
);
|
|
469
|
+
const shown = await showPage(page, answers, {}, practice);
|
|
470
|
+
assert.doesNotMatch(shown, /How well/);
|
|
471
|
+
assert.doesNotMatch(shown, /how well you know it/);
|
|
472
|
+
assert.match(shown, /Nothing left/, shown);
|
|
473
|
+
});
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* "seed": 7, fixes the order; change it and every id changes
|
|
41
41
|
* "groups": {"python": "Python"},
|
|
42
42
|
* "labeller_knows": ["python"], optional: items of other groups are not put on the page
|
|
43
|
+
* "familiarity": false, optional: no "how well do you know" question (approval page)
|
|
43
44
|
* "familiarity_question": "How well do you know {group}?", optional
|
|
44
45
|
* "questions": [{"id": "label", "text": "Is this correct?", "short": "correct?",
|
|
45
46
|
* "options": [{"value": "yes", "label": "Yes", "example": "one real case",
|
|
@@ -52,7 +53,11 @@
|
|
|
52
53
|
* "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
|
|
53
54
|
* "practice_round": 1, optional; raise it after rewording, the practice reopens
|
|
54
55
|
* "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
|
|
55
|
-
* "context": "…"
|
|
56
|
+
* "context": "…",
|
|
57
|
+
* "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
|
|
58
|
+
* unit: optional; one requirement of a line that holds
|
|
59
|
+
* several. Every part of the line is an item, in one group;
|
|
60
|
+
* id (never shown) tells two places with the same words apart.
|
|
56
61
|
* }
|
|
57
62
|
*
|
|
58
63
|
* TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
|