paperlint 6.3.0 → 7.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.
Files changed (185) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +20 -13
  3. package/dist/adapters/latex/conditionals.d.ts +21 -0
  4. package/dist/adapters/latex/conditionals.d.ts.map +1 -0
  5. package/dist/adapters/latex/conditionals.js +75 -0
  6. package/dist/adapters/latex/conditionals.js.map +1 -0
  7. package/dist/adapters/latex/headings.d.ts +10 -0
  8. package/dist/adapters/latex/headings.d.ts.map +1 -0
  9. package/dist/adapters/latex/headings.js +129 -0
  10. package/dist/adapters/latex/headings.js.map +1 -0
  11. package/dist/adapters/latex/index.d.ts +1 -0
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +3 -0
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/parse.d.ts +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +3 -2
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/build.d.ts +1 -1
  20. package/dist/build.d.ts.map +1 -1
  21. package/dist/build.js +1 -1
  22. package/dist/build.js.map +1 -1
  23. package/dist/cli.d.ts +1 -1
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +19 -5
  26. package/dist/cli.js.map +1 -1
  27. package/dist/domain/body-pages.d.ts +29 -0
  28. package/dist/domain/body-pages.d.ts.map +1 -1
  29. package/dist/domain/body-pages.js +25 -0
  30. package/dist/domain/body-pages.js.map +1 -1
  31. package/dist/domain/heading-case.d.ts +100 -0
  32. package/dist/domain/heading-case.d.ts.map +1 -0
  33. package/dist/domain/heading-case.js +365 -0
  34. package/dist/domain/heading-case.js.map +1 -0
  35. package/dist/domain/tex-document.d.ts +32 -0
  36. package/dist/domain/tex-document.d.ts.map +1 -1
  37. package/dist/domain/tex-document.js +11 -0
  38. package/dist/domain/tex-document.js.map +1 -1
  39. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  40. package/dist/eslint-rules/latex-language.js +16 -3
  41. package/dist/eslint-rules/latex-language.js.map +1 -1
  42. package/dist/facts-file.d.ts +3 -3
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +3 -3
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/folder-venue-rule.js +6 -6
  47. package/dist/folder-venue-rule.js.map +1 -1
  48. package/dist/heading-case.d.ts +48 -0
  49. package/dist/heading-case.d.ts.map +1 -0
  50. package/dist/heading-case.js +191 -0
  51. package/dist/heading-case.js.map +1 -0
  52. package/dist/paper-includes.d.ts +3 -0
  53. package/dist/paper-includes.d.ts.map +1 -1
  54. package/dist/paper-includes.js +17 -2
  55. package/dist/paper-includes.js.map +1 -1
  56. package/dist/ports/latex.d.ts +7 -1
  57. package/dist/ports/latex.d.ts.map +1 -1
  58. package/dist/preset-card-rule.d.ts +100 -0
  59. package/dist/preset-card-rule.d.ts.map +1 -0
  60. package/dist/preset-card-rule.js +221 -0
  61. package/dist/preset-card-rule.js.map +1 -0
  62. package/dist/presets.d.ts +29 -5
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +104 -68
  65. package/dist/presets.js.map +1 -1
  66. package/dist/submission.d.ts +8 -0
  67. package/dist/submission.d.ts.map +1 -1
  68. package/dist/submission.js +19 -7
  69. package/dist/submission.js.map +1 -1
  70. package/dist/tex-requirements.d.ts +42 -3
  71. package/dist/tex-requirements.d.ts.map +1 -1
  72. package/dist/tex-requirements.js +34 -5
  73. package/dist/tex-requirements.js.map +1 -1
  74. package/dist/tex-venue-rules.d.ts +6 -3
  75. package/dist/tex-venue-rules.d.ts.map +1 -1
  76. package/dist/tex-venue-rules.js +29 -10
  77. package/dist/tex-venue-rules.js.map +1 -1
  78. package/dist/venue-rules.d.ts +2 -1
  79. package/dist/venue-rules.d.ts.map +1 -1
  80. package/dist/venue-rules.js +55 -7
  81. package/dist/venue-rules.js.map +1 -1
  82. package/docs/configuration.md +13 -1
  83. package/docs/e2e.md +14 -12
  84. package/docs/optional-rules.md +4 -3
  85. package/docs/rules/format/page-limit.md +19 -2
  86. package/docs/rules/paper/folder-venue-leftover.md +1 -1
  87. package/docs/rules/talk/profile.md +1 -1
  88. package/docs/rules/tex/heading-case.md +424 -0
  89. package/docs/rules/tex/template.md +25 -5
  90. package/docs/rules/tex/venue-leftover.md +13 -0
  91. package/docs/rules.md +74 -57
  92. package/docs/submission.md +5 -1
  93. package/eslint-rules/latex-language.test.mjs +12 -0
  94. package/eslint-rules/latex-language.ts +17 -4
  95. package/eslint-rules/paper-typography.test.mjs +12 -0
  96. package/eslint.config.mjs +59 -1
  97. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  98. package/fixtures/build-e2e/fallback/expect.json +3 -3
  99. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  100. package/fixtures/build-e2e/msr/expect.json +18 -0
  101. package/fixtures/build-e2e/msr/paper.tex +49 -0
  102. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  103. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  104. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  105. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  106. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  107. package/lib/skill-checks.mjs +31 -0
  108. package/package.json +7 -1
  109. package/presets/acm-sigconf.jsonc +5 -0
  110. package/presets/acm-sigconf.md +43 -0
  111. package/presets/agenticdev.jsonc +27 -6
  112. package/presets/agenticdev.md +151 -0
  113. package/presets/aidc.jsonc +6 -0
  114. package/presets/aidc.md +75 -0
  115. package/presets/aisec.jsonc +5 -1
  116. package/presets/aisec.md +117 -0
  117. package/presets/ieee-conference.jsonc +5 -0
  118. package/presets/ieee-conference.md +38 -0
  119. package/presets/msr.jsonc +71 -0
  120. package/presets/msr.md +94 -0
  121. package/presets/realm.jsonc +7 -2
  122. package/presets/realm.md +166 -0
  123. package/presets/tex/agenticdev.tex +1 -1
  124. package/presets/tex-base.jsonc +6 -0
  125. package/presets/tex-base.md +30 -0
  126. package/presets/venue-profile.schema.json +65 -1
  127. package/skills/camera-ready/SKILL.md +8 -1
  128. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  129. package/skills/find-venue/SKILL.md +6 -5
  130. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  131. package/skills/paper-pipeline/SKILL.md +5 -6
  132. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  133. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  134. package/skills/study-accepted-papers/SKILL.md +3 -3
  135. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  136. package/skills/submission-portal/SKILL.md +2 -2
  137. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  138. package/skills/submit-paper/SKILL.md +34 -22
  139. package/skills/submit-paper/SKILL.md.spec.ts +33 -21
  140. package/skills/submit-paper/references/publishers/acm.md +1 -1
  141. package/src/adapters/latex/conditionals.ts +130 -0
  142. package/src/adapters/latex/headings.ts +163 -0
  143. package/src/adapters/latex/index.test.ts +65 -0
  144. package/src/adapters/latex/index.ts +3 -0
  145. package/src/adapters/latex/parse.ts +3 -2
  146. package/src/build.ts +2 -2
  147. package/src/cli.test.ts +50 -7
  148. package/src/cli.ts +28 -6
  149. package/src/domain/body-pages.test.ts +144 -0
  150. package/src/domain/body-pages.ts +61 -1
  151. package/src/domain/heading-case.test.ts +660 -0
  152. package/src/domain/heading-case.ts +538 -0
  153. package/src/domain/tex-document.ts +36 -0
  154. package/src/domain/venue-name.test.ts +13 -7
  155. package/src/facts-file.test.ts +4 -4
  156. package/src/facts-file.ts +6 -6
  157. package/src/folder-venue-rule.test.ts +10 -10
  158. package/src/folder-venue-rule.ts +6 -6
  159. package/src/heading-case.test.ts +500 -0
  160. package/src/heading-case.ts +275 -0
  161. package/src/init.test.ts +1 -1
  162. package/src/new-paper.test.ts +37 -6
  163. package/src/paper-includes.test.ts +26 -0
  164. package/src/paper-includes.ts +20 -2
  165. package/src/paper-settings-commands.test.ts +26 -11
  166. package/src/ports/latex.ts +7 -0
  167. package/src/preset-card-rule.test.ts +267 -0
  168. package/src/preset-card-rule.ts +384 -0
  169. package/src/presets.test.ts +259 -41
  170. package/src/presets.ts +161 -75
  171. package/src/rule-cases.test.ts +136 -50
  172. package/src/submission.test.ts +44 -4
  173. package/src/submission.ts +28 -7
  174. package/src/talk-rules.test.ts +6 -3
  175. package/src/tex-requirements.harness.ts +25 -11
  176. package/src/tex-requirements.test.ts +127 -1
  177. package/src/tex-requirements.ts +91 -12
  178. package/src/tex-venue-rules.test.ts +196 -21
  179. package/src/tex-venue-rules.ts +47 -16
  180. package/src/venue-rules.test.ts +180 -8
  181. package/src/venue-rules.ts +71 -10
  182. package/templates/paper/paper.tex +1 -1
  183. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  184. package/skills/submit-paper/references/venues/aisec.md +0 -105
  185. package/skills/submit-paper/references/venues/realm.md +0 -155
