paperlint 14.0.0 → 14.1.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.
@@ -13,6 +13,7 @@ import {
13
13
  type Described as RuleText,
14
14
  type RuleRef,
15
15
  } from "./rule-descriptions.ts";
16
+ import { applyPreset } from "./label-page-presets.ts";
16
17
 
17
18
  // ── refusals and output ───────────────────────────────────────────────────────────────────────
18
19
  export class Refusal extends Error {
@@ -83,6 +84,11 @@ const writeJson = (path: string, value: unknown): void => {
83
84
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
84
85
  };
85
86
  const JSON_VALUE = z.json();
87
+ /** The config with its named preset applied (label-page-presets.ts), refused by path. */
88
+ const withPreset = (raw: Json): Json => {
89
+ const r = applyPreset(raw);
90
+ return r.ok ? JSON_VALUE.parse(r.value) : fail(r.message);
91
+ };
86
92
  const readJson = (path: string): Json => {
87
93
  try {
88
94
  return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
@@ -108,11 +114,22 @@ const RULES_FIELD = "rules";
108
114
  const UNIT_FIELD = "unit";
109
115
  // Two readings of one requirement, shown as rows A and B (#374).
110
116
  const READINGS_FIELD = "readings";
117
+ // Content blocks (#380): generic, on any kind. Every label and sentence a block shows comes from
118
+ // items.json (or a preset, label-page-presets.ts); the page knows only the block's shape.
119
+ const BLOCK_FIELDS = [
120
+ "situation",
121
+ "excerpt",
122
+ "references",
123
+ "more",
124
+ "table",
125
+ "repeat",
126
+ ] as const;
111
127
  const ALLOWED_FIELDS: readonly string[] = [
112
128
  ...PAGE_FIELDS,
113
129
  RULES_FIELD,
114
130
  UNIT_FIELD,
115
131
  READINGS_FIELD,
132
+ ...BLOCK_FIELDS,
116
133
  ...PRIVATE_FIELDS,
117
134
  ];
118
135
 
@@ -152,6 +169,7 @@ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
152
169
  // refusal, what a value must be (a pattern) or why a field is required (a page kind needs it).
153
170
  type SchemaNode = (
154
171
  | { kind: "string"; required: boolean; pattern?: string }
172
+ | { kind: "text"; required: boolean }
155
173
  | { kind: "integer"; required: boolean }
156
174
  | { kind: "boolean"; required: boolean }
157
175
  | { kind: "array"; required: boolean; min: number; items: SchemaNode }
@@ -179,6 +197,8 @@ const string = (required = true, pattern?: string, why?: string): SchemaNode =>
179
197
  pattern,
180
198
  ...(why === undefined ? {} : { why }),
181
199
  };
200
+ /** A string that may be empty: a blank line of code. */
201
+ const text = (): SchemaNode => ({ kind: "text", required: true });
182
202
  const integer = (required = true): SchemaNode => ({
183
203
  kind: "integer",
184
204
  required,
@@ -212,6 +232,14 @@ const obj = (
212
232
 
213
233
  // What an item and a practice item both may carry beyond text: a practice card looks like the real
214
234
  // cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
235
+ const LINK = string(false, "https?://\\S+", "an http(s) link");
236
+ const optional = (fields: Readonly<Record<string, SchemaNode>>): SchemaNode =>
237
+ obj(fields, true, false);
238
+ const TEXT_FIELD = obj({
239
+ label: string(false),
240
+ text: string(),
241
+ style: string(false, "code|note", "code or note"),
242
+ });
215
243
  const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
216
244
  [RULES_FIELD]: array(
217
245
  obj({
@@ -235,6 +263,51 @@ const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
235
263
  ),
236
264
  [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
237
265
  file: string(false),
266
+ situation: optional({ summary: string(), question: string() }),
267
+ excerpt: optional({
268
+ file: string(false),
269
+ url: LINK,
270
+ lines: array(obj({ n: integer(), text: text(), mark: boolean() }), true, 1),
271
+ caption: string(false),
272
+ mark_label: string(false),
273
+ }),
274
+ references: optional({
275
+ summary: string(),
276
+ entries: array(
277
+ obj({
278
+ label: string(false),
279
+ title: string(),
280
+ fields: array(TEXT_FIELD, true, 1),
281
+ url: LINK,
282
+ url_label: string(false),
283
+ }),
284
+ true,
285
+ 1,
286
+ ),
287
+ }),
288
+ more: optional({
289
+ summary: string(),
290
+ items: array(string(), true, 1),
291
+ rest: string(false),
292
+ }),
293
+ table: optional({
294
+ summary: string(),
295
+ note: string(false),
296
+ rows: array(
297
+ obj({
298
+ label: string(),
299
+ value: string(),
300
+ plain: string(false),
301
+ mark: optional({
302
+ tone: string(true, "yes|no", "yes or no"),
303
+ text: string(),
304
+ }),
305
+ }),
306
+ true,
307
+ 1,
308
+ ),
309
+ }),
310
+ repeat: optional({ group: string(), index: integer(), of: integer() }),
238
311
  };
239
312
  /**
240
313
  * THE PAGE KINDS, and the card fields each requires on every item and practice card (#373). A fix to
@@ -331,6 +404,15 @@ const configSchema = (kind: Kind): SchemaNode =>
331
404
  questions: array(QUESTION_SCHEMA, true, 1),
332
405
  practice: array(practiceSchema(kind), true, MIN_PRACTICE),
333
406
  practice_round: integer(false),
407
+ // Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
408
+ labels: optional({
409
+ place: string(false),
410
+ body: string(false),
411
+ line: string(false),
412
+ line_part: string(false),
413
+ repeat: string(false),
414
+ practice_example: string(false),
415
+ }),
334
416
  items: array(itemSchema(kind), true, 1),
335
417
  });
336
418
  /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
@@ -412,6 +494,20 @@ function checkBoolean(value: Json, where: string): void {
412
494
  if (typeof value !== "boolean")
413
495
  fail(`${where}: must be true or false, got ${repr(value)}`);
414
496
  }
497
+ /** A string that may be empty (a blank line of code), or an integer. */
498
+ function checkScalar(
499
+ value: Json,
500
+ kind: "text" | "integer",
501
+ where: string,
502
+ ): void {
503
+ if (kind === "text" && typeof value !== "string")
504
+ fail(`${where}: must be a string, got ${repr(value)}`);
505
+ if (
506
+ kind === "integer" &&
507
+ (typeof value !== "number" || !Number.isInteger(value))
508
+ )
509
+ fail(`${where}: must be an integer, got ${repr(value)}`);
510
+ }
415
511
  /** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
416
512
  function checkValue(value: Json, node: SchemaNode, path: string): void {
417
513
  const where = path || "items.json";
@@ -419,10 +515,9 @@ function checkValue(value: Json, node: SchemaNode, path: string): void {
419
515
  case "string":
420
516
  checkString(value, node, where);
421
517
  break;
518
+ case "text":
422
519
  case "integer":
423
- if (typeof value !== "number" || !Number.isInteger(value)) {
424
- fail(`${where}: must be an integer, got ${repr(value)}`);
425
- }
520
+ checkScalar(value, node.kind, where);
426
521
  break;
427
522
  case "boolean":
428
523
  checkBoolean(value, where);
@@ -459,15 +554,66 @@ interface Unit {
459
554
  readonly part: number;
460
555
  readonly of: number;
461
556
  }
557
+ interface TextField {
558
+ readonly label?: string;
559
+ readonly text: string;
560
+ readonly style?: string;
561
+ }
562
+ interface CodeLine {
563
+ readonly n: number;
564
+ readonly text: string;
565
+ readonly mark: boolean;
566
+ }
567
+ /** The content blocks (#380), each optional on any kind. */
568
+ interface Blocks {
569
+ readonly situation?: { readonly summary: string; readonly question: string };
570
+ readonly excerpt?: {
571
+ readonly file?: string;
572
+ readonly url?: string;
573
+ readonly lines: readonly CodeLine[];
574
+ readonly caption?: string;
575
+ readonly mark_label?: string;
576
+ };
577
+ readonly references?: {
578
+ readonly summary: string;
579
+ readonly entries: readonly {
580
+ readonly label?: string;
581
+ readonly title: string;
582
+ readonly fields: readonly TextField[];
583
+ readonly url?: string;
584
+ readonly url_label?: string;
585
+ }[];
586
+ };
587
+ readonly more?: {
588
+ readonly summary: string;
589
+ readonly items: readonly string[];
590
+ readonly rest?: string;
591
+ };
592
+ readonly table?: {
593
+ readonly summary: string;
594
+ readonly note?: string;
595
+ readonly rows: readonly {
596
+ readonly label: string;
597
+ readonly value: string;
598
+ readonly plain?: string;
599
+ readonly mark?: { readonly tone: string; readonly text: string };
600
+ }[];
601
+ };
602
+ readonly repeat?: {
603
+ readonly group: string;
604
+ readonly index: number;
605
+ readonly of: number;
606
+ };
607
+ }
462
608
  /** What a card shows, on an item and on a practice item alike. */
463
609
  type Card = {
464
610
  readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
465
- } & {
466
- readonly body: string;
467
- readonly rules?: readonly RuleRef[];
468
- readonly unit?: Unit;
469
- readonly readings?: { readonly a: string; readonly b: string };
470
- };
611
+ } & Blocks & {
612
+ readonly body: string;
613
+ readonly rules?: readonly RuleRef[];
614
+ readonly unit?: Unit;
615
+ readonly readings?: { readonly a: string; readonly b: string };
616
+ };
471
617
  type Item = Card & { readonly source_id: string; readonly group: string };
472
618
  type Practice = Card & {
473
619
  readonly expected: Readonly<Record<string, string>>;
@@ -486,6 +632,7 @@ interface Config {
486
632
  readonly questions: readonly Question[];
487
633
  readonly practice: readonly Practice[];
488
634
  readonly practice_round?: number;
635
+ readonly labels?: Readonly<Record<string, string>>;
489
636
  readonly items: readonly Item[];
490
637
  }
491
638
  /** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
@@ -688,6 +835,43 @@ function checkUnits(cfg: Config): void {
688
835
  });
689
836
  }
690
837
  }
838
+ /**
839
+ * A situation's sentence or question may name the excerpt's marked line as `{line}`: the page writes
840
+ * «line N: » and the line's code there, so a question sits next to the code it is about.
841
+ */
842
+ const LINE_TOKEN = "{line}";
843
+ const SITUATION_TEXTS = ["summary", "question"] as const;
844
+ /** The line `{line}` stands for: the first marked line, its number and its code without the indent. */
845
+ const lineOf = (card: Card) =>
846
+ card.excerpt?.lines
847
+ .filter((l) => l.mark)
848
+ .map((l) => ({ n: l.n, text: l.text.trim() }))[0];
849
+ const usesLine = (card: Card): boolean =>
850
+ SITUATION_TEXTS.some((f) => card.situation?.[f].includes(LINE_TOKEN));
851
+ /**
852
+ * What the block schema cannot say: an excerpt marks a line, `{line}` has a marked line to stand for,
853
+ * and a repeat sits inside its count.
854
+ */
855
+ function checkBlocks(cfg: Config): void {
856
+ cardsOf(cfg).forEach(([at, card]) => {
857
+ if (card.excerpt !== undefined && !card.excerpt.lines.some((l) => l.mark))
858
+ fail(
859
+ `${at}.excerpt.lines: no line is marked — mark the line the card is about (\`mark: true\`)`,
860
+ );
861
+ const token = SITUATION_TEXTS.find((f) =>
862
+ card.situation?.[f].includes(LINE_TOKEN),
863
+ );
864
+ if (token !== undefined && card.excerpt === undefined)
865
+ fail(
866
+ `${at}.situation.${token}: \`${LINE_TOKEN}\` names the excerpt's marked line, and the card has no \`excerpt\` — add it, or write the ${token} without \`${LINE_TOKEN}\``,
867
+ );
868
+ const r = card.repeat;
869
+ if (r !== undefined && (r.index < 1 || r.index > r.of))
870
+ fail(
871
+ `${at}.repeat.index: ${String(r.index)} is not between 1 and ${String(r.of)}`,
872
+ );
873
+ });
874
+ }
691
875
  /** Every card of the config, items then practice, by path. */
692
876
  const cardsOf = (cfg: Config) => [
693
877
  ...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
@@ -715,12 +899,24 @@ function checkItemFields(cfg: Config): void {
715
899
  );
716
900
  });
717
901
  }
718
- /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
719
- const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
720
- ...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
721
- [UNIT_FIELD, item.unit?.text ?? ""],
722
- [READINGS_FIELD, `${item.readings?.a ?? ""} ${item.readings?.b ?? ""}`],
723
- ];
902
+ /** Every string inside a value: each is tested on its own, as the page renders it, never a serialisation. */
903
+ const stringsIn = (v: unknown): readonly string[] =>
904
+ typeof v === "string"
905
+ ? [v]
906
+ : Array.isArray(v)
907
+ ? v.flatMap(stringsIn)
908
+ : typeof v === "object" && v !== null
909
+ ? Object.values(v).flatMap(stringsIn)
910
+ : [];
911
+ /**
912
+ * Every text of an item the labeller sees, by field: each string the page shows (the fields as
913
+ * `shownFields` passes them, so a repeat without its group), and every rule it names.
914
+ */
915
+ const visibleTexts = (item: Item): readonly (readonly [string, string])[] =>
916
+ Object.entries({
917
+ ...shownFields(item),
918
+ [RULES_FIELD]: item.rules ?? [],
919
+ }).flatMap(([f, v]) => stringsIn(v).map((t) => [f, t] as const));
724
920
  const escapeRegExp = (s: string): string =>
725
921
  s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
726
922
  /**
@@ -804,13 +1000,18 @@ function assignIds(
804
1000
  }
805
1001
 
806
1002
  // ── a build, short of the page ──────────────────────────────────────────────────────────────────
807
- /** A unit as the page shows it: the line, its mark and its part, without the line's private id. */
808
- type ShownUnit = Omit<Unit, "id">;
809
- const shownFields = (
810
- item: Card,
811
- ): Readonly<
812
- Record<string, string | ShownUnit | NonNullable<Card["readings"]>>
813
- > => ({
1003
+ /** A card's situation as the page shows it, with the line `{line}` stands for. */
1004
+ const situationShown = (item: Card) =>
1005
+ item.situation === undefined
1006
+ ? {}
1007
+ : {
1008
+ situation: {
1009
+ summary: item.situation.summary,
1010
+ question: item.situation.question,
1011
+ ...(usesLine(item) ? { line: lineOf(item) } : {}),
1012
+ },
1013
+ };
1014
+ const shownFields = (item: Card): Readonly<Record<string, unknown>> => ({
814
1015
  ...Object.fromEntries(
815
1016
  PAGE_FIELDS.flatMap((f) => {
816
1017
  const v = item[f];
@@ -828,6 +1029,15 @@ const shownFields = (
828
1029
  },
829
1030
  }),
830
1031
  ...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
1032
+ ...Object.fromEntries(
1033
+ BLOCK_FIELDS.filter((f) => f !== "repeat" && f !== "situation").flatMap(
1034
+ (f) => (item[f] === undefined ? [] : [[f, item[f]]]),
1035
+ ),
1036
+ ),
1037
+ ...situationShown(item),
1038
+ ...(item.repeat === undefined
1039
+ ? {}
1040
+ : { repeat: { index: item.repeat.index, of: item.repeat.of } }),
831
1041
  });
832
1042
  /** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
833
1043
  type Described = ReadonlyMap<string, RuleText>;
@@ -836,14 +1046,16 @@ const ruleKey = (r: RuleRef): string => `${r.linter} ${r.id}`;
836
1046
  function describeRules(cfg: Config, fromDir: string): Described {
837
1047
  return new Map(
838
1048
  cardsOf(cfg).flatMap(([at, card]) =>
839
- (card.rules ?? []).map((r, k): readonly [string, RuleText] => {
840
- const text = describeRule(r, fromDir);
841
- return text === undefined
842
- ? fail(
843
- `${at}.rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`,
844
- )
845
- : [ruleKey(r), text];
846
- }),
1049
+ (card.rules ?? []).flatMap(
1050
+ (r, k): readonly (readonly [string, RuleText])[] => {
1051
+ const text = describeRule(r, fromDir);
1052
+ return text === undefined
1053
+ ? fail(
1054
+ `${at}.rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`,
1055
+ )
1056
+ : [[ruleKey(r), text]];
1057
+ },
1058
+ ),
847
1059
  ),
848
1060
  );
849
1061
  }
@@ -882,6 +1094,14 @@ const practiceShown = (cfg: Config, described: Described) =>
882
1094
  ...shownFields(p),
883
1095
  ...rulesShown(p, described),
884
1096
  }));
1097
+ /** The page's own words where `labels` leaves one out (references/closed-choice-generator.md). */
1098
+ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
1099
+ place: "{id} · {n} of {total}",
1100
+ line: "Line",
1101
+ line_part: "{line} · part {part} of {of}",
1102
+ repeat: "Card {index} of {of}",
1103
+ practice_example: "",
1104
+ };
885
1105
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
886
1106
  function payloadOf(
887
1107
  cfg: Config,
@@ -894,6 +1114,7 @@ function payloadOf(
894
1114
  return {
895
1115
  title: cfg.title,
896
1116
  intro: cfg.intro ?? "",
1117
+ labels: { ...DEFAULT_LABELS, ...cfg.labels },
897
1118
  groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
898
1119
  familiarity_question: asksFamiliarity(cfg)
899
1120
  ? (cfg.familiarity_question ?? "How well do you know {group}?")
@@ -983,13 +1204,14 @@ function keyOf(
983
1204
  * returns what the page shows. A page written by hand can take its blindness and its key from here.
984
1205
  */
985
1206
  export function prepareBuild(configPath: string, outDir: string) {
986
- const cfg = readJson(configPath);
1207
+ const cfg = withPreset(readJson(configPath));
987
1208
  assertConfig(cfg);
988
1209
  // Once its `kind` is known to be one, the fields that kind needs on every card.
989
1210
  checkValue(cfg, configSchema(cfg.kind), "");
990
1211
  checkConfig(cfg);
991
1212
  checkItemFields(cfg);
992
1213
  checkUnits(cfg);
1214
+ checkBlocks(cfg);
993
1215
  checkNoSourceShown(cfg);
994
1216
  const described = describeRules(cfg, dirname(configPath));
995
1217
  mkdirSync(outDir, { recursive: true });
@@ -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
+ }