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
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "vitest";
3
- import { loadTypescript } from "./tex-requirements.ts";
3
+ import {
4
+ identityHint,
5
+ identityHints,
6
+ loadTypescript,
7
+ parsePreset,
8
+ } from "./tex-requirements.ts";
4
9
 
5
10
  test("loadTypescript refuses a module without the two functions a profile is read with", () => {
6
11
  assert.throws(
@@ -19,3 +24,124 @@ test("loadTypescript hands back what `require` loaded when it has them", () => {
19
24
  ts,
20
25
  );
21
26
  });
27
+
28
+ // ── what a preset is: the identity its `type` declares ─────────────────────────────────────
29
+
30
+ /** What `parsePreset` throws for `preset`, as text; "" when it parses. */
31
+ function refusalOf(name: string, preset: object): string {
32
+ try {
33
+ parsePreset(JSON.stringify(preset), name);
34
+ return "";
35
+ } catch (e) {
36
+ return e instanceof Error ? e.message : String(e);
37
+ }
38
+ }
39
+
40
+ test("a venue without the link to its call for papers is refused at load, saying what to add", () => {
41
+ // Guards: the wrong state the identity exists to make unrepresentable — a venue nobody can check.
42
+ assert.equal(
43
+ refusalOf("v.jsonc", {
44
+ type: "venue",
45
+ name: "X",
46
+ extends: "paperlint:acm-sigconf",
47
+ }),
48
+ [
49
+ "v.jsonc does not match venue-profile.schema.json:",
50
+ ' a venue preset names its venue and links its call for papers: "name": "<the venue, as its call spells it>", "url": "https://<the call for papers>"',
51
+ " v.jsonc: (top level) should have required property 'url'",
52
+ ' v.jsonc: (top level) should match "then" schema',
53
+ ].join("\n"),
54
+ );
55
+ });
56
+
57
+ test("a template family with a venue's name and url is refused, naming each key", () => {
58
+ assert.equal(
59
+ refusalOf("f.jsonc", {
60
+ type: "family",
61
+ name: "ACM",
62
+ url: "https://acm.org/",
63
+ extends: "paperlint:acm-sigconf",
64
+ }),
65
+ [
66
+ "f.jsonc does not match venue-profile.schema.json:",
67
+ ' a template family names no venue: drop "name" — "name" and "url" belong to the venue presets that extend it',
68
+ ' a template family names no venue: drop "url" — "name" and "url" belong to the venue presets that extend it',
69
+ " f.jsonc: .name boolean schema is false",
70
+ " f.jsonc: .url boolean schema is false",
71
+ ' f.jsonc: (top level) should match "then" schema',
72
+ ].join("\n"),
73
+ );
74
+ });
75
+
76
+ test("a preset that does not say what it is is refused, with the three answers", () => {
77
+ assert.equal(
78
+ refusalOf("n.jsonc", { extends: "paperlint:acm-sigconf" }),
79
+ [
80
+ "n.jsonc does not match venue-profile.schema.json:",
81
+ ' say what this preset is: "type": "venue" (one venue\'s call for papers), "family" (a publisher\'s template that venues extend) or "base" (the TeX base set)',
82
+ " n.jsonc: (top level) should have required property 'type'",
83
+ ].join("\n"),
84
+ );
85
+ });
86
+
87
+ test("the TeX base set holds its packages and nothing else", () => {
88
+ assert.equal(
89
+ refusalOf("b.jsonc", {
90
+ type: "base",
91
+ tex: { packages: { a: ["a.sty"] } },
92
+ format: { columns: 2 },
93
+ }),
94
+ [
95
+ "b.jsonc does not match venue-profile.schema.json:",
96
+ ' the TeX base set holds "type" and "tex" only — a venue\'s or a family\'s settings go in its own preset',
97
+ " b.jsonc: (top level) should NOT have more than 2 properties",
98
+ ' b.jsonc: (top level) should match "then" schema',
99
+ ].join("\n"),
100
+ );
101
+ });
102
+
103
+ test("a venue is parsed into its identity; a family and the base set carry none of a venue's fields", () => {
104
+ const read = (p: object) =>
105
+ parsePreset(JSON.stringify(p), "p.jsonc").identity;
106
+ assert.deepEqual(
107
+ [
108
+ read({
109
+ type: "venue",
110
+ name: "X",
111
+ url: "https://x.org/cfp",
112
+ extends: "paperlint:acm-sigconf",
113
+ }),
114
+ read({ type: "family", extends: "paperlint:acm-sigconf" }),
115
+ read({ type: "base", tex: { packages: { a: ["a.sty"] } } }),
116
+ ],
117
+ [
118
+ { type: "venue", name: "X", url: "https://x.org/cfp" },
119
+ { type: "family" },
120
+ { type: "base" },
121
+ ],
122
+ );
123
+ });
124
+
125
+ test("identityHint has nothing to add to a violation that is not about the identity", () => {
126
+ assert.equal(
127
+ identityHint({
128
+ keyword: "additionalProperties",
129
+ dataPath: "",
130
+ params: { additionalProperty: "colums" },
131
+ }),
132
+ null,
133
+ );
134
+ });
135
+
136
+ test("identityHints: Ajv's null errors give none; a hint two violations share is given once", () => {
137
+ assert.deepEqual(identityHints({ errors: null }), []);
138
+ const missing = {
139
+ keyword: "required",
140
+ dataPath: "",
141
+ schemaPath: "#/required",
142
+ params: { missingProperty: "type" },
143
+ };
144
+ const hints = identityHints({ errors: [missing, missing] });
145
+ assert.equal(hints.length, 1);
146
+ assert.deepEqual(hints, [identityHint(missing)]);
147
+ });
@@ -140,6 +140,11 @@ export interface VenueFormat {
140
140
  readonly fontsTitle: string | null;
141
141
  /** How the body is counted: null is banal's count; `references`, the pages before the references. */
142
142
  readonly bodyEndsAt: "references" | null;
143
+ /**
144
+ * With `bodyEndsAt`: appendices count as body wherever they sit, and the reference pages are the
145
+ * ones holding only references. Null: the appendix ends the body, as `references` alone counts it.
146
+ */
147
+ readonly appendixInBody: boolean | null;
143
148
  readonly kinds: ReadonlyMap<string, KindLimits>;
144
149
  }
145
150
 
@@ -155,6 +160,7 @@ export const NO_FORMAT: VenueFormat = {
155
160
  fontsText: null,
156
161
  fontsTitle: null,
157
162
  bodyEndsAt: null,
163
+ appendixInBody: null,
158
164
  kinds: new Map(),
159
165
  };
160
166
 
@@ -165,21 +171,38 @@ export interface RequiredSection {
165
171
  readonly position: "last" | null;
166
172
  }
167
173
 
174
+ /**
175
+ * What a preset file IS, as its `type` declares it. A venue names itself and its call for papers; a
176
+ * template family and the base set name no venue, so they cannot carry either — the union has no
177
+ * field to put them in, the way the schema refuses them.
178
+ */
179
+ export type PresetIdentity =
180
+ | { readonly type: "venue"; readonly name: string; readonly url: string }
181
+ | { readonly type: "family" }
182
+ | { readonly type: "base" };
183
+
184
+ /** A preset's kind of file: `venue`, `family` or `base`. */
185
+ export type PresetType = PresetIdentity["type"];
186
+
168
187
  /**
169
188
  * One preset FILE, parsed — before its `extends` chain is resolved (`src/presets.ts` does that).
170
- * A preset is a venue or a template family: `{ extends?, name?, template?, format?, tex?, rules? }`.
189
+ * A preset is a venue, a template family, or the TeX base set (`identity`).
171
190
  */
172
191
  export interface PresetFile {
192
+ /** What the file is, and for a venue, its name and call for papers. */
193
+ readonly identity: PresetIdentity;
173
194
  /** The preset this one builds on: `paperlint:<name>` or a relative path. */
174
195
  readonly extends: string | null;
175
- /** A display name for messages; the file name otherwise. */
176
- readonly name: string | null;
177
196
  /** The `\\documentclass` the venue's template uses, as the file spells it; null when it names none. */
178
197
  readonly template: string | null;
198
+ /** Class options the venue forbids, read with `template`; empty when the file names none. */
199
+ readonly templateForbids: readonly string[];
179
200
  /** Other names the venue goes by in a paper's text; empty when the file names none. */
180
201
  readonly aliases: readonly string[];
181
202
  /** Whether the venue reviews double-blind; null when the file does not say. */
182
203
  readonly blind: boolean | null;
204
+ /** When the venue's names count as a mention in a paper's text; null when the file does not say. */
205
+ readonly mentions: "with-year" | null;
183
206
  /** Sections the venue requires; null when the file names none (a child's list replaces its parent's). */
184
207
  readonly requiredSections: readonly RequiredSection[] | null;
185
208
  /** Null when the file declares no `tex` block (allowed only with `extends`). */
@@ -214,16 +237,28 @@ interface FormatJson {
214
237
  readonly fonts_text?: string;
215
238
  readonly fonts_title?: string;
216
239
  readonly body_ends_at?: "references";
240
+ readonly appendix_in_body?: boolean;
217
241
  readonly kinds?: KindsJson;
218
242
  }
219
243
 
220
- /** The preset's JSON after the schema accepted it — the shape `venue-profile.schema.json` allows. */
221
- interface PresetJson {
244
+ /**
245
+ * The preset's JSON after the schema accepted it — the shape `venue-profile.schema.json` allows,
246
+ * the identity included: a venue has its `name` and `url`, a family and the base set have neither.
247
+ */
248
+ type PresetJson = PresetBodyJson &
249
+ (
250
+ | { readonly type: "venue"; readonly name: string; readonly url: string }
251
+ | { readonly type: "family" | "base" }
252
+ );
253
+
254
+ /** Every key of a preset but its identity. */
255
+ interface PresetBodyJson {
222
256
  readonly extends?: string;
223
- readonly name?: string;
224
257
  readonly template?: string;
258
+ readonly template_forbids?: readonly string[];
225
259
  readonly aliases?: readonly string[];
226
260
  readonly blind?: boolean;
261
+ readonly mentions?: "with-year";
227
262
  readonly required_sections?: readonly {
228
263
  readonly title: string;
229
264
  readonly position?: "last";
@@ -270,6 +305,7 @@ function formatOf(j: FormatJson = {}): VenueFormat {
270
305
  fontsText: orNull(j.fonts_text),
271
306
  fontsTitle: orNull(j.fonts_title),
272
307
  bodyEndsAt: orNull(j.body_ends_at),
308
+ appendixInBody: orNull(j.appendix_in_body),
273
309
  kinds: kindsOf(j.kinds),
274
310
  };
275
311
  }
@@ -282,6 +318,46 @@ const sectionsOf = (
282
318
  ? null
283
319
  : r.map((x) => ({ title: x.title, position: orNull(x.position) }));
284
320
 
321
+ /** The identity a preset's `type` declares. Pure. */
322
+ const identityOf = (j: PresetJson): PresetIdentity =>
323
+ j.type === "venue"
324
+ ? { type: "venue", name: j.name, url: j.url }
325
+ : { type: j.type };
326
+
327
+ /** One ajv error, as far as the hints read it. */
328
+ interface SchemaError {
329
+ readonly keyword: string;
330
+ readonly dataPath: string;
331
+ readonly params: object;
332
+ }
333
+
334
+ /**
335
+ * What to write instead, for the violations of a preset's identity — ajv's own words for them
336
+ * ("should have required property 'url'", "boolean schema is false") name the key but not why.
337
+ * Read off the error's keyword and parameters, never its message text. Pure.
338
+ */
339
+ export function identityHint(e: SchemaError): string | null {
340
+ const missing = fieldOf(e.params, "missingProperty");
341
+ if (e.keyword === "required" && missing === "type")
342
+ return 'say what this preset is: "type": "venue" (one venue\'s call for papers), "family" (a publisher\'s template that venues extend) or "base" (the TeX base set)';
343
+ if (e.keyword === "required" && (missing === "name" || missing === "url"))
344
+ return 'a venue preset names its venue and links its call for papers: "name": "<the venue, as its call spells it>", "url": "https://<the call for papers>"';
345
+ if (e.keyword === "false schema")
346
+ return `a template family names no venue: drop "${e.dataPath.slice(1)}" — "name" and "url" belong to the venue presets that extend it`;
347
+ if (e.keyword === "maxProperties")
348
+ return 'the TeX base set holds "type" and "tex" only — a venue\'s or a family\'s settings go in its own preset';
349
+ return null;
350
+ }
351
+
352
+ /** The hints for every violation that has one, each once. Ajv's null `errors` has none. */
353
+ export const identityHints = (
354
+ v: Readonly<Pick<Validate, "errors">>,
355
+ ): readonly string[] => [
356
+ ...new Set(
357
+ (v.errors ?? []).map(identityHint).filter((h): h is string => h !== null),
358
+ ),
359
+ ];
360
+
285
361
  /** A preset's JSONC text → its value, or an Error naming the syntax problem. */
286
362
  function jsoncOf(text: string, file: string): unknown {
287
363
  const parsed = typescript().parseConfigFileTextToJson(file, text);
@@ -314,6 +390,10 @@ const anchorsOf = (
314
390
  },
315
391
  }));
316
392
 
393
+ /** A preset's `portal`, or null when it names none. */
394
+ const portalOf = (p: PresetJson["portal"]): Readonly<VenuePortal> | null =>
395
+ p === undefined ? null : { kind: p.kind, url: p.url };
396
+
317
397
  /**
318
398
  * The text of one preset file → the typed file, or an Error naming every problem. The ONE parser
319
399
  * of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
@@ -331,25 +411,24 @@ export function parsePreset(
331
411
  const validate = validatorFor(dir);
332
412
  if (!matchesSchema(config, validate))
333
413
  throw new Error(
334
- `${file} does not match ${SCHEMA_FILE}:\n ${violations(file, validate).join("\n ")}`,
414
+ `${file} does not match ${SCHEMA_FILE}:\n ${[...identityHints(validate), ...violations(file, validate)].join("\n ")}`,
335
415
  );
336
416
  const j = config;
337
417
  return {
418
+ identity: identityOf(j),
338
419
  extends: orNull(j.extends),
339
- name: orNull(j.name),
340
420
  template: orNull(j.template),
421
+ templateForbids: j.template_forbids ?? [],
341
422
  aliases: j.aliases ?? [],
342
423
  blind: orNull(j.blind),
424
+ mentions: orNull(j.mentions),
343
425
  requiredSections: sectionsOf(j.required_sections),
344
426
  tex: texOf(j.tex),
345
427
  format: formatOf(j.format),
346
428
  rules: j.rules ?? {},
347
429
  registerAnchors: anchorsOf(j.register),
348
430
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
349
- portal:
350
- j.portal === undefined
351
- ? null
352
- : { kind: j.portal.kind, url: j.portal.url },
431
+ portal: portalOf(j.portal),
353
432
  };
354
433
  }
355
434
 
@@ -20,6 +20,7 @@ import {
20
20
  } from "./tex-venue-rules.ts";
21
21
  import { resolvePreset } from "./presets.ts";
22
22
  import { buildConfig, SHIPPED_RULES } from "./cli.ts";
23
+ import { venuePreset } from "../test/support.ts";
23
24
 
24
25
  const VENUES = presetsDir();
25
26
  const PAPER = "/work/papers/p";
@@ -136,13 +137,13 @@ describe("tex/template — the class and every option the preset names", () => {
136
137
  "[conference]{IEEEtran} — compsoc missing",
137
138
  "\\documentclass[conference]{IEEEtran}",
138
139
  ["tex/template:missingOption"],
139
- /the class option `compsoc` is missing: aidc requires `\\documentclass\[conference,compsoc\]\{IEEEtran\}`/,
140
+ /the class option `compsoc` is missing: AIDC requires `\\documentclass\[conference,compsoc\]\{IEEEtran\}`/,
140
141
  ],
141
142
  [
142
143
  "{acmart} under an IEEE preset",
143
144
  "\\documentclass[sigconf]{acmart}",
144
145
  ["tex/template:wrongClass"],
145
- /the class is `acmart`, and aidc requires/,
146
+ /the class is `acmart`, and AIDC requires/,
146
147
  ],
147
148
  [
148
149
  "[journal,compsoc]{IEEEtran} — conference missing",
@@ -183,7 +184,7 @@ describe("tex/template — the class line's states: missing, empty, commented ou
183
184
  expect(ids(fs)).toEqual(["tex/template:emptyClass"]);
184
185
  expect(fs[0]?.line).toBe(2);
185
186
  expect(fs[0]?.message).toBe(
186
- "the \\documentclass names no class; aidc requires `\\documentclass[conference,compsoc]{IEEEtran}`",
187
+ "the \\documentclass names no class; AIDC requires `\\documentclass[conference,compsoc]{IEEEtran}`",
187
188
  );
188
189
  });
189
190
 
@@ -258,7 +259,7 @@ describe("tex/template — several \\documentclass lines behind a TeX switch", (
258
259
  // Line 4: the first candidate, after the comment, `\\def` and `\\if` lines.
259
260
  expect(fs[0]?.line).toBe(4);
260
261
  expect(fs[0]?.message).toBe(
261
- "none of the 2 \\documentclass lines is `\\documentclass[conference,compsoc]{IEEEtran}`, which aidc requires: `\\documentclass{article}`, `\\documentclass[conference]{IEEEtran}`. The source picks one behind a TeX switch, which is not evaluated; make one of them the venue's",
262
+ "none of the 2 \\documentclass lines is `\\documentclass[conference,compsoc]{IEEEtran}`, which AIDC requires: `\\documentclass{article}`, `\\documentclass[conference]{IEEEtran}`. The source picks one behind a TeX switch, which is not evaluated; make one of them the venue's",
262
263
  );
263
264
  });
264
265
 
@@ -309,13 +310,131 @@ describe("tex/template — a project's own preset, and nothing to judge", () =>
309
310
  paper("\\documentclass{whatever}"),
310
311
  { extends: "./own.jsonc" },
311
312
  {
312
- extra: { [own]: JSON.stringify({ tex: { packages: { x: ["x"] } } }) },
313
+ extra: {
314
+ [own]: JSON.stringify({
315
+ type: "family",
316
+ tex: { packages: { x: ["x"] } },
317
+ }),
318
+ },
313
319
  },
314
320
  ),
315
321
  ).toEqual([]);
316
322
  });
317
323
  });
318
324
 
325
+ /** A project's own venue on IEEEtran's conference class that forbids the two compsoc options. */
326
+ const forbidding = (body: Record<string, unknown> = {}) => ({
327
+ [`${PAPER}/own.jsonc`]: JSON.stringify(
328
+ venuePreset("Own", {
329
+ extends: "paperlint:ieee-conference",
330
+ template: "\\documentclass[10pt,conference]{IEEEtran}",
331
+ template_forbids: ["compsoc", "compsocconf"],
332
+ ...body,
333
+ }),
334
+ ),
335
+ });
336
+
337
+ describe("tex/template — class options the preset forbids (`template_forbids`)", () => {
338
+ const judge = (cls: string, extra = forbidding()) =>
339
+ lint(paper(cls, "Text."), { extends: "./own.jsonc" }, { extra });
340
+
341
+ it("the template's own line passes, and so does an extra option the preset does not forbid", () => {
342
+ expect(judge("\\documentclass[10pt,conference]{IEEEtran}")).toEqual([]);
343
+ expect(
344
+ judge("\\documentclass[10pt,conference,letterpaper]{IEEEtran}"),
345
+ ).toEqual([]);
346
+ });
347
+
348
+ it("🔴 a forbidden option is reported at the class line, naming it and the venue's line", () => {
349
+ const fs = judge("\\documentclass[10pt,conference,compsoc]{IEEEtran}");
350
+ expect(ids(fs)).toEqual(["tex/template:forbiddenOption"]);
351
+ expect(fs[0]?.message).toBe(
352
+ "the class option `compsoc` is forbidden: Own requires `\\documentclass[10pt,conference]{IEEEtran}` without `compsoc`, `compsocconf`",
353
+ );
354
+ expect(fs[0]?.line).toBe(2);
355
+ });
356
+
357
+ it("each forbidden option is one finding, beside a missing one", () => {
358
+ expect(
359
+ ids(judge("\\documentclass[compsoc,compsocconf,conference]{IEEEtran}")),
360
+ ).toEqual([
361
+ "tex/template:forbiddenOption",
362
+ "tex/template:forbiddenOption",
363
+ "tex/template:missingOption",
364
+ ]);
365
+ });
366
+ });
367
+
368
+ describe("tex/template under paperlint:msr — `[10pt,conference]`, without compsoc or compsocconf", () => {
369
+ const MSR = { extends: "paperlint:msr", kind: "technical" };
370
+
371
+ it("the class line MSR's call names passes", () => {
372
+ expect(
373
+ lint(paper("\\documentclass[10pt,conference]{IEEEtran}", "Text."), MSR),
374
+ ).toEqual([]);
375
+ });
376
+
377
+ it.each([["compsoc"], ["compsocconf"]])(
378
+ "🔴 `%s` in the class options fails tex/template",
379
+ (option) => {
380
+ const fs = lint(
381
+ paper(`\\documentclass[10pt,conference,${option}]{IEEEtran}`, "Text."),
382
+ MSR,
383
+ );
384
+ expect(ids(fs)).toEqual(["tex/template:forbiddenOption"]);
385
+ expect(fs[0]?.message).toMatch(
386
+ `the class option \`${option}\` is forbidden: MSR requires`,
387
+ );
388
+ },
389
+ );
390
+
391
+ it("AIDC's class line fails MSR on both counts: compsoc forbidden, 10pt missing", () => {
392
+ expect(ids(lint(OFFICIAL, MSR))).toEqual([
393
+ "tex/template:forbiddenOption",
394
+ "tex/template:missingOption",
395
+ ]);
396
+ });
397
+ });
398
+
399
+ describe("tex/template — forbidden options behind a switch, and down a chain", () => {
400
+ it("behind a TeX switch, a candidate with a forbidden option is not the venue's class", () => {
401
+ const two = (a: string, b: string) =>
402
+ `\\def\\v{1}\n\\if\\v1 ${a} \\fi\n\\if\\v2 ${b} \\fi\n\\begin{document}\nText.\n\\end{document}\n`;
403
+ const compsoc = "\\documentclass[10pt,conference,compsoc]{IEEEtran}";
404
+ const opts = { extra: forbidding() };
405
+ expect(
406
+ ids(lint(two(compsoc, compsoc), { extends: "./own.jsonc" }, opts)),
407
+ ).toEqual(["tex/template:noCandidate"]);
408
+ expect(
409
+ lint(
410
+ two(compsoc, "\\documentclass[10pt,conference]{IEEEtran}"),
411
+ { extends: "./own.jsonc" },
412
+ opts,
413
+ ),
414
+ ).toEqual([]);
415
+ });
416
+
417
+ it("a child's template replaces its parent's, the forbidden options with it", () => {
418
+ const child = `${PAPER}/child.jsonc`;
419
+ const extra = {
420
+ ...forbidding(),
421
+ [child]: JSON.stringify(
422
+ venuePreset("Child", {
423
+ extends: "./own.jsonc",
424
+ template: "\\documentclass[conference]{IEEEtran}",
425
+ }),
426
+ ),
427
+ };
428
+ expect(
429
+ lint(
430
+ paper("\\documentclass[conference,compsoc]{IEEEtran}", "Text."),
431
+ { extends: "./child.jsonc" },
432
+ { extra },
433
+ ),
434
+ ).toEqual([]);
435
+ });
436
+ });
437
+
319
438
  describe("tex/template — a template the reader cannot read, a bare class name, nothing to judge", () => {
320
439
  it("a template the reader cannot read is said once, naming its file — the class is not judged", () => {
321
440
  const own = `${PAPER}/own.jsonc`;
@@ -325,6 +444,7 @@ describe("tex/template — a template the reader cannot read, a bare class name,
325
444
  {
326
445
  extra: {
327
446
  [own]: JSON.stringify({
447
+ type: "family",
328
448
  template: "\\documentclass[a]{}",
329
449
  tex: { packages: { x: ["x"] } },
330
450
  }),
@@ -341,6 +461,7 @@ describe("tex/template — a template the reader cannot read, a bare class name,
341
461
  const own = `${PAPER}/own.jsonc`;
342
462
  const extra = {
343
463
  [own]: JSON.stringify({
464
+ type: "family",
344
465
  template: "article",
345
466
  tex: { packages: { x: ["x"] } },
346
467
  }),
@@ -353,7 +474,9 @@ describe("tex/template — a template the reader cannot read, a bare class name,
353
474
  ids(lint(paper("\\documentclass{report}"), settings, { extra })),
354
475
  ).toEqual(["tex/template:wrongClass"]);
355
476
  });
477
+ });
356
478
 
479
+ describe("tex/template — nothing to judge", () => {
357
480
  it.each<[string, object | undefined, string]>([
358
481
  ["no paperlint.json", undefined, `${PAPER}/paper.tex`],
359
482
  [
@@ -428,7 +551,7 @@ describe("tex/required-section — what fails", () => {
428
551
  const fs = required(lint(tex, AIDC));
429
552
  expect(ids(fs)).toEqual(["tex/required-section:missing"]);
430
553
  expect(fs[0]?.message).toBe(
431
- "aidc requires a section titled «LLM Usage Statement» and there is none — add `\\section*{LLM Usage Statement}` (the title exactly; a bold paragraph does not count)",
554
+ "AIDC requires a section titled «LLM Usage Statement» and there is none — add `\\section*{LLM Usage Statement}` (the title exactly; a bold paragraph does not count)",
432
555
  );
433
556
  // At `\end{document}`, where the section would go.
434
557
  expect(fs[0]?.line).toBe(tex.split("\n").indexOf("\\end{document}") + 1);
@@ -503,10 +626,12 @@ describe("tex/required-section — the edges", () => {
503
626
  it("a position other than last (none): anywhere will do; a child's list replaces its parent's", () => {
504
627
  const own = `${PAPER}/own.jsonc`;
505
628
  const extra = {
506
- [own]: JSON.stringify({
507
- extends: "paperlint:aidc",
508
- required_sections: [{ title: "Ethics" }],
509
- }),
629
+ [own]: JSON.stringify(
630
+ venuePreset("own", {
631
+ extends: "paperlint:aidc",
632
+ required_sections: [{ title: "Ethics" }],
633
+ }),
634
+ ),
510
635
  };
511
636
  const settings = { extends: "./own.jsonc", kind: "regular" };
512
637
  expect(
@@ -528,8 +653,8 @@ describe("tex/venue-leftover — another venue named in the text", () => {
528
653
  const tex = aidcPaper(`${INSTITUTION}\n${BODY}${STATEMENT}`);
529
654
  const fs = leftovers(lint(tex, AIDC));
530
655
  expect(fs.map((f) => f.message)).toEqual([
531
- "«AISec» names aisec, and this paper extends aidc — a leftover from an earlier submission? A reviewer reads it before the abstract. Comments and citation keys are not reported; a sentence that names the other venue on purpose can keep it with a disable directive",
532
- expect.stringContaining("«ACM CCS» names aisec"),
656
+ "«AISec» names AISec, and this paper extends AIDC — a leftover from an earlier submission? A reviewer reads it before the abstract. Comments and citation keys are not reported; a sentence that names the other venue on purpose can keep it with a disable directive",
657
+ expect.stringContaining("«ACM CCS» names AISec"),
533
658
  ]);
534
659
  const line = tex.split("\n").indexOf(INSTITUTION) + 1;
535
660
  expect(fs.map((f) => f.line)).toEqual([line, line]);
@@ -568,6 +693,53 @@ describe("tex/venue-leftover — another venue named in the text", () => {
568
693
  });
569
694
  });
570
695
 
696
+ describe("tex/venue-leftover — MSR, a name that is also an acronym, counts only with a year (`mentions`)", () => {
697
+ const under = (text: string) =>
698
+ leftovers(lint(aidcPaper(`${text}\n${BODY}${STATEMENT}`), AIDC));
699
+
700
+ it.each([
701
+ [
702
+ "an acronym the paper defines",
703
+ "We score it by Manual Speech Recognition (MSR), and MSR scores rise.",
704
+ ],
705
+ [
706
+ "Microsoft Research",
707
+ "Researchers at Microsoft Research (MSR) built it, and MSR released it.",
708
+ ],
709
+ [
710
+ "a citation of an MSR paper: the key, and the entry in the bibliography",
711
+ "As shown~\\cite{msr2024}.\n\\begin{thebibliography}{1}\\bibitem{msr2024} A. Author, ``Mining,'' in \\emph{Proc. MSR 2024}, 2024.\\end{thebibliography}",
712
+ ],
713
+ ["a year that is not beside the name", "MSR, in 2027, scored best."],
714
+ ])("silent: %s", (_, text) => {
715
+ expect(under(text)).toEqual([]);
716
+ });
717
+
718
+ it.each([
719
+ ["First written for MSR 2027.", "MSR"],
720
+ ["A version appeared as MSR'27 work.", "MSR"],
721
+ ["Submitted to MSR~2027.", "MSR"],
722
+ ])("🔴 reported: «%s»", (text, name) => {
723
+ const fs = under(text);
724
+ expect(fs.map((f) => f.message)).toEqual([
725
+ expect.stringMatching(
726
+ new RegExp(`^«${name}» names MSR, and this paper extends AIDC`),
727
+ ),
728
+ ]);
729
+ expect(fs[0]?.line).toBe(4);
730
+ });
731
+
732
+ it("under msr itself, its own name with a year is never a leftover", () => {
733
+ const tex = paper(
734
+ "\\documentclass[10pt,conference]{IEEEtran}",
735
+ "First written for MSR 2027.",
736
+ );
737
+ expect(
738
+ leftovers(lint(tex, { extends: "paperlint:msr", kind: "technical" })),
739
+ ).toEqual([]);
740
+ });
741
+ });
742
+
571
743
  describe("tex/venue-leftover — a URL is not text, a definition body is", () => {
572
744
  it("\\href: a name in the URL is not reported, a name in the link text is", () => {
573
745
  const inUrl = aidcPaper(
@@ -591,7 +763,7 @@ describe("tex/venue-leftover — a URL is not text, a definition body is", () =>
591
763
  );
592
764
  const fs = leftovers(lint(tex, AIDC));
593
765
  expect(fs.map((f) => [f.line, f.message.slice(0, 16)])).toEqual([
594
- [4, "«AISec» names ai"],
766
+ [4, "«AISec» names AI"],
595
767
  ]);
596
768
  },
597
769
  );
@@ -622,11 +794,12 @@ describe("tex/venue-leftover — where names are found, and whose they are", ()
622
794
  it("a name a venue shares with this paper's own chain is not a leftover", () => {
623
795
  const own = `${PAPER}/own.jsonc`;
624
796
  const extra = {
625
- [own]: JSON.stringify({
626
- extends: "paperlint:aidc",
627
- name: "AIDC 2027",
628
- aliases: ["ACM CCS"],
629
- }),
797
+ [own]: JSON.stringify(
798
+ venuePreset("AIDC 2027", {
799
+ extends: "paperlint:aidc",
800
+ aliases: ["ACM CCS"],
801
+ }),
802
+ ),
630
803
  };
631
804
  const tex = aidcPaper(`ACM CCS and AIDC 2027.\n${BODY}${STATEMENT}`);
632
805
  expect(
@@ -721,8 +894,9 @@ describe("otherVenues", () => {
721
894
  if (!aidc.ok) return;
722
895
  expect(otherVenues(aidc.value, deps).map((o) => o.label)).toEqual([
723
896
  "acm-sigconf",
724
- "agenticdev",
725
- "aisec",
897
+ "AgenticDev",
898
+ "AISec",
899
+ "MSR",
726
900
  ]);
727
901
  });
728
902
  });
@@ -790,7 +964,7 @@ describe("format/layout-override reports", () => {
790
964
  [13, "\\newgeometry"],
791
965
  ]);
792
966
  expect(overrides(tex)[0]?.message).toBe(
793
- "`\\usepackage{geometry}` changes the page layout aidc's template sets (`\\documentclass[conference,compsoc]{IEEEtran}`) — a desk-reject reason at venues that check the format. Remove it; if the venue allows it, disable this line with a comment saying so",
967
+ "`\\usepackage{geometry}` changes the page layout AIDC's template sets (`\\documentclass[conference,compsoc]{IEEEtran}`) — a desk-reject reason at venues that check the format. Remove it; if the venue allows it, disable this line with a comment saying so",
794
968
  );
795
969
  });
796
970
  it("a definition's body counts: the length it sets is set wherever it is used", () => {
@@ -834,6 +1008,7 @@ describe("format/layout-override stays silent", () => {
834
1008
  expect(overrides(tex, { extends: null })).toEqual([]);
835
1009
  const house = {
836
1010
  [`${PAPER}/house.jsonc`]: JSON.stringify({
1011
+ type: "family",
837
1012
  tex: { packages: { ieeetran: ["IEEEtran.cls"] } },
838
1013
  }),
839
1014
  };