package/src/presets.ts CHANGED
@@ -12,20 +12,27 @@
12
12
  * Anything else — an npm package name, a bare name — is refused by name. npm presets are not
13
13
  * supported yet; a bare name is almost always a shipped name missing its prefix, and the message says so.
14
14
  *
15
+ * ── WHAT EACH FILE IS ─────────────────────────────────────────────────────────────
16
+ * Every preset declares its `type` (`venue-profile.schema.json`): a `venue` names itself and links
17
+ * its call for papers, a template `family` names no venue, and the TeX `base` set is extended by
18
+ * nothing. A chain must read top-down as template → venue: a family extends only a family, and
19
+ * nothing extends the base set. A chain that does not is refused naming both files.
20
+ *
15
21
  * ── THE CHAIN ─────────────────────────────────────────────────────────────────────
16
22
  * At most `MAX_PRESET_DEPTH` files, root to leaf. A cycle (relative paths can form one) is refused
17
23
  * naming the chain. The chain is merged block by block:
18
24
  *
19
25
  * tex union — a child never removes a package its parent needs
20
26
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
21
- * rules per rule id, the child wins
22
- * template the child wins; so does `name`, and `required_sections`, `register`, `talk` and
27
+ * rules per rule id, the child wins; `ruleOrigins` keeps which file set each
28
+ * template the child wins, with its `template_forbids`; so does `name`, and `required_sections`, `register`, `talk` and
23
29
  * `portal` (each whole)
