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
|
@@ -482,3 +482,25 @@ test("🔴 familiarity off: no group asks how well it is known, and a group is d
|
|
|
482
482
|
assert.doesNotMatch(shown, /how well you know it/);
|
|
483
483
|
assert.match(shown, /Nothing left/, shown);
|
|
484
484
|
});
|
|
485
|
+
|
|
486
|
+
test("🔴 «All items are complete» waits for every group's familiarity too, as «Next unanswered» does", async () => {
|
|
487
|
+
const { page, key } = build(setup("all-done-groups", config()));
|
|
488
|
+
const answers = Object.fromEntries(
|
|
489
|
+
Object.keys(key.ids).map((id) => [
|
|
490
|
+
id,
|
|
491
|
+
{ answers: { label: "yes", boundary: "clear" }, source: "page" },
|
|
492
|
+
]),
|
|
493
|
+
);
|
|
494
|
+
const said = async (groups: Record<string, unknown>) =>
|
|
495
|
+
/All 12 items are complete/.test(
|
|
496
|
+
(await openLive(page, { answers, groups, practice: DONE_PRACTICE })).app
|
|
497
|
+
.textContent,
|
|
498
|
+
);
|
|
499
|
+
const rated = Object.fromEntries(
|
|
500
|
+
["python", "go", "rust"].map((g) => [
|
|
501
|
+
g,
|
|
502
|
+
{ familiarity: "well", source: "page" },
|
|
503
|
+
]),
|
|
504
|
+
);
|
|
505
|
+
assert.deepEqual([await said({}), await said(rated)], [false, true]);
|
|
506
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRESETS: named, opt-in words for one narrow use of the generic content blocks. An items.json picks
|
|
3
|
+
* one by name (`"preset": "linter-config-witness"`); the preset turns its own compact card fields into
|
|
4
|
+
* the generic blocks and supplies the page's `labels`, and the items file's own `labels` override
|
|
5
|
+
* them. The core and the page know only the blocks: every word below stays in this file.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
// ── linter-config-witness: two linter configs of one requirement disagree on a line of code ──────
|
|
9
|
+
const LINE = z.strictObject({
|
|
10
|
+
n: z.int(),
|
|
11
|
+
text: z.string(),
|
|
12
|
+
mark: z.boolean(),
|
|
13
|
+
});
|
|
14
|
+
const EXAMPLE = z.strictObject({
|
|
15
|
+
file: z.string().min(1),
|
|
16
|
+
url: z.url().optional(),
|
|
17
|
+
lines: z.array(LINE).min(1),
|
|
18
|
+
message: z.string().min(1),
|
|
19
|
+
caught_by: z.enum(["a", "b"]),
|
|
20
|
+
});
|
|
21
|
+
const EXPLANATION = z
|
|
22
|
+
.strictObject({
|
|
23
|
+
source: z.enum([
|
|
24
|
+
"ruff rule",
|
|
25
|
+
"eslint docs",
|
|
26
|
+
"plugin docs",
|
|
27
|
+
"config message",
|
|
28
|
+
"none",
|
|
29
|
+
]),
|
|
30
|
+
what: z.string().min(1).optional(),
|
|
31
|
+
why: z.string().min(1).optional(),
|
|
32
|
+
example_bad: z.string().min(1).optional(),
|
|
33
|
+
example_good: z.string().min(1).optional(),
|
|
34
|
+
url: z.url().optional(),
|
|
35
|
+
})
|
|
36
|
+
.refine((x) => x.source === "none" || x.what !== undefined, {
|
|
37
|
+
message: "`what` is required unless `source` is 'none'",
|
|
38
|
+
path: ["what"],
|
|
39
|
+
});
|
|
40
|
+
const RULE = z.strictObject({
|
|
41
|
+
linter: z.string().min(1),
|
|
42
|
+
id: z.string().min(1),
|
|
43
|
+
explanation: EXPLANATION,
|
|
44
|
+
});
|
|
45
|
+
const SETTING = z.strictObject({
|
|
46
|
+
key: z.string().regex(/^(files|ignores|rule|option:\S+)$/u),
|
|
47
|
+
raw: z.string().min(1),
|
|
48
|
+
plain: z.string().min(1),
|
|
49
|
+
matches_example: z.boolean().nullable().optional(),
|
|
50
|
+
});
|
|
51
|
+
const LINTER_CARD = z.looseObject({
|
|
52
|
+
example: EXAMPLE,
|
|
53
|
+
rules: z.array(RULE).min(1),
|
|
54
|
+
more_like_this: z
|
|
55
|
+
.strictObject({ count: z.int(), files: z.array(z.string()) })
|
|
56
|
+
.optional(),
|
|
57
|
+
details: z.strictObject({
|
|
58
|
+
a: z.array(SETTING).min(1),
|
|
59
|
+
b: z.array(SETTING).min(1),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
const LINTER_LABELS = {
|
|
63
|
+
place: "Card {n} of {total}",
|
|
64
|
+
body: "Requirement",
|
|
65
|
+
line: "Where this requirement comes from (the project's rules file)",
|
|
66
|
+
line_part: "Where this requirement comes from (the project's rules file)",
|
|
67
|
+
repeat: "Card {index} of {of} for this requirement",
|
|
68
|
+
practice_example: "Example of this answer, about other code: ",
|
|
69
|
+
};
|
|
70
|
+
const KEY_LABEL = {
|
|
71
|
+
files: "Checks files",
|
|
72
|
+
ignores: "Leaves out",
|
|
73
|
+
rule: "Rule",
|
|
74
|
+
};
|
|
75
|
+
const DOCS_NOTE = "The flagged/allowed examples are the docs' own, with the docs' own settings, not this project's.";
|
|
76
|
+
/** A rule as its docs explain it, as a reference entry. */
|
|
77
|
+
const ruleEntry = (r) => {
|
|
78
|
+
const x = r.explanation;
|
|
79
|
+
return {
|
|
80
|
+
label: "Rule",
|
|
81
|
+
title: `${r.linter} \`${r.id}\``,
|
|
82
|
+
fields: [
|
|
83
|
+
x.source === "none"
|
|
84
|
+
? { style: "note", text: "No official explanation for this rule." }
|
|
85
|
+
: {
|
|
86
|
+
...(x.source === "config message"
|
|
87
|
+
? { label: "The config's own message" }
|
|
88
|
+
: {}),
|
|
89
|
+
text: String(x.what),
|
|
90
|
+
},
|
|
91
|
+
...(x.why === undefined ? [] : [{ label: "Why", text: x.why }]),
|
|
92
|
+
...(x.example_bad === undefined && x.example_good === undefined
|
|
93
|
+
? []
|
|
94
|
+
: [{ style: "note", text: DOCS_NOTE }]),
|
|
95
|
+
...(x.example_bad === undefined
|
|
96
|
+
? []
|
|
97
|
+
: [{ label: "Flagged", style: "code", text: x.example_bad }]),
|
|
98
|
+
...(x.example_good === undefined
|
|
99
|
+
? []
|
|
100
|
+
: [{ label: "Allowed", style: "code", text: x.example_good }]),
|
|
101
|
+
],
|
|
102
|
+
...(x.url === undefined ? {} : { url: x.url, url_label: "Rule docs" }),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
/** Each glob or option on its own row: differences first (A only, B only), what both share last. */
|
|
106
|
+
const settingRows = (d) => {
|
|
107
|
+
const same = (r, s) => r.key === s.key && r.raw === s.raw;
|
|
108
|
+
const onlyIn = (xs, ys) => xs.filter((r) => !ys.some((s) => same(r, s)));
|
|
109
|
+
const rows = [
|
|
110
|
+
...onlyIn(d.a, d.b).map((r) => [r, "A only"]),
|
|
111
|
+
...onlyIn(d.b, d.a).map((r) => [r, "B only"]),
|
|
112
|
+
...d.a
|
|
113
|
+
.filter((r) => d.b.some((s) => same(r, s)))
|
|
114
|
+
.map((r) => [r, "A and B"]),
|
|
115
|
+
];
|
|
116
|
+
return rows.map(([r, side]) => ({
|
|
117
|
+
label: `${side} · ${KEY_LABEL[r.key] ?? `Option ${r.key.replace(/^option:/u, "")}`}`,
|
|
118
|
+
value: r.raw,
|
|
119
|
+
plain: r.plain,
|
|
120
|
+
...(r.matches_example === true
|
|
121
|
+
? { mark: { tone: "yes", text: "✓ matches this file" } }
|
|
122
|
+
: r.matches_example === false
|
|
123
|
+
? { mark: { tone: "no", text: "✗ does not match this file" } }
|
|
124
|
+
: {}),
|
|
125
|
+
}));
|
|
126
|
+
};
|
|
127
|
+
/** The files like this one, as a folded list; none when there are none. */
|
|
128
|
+
const moreList = (more) => more === undefined || more.count === 0
|
|
129
|
+
? {}
|
|
130
|
+
: {
|
|
131
|
+
more: {
|
|
132
|
+
summary: `${String(more.count)} more file(s) with the same situation`,
|
|
133
|
+
items: more.files.map((f) => `\`${f}\``),
|
|
134
|
+
...(more.count > more.files.length
|
|
135
|
+
? { rest: `and ${String(more.count - more.files.length)} more` }
|
|
136
|
+
: {}),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
/** One card in the generic blocks. */
|
|
140
|
+
function linterCard(card) {
|
|
141
|
+
const { example: ex, rules, more_like_this: more, details, ...rest } = card;
|
|
142
|
+
const by = ex.caught_by === "a" ? ["A", "B"] : ["B", "A"];
|
|
143
|
+
return {
|
|
144
|
+
...rest,
|
|
145
|
+
excerpt: {
|
|
146
|
+
file: ex.file,
|
|
147
|
+
...(ex.url === undefined ? {} : { url: ex.url }),
|
|
148
|
+
lines: ex.lines,
|
|
149
|
+
caption: `The linter says: ${ex.message}`,
|
|
150
|
+
},
|
|
151
|
+
references: {
|
|
152
|
+
summary: rules.length > 1
|
|
153
|
+
? "What the linter rules do (official docs)"
|
|
154
|
+
: "What the linter rule does (official docs)",
|
|
155
|
+
entries: rules.map(ruleEntry),
|
|
156
|
+
},
|
|
157
|
+
...moreList(more),
|
|
158
|
+
table: {
|
|
159
|
+
summary: "Settings behind this card",
|
|
160
|
+
note: `Setting ${String(by[0])} flags the example line; setting ${String(by[1])} passes it.`,
|
|
161
|
+
rows: settingRows(details),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const CONFIG = z.looseObject({
|
|
166
|
+
preset: z.string(),
|
|
167
|
+
labels: z.record(z.string(), z.string()).optional(),
|
|
168
|
+
practice: z.array(z.unknown()),
|
|
169
|
+
items: z.array(z.unknown()),
|
|
170
|
+
});
|
|
171
|
+
const PRESETS = {
|
|
172
|
+
"linter-config-witness": {
|
|
173
|
+
labels: LINTER_LABELS,
|
|
174
|
+
card: linterCard,
|
|
175
|
+
schema: LINTER_CARD,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
/** Each problem zod found, as the build's refusals read: the path, then what is wrong. */
|
|
179
|
+
const issuesOf = (where, e) => e.issues
|
|
180
|
+
.map((issue) => `${where}${issue.path
|
|
181
|
+
.map((p) => typeof p === "number" ? `[${String(p)}]` : `.${String(p)}`)
|
|
182
|
+
.join("")}: ${issue.message}`)
|
|
183
|
+
.join("; ");
|
|
184
|
+
/** Every card through the preset, practice first, each refused by its path. */
|
|
185
|
+
const mapCards = (preset, practice, items) => [
|
|
186
|
+
...practice.map((c, n) => [`practice[${String(n)}]`, c]),
|
|
187
|
+
...items.map((c, n) => [`items[${String(n)}]`, c]),
|
|
188
|
+
].map(([at, c]) => {
|
|
189
|
+
const r = preset.schema.safeParse(c);
|
|
190
|
+
return r.success
|
|
191
|
+
? { ok: true, value: preset.card(r.data) }
|
|
192
|
+
: { ok: false, message: issuesOf(at, r.error) };
|
|
193
|
+
});
|
|
194
|
+
/** The config with its preset applied; a config without `preset` comes back as it was. */
|
|
195
|
+
export function applyPreset(raw) {
|
|
196
|
+
const cfg = CONFIG.safeParse(raw);
|
|
197
|
+
if (!cfg.success)
|
|
198
|
+
return { ok: true, value: raw };
|
|
199
|
+
const { preset: name, labels, practice, items, ...rest } = cfg.data;
|
|
200
|
+
const preset = PRESETS[name];
|
|
201
|
+
if (preset === undefined)
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
message: `preset: '${name}' is not a preset (${Object.keys(PRESETS).join(", ")})`,
|
|
205
|
+
};
|
|
206
|
+
const cards = mapCards(preset, practice, items);
|
|
207
|
+
const bad = cards.find((c) => !c.ok);
|
|
208
|
+
if (bad !== undefined)
|
|
209
|
+
return bad;
|
|
210
|
+
const made = cards
|
|
211
|
+
.filter((c) => c.ok)
|
|
212
|
+
.map((c) => c.value);
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
value: {
|
|
216
|
+
...rest,
|
|
217
|
+
labels: { ...preset.labels, ...labels },
|
|
218
|
+
practice: made.slice(0, practice.length),
|
|
219
|
+
items: made.slice(practice.length),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `linter-config-witness` preset: a narrow use of the generic blocks (two linter configs of one
|
|
3
|
+
* requirement disagree on a line of code). Its words reach the page only through a page that names
|
|
4
|
+
* it, and the items file's own labels override them.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { test } from "vitest";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { build, run, setup, showPage } from "../../../test/label-page-kit.ts";
|
|
12
|
+
|
|
13
|
+
const Fixture = z
|
|
14
|
+
.object({
|
|
15
|
+
practice: z.array(
|
|
16
|
+
z.object({ expected: z.record(z.string(), z.string()) }).loose(),
|
|
17
|
+
),
|
|
18
|
+
items: z.array(z.record(z.string(), z.unknown())),
|
|
19
|
+
})
|
|
20
|
+
.loose();
|
|
21
|
+
const FIXTURE = Fixture.parse(
|
|
22
|
+
JSON.parse(
|
|
23
|
+
readFileSync(
|
|
24
|
+
join(import.meta.dirname, "fixtures", "linter-config-witness-items.json"),
|
|
25
|
+
"utf8",
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
const preset = (
|
|
30
|
+
over: Record<string, unknown> = {},
|
|
31
|
+
): Record<string, unknown> => ({ ...FIXTURE, ...over });
|
|
32
|
+
const firstItem = (
|
|
33
|
+
change: (i: Record<string, unknown>) => Record<string, unknown>,
|
|
34
|
+
) => preset({ items: FIXTURE.items.map((i, n) => (n === 0 ? change(i) : i)) });
|
|
35
|
+
const PRACTICE_DONE = Object.fromEntries(
|
|
36
|
+
FIXTURE.practice.map((p, n) => [
|
|
37
|
+
`r1-P${String(n + 1)}`,
|
|
38
|
+
{ answers: p.expected, source: "page" },
|
|
39
|
+
]),
|
|
40
|
+
);
|
|
41
|
+
const [FIRST] = FIXTURE.items;
|
|
42
|
+
assert.ok(FIRST !== undefined);
|
|
43
|
+
const firstRule = z
|
|
44
|
+
.array(z.record(z.string(), z.unknown()))
|
|
45
|
+
.parse(FIRST["rules"])[0];
|
|
46
|
+
|
|
47
|
+
const REFUSED: readonly (readonly [string, Record<string, unknown>, string])[] =
|
|
48
|
+
[
|
|
49
|
+
[
|
|
50
|
+
"preset-unknown",
|
|
51
|
+
preset({ preset: "no-such-preset" }),
|
|
52
|
+
"preset: 'no-such-preset' is not a preset (linter-config-witness)",
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"preset-no-explanation",
|
|
56
|
+
firstItem((i) => ({ ...i, rules: [{ linter: "ruff", id: "T201" }] })),
|
|
57
|
+
"items[0].rules[0].explanation: Invalid input: expected object, received undefined",
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"preset-explanation-empty",
|
|
61
|
+
firstItem((i) => ({
|
|
62
|
+
...i,
|
|
63
|
+
rules: [{ ...firstRule, explanation: { source: "ruff rule" } }],
|
|
64
|
+
})),
|
|
65
|
+
"items[0].rules[0].explanation.what: `what` is required unless `source` is 'none'",
|
|
66
|
+
],
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
test("🔴 the preset refuses an unknown name and, by path, a card it cannot map", () => {
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
REFUSED.map(([name, cfg]) => {
|
|
72
|
+
const dir = setup(name, cfg);
|
|
73
|
+
return run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
74
|
+
}),
|
|
75
|
+
REFUSED.map(([, , message]) => ({
|
|
76
|
+
status: 1,
|
|
77
|
+
out: `REFUSED: ${message}\n`,
|
|
78
|
+
})),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** `text` with every given part present, in this order. */
|
|
83
|
+
const inOrder = (text: string, parts: readonly string[]): readonly string[] => {
|
|
84
|
+
const at = parts.map((p) => text.indexOf(p));
|
|
85
|
+
return parts.filter(
|
|
86
|
+
(p, n) => at[n] === -1 || (at[n - 1] ?? -1) > (at[n] ?? 0),
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
test("🔴 a page naming the preset shows its words on the generic blocks: the linter's message, the rule's docs folded with the docs-examples note, the files like it, and the settings with their sides", async () => {
|
|
91
|
+
const { page, key } = build(setup("preset-face", preset()));
|
|
92
|
+
const shown = await showPage(page, {}, {}, PRACTICE_DONE);
|
|
93
|
+
const id = Object.entries(key.ids).find(
|
|
94
|
+
([, v]) => v.source_id === "req-logging:tests",
|
|
95
|
+
)?.[0];
|
|
96
|
+
assert.ok(id !== undefined);
|
|
97
|
+
const card = shown.slice(
|
|
98
|
+
shown.indexOf(`\nCard ${String(Number(id.slice(1)))} of 3`),
|
|
99
|
+
);
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
inOrder(card, [
|
|
102
|
+
"Requirement",
|
|
103
|
+
"Never log with",
|
|
104
|
+
"Card 1 of 2 for this requirement",
|
|
105
|
+
"One setting checks the scripts/ folder",
|
|
106
|
+
"scripts/seed_db.py",
|
|
107
|
+
"◀ this line",
|
|
108
|
+
"The linter says:",
|
|
109
|
+
"Should this requirement apply to the scripts/ folder?",
|
|
110
|
+
"What the linter rule does (official docs)",
|
|
111
|
+
"Rule",
|
|
112
|
+
"ruff",
|
|
113
|
+
"T201",
|
|
114
|
+
"Why:",
|
|
115
|
+
"The flagged/allowed examples are the docs' own, with the docs' own settings, not this project's.",
|
|
116
|
+
"Flagged",
|
|
117
|
+
"Allowed",
|
|
118
|
+
"Rule docs",
|
|
119
|
+
"3 more file(s) with the same situation",
|
|
120
|
+
"scripts/backfill_prices.py",
|
|
121
|
+
"and 1 more",
|
|
122
|
+
"Settings behind this card",
|
|
123
|
+
"Setting B flags the example line; setting A passes it.",
|
|
124
|
+
"A only · Leaves out",
|
|
125
|
+
"except the files under scripts/",
|
|
126
|
+
"✓ matches this file",
|
|
127
|
+
"A and B · Rule",
|
|
128
|
+
"flags every call to",
|
|
129
|
+
]),
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
assert.match(shown, /No official explanation for this rule\./);
|
|
133
|
+
const open = await showPage(page, {}, {}, {});
|
|
134
|
+
assert.match(shown, /✗ does not match this file/);
|
|
135
|
+
assert.match(
|
|
136
|
+
open,
|
|
137
|
+
/Example of this answer, about other code: \n?«Use/,
|
|
138
|
+
"a practice button's example says it is about other code",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a card naming two rules folds their docs under one plural heading", async () => {
|
|
143
|
+
const two = firstItem((i) => ({
|
|
144
|
+
...i,
|
|
145
|
+
rules: [firstRule, { ...firstRule, id: "T203" }],
|
|
146
|
+
}));
|
|
147
|
+
const { page } = build(setup("preset-two-rules", two));
|
|
148
|
+
const shown = await showPage(page, {}, {}, PRACTICE_DONE);
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
[
|
|
151
|
+
shown.includes("What the linter rules do (official docs)"),
|
|
152
|
+
shown.includes("T203"),
|
|
153
|
+
],
|
|
154
|
+
[true, true],
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("🔴 the items file's own labels override the preset's", async () => {
|
|
159
|
+
const { page } = build(
|
|
160
|
+
setup(
|
|
161
|
+
"preset-override",
|
|
162
|
+
preset({ labels: { body: "Rule text", place: "#{n}" } }),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
const shown = await showPage(page, {}, {}, PRACTICE_DONE);
|
|
166
|
+
assert.deepEqual(
|
|
167
|
+
[
|
|
168
|
+
shown.includes("Rule text"),
|
|
169
|
+
shown.includes("\nRequirement\n"),
|
|
170
|
+
/\n#1\n/.test(shown),
|
|
171
|
+
shown.includes("Card 1 of 2 for this requirement"),
|
|
172
|
+
],
|
|
173
|
+
[true, false, true, true],
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRESETS: named, opt-in words for one narrow use of the generic content blocks. An items.json picks
|
|
3
|
+
* one by name (`"preset": "linter-config-witness"`); the preset turns its own compact card fields into
|
|
4
|
+
* the generic blocks and supplies the page's `labels`, and the items file's own `labels` override
|
|
5
|
+
* them. The core and the page know only the blocks: every word below stays in this file.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/** A preset's result: the config in the generic shape, or why it cannot be made. */
|
|
10
|
+
export type Preset =
|
|
11
|
+
| { readonly ok: true; readonly value: unknown }
|
|
12
|
+
| { readonly ok: false; readonly message: string };
|
|
13
|
+
|
|
14
|
+
// ── linter-config-witness: two linter configs of one requirement disagree on a line of code ──────
|
|
15
|
+
const LINE = z.strictObject({
|
|
16
|
+
n: z.int(),
|
|
17
|
+
text: z.string(),
|
|
18
|
+
mark: z.boolean(),
|
|
19
|
+
});
|
|
20
|
+
const EXAMPLE = z.strictObject({
|
|
21
|
+
file: z.string().min(1),
|
|
22
|
+
url: z.url().optional(),
|
|
23
|
+
lines: z.array(LINE).min(1),
|
|
24
|
+
message: z.string().min(1),
|
|
25
|
+
caught_by: z.enum(["a", "b"]),
|
|
26
|
+
});
|
|
27
|
+
const EXPLANATION = z
|
|
28
|
+
.strictObject({
|
|
29
|
+
source: z.enum([
|
|
30
|
+
"ruff rule",
|
|
31
|
+
"eslint docs",
|
|
32
|
+
"plugin docs",
|
|
33
|
+
"config message",
|
|
34
|
+
"none",
|
|
35
|
+
]),
|
|
36
|
+
what: z.string().min(1).optional(),
|
|
37
|
+
why: z.string().min(1).optional(),
|
|
38
|
+
example_bad: z.string().min(1).optional(),
|
|
39
|
+
example_good: z.string().min(1).optional(),
|
|
40
|
+
url: z.url().optional(),
|
|
41
|
+
})
|
|
42
|
+
.refine((x) => x.source === "none" || x.what !== undefined, {
|
|
43
|
+
message: "`what` is required unless `source` is 'none'",
|
|
44
|
+
path: ["what"],
|
|
45
|
+
});
|
|
46
|
+
const RULE = z.strictObject({
|
|
47
|
+
linter: z.string().min(1),
|
|
48
|
+
id: z.string().min(1),
|
|
49
|
+
explanation: EXPLANATION,
|
|
50
|
+
});
|
|
51
|
+
const SETTING = z.strictObject({
|
|
52
|
+
key: z.string().regex(/^(files|ignores|rule|option:\S+)$/u),
|
|
53
|
+
raw: z.string().min(1),
|
|
54
|
+
plain: z.string().min(1),
|
|
55
|
+
matches_example: z.boolean().nullable().optional(),
|
|
56
|
+
});
|
|
57
|
+
const LINTER_CARD = z.looseObject({
|
|
58
|
+
example: EXAMPLE,
|
|
59
|
+
rules: z.array(RULE).min(1),
|
|
60
|
+
more_like_this: z
|
|
61
|
+
.strictObject({ count: z.int(), files: z.array(z.string()) })
|
|
62
|
+
.optional(),
|
|
63
|
+
details: z.strictObject({
|
|
64
|
+
a: z.array(SETTING).min(1),
|
|
65
|
+
b: z.array(SETTING).min(1),
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
type LinterCard = z.infer<typeof LINTER_CARD>;
|
|
69
|
+
type Setting = z.infer<typeof SETTING>;
|
|
70
|
+
|
|
71
|
+
const LINTER_LABELS = {
|
|
72
|
+
place: "Card {n} of {total}",
|
|
73
|
+
body: "Requirement",
|
|
74
|
+
line: "Where this requirement comes from (the project's rules file)",
|
|
75
|
+
line_part: "Where this requirement comes from (the project's rules file)",
|
|
76
|
+
repeat: "Card {index} of {of} for this requirement",
|
|
77
|
+
practice_example: "Example of this answer, about other code: ",
|
|
78
|
+
};
|
|
79
|
+
const KEY_LABEL: Readonly<Record<string, string>> = {
|
|
80
|
+
files: "Checks files",
|
|
81
|
+
ignores: "Leaves out",
|
|
82
|
+
rule: "Rule",
|
|
83
|
+
};
|
|
84
|
+
const DOCS_NOTE =
|
|
85
|
+
"The flagged/allowed examples are the docs' own, with the docs' own settings, not this project's.";
|
|
86
|
+
|
|
87
|
+
/** A rule as its docs explain it, as a reference entry. */
|
|
88
|
+
const ruleEntry = (r: LinterCard["rules"][number]) => {
|
|
89
|
+
const x = r.explanation;
|
|
90
|
+
return {
|
|
91
|
+
label: "Rule",
|
|
92
|
+
title: `${r.linter} \`${r.id}\``,
|
|
93
|
+
fields: [
|
|
94
|
+
x.source === "none"
|
|
95
|
+
? { style: "note", text: "No official explanation for this rule." }
|
|
96
|
+
: {
|
|
97
|
+
...(x.source === "config message"
|
|
98
|
+
? { label: "The config's own message" }
|
|
99
|
+
: {}),
|
|
100
|
+
text: String(x.what),
|
|
101
|
+
},
|
|
102
|
+
...(x.why === undefined ? [] : [{ label: "Why", text: x.why }]),
|
|
103
|
+
...(x.example_bad === undefined && x.example_good === undefined
|
|
104
|
+
? []
|
|
105
|
+
: [{ style: "note", text: DOCS_NOTE }]),
|
|
106
|
+
...(x.example_bad === undefined
|
|
107
|
+
? []
|
|
108
|
+
: [{ label: "Flagged", style: "code", text: x.example_bad }]),
|
|
109
|
+
...(x.example_good === undefined
|
|
110
|
+
? []
|
|
111
|
+
: [{ label: "Allowed", style: "code", text: x.example_good }]),
|
|
112
|
+
],
|
|
113
|
+
...(x.url === undefined ? {} : { url: x.url, url_label: "Rule docs" }),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
/** Each glob or option on its own row: differences first (A only, B only), what both share last. */
|
|
117
|
+
const settingRows = (d: LinterCard["details"]) => {
|
|
118
|
+
const same = (r: Setting, s: Setting) => r.key === s.key && r.raw === s.raw;
|
|
119
|
+
const onlyIn = (xs: readonly Setting[], ys: readonly Setting[]) =>
|
|
120
|
+
xs.filter((r) => !ys.some((s) => same(r, s)));
|
|
121
|
+
const rows: readonly (readonly [Setting, string])[] = [
|
|
122
|
+
...onlyIn(d.a, d.b).map((r) => [r, "A only"] as const),
|
|
123
|
+
...onlyIn(d.b, d.a).map((r) => [r, "B only"] as const),
|
|
124
|
+
...d.a
|
|
125
|
+
.filter((r) => d.b.some((s) => same(r, s)))
|
|
126
|
+
.map((r) => [r, "A and B"] as const),
|
|
127
|
+
];
|
|
128
|
+
return rows.map(([r, side]) => ({
|
|
129
|
+
label: `${side} · ${KEY_LABEL[r.key] ?? `Option ${r.key.replace(/^option:/u, "")}`}`,
|
|
130
|
+
value: r.raw,
|
|
131
|
+
plain: r.plain,
|
|
132
|
+
...(r.matches_example === true
|
|
133
|
+
? { mark: { tone: "yes", text: "✓ matches this file" } }
|
|
134
|
+
: r.matches_example === false
|
|
135
|
+
? { mark: { tone: "no", text: "✗ does not match this file" } }
|
|
136
|
+
: {}),
|
|
137
|
+
}));
|
|
138
|
+
};
|
|
139
|
+
/** The files like this one, as a folded list; none when there are none. */
|
|
140
|
+
const moreList = (more: LinterCard["more_like_this"]) =>
|
|
141
|
+
more === undefined || more.count === 0
|
|
142
|
+
? {}
|
|
143
|
+
: {
|
|
144
|
+
more: {
|
|
145
|
+
summary: `${String(more.count)} more file(s) with the same situation`,
|
|
146
|
+
items: more.files.map((f) => `\`${f}\``),
|
|
147
|
+
...(more.count > more.files.length
|
|
148
|
+
? { rest: `and ${String(more.count - more.files.length)} more` }
|
|
149
|
+
: {}),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
/** One card in the generic blocks. */
|
|
153
|
+
function linterCard(card: LinterCard): Record<string, unknown> {
|
|
154
|
+
const { example: ex, rules, more_like_this: more, details, ...rest } = card;
|
|
155
|
+
const by = ex.caught_by === "a" ? ["A", "B"] : ["B", "A"];
|
|
156
|
+
return {
|
|
157
|
+
...rest,
|
|
158
|
+
excerpt: {
|
|
159
|
+
file: ex.file,
|
|
160
|
+
...(ex.url === undefined ? {} : { url: ex.url }),
|
|
161
|
+
lines: ex.lines,
|
|
162
|
+
caption: `The linter says: ${ex.message}`,
|
|
163
|
+
},
|
|
164
|
+
references: {
|
|
165
|
+
summary:
|
|
166
|
+
rules.length > 1
|
|
167
|
+
? "What the linter rules do (official docs)"
|
|
168
|
+
: "What the linter rule does (official docs)",
|
|
169
|
+
entries: rules.map(ruleEntry),
|
|
170
|
+
},
|
|
171
|
+
...moreList(more),
|
|
172
|
+
table: {
|
|
173
|
+
summary: "Settings behind this card",
|
|
174
|
+
note: `Setting ${String(by[0])} flags the example line; setting ${String(by[1])} passes it.`,
|
|
175
|
+
rows: settingRows(details),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const CONFIG = z.looseObject({
|
|
181
|
+
preset: z.string(),
|
|
182
|
+
labels: z.record(z.string(), z.string()).optional(),
|
|
183
|
+
practice: z.array(z.unknown()),
|
|
184
|
+
items: z.array(z.unknown()),
|
|
185
|
+
});
|
|
186
|
+
const PRESETS: Readonly<
|
|
187
|
+
Record<
|
|
188
|
+
string,
|
|
189
|
+
{
|
|
190
|
+
readonly labels: Readonly<Record<string, string>>;
|
|
191
|
+
readonly card: (c: LinterCard) => Record<string, unknown>;
|
|
192
|
+
readonly schema: typeof LINTER_CARD;
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
> = {
|
|
196
|
+
"linter-config-witness": {
|
|
197
|
+
labels: LINTER_LABELS,
|
|
198
|
+
card: linterCard,
|
|
199
|
+
schema: LINTER_CARD,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Each problem zod found, as the build's refusals read: the path, then what is wrong. */
|
|
204
|
+
const issuesOf = (where: string, e: z.ZodError): string =>
|
|
205
|
+
e.issues
|
|
206
|
+
.map(
|
|
207
|
+
(issue) =>
|
|
208
|
+
`${where}${issue.path
|
|
209
|
+
.map((p) =>
|
|
210
|
+
typeof p === "number" ? `[${String(p)}]` : `.${String(p)}`,
|
|
211
|
+
)
|
|
212
|
+
.join("")}: ${issue.message}`,
|
|
213
|
+
)
|
|
214
|
+
.join("; ");
|
|
215
|
+
|
|
216
|
+
/** Every card through the preset, practice first, each refused by its path. */
|
|
217
|
+
const mapCards = (
|
|
218
|
+
preset: (typeof PRESETS)[string],
|
|
219
|
+
practice: readonly unknown[],
|
|
220
|
+
items: readonly unknown[],
|
|
221
|
+
): readonly Preset[] =>
|
|
222
|
+
[
|
|
223
|
+
...practice.map((c, n) => [`practice[${String(n)}]`, c] as const),
|
|
224
|
+
...items.map((c, n) => [`items[${String(n)}]`, c] as const),
|
|
225
|
+
].map(([at, c]) => {
|
|
226
|
+
const r = preset.schema.safeParse(c);
|
|
227
|
+
return r.success
|
|
228
|
+
? { ok: true, value: preset.card(r.data) }
|
|
229
|
+
: { ok: false, message: issuesOf(at, r.error) };
|
|
230
|
+
});
|
|
231
|
+
/** The config with its preset applied; a config without `preset` comes back as it was. */
|
|
232
|
+
export function applyPreset(raw: unknown): Preset {
|
|
233
|
+
const cfg = CONFIG.safeParse(raw);
|
|
234
|
+
if (!cfg.success) return { ok: true, value: raw };
|
|
235
|
+
const { preset: name, labels, practice, items, ...rest } = cfg.data;
|
|
236
|
+
const preset = PRESETS[name];
|
|
237
|
+
if (preset === undefined)
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
message: `preset: '${name}' is not a preset (${Object.keys(PRESETS).join(", ")})`,
|
|
241
|
+
};
|
|
242
|
+
const cards = mapCards(preset, practice, items);
|
|
243
|
+
const bad = cards.find((c) => !c.ok);
|
|
244
|
+
if (bad !== undefined) return bad;
|
|
245
|
+
const made = cards
|
|
246
|
+
.filter((c): c is Extract<Preset, { ok: true }> => c.ok)
|
|
247
|
+
.map((c) => c.value);
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
value: {
|
|
251
|
+
...rest,
|
|
252
|
+
labels: { ...preset.labels, ...labels },
|
|
253
|
+
practice: made.slice(0, practice.length),
|
|
254
|
+
items: made.slice(practice.length),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|