paperlint 13.1.0 → 13.2.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/CLAUDE.md +9 -1
- package/dist/adapters/git/index.d.ts.map +1 -1
- package/dist/adapters/git/index.js +13 -0
- package/dist/adapters/git/index.js.map +1 -1
- package/dist/adapters/study-registry/index.d.ts +142 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +160 -1
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +43 -0
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +45 -2
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/study-gates.d.ts +96 -0
- package/dist/domain/study-gates.d.ts.map +1 -0
- package/dist/domain/study-gates.js +56 -0
- package/dist/domain/study-gates.js.map +1 -0
- package/dist/domain/study-mode.d.ts +6 -0
- package/dist/domain/study-mode.d.ts.map +1 -1
- package/dist/domain/study-mode.js.map +1 -1
- package/dist/domain/study-registry.d.ts +24 -4
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +39 -2
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/domain/study-result.d.ts +151 -0
- package/dist/domain/study-result.d.ts.map +1 -0
- package/dist/domain/study-result.js +175 -0
- package/dist/domain/study-result.js.map +1 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +37 -3
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/ports/git-history.d.ts +12 -0
- package/dist/ports/git-history.d.ts.map +1 -1
- package/dist/ports/study-registry-codec.d.ts +3 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
- package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
- package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
- package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
- package/dist/study-modes.d.ts +3 -0
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +8 -1
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +1 -0
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-result-rule.d.ts +12 -0
- package/dist/study-result-rule.d.ts.map +1 -0
- package/dist/study-result-rule.js +87 -0
- package/dist/study-result-rule.js.map +1 -0
- package/docs/design/study-gates.md +208 -0
- package/docs/rules/repro/outputs-before-decision.md +8 -0
- package/docs/rules/repro/study-registered.md +38 -15
- package/docs/rules/repro/study-result.md +100 -0
- package/docs/rules.md +1 -0
- package/package.json +2 -1
- package/schemas/plan.v2.schema.json +341 -0
- package/skills/build-label-page/SKILL.md +85 -54
- package/skills/build-label-page/SKILL.md.spec.ts +85 -53
- package/skills/build-label-page/build-label-page.test.ts +10 -941
- package/skills/build-label-page/references/checklist.md +45 -3
- package/skills/build-label-page/references/closed-choice-generator.md +88 -0
- package/skills/build-label-page/scripts/label-page-cli.js +72 -0
- package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
- package/skills/build-label-page/scripts/label-page-commands.js +14 -0
- package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
- package/skills/build-label-page/scripts/label-page-core.js +850 -0
- package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
- package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
- package/skills/build-label-page/scripts/label-page-html.js +33 -0
- package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
- package/skills/build-label-page/scripts/label-page-html.ts +43 -0
- package/skills/build-label-page/scripts/label-page.js +70 -0
- package/skills/build-label-page/scripts/label-page.test.ts +71 -0
- package/skills/build-label-page/scripts/label-page.ts +71 -0
- package/skills/build-label-page/scripts/page-template.html +513 -0
- package/src/adapters/git/index.test.ts +33 -0
- package/src/adapters/git/index.ts +19 -0
- package/src/adapters/study-registry/gates.test.ts +181 -0
- package/src/adapters/study-registry/index.ts +219 -1
- package/src/adapters/study-registry/mode.test.ts +6 -0
- package/src/cli.ts +3 -0
- package/src/domain/decide-first.test.ts +131 -0
- package/src/domain/decide-first.ts +92 -2
- package/src/domain/study-gates.ts +125 -0
- package/src/domain/study-measurements.test.ts +71 -0
- package/src/domain/study-mode.ts +6 -0
- package/src/domain/study-registry.ts +64 -2
- package/src/domain/study-result.test.ts +300 -0
- package/src/domain/study-result.ts +358 -0
- package/src/outputs-before-decision-rule.test.ts +115 -2
- package/src/outputs-before-decision-rule.ts +50 -2
- package/src/ports/git-history.ts +12 -0
- package/src/ports/study-registry-codec.ts +3 -0
- package/src/rule-cases.test.ts +74 -0
- package/src/studies.test.ts +3 -0
- package/src/study-modes.ts +10 -1
- package/src/study-registry-rule.ts +1 -0
- package/src/study-result-rule.test.ts +281 -0
- package/src/study-result-rule.ts +123 -0
- package/skills/build-label-page/references/example-generator.py +0 -803
|
@@ -1,959 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* build-label-page — the free tier
|
|
3
|
-
* the
|
|
4
|
-
* No model, no network, no browser.
|
|
5
|
-
*
|
|
6
|
-
* 🔴 WHAT IS RUN, NOT READ: the generator is executed on a small items file and its output is
|
|
7
|
-
* inspected. A checklist that says "the build refuses a field outside the allow-list" is a claim
|
|
8
|
-
* about the code, and the only check that holds is to hand the code such a field.
|
|
2
|
+
* build-label-page — the free tier of the SKILL itself: it passes the shared per-skill checks, and its
|
|
3
|
+
* checklist has the eight rules the skill names. The generator is tested beside it, in scripts/.
|
|
4
|
+
* No model, no network, no browser.
|
|
9
5
|
*/
|
|
10
6
|
import assert from "node:assert/strict";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
mkdtempSync,
|
|
15
|
-
readFileSync,
|
|
16
|
-
realpathSync,
|
|
17
|
-
rmSync,
|
|
18
|
-
writeFileSync,
|
|
19
|
-
} from "node:fs";
|
|
20
|
-
import { tmpdir } from "node:os";
|
|
21
|
-
import { dirname, join } from "node:path";
|
|
22
|
-
import { fileURLToPath } from "node:url";
|
|
23
|
-
import { runInNewContext } from "node:vm";
|
|
24
|
-
import { afterAll, test } from "vitest";
|
|
25
|
-
import { z } from "zod";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { test } from "vitest";
|
|
26
10
|
import { checkSkill } from "../../lib/skill-checks.mjs";
|
|
27
11
|
|
|
28
|
-
const HERE =
|
|
29
|
-
const GENERATOR = join(HERE, "references", "example-generator.py");
|
|
30
|
-
const WORK = realpathSync(mkdtempSync(join(tmpdir(), "label-page-")));
|
|
31
|
-
afterAll(() => {
|
|
32
|
-
rmSync(WORK, { recursive: true, force: true });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
interface Item {
|
|
36
|
-
readonly source_id: string;
|
|
37
|
-
readonly group: string;
|
|
38
|
-
readonly body: string;
|
|
39
|
-
readonly [field: string]: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const QUESTIONS = [
|
|
43
|
-
{
|
|
44
|
-
id: "label",
|
|
45
|
-
text: "Is this correct?",
|
|
46
|
-
short: "correct?",
|
|
47
|
-
options: [
|
|
48
|
-
{ value: "yes", label: "Yes" },
|
|
49
|
-
{ value: "no", label: "No" },
|
|
50
|
-
],
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
id: "boundary",
|
|
54
|
-
text: "Clear-cut or a real boundary case?",
|
|
55
|
-
short: "boundary",
|
|
56
|
-
options: [
|
|
57
|
-
{ value: "clear", label: "Clear-cut" },
|
|
58
|
-
{ value: "boundary", label: "Boundary" },
|
|
59
|
-
],
|
|
60
|
-
},
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
const ITEMS: readonly Item[] = Array.from({ length: 12 }, (_, n) => ({
|
|
64
|
-
source_id: `case-${String(1000 + n)}`,
|
|
65
|
-
group: n % 3 === 0 ? "rust" : n % 2 === 0 ? "go" : "python",
|
|
66
|
-
body: `Item number ${String(n)} </script> & text`,
|
|
67
|
-
}));
|
|
68
|
-
|
|
69
|
-
const config = (
|
|
70
|
-
over: Record<string, unknown> = {},
|
|
71
|
-
): Record<string, unknown> => ({
|
|
72
|
-
study: "demo-gold",
|
|
73
|
-
title: "Label these",
|
|
74
|
-
seed: 7,
|
|
75
|
-
groups: { python: "Python", go: "Go", rust: "Rust" },
|
|
76
|
-
questions: QUESTIONS,
|
|
77
|
-
items: ITEMS,
|
|
78
|
-
...over,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
/** A fresh directory with `items.json` written into it. */
|
|
82
|
-
function setup(name: string, cfg: Record<string, unknown>): string {
|
|
83
|
-
const dir = join(WORK, name);
|
|
84
|
-
mkdirSync(dir, { recursive: true });
|
|
85
|
-
writeFileSync(join(dir, "items.json"), JSON.stringify(cfg));
|
|
86
|
-
return dir;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Run the generator; the exit status and both streams, never a throw. */
|
|
90
|
-
function run(args: readonly string[]): {
|
|
91
|
-
status: number | null;
|
|
92
|
-
out: string;
|
|
93
|
-
} {
|
|
94
|
-
const r = spawnSync("python3", [GENERATOR, ...args], { encoding: "utf8" });
|
|
95
|
-
assert.equal(r.error, undefined, `python3 is needed to run the generator`);
|
|
96
|
-
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function build(dir: string): { page: string; key: KeyFile } {
|
|
100
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
101
|
-
assert.equal(r.status, 0, r.out);
|
|
102
|
-
return {
|
|
103
|
-
page: readFileSync(join(dir, "out", "page.html"), "utf8"),
|
|
104
|
-
key: Key.parse(
|
|
105
|
-
JSON.parse(readFileSync(join(dir, "out", "key.json"), "utf8")),
|
|
106
|
-
),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const Allowed = z.object({
|
|
111
|
-
questions: z.record(z.string(), z.array(z.string())),
|
|
112
|
-
familiarity: z.array(z.string()),
|
|
113
|
-
});
|
|
114
|
-
const Key = z.object({
|
|
115
|
-
seed: z.number(),
|
|
116
|
-
questions: z.array(z.string()),
|
|
117
|
-
allowed: Allowed,
|
|
118
|
-
ids: z.record(
|
|
119
|
-
z.string(),
|
|
120
|
-
z.object({
|
|
121
|
-
source_id: z.string(),
|
|
122
|
-
group: z.string(),
|
|
123
|
-
on_page: z.boolean(),
|
|
124
|
-
}),
|
|
125
|
-
),
|
|
126
|
-
});
|
|
127
|
-
type KeyFile = z.infer<typeof Key>;
|
|
128
|
-
|
|
129
|
-
const Exported = z.object({
|
|
130
|
-
complete_items: z.number(),
|
|
131
|
-
total_items: z.number(),
|
|
132
|
-
groups: z.record(
|
|
133
|
-
z.string(),
|
|
134
|
-
z.object({
|
|
135
|
-
familiarity: z.string().nullable(),
|
|
136
|
-
invalid_familiarity: z.unknown(),
|
|
137
|
-
}),
|
|
138
|
-
),
|
|
139
|
-
items: z.array(
|
|
140
|
-
z.object({
|
|
141
|
-
id: z.string(),
|
|
142
|
-
source: z.string().nullable(),
|
|
143
|
-
complete: z.boolean(),
|
|
144
|
-
source_id: z.string(),
|
|
145
|
-
answers: z.record(z.string(), z.string().nullable()),
|
|
146
|
-
invalid_answers: z.record(z.string(), z.unknown()),
|
|
147
|
-
}),
|
|
148
|
-
),
|
|
149
|
-
});
|
|
12
|
+
const HERE = import.meta.dirname;
|
|
150
13
|
|
|
151
14
|
test("the shared per-skill checks pass", async () => {
|
|
152
15
|
await checkSkill("build-label-page");
|
|
153
16
|
});
|
|
154
17
|
|
|
155
|
-
test("the checklist has the
|
|
18
|
+
test("the checklist has the eight rules, numbered, and the skill names eight", () => {
|
|
156
19
|
const checklist = readFileSync(
|
|
157
20
|
join(HERE, "references", "checklist.md"),
|
|
158
21
|
"utf8",
|
|
159
22
|
);
|
|
160
23
|
const numbers = [...checklist.matchAll(/^## (\d+)\. /gm)].map((m) => m[1]);
|
|
161
|
-
assert.deepEqual(numbers, ["1", "2", "3", "4", "5", "6", "7"]);
|
|
24
|
+
assert.deepEqual(numbers, ["1", "2", "3", "4", "5", "6", "7", "8"]);
|
|
162
25
|
const skill = readFileSync(join(HERE, "SKILL.md"), "utf8");
|
|
163
26
|
const listed = [...skill.matchAll(/^(\d)\. \*\*/gm)].map((m) => m[1]);
|
|
164
|
-
assert.deepEqual(listed.slice(0,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("the page: neutral ids, the source ids nowhere, and the order is fixed by the seed", () => {
|
|
168
|
-
const dir = setup("order", config());
|
|
169
|
-
const { page, key } = build(dir);
|
|
170
|
-
assert.deepEqual(Object.keys(key.ids).slice(0, 3), ["G001", "G002", "G003"]);
|
|
171
|
-
for (const item of ITEMS) {
|
|
172
|
-
assert.ok(
|
|
173
|
-
!page.includes(item.source_id),
|
|
174
|
-
`${item.source_id} is on the page`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
const again = build(dir);
|
|
178
|
-
assert.equal(again.page, page, "the same input must give the same page");
|
|
179
|
-
const order = (k: KeyFile): string =>
|
|
180
|
-
Object.values(k.ids)
|
|
181
|
-
.map((v) => v.source_id)
|
|
182
|
-
.join(",");
|
|
183
|
-
const reseeded = build(setup("order-seed", config({ seed: 8 })));
|
|
184
|
-
assert.notEqual(
|
|
185
|
-
order(reseeded.key),
|
|
186
|
-
order(key),
|
|
187
|
-
"the seed must change the order",
|
|
188
|
-
);
|
|
189
|
-
assert.notEqual(
|
|
190
|
-
order(key),
|
|
191
|
-
ITEMS.map((i) => i.source_id).join(","),
|
|
192
|
-
"the order must not be the input order",
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("🔴 the leak guard: a field outside the allow-list stops the build and is named", () => {
|
|
197
|
-
for (const field of ["model_answer", "verdict", "source", "difficulty"]) {
|
|
198
|
-
const items = [{ ...ITEMS[0], [field]: "x" }, ...ITEMS.slice(1)];
|
|
199
|
-
const dir = setup(`leak-${field}`, config({ items }));
|
|
200
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
201
|
-
assert.notEqual(r.status, 0, field);
|
|
202
|
-
assert.match(r.out, new RegExp(`REFUSED.*${field}`), r.out);
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("🔴 the leak guard: a source id typed into a visible text stops the build", () => {
|
|
207
|
-
const items = [
|
|
208
|
-
{ ...ITEMS[0], body: `see case-1005 for the same` },
|
|
209
|
-
...ITEMS.slice(1),
|
|
210
|
-
];
|
|
211
|
-
const dir = setup("leak-text", config({ items }));
|
|
212
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
213
|
-
assert.notEqual(r.status, 0);
|
|
214
|
-
assert.match(r.out, /case-1005/);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("only the groups the labeller knows reach the page, and only they get ids", () => {
|
|
218
|
-
const { page, key } = build(
|
|
219
|
-
setup("knows", config({ labeller_knows: ["python", "go"] })),
|
|
220
|
-
);
|
|
221
|
-
const onPage = Object.values(key.ids).filter((v) => v.on_page);
|
|
222
|
-
assert.ok(onPage.length > 0 && onPage.length < ITEMS.length);
|
|
223
|
-
assert.ok(onPage.every((v) => v.group !== "rust"));
|
|
224
|
-
assert.equal(Object.keys(key.ids).length, onPage.length);
|
|
225
|
-
assert.ok(!page.includes('"id":"Rust"') && !page.includes('"label":"Rust"'));
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("rebuilding keeps every id; a new item takes the next one", () => {
|
|
229
|
-
const dir = setup("stable", config());
|
|
230
|
-
const first = build(dir).key;
|
|
231
|
-
const more = [
|
|
232
|
-
...ITEMS,
|
|
233
|
-
{ source_id: "case-2000", group: "go", body: "late addition" },
|
|
234
|
-
];
|
|
235
|
-
writeFileSync(
|
|
236
|
-
join(dir, "items.json"),
|
|
237
|
-
JSON.stringify(config({ items: more })),
|
|
238
|
-
);
|
|
239
|
-
const second = build(dir).key;
|
|
240
|
-
for (const [id, v] of Object.entries(first.ids)) {
|
|
241
|
-
assert.equal(second.ids[id]?.source_id, v.source_id, id);
|
|
242
|
-
}
|
|
243
|
-
assert.equal(second.ids["G013"]?.source_id, "case-2000");
|
|
244
|
-
const wrongSeed = setup("stable", config({ seed: 99, items: more }));
|
|
245
|
-
assert.notEqual(
|
|
246
|
-
run(["build", join(wrongSeed, "items.json"), join(wrongSeed, "out")])
|
|
247
|
-
.status,
|
|
248
|
-
0,
|
|
249
|
-
);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("🔴 build refuses an option without a usable value or label, and names the question and the option", () => {
|
|
253
|
-
const bad: readonly (readonly [string, unknown])[] = [
|
|
254
|
-
["no-value", { label: "Maybe" }],
|
|
255
|
-
["null-value", { value: null, label: "Maybe" }],
|
|
256
|
-
["empty-value", { value: "", label: "Maybe" }],
|
|
257
|
-
["no-label", { value: "maybe" }],
|
|
258
|
-
["empty-label", { value: "maybe", label: " " }],
|
|
259
|
-
["not-an-object", "maybe"],
|
|
260
|
-
// a different value behind a button that reads the same: the answer cannot be interpreted
|
|
261
|
-
[
|
|
262
|
-
"same-label",
|
|
263
|
-
{ value: "maybe", label: ` ${QUESTIONS[1]?.options[0]?.label ?? ""} ` },
|
|
264
|
-
],
|
|
265
|
-
];
|
|
266
|
-
for (const [name, option] of bad) {
|
|
267
|
-
const questions = [
|
|
268
|
-
QUESTIONS[0],
|
|
269
|
-
{
|
|
270
|
-
...QUESTIONS[1],
|
|
271
|
-
options: [...(QUESTIONS[1]?.options ?? []), option],
|
|
272
|
-
},
|
|
273
|
-
];
|
|
274
|
-
const dir = setup(`option-${name}`, config({ questions }));
|
|
275
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
276
|
-
assert.notEqual(r.status, 0, `${name} must stop the build`);
|
|
277
|
-
assert.match(r.out, /REFUSED.*questions\[1\]\.options\[2\]/s, r.out);
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test("🔴 export does not count an answer outside the question's allowed values; it marks the item incomplete and names it", () => {
|
|
282
|
-
const dir = setup("export-typo", config());
|
|
283
|
-
const { key } = build(dir);
|
|
284
|
-
const [a, b, c] = Object.keys(key.ids);
|
|
285
|
-
assert.ok(a !== undefined && b !== undefined && c !== undefined);
|
|
286
|
-
const dump = {
|
|
287
|
-
answers: {
|
|
288
|
-
[a]: { answers: { label: "yes", boundary: "clear" }, source: "page" },
|
|
289
|
-
// a typo from a chat answer: "yse" is not one of yes/no
|
|
290
|
-
[b]: { answers: { label: "yse", boundary: "clear" }, source: "chat" },
|
|
291
|
-
// options changed between builds: "maybe" is not offered any more
|
|
292
|
-
[c]: { answers: { label: "no", boundary: "maybe" }, source: "page" },
|
|
293
|
-
},
|
|
294
|
-
};
|
|
295
|
-
writeFileSync(join(dir, "dump.json"), JSON.stringify(dump));
|
|
296
|
-
const r = run([
|
|
297
|
-
"export",
|
|
298
|
-
join(dir, "dump.json"),
|
|
299
|
-
join(dir, "out", "key.json"),
|
|
300
|
-
join(dir, "data"),
|
|
301
|
-
"--date",
|
|
302
|
-
"2026-01-02",
|
|
303
|
-
]);
|
|
304
|
-
assert.equal(r.status, 0, r.out);
|
|
305
|
-
assert.match(r.out, new RegExp(`invalid ${b}: label='yse'`), r.out);
|
|
306
|
-
assert.match(r.out, new RegExp(`invalid ${c}: boundary='maybe'`), r.out);
|
|
307
|
-
const file = Exported.parse(
|
|
308
|
-
JSON.parse(
|
|
309
|
-
readFileSync(
|
|
310
|
-
join(dir, "data", "demo-gold-labels-2026-01-02.json"),
|
|
311
|
-
"utf8",
|
|
312
|
-
),
|
|
313
|
-
),
|
|
314
|
-
);
|
|
315
|
-
assert.equal(file.complete_items, 1, "only the valid item is complete");
|
|
316
|
-
assert.equal(file.items.find((i) => i.id === b)?.complete, false);
|
|
317
|
-
assert.equal(file.items.find((i) => i.id === c)?.complete, false);
|
|
318
|
-
assert.deepEqual(file.items.find((i) => i.id === b)?.invalid_answers, {
|
|
319
|
-
label: "yse",
|
|
320
|
-
});
|
|
321
|
-
assert.equal(file.items.find((i) => i.id === b)?.answers["label"], null);
|
|
322
|
-
assert.equal(file.items.find((i) => i.id === b)?.source, "chat");
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
/** A node of the declared config schema, as `example-generator.py schema` prints it. */
|
|
326
|
-
const Node: z.ZodType<SchemaNode> = z.lazy(() =>
|
|
327
|
-
z.object({
|
|
328
|
-
kind: z.enum(["string", "integer", "map", "array", "object"]),
|
|
329
|
-
required: z.boolean(),
|
|
330
|
-
pattern: z.string().optional(),
|
|
331
|
-
key_pattern: z.string().optional(),
|
|
332
|
-
min: z.number().optional(),
|
|
333
|
-
closed: z.boolean().optional(),
|
|
334
|
-
items: Node.optional(),
|
|
335
|
-
values: Node.optional(),
|
|
336
|
-
fields: z.record(z.string(), Node).optional(),
|
|
337
|
-
}),
|
|
338
|
-
);
|
|
339
|
-
interface SchemaNode {
|
|
340
|
-
readonly kind: "string" | "integer" | "map" | "array" | "object";
|
|
341
|
-
readonly required: boolean;
|
|
342
|
-
readonly pattern?: string | undefined;
|
|
343
|
-
readonly key_pattern?: string | undefined;
|
|
344
|
-
readonly min?: number | undefined;
|
|
345
|
-
readonly closed?: boolean | undefined;
|
|
346
|
-
readonly items?: SchemaNode | undefined;
|
|
347
|
-
readonly values?: SchemaNode | undefined;
|
|
348
|
-
readonly fields?: Readonly<Record<string, SchemaNode>> | undefined;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const Doc = z.json();
|
|
352
|
-
type Doc = z.infer<typeof Doc>;
|
|
353
|
-
interface DocObject {
|
|
354
|
-
readonly [key: string]: Doc;
|
|
355
|
-
}
|
|
356
|
-
const isObject = (v: Doc | undefined): v is DocObject =>
|
|
357
|
-
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
358
|
-
|
|
359
|
-
/** A config with EVERY optional field present, so every schema path has a value to spoil. */
|
|
360
|
-
const FULL: DocObject = (() => {
|
|
361
|
-
const doc = Doc.parse(
|
|
362
|
-
config({
|
|
363
|
-
intro: "Label these.",
|
|
364
|
-
labeller_knows: ["python", "go", "rust"],
|
|
365
|
-
familiarity_question: "How well do you know {group}?",
|
|
366
|
-
items: ITEMS.map((i, n) =>
|
|
367
|
-
n === 0 ? { ...i, title: "T", code: "c = 1", context: "ctx" } : i,
|
|
368
|
-
),
|
|
369
|
-
}),
|
|
370
|
-
);
|
|
371
|
-
assert.ok(isObject(doc));
|
|
372
|
-
return doc;
|
|
373
|
-
})();
|
|
374
|
-
|
|
375
|
-
interface Leaf {
|
|
376
|
-
readonly path: string;
|
|
377
|
-
readonly node: SchemaNode;
|
|
378
|
-
/** An array entry or a map value: it is not "a field", so it cannot be missing. */
|
|
379
|
-
readonly member: boolean;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** The leaves one level down from `node`, whose value in the config is `value`. */
|
|
383
|
-
function children(
|
|
384
|
-
node: SchemaNode,
|
|
385
|
-
value: Doc | undefined,
|
|
386
|
-
path: string,
|
|
387
|
-
): readonly Leaf[] {
|
|
388
|
-
if (isObject(value)) {
|
|
389
|
-
return node.kind === "object"
|
|
390
|
-
? Object.entries(node.fields ?? {}).flatMap(([name, sub]) =>
|
|
391
|
-
leaves(
|
|
392
|
-
sub,
|
|
393
|
-
value[name],
|
|
394
|
-
path === "" ? name : `${path}.${name}`,
|
|
395
|
-
false,
|
|
396
|
-
),
|
|
397
|
-
)
|
|
398
|
-
: mapChildren(node, value, path);
|
|
399
|
-
}
|
|
400
|
-
if (Array.isArray(value) && node.kind === "array" && node.items) {
|
|
401
|
-
return leaves(node.items, value[0], `${path}[0]`, true);
|
|
402
|
-
}
|
|
403
|
-
return [];
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function mapChildren(
|
|
407
|
-
node: SchemaNode,
|
|
408
|
-
value: DocObject,
|
|
409
|
-
path: string,
|
|
410
|
-
): readonly Leaf[] {
|
|
411
|
-
if (node.kind !== "map" || !node.values) return [];
|
|
412
|
-
const [k, v] = Object.entries(value)[0] ?? ["", undefined];
|
|
413
|
-
return leaves(node.values, v, `${path}.${k}`, true);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/** Every concrete path of `value` under `node`, with the node that governs it. */
|
|
417
|
-
function leaves(
|
|
418
|
-
node: SchemaNode,
|
|
419
|
-
value: Doc | undefined,
|
|
420
|
-
path: string,
|
|
421
|
-
member: boolean,
|
|
422
|
-
): readonly Leaf[] {
|
|
423
|
-
return [{ path, node, member }, ...children(node, value, path)];
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/** `doc` with the value at `steps` replaced, or removed when `bad` is `undefined`. */
|
|
427
|
-
function put(
|
|
428
|
-
doc: Doc,
|
|
429
|
-
steps: readonly (string | number)[],
|
|
430
|
-
bad: Doc | undefined,
|
|
431
|
-
): Doc {
|
|
432
|
-
const [head, ...rest] = steps;
|
|
433
|
-
if (head === undefined) return bad ?? null;
|
|
434
|
-
if (Array.isArray(doc) && typeof head === "number") {
|
|
435
|
-
return doc.map((v, i) => (i === head ? put(v, rest, bad) : v));
|
|
436
|
-
}
|
|
437
|
-
if (isObject(doc) && typeof head === "string") {
|
|
438
|
-
if (rest.length === 0 && bad === undefined) {
|
|
439
|
-
return Object.fromEntries(
|
|
440
|
-
Object.entries(doc).filter(([k]) => k !== head),
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
return { ...doc, [head]: put(doc[head] ?? null, rest, bad) };
|
|
444
|
-
}
|
|
445
|
-
return doc;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const stepsOf = (path: string): readonly (string | number)[] =>
|
|
449
|
-
[...path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)].map((m) =>
|
|
450
|
-
m[2] !== undefined ? Number(m[2]) : (m[1] ?? ""),
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
/** The config with the value at `path` replaced (or removed when `bad` is `undefined`). */
|
|
454
|
-
function spoil(path: string, bad: Doc | undefined): DocObject {
|
|
455
|
-
const doc = put(FULL, stepsOf(path), bad);
|
|
456
|
-
assert.ok(isObject(doc));
|
|
457
|
-
return doc;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const WRONG_TYPES: Readonly<Record<SchemaNode["kind"], readonly Doc[]>> = {
|
|
461
|
-
string: [null, 1, true, ["x"], {}, "", " "],
|
|
462
|
-
integer: [null, "7", true, 7.5, []],
|
|
463
|
-
map: [null, "x", ["x"], 1],
|
|
464
|
-
array: [null, "x", {}, 1],
|
|
465
|
-
object: [null, "x", ["x"], 1],
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
/** Build `doc`; the exit status and output. */
|
|
469
|
-
function tryBuild(name: string, doc: DocObject): ReturnType<typeof run> {
|
|
470
|
-
const dir = setup(name, doc);
|
|
471
|
-
return run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
type BadCase = readonly [label: string, value: Doc | undefined];
|
|
475
|
-
const bad = (label: string, value: Doc | undefined): BadCase => [label, value];
|
|
476
|
-
|
|
477
|
-
/** The values that must be refused at `leaf`: wrong type, null, empty, a malformed id, missing. */
|
|
478
|
-
function badCases({ node, member }: Leaf): readonly BadCase[] {
|
|
479
|
-
const wrongType = WRONG_TYPES[node.kind]
|
|
480
|
-
// an empty list is a legal value where nothing is required of it
|
|
481
|
-
.filter((v) => !(Array.isArray(v) && node.kind === "array"))
|
|
482
|
-
.map((v) => bad(JSON.stringify(v), v));
|
|
483
|
-
const extra: readonly BadCase[] = [
|
|
484
|
-
// `__proto__` matches the id pattern, but the page stores answers in plain JavaScript objects,
|
|
485
|
-
// where assigning it sets the prototype instead of an answer: every tap would save nothing.
|
|
486
|
-
...(node.pattern === undefined
|
|
487
|
-
? []
|
|
488
|
-
: [bad("bad id!", "bad id!"), bad("__proto__", "__proto__")]),
|
|
489
|
-
...(node.kind === "array" && node.min ? [bad("[]", [])] : []),
|
|
490
|
-
...(node.kind === "map" && node.min ? [bad("{}", {})] : []),
|
|
491
|
-
...(node.required && !member ? [bad("(missing)", undefined)] : []),
|
|
492
|
-
];
|
|
493
|
-
return [...wrongType, ...extra];
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/** Spoil `leaf` each way in `badCases`; every one must stop the build and name the path. */
|
|
497
|
-
function expectRefused(leaf: Leaf): void {
|
|
498
|
-
badCases(leaf).forEach(([label, value], i) => {
|
|
499
|
-
const name = `schema-${leaf.path.replace(/\W/g, "_")}-${String(i)}`;
|
|
500
|
-
const r = tryBuild(name, spoil(leaf.path, value));
|
|
501
|
-
const what = `${leaf.path} = ${label}`;
|
|
502
|
-
assert.notEqual(r.status, 0, `${what} must stop the build`);
|
|
503
|
-
assert.ok(
|
|
504
|
-
r.out.includes(`REFUSED: ${leaf.path}`),
|
|
505
|
-
`${what}: the refusal must name the path\n${r.out}`,
|
|
506
|
-
);
|
|
507
|
-
assert.doesNotMatch(r.out, /Traceback/, `${what}\n${r.out}`);
|
|
508
|
-
});
|
|
509
|
-
if (!leaf.node.required && !leaf.member) {
|
|
510
|
-
const r = tryBuild(
|
|
511
|
-
`schema-${leaf.path.replace(/\W/g, "_")}-absent`,
|
|
512
|
-
spoil(leaf.path, undefined),
|
|
513
|
-
);
|
|
514
|
-
assert.equal(r.status, 0, `${leaf.path} is optional: ${r.out}`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const DOCUMENTED_FIELDS = [
|
|
519
|
-
"study",
|
|
520
|
-
"title",
|
|
521
|
-
"intro",
|
|
522
|
-
"seed",
|
|
523
|
-
"groups",
|
|
524
|
-
"labeller_knows",
|
|
525
|
-
"familiarity_question",
|
|
526
|
-
"questions",
|
|
527
|
-
"items",
|
|
528
|
-
];
|
|
529
|
-
|
|
530
|
-
test("🔴 the config is checked against one declared schema: every field, with a wrong type, null or empty, is refused and named by its path", () => {
|
|
531
|
-
const printed = run(["schema"]);
|
|
532
|
-
assert.equal(printed.status, 0, printed.out);
|
|
533
|
-
const schema = Node.parse(JSON.parse(printed.out));
|
|
534
|
-
assert.equal(
|
|
535
|
-
tryBuild("schema-ok", FULL).status,
|
|
536
|
-
0,
|
|
537
|
-
"the full config must build before it is spoiled",
|
|
538
|
-
);
|
|
539
|
-
const all = leaves(schema, FULL, "", false).filter((l) => l.path !== "");
|
|
540
|
-
assert.ok(
|
|
541
|
-
all.length > 25,
|
|
542
|
-
`the schema walk found ${String(all.length)} fields`,
|
|
543
|
-
);
|
|
544
|
-
all.forEach(expectRefused);
|
|
545
|
-
// A key the schema does not know, in each closed object, is refused by path.
|
|
546
|
-
const closed = [{ path: "", node: schema, member: false }, ...all].filter(
|
|
547
|
-
(l) => l.node.kind === "object" && l.node.closed !== false,
|
|
548
|
-
);
|
|
549
|
-
assert.ok(
|
|
550
|
-
closed.length >= 3,
|
|
551
|
-
"the root, a question and an option are closed",
|
|
552
|
-
);
|
|
553
|
-
closed.forEach(({ path }, i) => {
|
|
554
|
-
const where = path === "" ? "zzz" : `${path}.zzz`;
|
|
555
|
-
const r = tryBuild(`schema-unknown-${String(i)}`, spoil(where, 1));
|
|
556
|
-
assert.notEqual(r.status, 0, `${where} must stop the build`);
|
|
557
|
-
assert.ok(r.out.includes(`REFUSED: ${where}`), `${where}\n${r.out}`);
|
|
558
|
-
});
|
|
559
|
-
// Every field the docstring documents has a line in the schema.
|
|
560
|
-
const paths = new Set(all.map((l) => l.path));
|
|
561
|
-
for (const f of DOCUMENTED_FIELDS) {
|
|
562
|
-
assert.ok(paths.has(f), `${f} is in the schema`);
|
|
563
|
-
}
|
|
564
|
-
}, 120_000);
|
|
565
|
-
|
|
566
|
-
test("🔴 a numeric question id is refused at build: JSON keys are strings, so it would be stored under one name and keyed under another", () => {
|
|
567
|
-
const questions = [{ ...QUESTIONS[0], id: 1 }, QUESTIONS[1]];
|
|
568
|
-
const dir = setup("numeric-id", config({ questions }));
|
|
569
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
570
|
-
assert.notEqual(r.status, 0);
|
|
571
|
-
assert.match(r.out, /REFUSED: questions\[0\]\.id/, r.out);
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
test("🔴 a familiarity_question that is not a string is refused at build, not a page that throws when it renders", () => {
|
|
575
|
-
for (const bad of [null, 5, ["x"]]) {
|
|
576
|
-
const dir = setup("fam-q", config({ familiarity_question: bad }));
|
|
577
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
578
|
-
assert.notEqual(r.status, 0, JSON.stringify(bad));
|
|
579
|
-
assert.match(r.out, /REFUSED: familiarity_question/, r.out);
|
|
580
|
-
}
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
/** A DOM just big enough for the page script: nodes with children and text. */
|
|
584
|
-
class FakeNode {
|
|
585
|
-
children: FakeNode[] = [];
|
|
586
|
-
className = "";
|
|
587
|
-
disabled = false;
|
|
588
|
-
type = "";
|
|
589
|
-
style: Record<string, string> = {};
|
|
590
|
-
private own = "";
|
|
591
|
-
get textContent(): string {
|
|
592
|
-
return this.own + this.children.map((c) => c.textContent).join("\n");
|
|
593
|
-
}
|
|
594
|
-
set textContent(v: string) {
|
|
595
|
-
this.own = v;
|
|
596
|
-
this.children = [];
|
|
597
|
-
}
|
|
598
|
-
appendChild(c: FakeNode): FakeNode {
|
|
599
|
-
this.children.push(c);
|
|
600
|
-
return c;
|
|
601
|
-
}
|
|
602
|
-
setAttribute(): void {}
|
|
603
|
-
addEventListener(): void {}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/** Open the built page against a fake store holding `answers` and `groups`; return what it shows. */
|
|
607
|
-
async function showPage(
|
|
608
|
-
page: string,
|
|
609
|
-
answers: Record<string, unknown>,
|
|
610
|
-
groups: Record<string, unknown>,
|
|
611
|
-
): Promise<string> {
|
|
612
|
-
const script = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)][0]?.[1];
|
|
613
|
-
const json =
|
|
614
|
-
/<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
|
|
615
|
-
page,
|
|
616
|
-
)?.[1];
|
|
617
|
-
assert.ok(script !== undefined && json !== undefined);
|
|
618
|
-
const app = new FakeNode();
|
|
619
|
-
const data = new FakeNode();
|
|
620
|
-
data.textContent = json.replace(/<\\\//g, "</");
|
|
621
|
-
const docsOf = (m: Record<string, unknown>) => ({
|
|
622
|
-
docs: Object.entries(m).map(([id, d]) => ({ id, data: () => d })),
|
|
623
|
-
});
|
|
624
|
-
const store = {
|
|
625
|
-
collection: (name: string) => ({
|
|
626
|
-
onSnapshot: (cb: (snap: unknown) => void) => {
|
|
627
|
-
cb(docsOf(name === "answers" ? answers : groups));
|
|
628
|
-
},
|
|
629
|
-
doc: () => ({ set: () => Promise.resolve() }),
|
|
630
|
-
}),
|
|
631
|
-
};
|
|
632
|
-
runInNewContext(script, {
|
|
633
|
-
document: {
|
|
634
|
-
getElementById: (id: string) => (id === "data" ? data : app),
|
|
635
|
-
createElement: () => new FakeNode(),
|
|
636
|
-
},
|
|
637
|
-
window: { claude: { use: () => Promise.resolve(store) } },
|
|
638
|
-
localStorage: { getItem: () => "next", setItem: () => undefined },
|
|
639
|
-
Promise,
|
|
640
|
-
JSON,
|
|
641
|
-
Date,
|
|
642
|
-
Object,
|
|
643
|
-
String,
|
|
644
|
-
isNaN,
|
|
645
|
-
});
|
|
646
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
647
|
-
return app.textContent;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
test("🔴 the page and the export share one definition of a valid answer: a stored value outside the options is unanswered in both", async () => {
|
|
651
|
-
const dir = setup("stale", config());
|
|
652
|
-
const { page, key } = build(dir);
|
|
653
|
-
const [a, b, c] = Object.keys(key.ids);
|
|
654
|
-
assert.ok(a !== undefined && b !== undefined && c !== undefined);
|
|
655
|
-
const answers = {
|
|
656
|
-
[a]: { answers: { label: "yes", boundary: "clear" }, source: "page" },
|
|
657
|
-
// options changed between builds: neither value is offered any more
|
|
658
|
-
[b]: { answers: { label: "yes", boundary: "maybe" }, source: "page" },
|
|
659
|
-
[c]: { answers: { label: "yse", boundary: "clear" }, source: "chat" },
|
|
660
|
-
};
|
|
661
|
-
const groups = {
|
|
662
|
-
python: { familiarity: "somewhatt", source: "chat" },
|
|
663
|
-
go: { familiarity: "well", source: "page" },
|
|
664
|
-
};
|
|
665
|
-
const shown = await showPage(page, answers, groups);
|
|
666
|
-
assert.match(shown, /1 of 12 items complete/, shown);
|
|
667
|
-
assert.match(shown, new RegExp(`${b} needs boundary`), shown);
|
|
668
|
-
assert.match(shown, new RegExp(`${c} needs correct\\?`), shown);
|
|
669
|
-
assert.match(shown, /Python: how well you know it/, shown);
|
|
670
|
-
assert.doesNotMatch(shown, /Go: how well you know it/, shown);
|
|
671
|
-
|
|
672
|
-
writeFileSync(join(dir, "dump.json"), JSON.stringify({ answers, groups }));
|
|
673
|
-
const r = run([
|
|
674
|
-
"export",
|
|
675
|
-
join(dir, "dump.json"),
|
|
676
|
-
join(dir, "out", "key.json"),
|
|
677
|
-
join(dir, "data"),
|
|
678
|
-
"--date",
|
|
679
|
-
"2026-01-02",
|
|
680
|
-
]);
|
|
681
|
-
assert.equal(r.status, 0, r.out);
|
|
682
|
-
const file = Exported.parse(
|
|
683
|
-
JSON.parse(
|
|
684
|
-
readFileSync(
|
|
685
|
-
join(dir, "data", "demo-gold-labels-2026-01-02.json"),
|
|
686
|
-
"utf8",
|
|
687
|
-
),
|
|
688
|
-
),
|
|
689
|
-
);
|
|
690
|
-
assert.equal(
|
|
691
|
-
file.complete_items,
|
|
692
|
-
1,
|
|
693
|
-
"the export counts what the page counts",
|
|
694
|
-
);
|
|
695
|
-
assert.match(r.out, /invalid group python: familiarity='somewhatt'/, r.out);
|
|
696
|
-
const { python, go } = file.groups;
|
|
697
|
-
assert.ok(python !== undefined && go !== undefined);
|
|
698
|
-
assert.equal(python.familiarity, null);
|
|
699
|
-
assert.equal(python.invalid_familiarity, "somewhatt");
|
|
700
|
-
assert.equal(go.familiarity, "well");
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
test("the page's allowed values are the key's: both are written from the same definition", () => {
|
|
704
|
-
const { page, key } = build(setup("one-source", config()));
|
|
705
|
-
const json =
|
|
706
|
-
/<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
|
|
707
|
-
page,
|
|
708
|
-
)?.[1];
|
|
709
|
-
assert.ok(json !== undefined);
|
|
710
|
-
const data = z
|
|
711
|
-
.object({ allowed: Allowed })
|
|
712
|
-
.parse(JSON.parse(json.replace(/<\\\//g, "</")));
|
|
713
|
-
assert.deepEqual(data.allowed, key.allowed);
|
|
714
|
-
assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
test("the page script parses, saves on click, and has nothing that needs scrolling or a toast", () => {
|
|
718
|
-
const { page } = build(setup("page", config()));
|
|
719
|
-
const scripts = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(
|
|
720
|
-
(m) => m[1] ?? "",
|
|
721
|
-
);
|
|
722
|
-
assert.equal(scripts.length, 1);
|
|
723
|
-
assert.doesNotThrow(() =>
|
|
724
|
-
runInNewContext(`(function(){${scripts[0] ?? ""}\n})`),
|
|
725
|
-
);
|
|
726
|
-
assert.match(page, /claude\.use\("db"\)/);
|
|
727
|
-
assert.match(page, /\.set\(/, "an answer is saved by a write");
|
|
728
|
-
assert.doesNotMatch(page, /scrollIntoView|position: *fixed|100vh|alert\(/);
|
|
729
|
-
assert.doesNotMatch(
|
|
730
|
-
page,
|
|
731
|
-
/type="submit"|>Submit</i,
|
|
732
|
-
"there is no submit button",
|
|
733
|
-
);
|
|
734
|
-
assert.match(page, /Next unanswered/);
|
|
735
|
-
assert.match(page, /cannot be saved in this view/);
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("export: one file named by the date, chat answers kept, incomplete items named", () => {
|
|
739
|
-
const dir = setup("export", config());
|
|
740
|
-
const { key } = build(dir);
|
|
741
|
-
const ids = Object.keys(key.ids);
|
|
742
|
-
const [a, b] = ids;
|
|
743
|
-
assert.ok(a !== undefined && b !== undefined);
|
|
744
|
-
const dump = {
|
|
745
|
-
answers: {
|
|
746
|
-
[a]: {
|
|
747
|
-
answers: { label: "yes", boundary: "clear" },
|
|
748
|
-
savedAt: "2026-01-02T03:04:05Z",
|
|
749
|
-
source: "page",
|
|
750
|
-
},
|
|
751
|
-
[b]: {
|
|
752
|
-
answers: { label: "no" },
|
|
753
|
-
savedAt: "2026-01-02T03:05:00Z",
|
|
754
|
-
source: "chat",
|
|
755
|
-
},
|
|
756
|
-
},
|
|
757
|
-
groups: {
|
|
758
|
-
python: {
|
|
759
|
-
familiarity: "well",
|
|
760
|
-
savedAt: "2026-01-02T03:04:00Z",
|
|
761
|
-
source: "page",
|
|
762
|
-
},
|
|
763
|
-
},
|
|
764
|
-
};
|
|
765
|
-
writeFileSync(join(dir, "dump.json"), JSON.stringify(dump));
|
|
766
|
-
const r = run([
|
|
767
|
-
"export",
|
|
768
|
-
join(dir, "dump.json"),
|
|
769
|
-
join(dir, "out", "key.json"),
|
|
770
|
-
join(dir, "data"),
|
|
771
|
-
"--date",
|
|
772
|
-
"2026-01-02",
|
|
773
|
-
]);
|
|
774
|
-
assert.equal(r.status, 0, r.out);
|
|
775
|
-
assert.match(r.out, new RegExp(`incomplete ${b}: missing boundary`));
|
|
776
|
-
const file = Exported.parse(
|
|
777
|
-
JSON.parse(
|
|
778
|
-
readFileSync(
|
|
779
|
-
join(dir, "data", "demo-gold-labels-2026-01-02.json"),
|
|
780
|
-
"utf8",
|
|
781
|
-
),
|
|
782
|
-
),
|
|
783
|
-
);
|
|
784
|
-
assert.equal(file.complete_items, 1);
|
|
785
|
-
assert.equal(file.total_items, ids.length);
|
|
786
|
-
assert.equal(file.items.find((i) => i.id === a)?.source, "page");
|
|
787
|
-
assert.equal(file.items.find((i) => i.id === b)?.source, "chat");
|
|
788
|
-
assert.equal(
|
|
789
|
-
file.items.find((i) => i.id === a)?.source_id,
|
|
790
|
-
key.ids[a]?.source_id,
|
|
791
|
-
);
|
|
792
|
-
});
|
|
793
|
-
|
|
794
|
-
test("export refuses a source it does not know, and an id the key does not know", () => {
|
|
795
|
-
const dir = setup("export-bad", config());
|
|
796
|
-
build(dir);
|
|
797
|
-
const keyPath = join(dir, "out", "key.json");
|
|
798
|
-
writeFileSync(
|
|
799
|
-
join(dir, "d1.json"),
|
|
800
|
-
JSON.stringify({ answers: { G001: { answers: {}, source: "guess" } } }),
|
|
801
|
-
);
|
|
802
|
-
assert.notEqual(
|
|
803
|
-
run(["export", join(dir, "d1.json"), keyPath, join(dir, "data")]).status,
|
|
804
|
-
0,
|
|
805
|
-
);
|
|
806
|
-
writeFileSync(
|
|
807
|
-
join(dir, "d2.json"),
|
|
808
|
-
JSON.stringify({ answers: { G999: { answers: {} } } }),
|
|
809
|
-
);
|
|
810
|
-
assert.notEqual(
|
|
811
|
-
run(["export", join(dir, "d2.json"), keyPath, join(dir, "data")]).status,
|
|
812
|
-
0,
|
|
813
|
-
);
|
|
814
|
-
// A familiarity merged under a misspelled group would be dropped, and its group exported unrated.
|
|
815
|
-
writeFileSync(
|
|
816
|
-
join(dir, "d3.json"),
|
|
817
|
-
JSON.stringify({
|
|
818
|
-
groups: { pyhton: { familiarity: "well", source: "chat" } },
|
|
819
|
-
}),
|
|
820
|
-
);
|
|
821
|
-
const r3 = run(["export", join(dir, "d3.json"), keyPath, join(dir, "data")]);
|
|
822
|
-
assert.notEqual(r3.status, 0, r3.out);
|
|
823
|
-
assert.match(
|
|
824
|
-
r3.out,
|
|
825
|
-
/groups\/pyhton: the key does not know this group/,
|
|
826
|
-
r3.out,
|
|
827
|
-
);
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
test("🔴 export checks every name in the store against the key: a stray one is refused by its path, never dropped", () => {
|
|
831
|
-
const dir = setup("export-stray", config());
|
|
832
|
-
build(dir);
|
|
833
|
-
const keyPath = join(dir, "out", "key.json");
|
|
834
|
-
const doc = { answers: { label: "yes" }, source: "chat" };
|
|
835
|
-
const cases: readonly (readonly [string, unknown])[] = [
|
|
836
|
-
[
|
|
837
|
-
"answers/G001/answers/boundry",
|
|
838
|
-
{ answers: { G001: { ...doc, answers: { boundry: "clear" } } } },
|
|
839
|
-
],
|
|
840
|
-
["answers/G001/note", { answers: { G001: { ...doc, note: "x" } } }],
|
|
841
|
-
[
|
|
842
|
-
"groups/python/familarity",
|
|
843
|
-
{ groups: { python: { familarity: "well", source: "chat" } } },
|
|
844
|
-
],
|
|
845
|
-
["answer: the store has no such collection", { answer: {} }],
|
|
846
|
-
];
|
|
847
|
-
cases.forEach(([path, dump], i) => {
|
|
848
|
-
const file = join(dir, `stray-${String(i)}.json`);
|
|
849
|
-
writeFileSync(file, JSON.stringify(dump));
|
|
850
|
-
const r = run(["export", file, keyPath, join(dir, "data")]);
|
|
851
|
-
assert.notEqual(r.status, 0, `${path}: ${r.out}`);
|
|
852
|
-
assert.ok(r.out.includes(path), `${path} must be named\n${r.out}`);
|
|
853
|
-
});
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
test("🔴 export refuses a stored answer or familiarity without a source, and names it: a missing one is not relabelled as the page's", () => {
|
|
857
|
-
const dir = setup("export-no-source", config());
|
|
858
|
-
build(dir);
|
|
859
|
-
const keyPath = join(dir, "out", "key.json");
|
|
860
|
-
const dumps = {
|
|
861
|
-
"answers/G001": { answers: { G001: { answers: { label: "yes" } } } },
|
|
862
|
-
"groups/python": { groups: { python: { familiarity: "well" } } },
|
|
863
|
-
};
|
|
864
|
-
for (const [path, dump] of Object.entries(dumps)) {
|
|
865
|
-
const file = join(dir, `${path.replace("/", "-")}.json`);
|
|
866
|
-
writeFileSync(file, JSON.stringify(dump));
|
|
867
|
-
const r = run(["export", file, keyPath, join(dir, "data")]);
|
|
868
|
-
assert.notEqual(r.status, 0, r.out);
|
|
869
|
-
assert.match(r.out, new RegExp(`${path}: no source`), r.out);
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
test("🔴 build refuses two groups whose headings read the same: the labeller could not tell which one a familiarity is for", () => {
|
|
874
|
-
const dir = setup(
|
|
875
|
-
"group-labels",
|
|
876
|
-
config({ groups: { python: "Python", python3: " python ", go: "Go" } }),
|
|
877
|
-
);
|
|
878
|
-
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
879
|
-
assert.notEqual(r.status, 0, r.out);
|
|
880
|
-
assert.match(r.out, /groups\.python3/, r.out);
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
test("🔴 export refuses an answer kept for an item a rebuild took off the page, instead of dropping it", () => {
|
|
884
|
-
const dir = setup("off-page", config());
|
|
885
|
-
const before = build(dir).key;
|
|
886
|
-
writeFileSync(
|
|
887
|
-
join(dir, "items.json"),
|
|
888
|
-
JSON.stringify(config({ labeller_knows: ["python"] })),
|
|
889
|
-
);
|
|
890
|
-
const after = build(dir).key;
|
|
891
|
-
const gone = Object.keys(after.ids).find(
|
|
892
|
-
(id) =>
|
|
893
|
-
before.ids[id]?.on_page === true && after.ids[id]?.on_page === false,
|
|
894
|
-
);
|
|
895
|
-
assert.ok(gone !== undefined, "the rebuild must take an item off the page");
|
|
896
|
-
const group = after.ids[gone]?.group ?? "";
|
|
897
|
-
const cases = {
|
|
898
|
-
[`answers/${gone}`]: {
|
|
899
|
-
answers: { [gone]: { answers: { label: "yes" }, source: "page" } },
|
|
900
|
-
},
|
|
901
|
-
[`groups/${group}`]: {
|
|
902
|
-
groups: { [group]: { familiarity: "well", source: "page" } },
|
|
903
|
-
},
|
|
904
|
-
};
|
|
905
|
-
Object.entries(cases).forEach(([path, dump], i) => {
|
|
906
|
-
const file = join(dir, `off-${String(i)}.json`);
|
|
907
|
-
writeFileSync(file, JSON.stringify(dump));
|
|
908
|
-
const r = run([
|
|
909
|
-
"export",
|
|
910
|
-
file,
|
|
911
|
-
join(dir, "out", "key.json"),
|
|
912
|
-
join(dir, "data"),
|
|
913
|
-
]);
|
|
914
|
-
assert.notEqual(r.status, 0, `${path}: ${r.out}`);
|
|
915
|
-
assert.ok(
|
|
916
|
-
r.out.includes(`${path}: `) && r.out.includes("no longer on the page"),
|
|
917
|
-
r.out,
|
|
918
|
-
);
|
|
919
|
-
});
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
test("🔴 export refuses a stored field of the wrong type by its path, never a Python traceback", () => {
|
|
923
|
-
const dir = setup("export-types", config());
|
|
924
|
-
build(dir);
|
|
925
|
-
const keyPath = join(dir, "out", "key.json");
|
|
926
|
-
const cases: readonly (readonly [string, unknown])[] = [
|
|
927
|
-
[
|
|
928
|
-
"answers/G001/answers",
|
|
929
|
-
{ answers: { G001: { answers: [], source: "chat" } } },
|
|
930
|
-
],
|
|
931
|
-
[
|
|
932
|
-
"answers/G001/answers/label",
|
|
933
|
-
{ answers: { G001: { answers: { label: 5 }, source: "chat" } } },
|
|
934
|
-
],
|
|
935
|
-
[
|
|
936
|
-
"answers/G001/savedAt",
|
|
937
|
-
{ answers: { G001: { answers: {}, savedAt: 1, source: "chat" } } },
|
|
938
|
-
],
|
|
939
|
-
[
|
|
940
|
-
"groups/python/familiarity",
|
|
941
|
-
{ groups: { python: { familiarity: 3, source: "chat" } } },
|
|
942
|
-
],
|
|
943
|
-
[
|
|
944
|
-
"answers/G001/source",
|
|
945
|
-
{ answers: { G001: { answers: {}, source: ["chat"] } } },
|
|
946
|
-
],
|
|
947
|
-
["answers", { answers: [], groups: {} }],
|
|
948
|
-
["groups", { groups: "python" }],
|
|
949
|
-
["store", []],
|
|
950
|
-
];
|
|
951
|
-
cases.forEach(([path, dump], i) => {
|
|
952
|
-
const file = join(dir, `type-${String(i)}.json`);
|
|
953
|
-
writeFileSync(file, JSON.stringify(dump));
|
|
954
|
-
const r = run(["export", file, keyPath, join(dir, "data")]);
|
|
955
|
-
assert.notEqual(r.status, 0, `${path}: ${r.out}`);
|
|
956
|
-
assert.doesNotMatch(r.out, /Traceback/, `${path}\n${r.out}`);
|
|
957
|
-
assert.ok(r.out.includes(`${path}: `), `${path} must be named\n${r.out}`);
|
|
958
|
-
});
|
|
27
|
+
assert.deepEqual(listed.slice(0, 8), numbers);
|
|
959
28
|
});
|