24
30
  * aliases union, with every `name` — what the venue is called along the chain
25
31
  *
26
- * ── THE LABEL ─────────────────────────────────────────────────────────────────────
27
- * Messages, the facts file and the build plan need a word for the venue. It is the most derived
28
- * preset's `name` when one sets it, else the spec's file name without its extension
32
+ * ── THE ID AND THE LABEL ──────────────────────────────────────────────────────────
33
+ * The facts file and the build plan carry the preset's `id`: the spec's file name without its
34
+ * extension, a stable key consumers match against preset ids. Messages say the `label`: the most
35
+ * derived venue's `name` when the chain has a venue, else the id
29
36
  * (`paperlint:agenticdev` → `agenticdev`, `./venues/usenix-sec.jsonc` → `usenix-sec`). Display
30
37
  * only: nothing is ever resolved by it.
31
38
  */
@@ -38,6 +45,7 @@ import {
38
45
  profileFileOf,
39
46
  venueNames,
40
47
  type PresetFile,
48
+ type PresetIdentity,
41
49
  type RequiredSection,
42
50
  type TexRequirements,
43
51
  type VenueFormat,
@@ -67,18 +75,27 @@ export interface PresetDeps {
67
75
  readonly venuesDir: string;
68
76
  }
69
77
 
70
- /** A preset's `template`, as its file spells it. */
78
+ /** A preset's `template`, as its file spells it, and the class options that file forbids. */
71
79
  export interface PresetTemplate {
72
80
  readonly text: string;
73
81
  readonly file: string;
82
+ /** `template_forbids` of the same file: options the paper's class line must not carry. */
83
+ readonly forbids: readonly string[];
74
84
  }
75
85
 
76
86
  /** A resolved chain, merged. */
77
87
  export interface Preset {
78
- /** The word for this venue in messages. */
88
+ /**
89
+ * The preset's id: the spec's file name without its extension (`agenticdev`, `usenix-sec`). The
90
+ * stable key a machine reads — the facts file and the build plan carry it — whatever `name` says.
91
+ */
92
+ readonly id: string;
93
+ /** The word for this venue in messages: the venue's `name`, else the id. */
79
94
  readonly label: string;
80
95
  /** Every file of the chain, root first. */
81
96
  readonly chain: readonly string[];
97
+ /** What the spec names — the leaf file of the chain: a venue (with its call), or a family. */
98
+ readonly identity: PresetIdentity;
82
99
  /**
83
100
  * The `\\documentclass` the venue's template uses, as the preset spells it, and the file that
84
101
  * spells it; null when no file of the chain names one. Read by the rules and `paperlint new`,
@@ -89,10 +106,17 @@ export interface Preset {
89
106
  readonly aliases: readonly string[];
90
107
  /** Whether the venue reviews double-blind (`anonymity/identity`); false when no file of the chain says. */
91
108
  readonly blind: boolean;
109
+ /**
110
+ * When the venue's names count as a mention in a paper's text (`tex/venue-leftover`): `any`
111
+ * whole-word use, or `with-year` beside it; `any` when no file of the chain says.
112
+ */
113
+ readonly mentions: "any" | "with-year";
92
114
  readonly requiredSections: readonly RequiredSection[];
93
115
  readonly tex: TexRequirements;
94
116
  readonly format: VenueFormat;
95
117
  readonly rules: Readonly<Record<string, unknown>>;
118
+ /** Rule id → the file of the chain that set its entry in `rules` (the last one to name it). */
119
+ readonly ruleOrigins: Readonly<Record<string, string>>;
96
120
  /** The accepted papers the register rules measure a body against; empty when no file names any. */
97
121
  readonly registerAnchors: readonly RegisterAnchor[];
98
122
  /** What the venue asks a presenter to send (`talk/*`); null when no file of the chain says. */
@@ -112,6 +136,14 @@ export type PresetProblem =
112
136
  }
113
137
  | { readonly kind: "cycle"; readonly chain: readonly string[] }
114
138
  | { readonly kind: "too-deep"; readonly chain: readonly string[] }
139
+ /** `file` extends `parent`, which a preset of its type may not extend. */
140
+ | {
141
+ readonly kind: "wrong-parent";
142
+ readonly file: string;
143
+ readonly parent: string;
144
+ /** A family extending a venue, or anything extending the base set. */
145
+ readonly parentType: "venue" | "base";
146
+ }
115
147
  | { readonly kind: "broken"; readonly file: string; readonly why: string };
116
148
 
117
149
  /** The display label of a spec: the file name without its extension. Pure. */
@@ -166,27 +198,47 @@ function readPreset(
166
198
  }
167
199
  }
