paperlint 7.0.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 (75) hide show
  1. package/README.md +18 -11
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +2 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/domain/body-pages.d.ts +29 -0
  6. package/dist/domain/body-pages.d.ts.map +1 -1
  7. package/dist/domain/body-pages.js +25 -0
  8. package/dist/domain/body-pages.js.map +1 -1
  9. package/dist/paper-includes.d.ts +3 -0
  10. package/dist/paper-includes.d.ts.map +1 -1
  11. package/dist/paper-includes.js +17 -2
  12. package/dist/paper-includes.js.map +1 -1
  13. package/dist/presets.d.ts +10 -3
  14. package/dist/presets.d.ts.map +1 -1
  15. package/dist/presets.js +8 -7
  16. package/dist/presets.js.map +1 -1
  17. package/dist/submission.d.ts +8 -0
  18. package/dist/submission.d.ts.map +1 -1
  19. package/dist/submission.js +19 -7
  20. package/dist/submission.js.map +1 -1
  21. package/dist/tex-requirements.d.ts +9 -0
  22. package/dist/tex-requirements.d.ts.map +1 -1
  23. package/dist/tex-requirements.js +7 -3
  24. package/dist/tex-requirements.js.map +1 -1
  25. package/dist/tex-venue-rules.d.ts +5 -2
  26. package/dist/tex-venue-rules.d.ts.map +1 -1
  27. package/dist/tex-venue-rules.js +29 -10
  28. package/dist/tex-venue-rules.js.map +1 -1
  29. package/dist/venue-rules.d.ts +2 -1
  30. package/dist/venue-rules.d.ts.map +1 -1
  31. package/dist/venue-rules.js +55 -7
  32. package/dist/venue-rules.js.map +1 -1
  33. package/docs/configuration.md +1 -1
  34. package/docs/e2e.md +14 -12
  35. package/docs/rules/format/page-limit.md +18 -1
  36. package/docs/rules/tex/template.md +25 -5
  37. package/docs/rules/tex/venue-leftover.md +13 -0
  38. package/docs/rules.md +63 -55
  39. package/docs/submission.md +5 -1
  40. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  41. package/fixtures/build-e2e/msr/expect.json +18 -0
  42. package/fixtures/build-e2e/msr/paper.tex +49 -0
  43. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  44. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  45. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  46. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  47. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  48. package/package.json +1 -1
  49. package/presets/ieee-conference.md +1 -1
  50. package/presets/msr.jsonc +71 -0
  51. package/presets/msr.md +94 -0
  52. package/presets/venue-profile.schema.json +25 -0
  53. package/skills/camera-ready/SKILL.md +8 -1
  54. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  55. package/skills/submission-portal/SKILL.md +2 -2
  56. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  57. package/skills/submit-paper/SKILL.md +9 -1
  58. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  59. package/src/cli.ts +7 -2
  60. package/src/domain/body-pages.test.ts +144 -0
  61. package/src/domain/body-pages.ts +61 -1
  62. package/src/new-paper.test.ts +27 -0
  63. package/src/paper-includes.test.ts +26 -0
  64. package/src/paper-includes.ts +20 -2
  65. package/src/paper-settings-commands.test.ts +1 -1
  66. package/src/preset-card-rule.test.ts +1 -0
  67. package/src/presets.test.ts +66 -1
  68. package/src/presets.ts +23 -6
  69. package/src/submission.test.ts +42 -3
  70. package/src/submission.ts +28 -7
  71. package/src/tex-requirements.ts +21 -4
  72. package/src/tex-venue-rules.test.ts +161 -0
  73. package/src/tex-venue-rules.ts +45 -15
  74. package/src/venue-rules.test.ts +173 -2
  75. package/src/venue-rules.ts +71 -10
@@ -91,6 +91,7 @@ describe("the shipped cards", () => {
91
91
  "aidc.md",
92
92
  "aisec.md",
93
93
  "ieee-conference.md",
94
+ "msr.md",
94
95
  "realm.md",
95
96
  "tex-base.md",
96
97
  ]);
