paperlint 14.0.0 → 14.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/dist/skills/build-label-page/scripts/label-page-core.d.ts +105 -25
- package/dist/skills/build-label-page/scripts/label-page-presets.d.ts +10 -0
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +20 -2
- package/skills/build-label-page/SKILL.md.spec.ts +19 -1
- package/skills/build-label-page/references/closed-choice-generator.md +171 -5
- package/skills/build-label-page/scripts/fixtures/blocks-items.json +214 -0
- package/skills/build-label-page/scripts/fixtures/linter-config-witness-items.json +448 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +335 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +637 -2
- package/skills/build-label-page/scripts/label-page-core.js +360 -70
- package/skills/build-label-page/scripts/label-page-core.test.ts +78 -11
- package/skills/build-label-page/scripts/label-page-core.ts +585 -107
- package/skills/build-label-page/scripts/label-page-html.test.ts +22 -0
- package/skills/build-label-page/scripts/label-page-presets.js +222 -0
- package/skills/build-label-page/scripts/label-page-presets.test.ts +175 -0
- package/skills/build-label-page/scripts/label-page-presets.ts +257 -0
- package/skills/build-label-page/scripts/label-page.js +13 -2
- package/skills/build-label-page/scripts/label-page.ts +13 -2
- package/skills/build-label-page/scripts/page-template.html +438 -44
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content blocks (`situation`, `excerpt`, `references`, `more`, `table`, `repeat`) and the
|
|
3
|
+
* page's `labels`, on a fixture that is not about linters at all (a code review): the core's checks,
|
|
4
|
+
* both ways, and what a card shows, in the fake DOM. Phone layout and saving are in
|
|
5
|
+
* label-page-browser.test.ts; the linter preset is in label-page-presets.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { test } from "vitest";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import {
|
|
13
|
+
build,
|
|
14
|
+
openLive,
|
|
15
|
+
run,
|
|
16
|
+
setup,
|
|
17
|
+
showPage,
|
|
18
|
+
} from "../../../test/label-page-kit.ts";
|
|
19
|
+
|
|
20
|
+
const Fixture = z
|
|
21
|
+
.object({
|
|
22
|
+
practice: z.array(
|
|
23
|
+
z.object({ expected: z.record(z.string(), z.string()) }).loose(),
|
|
24
|
+
),
|
|
25
|
+
items: z.array(z.record(z.string(), z.unknown())),
|
|
26
|
+
})
|
|
27
|
+
.loose();
|
|
28
|
+
/** A code-review page: every word on its cards comes from this file, none from the generator. */
|
|
29
|
+
const FIXTURE = Fixture.parse(
|
|
30
|
+
JSON.parse(
|
|
31
|
+
readFileSync(
|
|
32
|
+
join(import.meta.dirname, "fixtures", "blocks-items.json"),
|
|
33
|
+
"utf8",
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
const page = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
38
|
+
...FIXTURE,
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
/** The fixture's items with `change` applied to the first one. */
|
|
42
|
+
const firstItem = (
|
|
43
|
+
change: (i: Record<string, unknown>) => Record<string, unknown>,
|
|
44
|
+
) => page({ items: FIXTURE.items.map((i, n) => (n === 0 ? change(i) : i)) });
|
|
45
|
+
const PRACTICE_DONE = Object.fromEntries(
|
|
46
|
+
FIXTURE.practice.map((p, n) => [
|
|
47
|
+
`r1-P${String(n + 1)}`,
|
|
48
|
+
{ answers: p.expected, source: "page" },
|
|
49
|
+
]),
|
|
50
|
+
);
|
|
51
|
+
const [FIRST] = FIXTURE.items;
|
|
52
|
+
assert.ok(FIRST !== undefined);
|
|
53
|
+
const Excerpt = z
|
|
54
|
+
.object({ lines: z.array(z.record(z.string(), z.unknown())) })
|
|
55
|
+
.loose();
|
|
56
|
+
const firstExcerpt = Excerpt.parse(FIRST["excerpt"]);
|
|
57
|
+
const withMarkedLine = (text: string) => ({
|
|
58
|
+
...firstExcerpt,
|
|
59
|
+
lines: firstExcerpt.lines.map((l) =>
|
|
60
|
+
l["mark"] === true ? { ...l, text } : l,
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
const LEAK =
|
|
64
|
+
"the source id 'review-sql-1' appears in the visible `FIELD` of item review-sql-1. Take it out of the text (or use source ids that are not natural words).";
|
|
65
|
+
|
|
66
|
+
/** Pages the core refuses, each with its whole refusal. */
|
|
67
|
+
const REFUSED: readonly (readonly [string, Record<string, unknown>, string])[] =
|
|
68
|
+
[
|
|
69
|
+
[
|
|
70
|
+
"blocks-no-mark",
|
|
71
|
+
firstItem((i) => ({
|
|
72
|
+
...i,
|
|
73
|
+
excerpt: {
|
|
74
|
+
...firstExcerpt,
|
|
75
|
+
lines: firstExcerpt.lines.map((l) => ({ ...l, mark: false })),
|
|
76
|
+
},
|
|
77
|
+
})),
|
|
78
|
+
"items[0].excerpt.lines: no line is marked — mark the line the card is about (`mark: true`)",
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
"blocks-line-no-excerpt",
|
|
82
|
+
firstItem((i) =>
|
|
83
|
+
Object.fromEntries(Object.entries(i).filter(([k]) => k !== "excerpt")),
|
|
84
|
+
),
|
|
85
|
+
"items[0].situation.summary: `{line}` names the excerpt's marked line, and the card has no `excerpt` — add it, or write the summary without `{line}`",
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
"blocks-source-in-reference",
|
|
89
|
+
firstItem((i) => ({
|
|
90
|
+
...i,
|
|
91
|
+
references: {
|
|
92
|
+
summary: "s",
|
|
93
|
+
entries: [
|
|
94
|
+
{ title: "t", fields: [{ text: "drawn for review-sql-1" }] },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
})),
|
|
98
|
+
LEAK.replace("FIELD", "references"),
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
"blocks-source-after-newline",
|
|
102
|
+
firstItem((i) => ({
|
|
103
|
+
...i,
|
|
104
|
+
table: {
|
|
105
|
+
summary: "s",
|
|
106
|
+
rows: [{ label: "l", value: "v", plain: "one\nreview-sql-1" }],
|
|
107
|
+
},
|
|
108
|
+
})),
|
|
109
|
+
LEAK.replace("FIELD", "table"),
|
|
110
|
+
],
|
|
111
|
+
[
|
|
112
|
+
"blocks-source-after-tab",
|
|
113
|
+
firstItem((i) => ({ ...i, excerpt: withMarkedLine("\treview-sql-1") })),
|
|
114
|
+
LEAK.replace("FIELD", "excerpt"),
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
"blocks-repeat-range",
|
|
118
|
+
firstItem((i) => ({ ...i, repeat: { group: "g", index: 3, of: 2 } })),
|
|
119
|
+
"items[0].repeat.index: 3 is not between 1 and 2",
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
"blocks-mark-tone",
|
|
123
|
+
firstItem((i) => ({
|
|
124
|
+
...i,
|
|
125
|
+
table: {
|
|
126
|
+
summary: "s",
|
|
127
|
+
rows: [
|
|
128
|
+
{ label: "l", value: "v", mark: { tone: "maybe", text: "?" } },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
})),
|
|
132
|
+
"items[0].table.rows[0].mark.tone: 'maybe' must be yes or no",
|
|
133
|
+
],
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
test("🔴 the blocks refuse an excerpt with no marked line, `{line}` without an excerpt, a source id in any rendered string (a reference, after a newline, after a tab), a repeat out of range and an unknown mark tone", () => {
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
REFUSED.map(([name, cfg]) => {
|
|
139
|
+
const dir = setup(name, cfg);
|
|
140
|
+
return run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
141
|
+
}),
|
|
142
|
+
REFUSED.map(([, , message]) => ({
|
|
143
|
+
status: 1,
|
|
144
|
+
out: `REFUSED: ${message}\n`,
|
|
145
|
+
})),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a page of blocks builds with no preset; no source id and no repeat group reaches the page", () => {
|
|
150
|
+
const { page: html, key } = build(setup("blocks-fixture", page()));
|
|
151
|
+
assert.equal(Object.keys(key.ids).length, FIXTURE.items.length);
|
|
152
|
+
// Guards: a repeat's group is private like the source id.
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
["review-sql-1", "guide-sql"].filter((s) => html.includes(s)),
|
|
155
|
+
[],
|
|
156
|
+
);
|
|
157
|
+
assert.ok(html.includes('"repeat": {"index": 1, "of": 2}'));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("🔴 a repeat whose group is the item's own source id builds: the group never reaches the page, so it is no leak", () => {
|
|
161
|
+
const { page: html } = build(
|
|
162
|
+
setup(
|
|
163
|
+
"blocks-repeat-own-id",
|
|
164
|
+
firstItem((i) => ({
|
|
165
|
+
...i,
|
|
166
|
+
repeat: { group: "review-sql-1", index: 1, of: 2 },
|
|
167
|
+
})),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
assert.ok(html.includes('"repeat": {"index": 1, "of": 2}'));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** What the page shows, with the fake DOM's line breaks between elements taken out. */
|
|
174
|
+
const flat = async (
|
|
175
|
+
cfg: Record<string, unknown>,
|
|
176
|
+
name: string,
|
|
177
|
+
practice: Record<string, unknown> = PRACTICE_DONE,
|
|
178
|
+
) => {
|
|
179
|
+
const { page: html, key } = build(setup(name, cfg));
|
|
180
|
+
return {
|
|
181
|
+
shown: (await showPage(html, {}, {}, practice)).replace(/\n/g, ""),
|
|
182
|
+
key,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
test("🔴 `{line}` becomes «line N: » and the marked line's code, a backtick kept; a text without it is kept as written", async () => {
|
|
187
|
+
const { shown } = await flat(page(), "blocks-line");
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
[
|
|
190
|
+
'Does «Every query is parameterised» cover line 12: rows = db.raw("SELECT * FROM sales WHERE m=" + month)?',
|
|
191
|
+
"Does «Every query is parameterised» cover line 30: q = `SELECT * FROM items WHERE name LIKE '${term}'`?",
|
|
192
|
+
"Does «Functions stay short» cover this function?",
|
|
193
|
+
].filter((t) => !shown.includes(t)),
|
|
194
|
+
[],
|
|
195
|
+
);
|
|
196
|
+
assert.ok(!shown.includes("{line}"));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** `text` with every given part present, in this order. */
|
|
200
|
+
const inOrder = (text: string, parts: readonly string[]): readonly string[] => {
|
|
201
|
+
const at = parts.map((p) => text.indexOf(p));
|
|
202
|
+
return parts.filter(
|
|
203
|
+
(p, n) => at[n] === -1 || (at[n - 1] ?? -1) > (at[n] ?? 0),
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
test("🔴 a card reads top to bottom: its place and body in the page's words, the repeat, the situation, the excerpt with its marked line labelled and its caption, the question and its answers, then the references, the list and the table, each folded", async () => {
|
|
208
|
+
const { page: html, key } = build(setup("blocks-face", page()));
|
|
209
|
+
const shown = await showPage(html, {}, {}, PRACTICE_DONE);
|
|
210
|
+
const id = Object.entries(key.ids).find(
|
|
211
|
+
([, v]) => v.source_id === "review-sql-1",
|
|
212
|
+
)?.[0];
|
|
213
|
+
assert.ok(id !== undefined);
|
|
214
|
+
const from = shown.indexOf(`\nComment ${String(Number(id.slice(1)))} of 3`);
|
|
215
|
+
const rest = shown.slice(from + 1).search(/\nComment \d+ of 3/);
|
|
216
|
+
const next = rest === -1 ? -1 : from + 1 + rest;
|
|
217
|
+
const card = shown.slice(from, next === -1 ? undefined : next);
|
|
218
|
+
// Guards: every part in place; an empty list means nothing is missing or out of order.
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
inOrder(card, [
|
|
221
|
+
"Guideline",
|
|
222
|
+
"Every query is parameterised",
|
|
223
|
+
"Comment 1 of 2 on this guideline",
|
|
224
|
+
"Reviewer A asked for a change at",
|
|
225
|
+
"app/report.py",
|
|
226
|
+
"monthly(month)",
|
|
227
|
+
"▶",
|
|
228
|
+
"◀ the line in question",
|
|
229
|
+
"Reviewer A wrote: concatenated SQL.",
|
|
230
|
+
"Does «Every query is parameterised» cover",
|
|
231
|
+
"Unclear",
|
|
232
|
+
"What the guideline says",
|
|
233
|
+
"Security guide",
|
|
234
|
+
"SQL-1",
|
|
235
|
+
"Build every query with parameters",
|
|
236
|
+
"Why: A query built from strings",
|
|
237
|
+
"The examples below are the guide's own.",
|
|
238
|
+
"Not like this",
|
|
239
|
+
"Like this",
|
|
240
|
+
"3 more lines like this",
|
|
241
|
+
"app/export.py",
|
|
242
|
+
"and 1 more",
|
|
243
|
+
"What each reviewer wrote",
|
|
244
|
+
"Reviewer A asked for the change; reviewer B approved.",
|
|
245
|
+
"A only · Comment",
|
|
246
|
+
"use parameters",
|
|
247
|
+
"asks for a parameterised query",
|
|
248
|
+
"✓ about this line",
|
|
249
|
+
"B only · Approval",
|
|
250
|
+
"✗ says nothing about this line",
|
|
251
|
+
]),
|
|
252
|
+
[],
|
|
253
|
+
);
|
|
254
|
+
// Guards: no word of the generator's own on a card.
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
["linter", "Setting", "Rule docs", "◀ this line", "Card "].filter((w) =>
|
|
257
|
+
card.includes(w),
|
|
258
|
+
),
|
|
259
|
+
[],
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("🔴 the page's labels: a practice button's example carries `practice_example`; without labels a card shows its id and the default repeat", async () => {
|
|
264
|
+
const { shown } = await flat(page(), "blocks-labels", {});
|
|
265
|
+
assert.ok(
|
|
266
|
+
shown.includes("Another case: «No string-built SQL» and an f-string query"),
|
|
267
|
+
);
|
|
268
|
+
const bare = Object.fromEntries(
|
|
269
|
+
Object.entries(page()).filter(([k]) => k !== "labels"),
|
|
270
|
+
);
|
|
271
|
+
const plain = await flat(bare, "blocks-no-labels");
|
|
272
|
+
assert.deepEqual(
|
|
273
|
+
[
|
|
274
|
+
/G00\d · \d of 3/.test(plain.shown),
|
|
275
|
+
plain.shown.includes("Card 1 of 2"),
|
|
276
|
+
plain.shown.includes("GuidelineEvery query is parameterised"),
|
|
277
|
+
plain.shown.includes("Another case"),
|
|
278
|
+
],
|
|
279
|
+
[true, true, false, false],
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("🔴 once the practice is done, «How to answer» folds into a toggle; with every card answered the page says so and what to do next", async () => {
|
|
284
|
+
const { page: html, key } = build(setup("blocks-howto", page()));
|
|
285
|
+
const folded = async (practice: Record<string, unknown>) => {
|
|
286
|
+
const { app } = await openLive(html, { answers: {}, groups: {}, practice });
|
|
287
|
+
return app
|
|
288
|
+
.all()
|
|
289
|
+
.filter((n) => n.tag === "details")
|
|
290
|
+
.map((n) => n.children[0]?.textContent);
|
|
291
|
+
};
|
|
292
|
+
assert.ok(!(await folded({})).includes("📖 How to answer"));
|
|
293
|
+
assert.ok((await folded(PRACTICE_DONE)).includes("📖 How to answer"));
|
|
294
|
+
const all = Object.fromEntries(
|
|
295
|
+
Object.keys(key.ids).map((id) => [
|
|
296
|
+
id,
|
|
297
|
+
{ answers: { applies: "yes" }, source: "page" },
|
|
298
|
+
]),
|
|
299
|
+
);
|
|
300
|
+
const { app } = await openLive(html, {
|
|
301
|
+
answers: all,
|
|
302
|
+
groups: {},
|
|
303
|
+
practice: PRACTICE_DONE,
|
|
304
|
+
});
|
|
305
|
+
assert.match(
|
|
306
|
+
app.textContent,
|
|
307
|
+
/All 3 items are complete\. Tell the agent you are done; you can still change any answer\./,
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("🔴 `labels.line_part` names a unit row whose line holds several requirements", async () => {
|
|
312
|
+
const unit = {
|
|
313
|
+
id: "u1",
|
|
314
|
+
text: "Short functions; no globals.",
|
|
315
|
+
mark: "Short functions",
|
|
316
|
+
part: 1,
|
|
317
|
+
of: 2,
|
|
318
|
+
};
|
|
319
|
+
const cfg = firstItem((i) => ({ ...i, unit }));
|
|
320
|
+
const own = await flat(
|
|
321
|
+
{ ...cfg, labels: { line: "Source", line_part: "{line} ({part}/{of})" } },
|
|
322
|
+
"blocks-line-part",
|
|
323
|
+
);
|
|
324
|
+
const plain = await flat(
|
|
325
|
+
Object.fromEntries(Object.entries(cfg).filter(([k]) => k !== "labels")),
|
|
326
|
+
"blocks-line-part-default",
|
|
327
|
+
);
|
|
328
|
+
assert.deepEqual(
|
|
329
|
+
[
|
|
330
|
+
own.shown.includes("Source (1/2)"),
|
|
331
|
+
plain.shown.includes("Line · part 1 of 2"),
|
|
332
|
+
],
|
|
333
|
+
[true, true],
|
|
334
|
+
);
|
|
335
|
+
});
|