168
200
 
169
- /** The chain's files, leaf first, each parsed. */
170
- function chainOf(
171
- spec: string,
172
- from: string,
201
+ /** One file of a chain: where it is, and what it says. */
202
+ interface Link {
203
+ readonly file: string;
204
+ readonly preset: PresetFile;
205
+ }
206
+
207
+ /**
208
+ * The chain from `child` up, leaf first: `below` is every file read so far, `child` the last of them.
209
+ * Each parent is refused when it closes a cycle, makes the chain too long, or is of a type `child`
210
+ * may not extend.
211
+ */
212
+ function chainUp(
213
+ below: readonly Link[],
214
+ child: Link,
173
215
  deps: PresetDeps,
174
- ): Result<{ file: string; preset: PresetFile }[], PresetProblem> {
175
- const out: { file: string; preset: PresetFile }[] = [];
176
- let next: { spec: string; from: string } | null = { spec, from };
177
- while (next !== null) {
178
- const r = readPreset(next.spec, next.from, deps);
179
- if (!r.ok) return r;
180
- const files = out.map((x) => x.file);
181
- if (files.includes(r.value.file))
182
- return err({ kind: "cycle", chain: [...files, r.value.file] });
183
- if (out.length === MAX_PRESET_DEPTH)
184
- return err({ kind: "too-deep", chain: [...files, r.value.file] });
185
- out.push(r.value);
186
- const up: string | null = r.value.preset.extends;
187
- next = up === null ? null : { spec: up, from: r.value.file };
188
- }
189
- return ok(out);
216
+ ): Result<readonly Link[], PresetProblem> {
217
+ const up = child.preset.extends;
218
+ if (up === null) return ok(below);
219
+ const r = readPreset(up, child.file, deps);
220
+ if (!r.ok) return r;
221
+ const files = below.map((x) => x.file);
222
+ if (files.includes(r.value.file))
223
+ return err({ kind: "cycle", chain: [...files, r.value.file] });
224
+ if (below.length === MAX_PRESET_DEPTH)
225
+ return err({ kind: "too-deep", chain: [...files, r.value.file] });
226
+ const parentType = r.value.preset.identity.type;
227
+ if (parentType === "venue" && child.preset.identity.type === "family")
228
+ return err({
229
+ kind: "wrong-parent",
230
+ file: child.file,
231
+ parent: r.value.file,
232
+ parentType,
233
+ });
234
+ if (parentType === "base")
235
+ return err({
236
+ kind: "wrong-parent",
237
+ file: child.file,
238
+ parent: r.value.file,
239
+ parentType,
240
+ });
241
+ return chainUp([...below, r.value], r.value, deps);
190
242
  }
191
243
 
192
244
  /** A child's format over its parent's: per key the child wins, kinds by name. Pure. */
@@ -209,56 +261,78 @@ export function mergeFormat(
209
261
  fontsText: pick("fontsText"),
210
262
  fontsTitle: pick("fontsTitle"),
211
263
  bodyEndsAt: pick("bodyEndsAt"),
264
+ appendixInBody: pick("appendixInBody"),
212
265
  kinds: new Map([...parent.kinds, ...child.kinds]),
213
266
  };
214
267
  }