@@ -63,6 +63,7 @@ describe("shipped presets", () => {
63
63
  expect(r.value.template).toEqual({
64
64
  text: "\\documentclass[sigconf]{acmart}",
65
65
  file: join(VENUES, "acm-sigconf.jsonc"),
66
+ forbids: [],
66
67
  });
67
68
  expect("acmart" in r.value.tex.packages).toBe(true);
68
69
  // The label is the venue's own name, from its file's `name`; the id stays the file name โ€” the
@@ -75,7 +76,40 @@ describe("shipped presets", () => {
75
76
  url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
76
77
  });
77
78
  });
79
+ });
80
+
81
+ describe("shipped presets: paperlint:msr", () => {
82
+ it("paperlint:msr โ†’ msr over ieee-conference: one kind, appendices counted as body, compsoc forbidden", () => {
83
+ const r = resolve("paperlint:msr");
84
+ expect(r.ok).toBe(true);
85
+ if (!r.ok) return;
86
+ expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
87
+ "ieee-conference.jsonc",
88
+ "msr.jsonc",
89
+ ]);
90
+ expect([...r.value.format.kinds]).toEqual([
91
+ ["technical", { bodyPagesMax: 10, refPagesMax: 2 }],
92
+ ]);
93
+ expect(r.value.format.bodyEndsAt).toBe("references");
94
+ expect(r.value.format.appendixInBody).toBe(true);
95
+ expect(r.value.format.pageWidthIn).toBe(8.5);
96
+ expect(r.value.template).toEqual({
97
+ text: "\\documentclass[10pt,conference]{IEEEtran}",
98
+ file: join(VENUES, "msr.jsonc"),
99
+ forbids: ["compsoc", "compsocconf"],
100
+ });
101
+ expect(r.value.blind).toBe(true);
102
+ expect(r.value.portal).toEqual({
103
+ kind: "hotcrp",
104
+ url: "https://msr2027.hotcrp.com",
105
+ });
106
+ // No rule turned on: the IEEE capitalization rule has not been read and quoted.
107
+ expect(r.value.rules).toEqual({});
108
+ expect(r.value.registerAnchors).toEqual([]);
109
+ });
110
+ });
78
111
 