215
268
 
269
+ /** A venue's name; a family and the base set have none. Pure. */
270
+ const nameOf = (i: PresetIdentity): string | null =>
271
+ i.type === "venue" ? i.name : null;
272
+
273
+ /** A venue's name as a list, empty for a family and the base set. Pure. */
274
+ const namesOf = (i: PresetIdentity): readonly string[] =>
275
+ i.type === "venue" ? [i.name] : [];
276
+
277
+ /** What the merge carries from file to file: the preset without what only the whole chain knows. */
278
+ type Merging = Omit<Preset, "id" | "label" | "chain" | "identity"> & {
279
+ readonly name: string | null;
280
+ };
281
+
282
+ const NOTHING_MERGED: Merging = {
283
+ name: null,
284
+ template: null,
285
+ aliases: [],
286
+ blind: false,
287
+ mentions: "any",
288
+ requiredSections: [],
289
+ tex: NO_REQUIREMENTS,
290
+ format: NO_FORMAT,
291
+ rules: {},
292
+ ruleOrigins: {},
293
+ registerAnchors: [],
294
+ talk: null,
295
+ portal: null,
296
+ };
297
+
298
+ /** One file of the chain, `file`, merged over what its parents gave. Pure. */
299
+ const mergeOne = (
300
+ acc: Merging,
301
+ { preset: p, file }: { readonly preset: PresetFile; readonly file: string },
302
+ ): Merging => ({
303
+ name: nameOf(p.identity) ?? acc.name,
304
+ template:
305
+ p.template === null
306
+ ? acc.template
307
+ : { text: p.template, file, forbids: p.templateForbids },
308
+ aliases: [...new Set([...acc.aliases, ...namesOf(p.identity), ...p.aliases])],
309
+ blind: p.blind ?? acc.blind,
310
+ mentions: p.mentions ?? acc.mentions,
311
+ requiredSections: p.requiredSections ?? acc.requiredSections,
312
+ tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
313
+ format: mergeFormat(acc.format, p.format),
314
+ rules: { ...acc.rules, ...p.rules },
315
+ ruleOrigins: {
316
+ ...acc.ruleOrigins,
317
+ ...Object.fromEntries(Object.keys(p.rules).map((id) => [id, file])),
318
+ },
319
+ registerAnchors: p.registerAnchors ?? acc.registerAnchors,
320
+ talk: p.talk ?? acc.talk,
321
+ portal: p.portal ?? acc.portal,
322
+ });
323
+
216
324
  /** The chain, root first, merged into one preset. Pure. */
217
325
  function merged(
218
326
  spec: string,
219
327
  rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
328
+ leaf: { readonly preset: PresetFile },
220
329
  ): Preset {
221
- const base: Omit<Preset, "label" | "chain"> & {
222
- readonly name: string | null;
223
- } = {
224
- name: null,
225
- template: null,
226
- aliases: [],
227
- blind: false,
228
- requiredSections: [],
229
- tex: NO_REQUIREMENTS,
230
- format: NO_FORMAT,
231
- rules: {},
232
- registerAnchors: [],
233
- talk: null,
234
- portal: null,
235
- };
236
- const m = rootFirst.reduce(
237
- (acc, { preset: p, file }) => ({
238
- name: p.name ?? acc.name,
239
- template: p.template === null ? acc.template : { text: p.template, file },
240
- aliases: [
241
- ...new Set([
242
- ...acc.aliases,
243
- ...(p.name === null ? [] : [p.name]),
244
- ...p.aliases,
245
- ]),
246
- ],
247
- blind: p.blind ?? acc.blind,
248
- requiredSections: p.requiredSections ?? acc.requiredSections,
249
- tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
250
- format: mergeFormat(acc.format, p.format),
251
- rules: { ...acc.rules, ...p.rules },
252
- registerAnchors: p.registerAnchors ?? acc.registerAnchors,
253
- talk: p.talk ?? acc.talk,
254
- portal: p.portal ?? acc.portal,
255
- }),
256
- base,
257
- );
258
- const { name, ...rest } = m;
330
+ const { name, ...rest } = rootFirst.reduce(mergeOne, NOTHING_MERGED);
259
331
  return {
332
+ id: labelOf(spec),
260
333
  label: name ?? labelOf(spec),
261
334
  chain: rootFirst.map((x) => x.file),
335
+ identity: leaf.preset.identity,
262
336
  ...rest,
263
337
  };
264
338
  }
@@ -272,13 +346,17 @@ export function resolvePreset(
272
346
  fromFile: string,
273
347
  deps: PresetDeps,
274
348
  ): Result<Preset, PresetProblem> {
275
- const chain = chainOf(spec, fromFile, deps);
276
- if (!chain.ok)
277
- return chain.error.kind === "not-found"
278
- ? err({ ...chain.error, shipped: shippedPresets(deps.venuesDir) })
279
- : chain;
280
- const rootFirst = [...chain.value].reverse();
281
- return ok(merged(spec, rootFirst));
349
+ const refused = (p: PresetProblem): Result<Preset, PresetProblem> =>
350
+ err(
351
+ p.kind === "not-found"
352
+ ? { ...p, shipped: shippedPresets(deps.venuesDir) }
353
+ : p,
354
+ );
355
+ const leaf = readPreset(spec, fromFile, deps);
356
+ if (!leaf.ok) return refused(leaf.error);
357
+ const chain = chainUp([leaf.value], leaf.value, deps);
358
+ if (!chain.ok) return refused(chain.error);
359
+ return ok(merged(spec, [...chain.value].reverse(), leaf.value));
282
360
  }
283
361
 
284
362
  /** The shipped presets' names (each is spelled `paperlint:<name>`). */
@@ -303,17 +381,21 @@ export function resolveShipped(deps: PresetDeps): readonly Preset[] {
303
381
  }
304
382
 
305
383
  /** A shipped venue as a name knows it, and the files of its chain: what tells it from a paper's own. */
306
- export type ShippedVenue = Pick<Preset, "label" | "aliases" | "chain">;
384
+ export type ShippedVenue = Pick<
385
+ Preset,
386
+ "label" | "aliases" | "chain" | "mentions"
387
+ >;
307
388
 
308
389
  /**
309
390
  * A resolved preset as a name knows it: its label, its aliases, its chain. Pure. (Not Remeda's
310
391
  * `pick`: the app layer may not import it — `boundaries/dependencies`, `APP_EXTERNALS`.)
311
392
  */
312
- export const venueOf = ({ label, aliases, chain }: Preset): ShippedVenue => ({
393
+ export const venueOf = ({
313
394
  label,
314
395
  aliases,
315
396
  chain,
316
- });
397
+ mentions,
398
+ }: Preset): ShippedVenue => ({ label, aliases, chain, mentions });
317
399
 
318
400
  /**
319
401
  * THE ONE SOURCE OF VENUE NAMES: every shipped venue's label and `aliases` (with each `name` along
@@ -336,6 +418,10 @@ export function presetProblemText(p: PresetProblem): string {
336
418
  return `the preset chain is longer than ${String(MAX_PRESET_DEPTH)}: ${p.chain.join(" → ")}`;
337
419
  case "broken":
338
420
  return `the preset ${p.file} does not parse: ${p.why}`;
421
+ case "wrong-parent":
422
+ return p.parentType === "base"
423
+ ? `the preset ${p.file} extends ${p.parent}, the TeX base set — every paper gets that set already, and no preset extends it`
424
+ : `the preset ${p.file} is a template family and extends ${p.parent}, a venue — a family builds only on a family; a venue extends a family or another venue`;
339
425
  }
340
426
  }
341
427