112
+ describe("shipped presets: a family, and a venue that stands alone", () => {
79
113
  it("a paper may extend a family directly: format and fonts, and no kinds", () => {
80
114
  const r = resolve("paperlint:acm-sigconf");
81
115
  expect(r.ok && r.value.format.kinds.size).toBe(0);
@@ -139,6 +173,11 @@ describe("what each shipped preset declares itself to be", () => {
139
173
  url: "https://aisec.cc/",
140
174
  },
141
175
  "ieee-conference.jsonc": { type: "family" },
176
+ "msr.jsonc": {
177
+ type: "venue",
178
+ name: "MSR",
179
+ url: "https://2027.msrconf.org/track/msr-2027-technical-papers",
180
+ },
142
181
  "realm.jsonc": {
143
182
  type: "venue",
144
183
  name: "REALM",
@@ -300,7 +339,7 @@ describe("what does not resolve, and says why", () => {
300
339
  "a shipped name with a typo",
301
340
  "paperlint:agenticdve",
302
341
  "not-found",
303
- /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
342
+ /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, msr, realm/,
304
343
  ],
305
344
  ["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
306
345
  [
@@ -422,8 +461,32 @@ describe("a preset's template: kept as its file spells it, with that file", () =
422
461
  expect(r.ok && r.value.template).toEqual({
423
462
  text: "\\documentclass[conference]{IEEEtran}",
424
463
  file: "/work/papers/p/own.jsonc",
464
+ forbids: [],
425
465
  });
426
466
  });
467
+
468
+ it("the options a file forbids travel with its template", () => {
469
+ const r = resolve("./own.jsonc", {
470
+ "/work/papers/p/own.jsonc": venue({
471
+ extends: "paperlint:ieee-conference",
472
+ template: "\\documentclass[conference]{IEEEtran}",
473
+ template_forbids: ["compsoc"],
474
+ }),
475
+ });
476
+ expect(r.ok && r.value.template?.forbids).toEqual(["compsoc"]);
477
+ });
478
+
479
+ it("forbidden options without a template of the same file are refused by the schema", () => {
480
+ const r = resolve("./own.jsonc", {
481
+ "/work/papers/p/own.jsonc": venue({
482
+ extends: "paperlint:aidc",
483
+ template_forbids: ["compsoc"],
484
+ }),
485
+ });
486
+ expect(r.ok).toBe(false);
487
+ if (r.ok) return;
488
+ expect(presetProblemText(r.error)).toMatch(/template_forbids/);
489
+ });
427
490
  });
428
491
 
429
492
  describe("labelOf โ€” display only, never used to resolve", () => {
@@ -448,6 +511,7 @@ describe("shippedVenueNames โ€” what each shipped venue is called", () => {
448
511
  "AIDC",
449
512
  "AISec",
450
513
  "ieee-conference",
514
+ "MSR",
451
515
  "REALM",
452
516
  ]);
453
517
  expect(names.find((n) => n.label === "AISec")?.aliases).toEqual(
@@ -469,6 +533,7 @@ describe("shippedVenueNames โ€” what each shipped venue is called", () => {
469
533
  "AIDC",
470
534
  "AISec",
471
535
  "ieee-conference",
536
+ "MSR",
472
537
  ]);
473
538
  });
474
539
  });
package/src/presets.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * tex union โ€” a child never removes a package its parent needs
26
26
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
27
27
  * rules per rule id, the child wins; `ruleOrigins` keeps which file set each
28
- * template the child wins; so does `name`, and `required_sections`, `register`, `talk` and
28
+ * template the child wins, with its `template_forbids`; so does `name`, and `required_sections`, `register`, `talk` and
29
29
  * `portal` (each whole)
30
30
  * aliases union, with every `name` โ€” what the venue is called along the chain
31
31
  *
@@ -75,10 +75,12 @@ export interface PresetDeps {
75
75
  readonly venuesDir: string;
76
76
  }
77
77
 
78
- /** 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. */
79
79
  export interface PresetTemplate {
80
80
  readonly text: string;
81
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[];
82
84
  }
83
85
 
84
86
  /** A resolved chain, merged. */
@@ -104,6 +106,11 @@ export interface Preset {
104
106
  readonly aliases: readonly string[];
105
107
  /** Whether the venue reviews double-blind (`anonymity/identity`); false when no file of the chain says. */
106
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";
107
114
  readonly requiredSections: readonly RequiredSection[];
108
115
  readonly tex: TexRequirements;
109
116
  readonly format: VenueFormat;
@@ -254,6 +261,7 @@ export function mergeFormat(
254
261
  fontsText: pick("fontsText"),
255
262
  fontsTitle: pick("fontsTitle"),
256
263
  bodyEndsAt: pick("bodyEndsAt"),
264
+ appendixInBody: pick("appendixInBody"),
257
265
  kinds: new Map([...parent.kinds, ...child.kinds]),
258
266
  };
259
267
  }
@@ -276,6 +284,7 @@ const NOTHING_MERGED: Merging = {
276
284
  template: null,
277
285
  aliases: [],
278
286
  blind: false,
287
+ mentions: "any",
279
288
  requiredSections: [],
280
289
  tex: NO_REQUIREMENTS,
281
290
  format: NO_FORMAT,
@@ -292,9 +301,13 @@ const mergeOne = (
292
301
  { preset: p, file }: { readonly preset: PresetFile; readonly file: string },
293
302
  ): Merging => ({
294
303
  name: nameOf(p.identity) ?? acc.name,
295
- template: p.template === null ? acc.template : { text: p.template, file },
304
+ template:
305
+ p.template === null
306
+ ? acc.template
307
+ : { text: p.template, file, forbids: p.templateForbids },
296
308
  aliases: [...new Set([...acc.aliases, ...namesOf(p.identity), ...p.aliases])],
297
309
  blind: p.blind ?? acc.blind,
310
+ mentions: p.mentions ?? acc.mentions,
298
311
  requiredSections: p.requiredSections ?? acc.requiredSections,
299
312
  tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
300
313
  format: mergeFormat(acc.format, p.format),
@@ -368,17 +381,21 @@ export function resolveShipped(deps: PresetDeps): readonly Preset[] {
368
381
  }
369
382
 
370
383
  /** A shipped venue as a name knows it, and the files of its chain: what tells it from a paper's own. */
371
- export type ShippedVenue = Pick<Preset, "label" | "aliases" | "chain">;
384
+ export type ShippedVenue = Pick<
385
+ Preset,
386
+ "label" | "aliases" | "chain" | "mentions"
387
+ >;
372
388
 
373
389
  /**
374
390
  * A resolved preset as a name knows it: its label, its aliases, its chain. Pure. (Not Remeda's
375
391
  * `pick`: the app layer may not import it โ€” `boundaries/dependencies`, `APP_EXTERNALS`.)
376
392
  */
377
- export const venueOf = ({ label, aliases, chain }: Preset): ShippedVenue => ({
393
+ export const venueOf = ({
378
394
  label,
379
395
  aliases,
380
396
  chain,
381
- });
397
+ mentions,
398
+ }: Preset): ShippedVenue => ({ label, aliases, chain, mentions });
382
399
 
383
400
  /**
384
401
  * THE ONE SOURCE OF VENUE NAMES: every shipped venue's label and `aliases` (with each `name` along
@@ -117,6 +117,10 @@ const fakePortal = (answers: {
117
117
  return { seen, portalFor };
118
118
  };
119
119
 
120
+ /** The line `update` prints before it sends `pdf`, naming `call`. */
121
+ const CHECK = (pdf: string, call: string): string =>
122
+ `before you rely on it, open ${pdf} yourself and check it against ${call}: the page count and what counts toward the limit, the template and its class options, anonymity, and that every figure, table and reference renders โ€” paperlint passing is not the venue accepting the format`;
123
+
120
124
  const ARGS: SubmissionArgs = {
121
125
  sub: "show",
122
126
  paperDir: "papers/p",
@@ -287,6 +291,7 @@ test("update: a dry run by default โ€” the paper's PDF sent, nothing saved, exit
287
291
  ],
288
292
  ]);
289
293
  assert.deepEqual(r.out, [
294
+ CHECK("papers/p/paper.pdf", "the call for papers, https://example.org/cfp"),
290
295
  "dry run โ€” the portal checked the change and kept nothing; --save sends it for real",
291
296
  "HTTP 200",
292
297
  "valid yes",
@@ -322,7 +327,41 @@ test("update --save --submit --abstract --pdf: all of it sent, and said to be sa
322
327
  { save: true },
323
328
  ],
324
329
  ]);
325
- assert.equal(r.out[0], "saved");
330
+ // Guards: the PDF that is sent is the one named, and --save is told to check it too.
331
+ assert.deepEqual(r.out.slice(0, 2), [
332
+ CHECK("other.pdf", "the call for papers, https://example.org/cfp"),
333
+ "saved",
334
+ ]);
335
+ });
336
+
337
+ test("๐Ÿ”ด update: the check line names the venue's call from the preset's `url`; a preset that names no venue gets the generic line", async () => {
338
+ const family = memoryFiles({
339
+ "/w/package.json": "{}",
340
+ "/w/house.jsonc": JSON.stringify({
341
+ type: "family",
342
+ extends: "paperlint:ieee-conference",
343
+ portal: { kind: "hotcrp", url: `${SITE}/` },
344
+ }),
345
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
346
+ extends: "../../house.jsonc",
347
+ submission: { id: 7 },
348
+ }),
349
+ [`${PAPER}/paper.pdf`]: PDF,
350
+ });
351
+ const r = await run({ sub: "update" }, { files: family });
352
+ assert.equal(r.code, 0);
353
+ assert.equal(
354
+ r.out[0],
355
+ CHECK("papers/p/paper.pdf", "the venue's call for papers"),
356
+ );
357
+ });
358
+
359
+ test("show sends nothing, so it asks for no check", async () => {
360
+ const r = await run({});
361
+ assert.equal(
362
+ r.out.some((l) => l.includes("call for papers")),
363
+ false,
364
+ );
326
365
  });
327
366
 
328
367
  test("update: valid:false, or a non-2xx status, exits 1 with the portal's messages", async () => {
@@ -338,7 +377,7 @@ test("update: valid:false, or a non-2xx status, exits 1 with the portal's messag
338
377
  { portal: fakePortal({ update: invalid }) },
339
378
  );
340
379
  assert.equal(r1.code, 1);
341
- assert.deepEqual(r1.out.slice(2), [
380
+ assert.deepEqual(r1.out.slice(3), [
342
381
  "valid NO",
343
382
  "changes โ€”",
344
383
  `local PDF papers/p/paper.pdf sha2-${HEX}`,
@@ -380,7 +419,7 @@ test("update --save that the portal withheld says so", async () => {
380
419
  { sub: "update", save: true },
381
420
  { portal: fakePortal({ update: withheld }) },
382
421
  );
383
- assert.equal(r.out[0], "SAVE WITHHELD by the portal โ€” nothing changed");
422
+ assert.equal(r.out[1], "SAVE WITHHELD by the portal โ€” nothing changed");
384
423
  });
385
424
 
386
425
  test("update: no PDF to send, or no abstract file, is refused before the portal is asked", async () => {
package/src/submission.ts CHANGED
@@ -80,6 +80,8 @@ export interface SubmissionTarget {
80
80
  readonly id: number;
81
81
  /** The paper's built PDF, absolute. */
82
82
  readonly pdf: string;
83
+ /** The venue's call for papers (the preset's `url`); null when the preset names no venue. */
84
+ readonly call: string | null;
83
85
  }
84
86
 
85
87
  const configOf = (dir: string): string => join(dir, CONFIG_FILE);
@@ -109,7 +111,9 @@ export function submissionTarget(
109
111
  const portal = supportedPortal(declared);
110
112
  if (!portal.ok) return portal;
111
113
  const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
112
- return ok({ portal: portal.value, id: sub.id, pdf });
114
+ const identity = p.preset.identity;
115
+ const call = identity.type === "venue" ? identity.url : null;
116
+ return ok({ portal: portal.value, id: sub.id, pdf, call });
113
117
  }
114
118
 
115
119
  /** The token from the environment, or the line naming the variable and where to get one. */
@@ -182,6 +186,14 @@ export function showLines(
182
186
  ];
183
187
  }
184
188
 
189
+ /**
190
+ * What `update` says before it sends a PDF, dry run or not: the author opens that file and checks it
191
+ * against the venue's call. paperlint's checks are an aid; the venue's reading of the PDF is what
192
+ * counts. Pure.
193
+ */
194
+ export const checkPdfLine = (shown: string, call: string | null): string =>
195
+ `before you rely on it, open ${shown} yourself and check it against ${call === null ? "the venue's call for papers" : `the call for papers, ${call}`}: the page count and what counts toward the limit, the template and its class options, anonymity, and that every figure, table and reference renders โ€” paperlint passing is not the venue accepting the format`;
196
+
185
197
  /** `update`'s report. Pure. */
186
198
  export function updateLines(
187
199
  o: UpdateOutcome,
@@ -243,6 +255,17 @@ function abstractOf(
243
255
  : ok(new TextDecoder().decode(bytes).trim());
244
256
  }
245
257
 
258
+ /** What `update` sends: the PDF's bytes, the abstract's text, and whether to mark it submitted. */
259
+ const changeOf = (
260
+ a: SubmissionArgs & { readonly pdfPath: string },
261
+ bytes: Uint8Array,
262
+ abstract: string | null,
263
+ ) => ({
264
+ pdf: { name: basename(a.pdfPath), bytes },
265
+ abstract,
266
+ submit: a.submit,
267
+ });
268
+
246
269
  /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
247
270
  async function runUpdate(
248
271
  t: SubmissionTarget,
@@ -259,12 +282,10 @@ async function runUpdate(
259
282
  );
260
283
  const abstract = abstractOf(a, deps);
261
284
  if (!abstract.ok) return (deps.err(abstract.error), 2);
262
- const change = {
263
- pdf: { name: basename(a.pdfPath), bytes },
264
- abstract: abstract.value,
265
- submit: a.submit,
266
- };
267
- const r = await portal.update(t.id, change, { save: a.save });
285
+ deps.log(checkPdfLine(shown, t.call));
286
+ const r = await portal.update(t.id, changeOf(a, bytes, abstract.value), {
287
+ save: a.save,
288
+ });
268
289
  if (!r.ok) return (deps.err(failureText(r.error)), 1);
269
290
  const local = { shown, sha256: sha256Hex(bytes) };
270
291
  updateLines(r.value, local, a.save).forEach((l) => {
@@ -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
 
@@ -189,10 +195,14 @@ export interface PresetFile {
189
195
  readonly extends: string | null;
190
196
  /** The `\\documentclass` the venue's template uses, as the file spells it; null when it names none. */
191
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[];
192
200
  /** Other names the venue goes by in a paper's text; empty when the file names none. */
193
201
  readonly aliases: readonly string[];
194
202
  /** Whether the venue reviews double-blind; null when the file does not say. */
195
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;
196
206
  /** Sections the venue requires; null when the file names none (a child's list replaces its parent's). */
197
207
  readonly requiredSections: readonly RequiredSection[] | null;
198
208
  /** Null when the file declares no `tex` block (allowed only with `extends`). */
@@ -227,6 +237,7 @@ interface FormatJson {
227
237
  readonly fonts_text?: string;
228
238
  readonly fonts_title?: string;
229
239
  readonly body_ends_at?: "references";
240
+ readonly appendix_in_body?: boolean;
230
241
  readonly kinds?: KindsJson;
231
242
  }
232
243
 
@@ -244,8 +255,10 @@ type PresetJson = PresetBodyJson &
244
255
  interface PresetBodyJson {
245
256
  readonly extends?: string;
246
257
  readonly template?: string;
258
+ readonly template_forbids?: readonly string[];
247
259
  readonly aliases?: readonly string[];
248
260
  readonly blind?: boolean;
261
+ readonly mentions?: "with-year";
249
262
  readonly required_sections?: readonly {
250
263
  readonly title: string;
251
264
  readonly position?: "last";
@@ -292,6 +305,7 @@ function formatOf(j: FormatJson = {}): VenueFormat {
292
305
  fontsText: orNull(j.fonts_text),
293
306
  fontsTitle: orNull(j.fonts_title),
294
307
  bodyEndsAt: orNull(j.body_ends_at),
308
+ appendixInBody: orNull(j.appendix_in_body),
295
309
  kinds: kindsOf(j.kinds),
296
310
  };
297
311
  }
@@ -376,6 +390,10 @@ const anchorsOf = (
376
390
  },
377
391
  }));
378
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
+
379
397
  /**
380
398
  * The text of one preset file โ†’ the typed file, or an Error naming every problem. The ONE parser
381
399
  * of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
@@ -400,18 +418,17 @@ export function parsePreset(
400
418
  identity: identityOf(j),
401
419
  extends: orNull(j.extends),
402
420
  template: orNull(j.template),
421
+ templateForbids: j.template_forbids ?? [],
403
422
  aliases: j.aliases ?? [],
404
423
  blind: orNull(j.blind),
424
+ mentions: orNull(j.mentions),
405
425
  requiredSections: sectionsOf(j.required_sections),
406
426
  tex: texOf(j.tex),
407
427
  format: formatOf(j.format),
408
428
  rules: j.rules ?? {},
409
429
  registerAnchors: anchorsOf(j.register),
410
430
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
411
- portal:
412
- j.portal === undefined
413
- ? null
414
- : { kind: j.portal.kind, url: j.portal.url },
431
+ portal: portalOf(j.portal),
415
432
  };
416
433
  }
417
434
 
@@ -322,6 +322,119 @@ describe("tex/template โ€” a project's own preset, and nothing to judge", () =>
322
322
  });
323
323
  });
324
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
+
325
438
  describe("tex/template โ€” a template the reader cannot read, a bare class name, nothing to judge", () => {
326
439
  it("a template the reader cannot read is said once, naming its file โ€” the class is not judged", () => {
327
440
  const own = `${PAPER}/own.jsonc`;
@@ -580,6 +693,53 @@ describe("tex/venue-leftover โ€” another venue named in the text", () => {
580
693
  });
581
694
  });
582
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
+
583
743
  describe("tex/venue-leftover โ€” a URL is not text, a definition body is", () => {
584
744
  it("\\href: a name in the URL is not reported, a name in the link text is", () => {
585
745
  const inUrl = aidcPaper(
@@ -736,6 +896,7 @@ describe("otherVenues", () => {
736
896
  "acm-sigconf",
737
897
  "AgenticDev",
738
898
  "AISec",
899
+ "MSR",
739
900
  ]);
740
901
  });
741
902
  });