paperlint 4.0.0 → 4.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 (172) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +31 -11
  3. package/CONTRIBUTING.md +23 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +22 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +102 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +29 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -0
  16. package/dist/adapters/latex/index.js +18 -0
  17. package/dist/adapters/latex/index.js.map +1 -0
  18. package/dist/adapters/latex/nodes.d.ts +54 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  20. package/dist/adapters/latex/nodes.js +93 -0
  21. package/dist/adapters/latex/nodes.js.map +1 -0
  22. package/dist/adapters/latex/outline.d.ts +10 -0
  23. package/dist/adapters/latex/outline.d.ts.map +1 -0
  24. package/dist/adapters/latex/outline.js +39 -0
  25. package/dist/adapters/latex/outline.js.map +1 -0
  26. package/dist/adapters/latex/parse.d.ts +42 -0
  27. package/dist/adapters/latex/parse.d.ts.map +1 -0
  28. package/dist/adapters/latex/parse.js +94 -0
  29. package/dist/adapters/latex/parse.js.map +1 -0
  30. package/dist/adapters/latex/rendered.d.ts +10 -0
  31. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  32. package/dist/adapters/latex/rendered.js +39 -0
  33. package/dist/adapters/latex/rendered.js.map +1 -0
  34. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  35. package/dist/adapters/pdfjs/fill.js +12 -12
  36. package/dist/adapters/pdfjs/fill.js.map +1 -1
  37. package/dist/build.js +4 -4
  38. package/dist/build.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +29 -5
  41. package/dist/cli.js.map +1 -1
  42. package/dist/domain/tex-document.d.ts +84 -0
  43. package/dist/domain/tex-document.d.ts.map +1 -0
  44. package/dist/domain/tex-document.js +43 -0
  45. package/dist/domain/tex-document.js.map +1 -0
  46. package/dist/facts-file.d.ts.map +1 -1
  47. package/dist/facts-file.js +2 -2
  48. package/dist/facts-file.js.map +1 -1
  49. package/dist/new-paper.d.ts +5 -0
  50. package/dist/new-paper.d.ts.map +1 -1
  51. package/dist/new-paper.js +3 -0
  52. package/dist/new-paper.js.map +1 -1
  53. package/dist/package-dirs.d.ts +5 -0
  54. package/dist/package-dirs.d.ts.map +1 -0
  55. package/dist/package-dirs.js +14 -0
  56. package/dist/package-dirs.js.map +1 -0
  57. package/dist/ports/latex.d.ts +24 -0
  58. package/dist/ports/latex.d.ts.map +1 -0
  59. package/dist/ports/latex.js +2 -0
  60. package/dist/ports/latex.js.map +1 -0
  61. package/dist/presets.d.ts +15 -2
  62. package/dist/presets.d.ts.map +1 -1
  63. package/dist/presets.js +19 -6
  64. package/dist/presets.js.map +1 -1
  65. package/dist/tex-requirements.d.ts +11 -0
  66. package/dist/tex-requirements.d.ts.map +1 -1
  67. package/dist/tex-requirements.js +14 -8
  68. package/dist/tex-requirements.js.map +1 -1
  69. package/dist/tex-venue-rules.d.ts +83 -0
  70. package/dist/tex-venue-rules.d.ts.map +1 -0
  71. package/dist/tex-venue-rules.js +254 -0
  72. package/dist/tex-venue-rules.js.map +1 -0
  73. package/dist/venue-rules.d.ts +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/docs/configuration.md +3 -2
  76. package/docs/e2e.md +69 -40
  77. package/docs/install.md +1 -1
  78. package/docs/prior-art/README.md +1 -1
  79. package/docs/prior-art/package-location.md +5 -5
  80. package/docs/prior-art/repro/README.md +1 -1
  81. package/docs/prior-art/test-tooling.md +21 -10
  82. package/docs/rules/tex/required-section.md +73 -0
  83. package/docs/rules/tex/template.md +62 -0
  84. package/docs/rules/tex/venue-leftover.md +71 -0
  85. package/docs/rules.md +70 -48
  86. package/docs/testing.md +17 -4
  87. package/docs/texlive-install-decision.md +1 -1
  88. package/docs/toolchain.md +2 -2
  89. package/eslint.config.mjs +155 -15
  90. package/fixtures/build-e2e/acmart/expect.json +26 -0
  91. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  92. package/fixtures/build-e2e/aidc/expect.json +14 -0
  93. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  94. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  95. package/fixtures/build-e2e/broken/expect.json +11 -0
  96. package/fixtures/build-e2e/cite/expect.json +27 -0
  97. package/fixtures/build-e2e/empty/expect.json +12 -0
  98. package/fixtures/build-e2e/fallback/expect.json +53 -0
  99. package/fixtures/build-e2e/guards/expect.json +6 -0
  100. package/fixtures/build-e2e/no-source/expect.json +10 -0
  101. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.ts +1 -1
  104. package/fixtures/render-paper/build-clean.sh +3 -5
  105. package/package.json +13 -2
  106. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  107. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  108. package/presets/aidc.jsonc +73 -0
  109. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  110. package/presets/ieee-conference.jsonc +66 -0
  111. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  112. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  113. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  114. package/scripts/check.harness.ts +64 -10
  115. package/scripts/check.test.ts +60 -13
  116. package/scripts/check.ts +83 -30
  117. package/scripts/test.test.ts +1 -1
  118. package/scripts/test.ts +1 -1
  119. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  120. package/skills/paper-pipeline/SKILL.md +2 -2
  121. package/skills/paper-pipeline/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  123. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  124. package/skills/render-paper/SKILL.md +2 -2
  125. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  126. package/skills/render-paper/check-render.sh +16 -63
  127. package/skills/render-paper/render-paper.harness.mjs +17 -59
  128. package/src/adapters/banal/index.test.ts +1 -1
  129. package/src/adapters/banal/invocation.ts +1 -1
  130. package/src/adapters/banal/output.test.ts +1 -1
  131. package/src/adapters/banal/run.test.ts +1 -1
  132. package/src/adapters/banal/xml.test.ts +1 -1
  133. package/src/adapters/latex/document-class.ts +147 -0
  134. package/src/adapters/latex/hidden.ts +70 -0
  135. package/src/adapters/latex/index.test.ts +361 -0
  136. package/src/adapters/latex/index.ts +55 -0
  137. package/src/adapters/latex/nodes.ts +141 -0
  138. package/src/adapters/latex/outline.ts +64 -0
  139. package/src/adapters/latex/parse.ts +136 -0
  140. package/src/adapters/latex/rendered.ts +59 -0
  141. package/src/adapters/pdfjs/fill.test.ts +1 -1
  142. package/src/adapters/pdfjs/fill.ts +18 -10
  143. package/src/build.harness.mjs +5 -7
  144. package/src/build.ts +4 -4
  145. package/src/cli.harness.mjs +1 -1
  146. package/src/cli.test.ts +2 -8
  147. package/src/cli.ts +33 -8
  148. package/src/domain/tex-document.test.ts +80 -0
  149. package/src/domain/tex-document.ts +110 -0
  150. package/src/engine.harness.mjs +1 -1
  151. package/src/facts-file.test.ts +4 -7
  152. package/src/facts-file.ts +2 -2
  153. package/src/latex-loop.harness.ts +1 -1
  154. package/src/new-paper.test.ts +28 -3
  155. package/src/new-paper.ts +8 -0
  156. package/src/package-dirs.ts +17 -0
  157. package/src/paper-settings-commands.test.ts +6 -1
  158. package/src/ports/latex.ts +29 -0
  159. package/src/presets.test.ts +65 -4
  160. package/src/presets.ts +38 -15
  161. package/src/rule-cases.test.ts +474 -0
  162. package/src/rule-docs.test.ts +159 -0
  163. package/src/tex-requirements.harness.ts +13 -23
  164. package/src/tex-requirements.ts +35 -8
  165. package/src/tex-venue-rules.test.ts +659 -0
  166. package/src/tex-venue-rules.ts +429 -0
  167. package/src/toolchain.harness.mjs +1 -1
  168. package/src/venue-rules.test.ts +96 -4
  169. package/src/venue-rules.ts +1 -1
  170. package/templates/paper/paper.tex +2 -1
  171. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  172. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * THE VENUE-CONFORMANCE RULES OVER THE SOURCE — `paper.tex` judged against what its venue preset
3
+ * requires of the LaTeX itself, before any build:
4
+ *
5
+ * tex/template error the `\documentclass` is the preset's class, with every option it names
6
+ * tex/required-section error each section the preset requires is there, titled exactly, and
7
+ * where the preset says (`last`: after every section of the body)
8
+ * tex/venue-leftover warn the text a reader sees names ANOTHER shipped venue — its `name`
9
+ * or one of its `aliases` — than the one the paper extends
10
+ *
11
+ * ── WHY THEY LIVE HERE AND NOT IN `eslint-rules/` ────────────────────────────────
12
+ * Like the `pdf/*` venue rules (`venue-rules.ts`), they need the paper's resolved preset —
13
+ * `paperPreset`, the one answer to "which venue is this paper judged against" — and read it through
14
+ * the `Files` port. What they read of the source comes through the `LatexReader` port
15
+ * (`src/ports/latex.ts`, implemented over unified-latex in `src/adapters/latex/`): the parse tree of
16
+ * `sourceCode.raw`, never a search of the text.
17
+ *
18
+ * ── WHO SPEAKS WHEN THERE IS NOTHING TO JUDGE ───────────────────────────────────
19
+ * No `paperlint.json`, no `extends`, or a preset that does not resolve: silent. `pdf/measured` and
20
+ * `pdf/profile` already say so, once. A preset that names no template: silent — there is nothing to
21
+ * compare, and a template-family preset is where it is named.
22
+ */
23
+ import { basename, dirname, join } from "node:path";
24
+ import { CONFIG_FILE } from "#lib/paper-config";
25
+ import {
26
+ collapse,
27
+ documentClassLine,
28
+ missingOptions,
29
+ runText,
30
+ spanIn,
31
+ type ClassLine,
32
+ type DocumentClass,
33
+ type Heading,
34
+ type Outline,
35
+ type Place,
36
+ type Span,
37
+ type TextRun,
38
+ } from "./domain/tex-document.ts";
39
+ import type { LatexReader } from "./ports/latex.ts";
40
+ import {
41
+ paperPreset,
42
+ resolvePreset,
43
+ SHIPPED_PREFIX,
44
+ shippedPresets,
45
+ type Preset,
46
+ } from "./presets.ts";
47
+ import type { RequiredSection } from "./tex-requirements.ts";
48
+ import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
49
+
50
+ /** A finding and where it points; null points at the top of the file. */
51
+ export interface Located extends Finding {
52
+ readonly at: Span | null;
53
+ }
54
+
55
+ /** The slice of ESLint's rule context these rules use. `raw` exists on the `.tex` language's. */
56
+ export interface TexRuleContext {
57
+ readonly filename: string;
58
+ readonly sourceCode: {
59
+ readonly text: string;
60
+ readonly raw?: string;
61
+ getLocFromIndex(index: number): Readonly<{ line: number; column: number }>;
62
+ };
63
+ report(d: {
64
+ readonly loc: {
65
+ readonly start: { line: number; column: number };
66
+ readonly end: { line: number; column: number };
67
+ };
68
+ readonly messageId: string;
69
+ readonly data?: Readonly<Record<string, string | number>>;
70
+ }): void;
71
+ }
72
+
73
+ export interface TexRuleModule {
74
+ readonly meta: {
75
+ readonly type: "problem" | "suggestion";
76
+ readonly docs: { readonly description: string; readonly url: string };
77
+ readonly schema: readonly object[];
78
+ readonly messages: Readonly<Record<string, string>>;
79
+ };
80
+ create(context: TexRuleContext): { root?: () => void };
81
+ }
82
+
83
+ // ── the judges: what the source says + a resolved preset → findings. Pure. ─────────
84
+
85
+ /** A place as a finding points at it: its span, or null — the top of the file — when unplaced. */
86
+ const spanOf = (p: Place): Span | null => (p.kind === "at" ? p.span : null);
87
+
88
+ /**
89
+ * What every template finding names: the venue and the class line it requires. A type alias, not an
90
+ * interface: a finding's `data` is a string record, and only an alias is assignable to one.
91
+ */
92
+ type TemplateData = {
93
+ readonly venue: string;
94
+ readonly template: string;
95
+ };
96
+
97
+ /** A class line that names a class, against the template's: the same class, with every option. */
98
+ function judgeClass(
99
+ line: Extract<ClassLine, { readonly kind: "class" }>,
100
+ want: DocumentClass,
101
+ data: TemplateData,
102
+ ): readonly Located[] {
103
+ const at = spanOf(line.place);
104
+ if (line.cls !== want.cls)
105
+ return [{ messageId: "wrongClass", data: { ...data, got: line.cls }, at }];
106
+ return missingOptions(line, want).map((option) => ({
107
+ messageId: "missingOption",
108
+ data: { ...data, option },
109
+ at,
110
+ }));
111
+ }
112
+
113
+ /** The paper's class line against a template that was read: present, naming a class, that class. */
114
+ function judgeClassLine(
115
+ line: ClassLine,
116
+ want: DocumentClass,
117
+ data: TemplateData,
118
+ ): readonly Located[] {
119
+ switch (line.kind) {
120
+ case "missing":
121
+ return [{ messageId: "noClass", data, at: null }];
122
+ case "empty":
123
+ return [{ messageId: "emptyClass", data, at: spanOf(line.place) }];
124
+ case "class":
125
+ return judgeClass(line, want, data);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The paper's class line against the preset's template: a template the reader cannot read is said
131
+ * once; then the class must be there, name a class, be the template's class, and carry every
132
+ * option the template names.
133
+ */
134
+ export function judgeTemplate(
135
+ line: ClassLine,
136
+ preset: Preset,
137
+ latex: Pick<LatexReader, "template">,
138
+ ): readonly Located[] {
139
+ const t = preset.template;
140
+ if (t === null) return [];
141
+ const want = latex.template(t.text);
142
+ if (want === null) {
143
+ const data = { file: t.file, text: JSON.stringify(t.text) };
144
+ return [{ messageId: "badTemplate", data, at: null }];
145
+ }
146
+ const data = { venue: preset.label, template: documentClassLine(want) };
147
+ return judgeClassLine(line, want, data);
148
+ }
149
+
150
+ /** Where a heading starts, or null when the parser gave it no place. */
151
+ const startOf = (p: Place): number | null =>
152
+ p.kind === "at" ? p.span.start : null;
153
+
154
+ /** A required section that is absent, reported where the document ends — where it would go. */
155
+ const missingSection = (
156
+ outline: Outline,
157
+ data: { readonly venue: string; readonly title: string },
158
+ ): Located => ({
159
+ messageId: "missing",
160
+ data,
161
+ at: outline.end === null ? null : { start: outline.end, end: outline.end },
162
+ });
163
+
164
+ /** The first section of the body that starts after `start` and is not titled `title`. */
165
+ function bodySectionAfter(
166
+ outline: Outline,
167
+ start: number,
168
+ title: string,
169
+ ): Heading | undefined {
170
+ const backMatter = outline.backMatter ?? Number.POSITIVE_INFINITY;
171
+ return outline.sections.find((h) => {
172
+ const s = startOf(h.place);
173
+ return s !== null && s > start && s < backMatter && h.title !== title;
174
+ });
175
+ }
176
+
177
+ /**
178
+ * One required section against the outline: missing (reported where the document ends, where it
179
+ * would go), or — for `last` — standing before a section of the body. The body is every section
180
+ * before the appendix and the bibliography, so the required one may follow the bibliography. A
181
+ * heading without a place cannot be ordered, so it is never reported as out of order.
182
+ */
183
+ function judgeSection(
184
+ outline: Outline,
185
+ want: RequiredSection,
186
+ venue: string,
187
+ ): readonly Located[] {
188
+ const title = collapse(want.title);
189
+ const last = outline.sections.filter((h) => h.title === title).at(-1);
190
+ const data = { venue, title };
191
+ if (last === undefined) return [missingSection(outline, data)];
192
+ const lastStart = startOf(last.place);
193
+ if (want.position !== "last" || lastStart === null) return [];
194
+ const after = bodySectionAfter(outline, lastStart, title);
195
+ if (after === undefined) return [];
196
+ const notLast = { ...data, after: after.title };
197
+ return [{ messageId: "notLast", data: notLast, at: spanOf(last.place) }];
198
+ }
199
+
200
+ /** Every section the preset requires, against the paper's outline. */
201
+ export function judgeRequiredSections(
202
+ outline: () => Outline,
203
+ preset: Preset,
204
+ ): readonly Located[] {
205
+ if (preset.requiredSections.length === 0) return [];
206
+ const o = outline();
207
+ return preset.requiredSections.flatMap((r) =>
208
+ judgeSection(o, r, preset.label),
209
+ );
210
+ }
211
+
212
+ /** Another shipped venue: the word for it, and what it is called in a paper's text. */
213
+ export interface OtherVenue {
214
+ readonly label: string;
215
+ readonly aliases: readonly string[];
216
+ }
217
+
218
+ /** `s` as a pattern that matches it literally. */
219
+ const literal = (s: string): string =>
220
+ s.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
221
+
222
+ /**
223
+ * Each place the rendered text names another venue — one of its aliases as a whole word, case
224
+ * and all — unless that alias is also this paper's venue's own (a shared parent conference).
225
+ */
226
+ export function judgeLeftover(
227
+ runs: readonly TextRun[],
228
+ preset: Preset,
229
+ others: readonly OtherVenue[],
230
+ ): readonly Located[] {
231
+ const own = new Set(preset.aliases);
232
+ const names = others.flatMap((o) =>
233
+ o.aliases
234
+ .filter((a) => !own.has(a))
235
+ .map((alias) => ({
236
+ other: o.label,
237
+ re: new RegExp(
238
+ `(?<![\\p{L}\\p{N}])${literal(alias)}(?![\\p{L}\\p{N}])`,
239
+ "gu",
240
+ ),
241
+ })),
242
+ );
243
+ return runs.flatMap((run) =>
244
+ names.flatMap(({ other, re }) =>
245
+ [...runText(run).matchAll(re)].map((m) => ({
246
+ messageId: "leftover",
247
+ data: { name: m[0], other, venue: preset.label },
248
+ at: spanIn(run, m.index, m.index + m[0].length),
249
+ })),
250
+ ),
251
+ );
252
+ }
253
+
254
+ /** Where a rule's page lives: `docs/rules/tex/<name>.md` on the default branch. */
255
+ export const ruleDocsUrl = (name: TexVenueRuleName): string =>
256
+ `https://github.com/zernie/paperlint/blob/main/docs/rules/tex/${name}.md`;
257
+
258
+ /** What a judge reads: the paper's source through the reader, its preset, the other venues. */
259
+ interface Reading {
260
+ readonly src: string;
261
+ readonly latex: LatexReader;
262
+ readonly preset: Preset;
263
+ readonly others: () => readonly OtherVenue[];
264
+ }
265
+ type Judge = (r: Reading) => readonly Located[];
266
+
267
+ export type TexVenueRuleName =
268
+ "template" | "required-section" | "venue-leftover";
269
+
270
+ const RULES: Readonly<
271
+ Record<
272
+ TexVenueRuleName,
273
+ { readonly judge: Judge; readonly meta: TexRuleModule["meta"] }
274
+ >
275
+ > = {
276
+ template: {
277
+ judge: (r) =>
278
+ judgeTemplate(r.latex.documentClass(r.src), r.preset, r.latex),
279
+ meta: {
280
+ type: "problem",
281
+ docs: {
282
+ description:
283
+ "the \\documentclass is the venue preset's class, with every option the preset names",
284
+ url: ruleDocsUrl("template"),
285
+ },
286
+ schema: [],
287
+ messages: {
288
+ noClass:
289
+ "paper.tex has no \\documentclass; {{venue}} requires `{{template}}`",
290
+ emptyClass:
291
+ "the \\documentclass names no class; {{venue}} requires `{{template}}`",
292
+ badTemplate:
293
+ '{{file}}: the preset\'s "template" {{text}} is neither a \\documentclass line nor a class name, so the class cannot be checked',
294
+ wrongClass:
295
+ "the class is `{{got}}`, and {{venue}} requires `{{template}}` — the page size, fonts and layout the venue checks come from the class",
296
+ missingOption:
297
+ "the class option `{{option}}` is missing: {{venue}} requires `{{template}}`",
298
+ },
299
+ },
300
+ },
301
+ "required-section": {
302
+ judge: (r) => judgeRequiredSections(() => r.latex.outline(r.src), r.preset),
303
+ meta: {
304
+ type: "problem",
305
+ docs: {
306
+ description:
307
+ "every section the venue preset requires is there, titled exactly, and where the preset says",
308
+ url: ruleDocsUrl("required-section"),
309
+ },
310
+ schema: [],
311
+ messages: {
312
+ missing:
313
+ "{{venue}} requires a section titled «{{title}}» and there is none — add `\\section*{{{title}}}` (the title exactly; a bold paragraph does not count)",
314
+ notLast:
315
+ "«{{title}}» must close the paper at {{venue}}, and the section «{{after}}» comes after it — move it after the last section of the body (after the bibliography is fine)",
316
+ },
317
+ },
318
+ },
319
+ "venue-leftover": {
320
+ judge: (r) =>
321
+ judgeLeftover(r.latex.renderedRuns(r.src), r.preset, r.others()),
322
+ meta: {
323
+ type: "suggestion",
324
+ docs: {
325
+ description:
326
+ "the text names another shipped venue than the one the paper extends — a leftover from an earlier submission",
327
+ url: ruleDocsUrl("venue-leftover"),
328
+ },
329
+ schema: [],
330
+ messages: {
331
+ leftover:
332
+ "«{{name}}» names {{other}}, and this paper extends {{venue}} — 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",
333
+ },
334
+ },
335
+ },
336
+ };
337
+
338
+ /** The level each rule is on at in paperlint's own config, for every `paper.tex`. */
339
+ export const TEX_VENUE_RULE_LEVELS: Readonly<
340
+ Record<`tex/${TexVenueRuleName}`, "error" | "warn">
341
+ > = {
342
+ "tex/template": "error",
343
+ "tex/required-section": "error",
344
+ "tex/venue-leftover": "warn",
345
+ };
346
+
347
+ /**
348
+ * Every shipped venue but the ones on this paper's own chain, each resolved the way a paper
349
+ * extending it would be. A preset that does not resolve is skipped: `pdf/profile` owns that.
350
+ */
351
+ export function otherVenues(
352
+ preset: Preset,
353
+ fromFile: string,
354
+ deps: VenueRuleDeps,
355
+ ): readonly OtherVenue[] {
356
+ return shippedPresets(deps.venuesDir).flatMap((name) => {
357
+ const r = resolvePreset(`${SHIPPED_PREFIX}${name}`, fromFile, deps);
358
+ // A preset all of whose files are on this paper's chain is this venue or one it extends.
359
+ return r.ok && !r.value.chain.every((f) => preset.chain.includes(f))
360
+ ? [{ label: r.value.label, aliases: r.value.aliases }]
361
+ : [];
362
+ });
363
+ }
364
+
365
+ /** What the venue-conformance rules are built with: the preset store, and the LaTeX reader. */
366
+ export interface TexVenueRuleDeps extends VenueRuleDeps {
367
+ readonly latex: LatexReader;
368
+ }
369
+
370
+ /**
371
+ * What a judge reads for the paper whose `paper.tex` is `filename`, or null when the paper has no
372
+ * resolved preset (`pdf/measured` and `pdf/profile` already say so).
373
+ */
374
+ function readingOf(
375
+ filename: string,
376
+ src: string,
377
+ deps: TexVenueRuleDeps,
378
+ ): Reading | null {
379
+ const dir = dirname(filename);
380
+ const p = paperPreset(dir, deps);
381
+ if (p.kind !== "resolved") return null;
382
+ const preset = p.preset;
383
+ const others = () => otherVenues(preset, join(dir, CONFIG_FILE), deps);
384
+ return { src, latex: deps.latex, preset, others };
385
+ }
386
+
387
+ /** Each finding reported at its span, or at the top of the file when it has none. */
388
+ function reportAll(
389
+ context: TexRuleContext,
390
+ findings: readonly Located[],
391
+ ): void {
392
+ const at = (i: number) => context.sourceCode.getLocFromIndex(i);
393
+ findings.forEach((f) => {
394
+ context.report({
395
+ loc: { start: at(f.at?.start ?? 0), end: at(f.at?.end ?? 0) },
396
+ messageId: f.messageId,
397
+ data: f.data,
398
+ });
399
+ });
400
+ }
401
+
402
+ function rule(name: TexVenueRuleName, deps: TexVenueRuleDeps): TexRuleModule {
403
+ const { judge, meta } = RULES[name];
404
+ return {
405
+ meta,
406
+ create(context) {
407
+ // Judged once per paper, on its paper.tex: the paper directory is the file's directory.
408
+ if (basename(context.filename) !== "paper.tex") return {};
409
+ return {
410
+ root() {
411
+ const sc = context.sourceCode;
412
+ const reading = readingOf(context.filename, sc.raw ?? sc.text, deps);
413
+ if (reading !== null) reportAll(context, judge(reading));
414
+ },
415
+ };
416
+ },
417
+ };
418
+ }
419
+
420
+ /** The venue-conformance rules, as rules of the `tex` plugin (beside `tex/future-promise`). */
421
+ export function texVenueRules(
422
+ deps: TexVenueRuleDeps,
423
+ ): Readonly<Record<TexVenueRuleName, TexRuleModule>> {
424
+ return {
425
+ template: rule("template", deps),
426
+ "required-section": rule("required-section", deps),
427
+ "venue-leftover": rule("venue-leftover", deps),
428
+ };
429
+ }
@@ -6,7 +6,7 @@
6
6
  * inside is `fixtures/toolchain-mirror/install-tl`, which lays out a tree whose `kpsewhich` and
7
7
  * `tlmgr` answer from two text files. So what runs is paperlint's own download, fallback, unpack,
8
8
  * install and verify logic — the part that decides success — and what is faked is only TeX.
9
- * The real TeX Live half is `test/e2e/toolchain.ts`.
9
+ * The real TeX Live half is `test/e2e/tex/toolchain.e2e.ts`.
10
10
  *
11
11
  * Tables, in order:
12
12
  * 1. the pure pieces (year, profile, tlmgr's unknown names, cache location);
@@ -12,12 +12,12 @@ import { join } from "node:path";
12
12
  import { describe, expect, it } from "vitest";
13
13
  import { memoryFiles } from "./adapters/memory/index.ts";
14
14
  import { sha256Hex } from "./domain/sha256.ts";
15
- import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
15
+ import { presetsDir } from "./package-dirs.ts";
16
16
  import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
17
17
  import { buildConfig, OPTIONAL_RULES, SHIPPED_RULES } from "./cli.ts";
18
18
  import { present } from "../test/support.ts";
19
19
 
20
- const VENUES = packageVenuesDir();
20
+ const VENUES = presetsDir();
21
21
  const PAPER = "/work/papers/p";
22
22
  const PDF_BYTES = new TextEncoder().encode(
23
23
  "%PDF-1.5 a stand-in for the built PDF",
@@ -213,13 +213,13 @@ describe("pdf/profile — the declaration must resolve, or nothing is judged", (
213
213
  "an unknown venue (a typo would silently disable every check)",
214
214
  { extends: "paperlint:agentic-dev", kind: "short" },
215
215
  "preset",
216
- /agenticdev, aisec, realm/,
216
+ /agenticdev, aidc, aisec, ieee-conference, realm/,
217
217
  ],
218
218
  [
219
219
  "the base TeX set is not a venue",
220
220
  { extends: "paperlint:tex-base" },
221
221
  "preset",
222
- /agenticdev, aisec, realm/,
222
+ /agenticdev, aidc, aisec, ieee-conference, realm/,
223
223
  ],
224
224
  [
225
225
  "paperlint.json that is not JSON",
@@ -560,6 +560,98 @@ describe("paperlint's own config turns the venue rules on for every paper.tex",
560
560
  });
561
561
  });
562
562
 
563
+ /**
564
+ * The facts of the 13-page `[conference,compsoc]` IEEEtran paper measured for ieee-conference.jsonc
565
+ * (2026-09-27): banal's page, columns and sizes, pdf.js's fonts.
566
+ */
567
+ /** Embedded Type 1 fonts of these names. */
568
+ const type1 = (names: readonly string[]): Font[] =>
569
+ names.map((name) => ({
570
+ name,
571
+ type: "Type 1",
572
+ embedded: true,
573
+ program: "Type1",
574
+ }));
575
+
576
+ const ieeeFacts = (patch: Patch = () => {}): Facts =>
577
+ withFacts((f) => {
578
+ f.page_w_in = 8.5;
579
+ f.page_h_in = 11;
580
+ f.columns = 2;
581
+ f.body_pt = 10.3;
582
+ f.ref_pt = 8.3;
583
+ f.body_pages = 12;
584
+ f.ref_pages = 1;
585
+ f.fonts = type1([
586
+ "NimbusRomNo9L-Medi",
587
+ "NimbusRomNo9L-Regu",
588
+ "NimbusRomNo9L-ReguItal",
589
+ "NimbusRomNo9L-MediItal",
590
+ ]);
591
+ patch(f);
592
+ });
593
+
594
+ describe("the IEEE conference family and AIDC", () => {
595
+ const FAMILY = { extends: "paperlint:ieee-conference" };
596
+ const AIDC = { extends: "paperlint:aidc", kind: "regular" };
597
+
598
+ it.each([
599
+ ["the family", FAMILY],
600
+ ["aidc/regular", AIDC],
601
+ ["aidc/short", { extends: "paperlint:aidc", kind: "short" }],
602
+ ])("the measured IEEEtran build passes %s", (_, venue) => {
603
+ expect(lint({ venue, facts: ieeeFacts() })).toEqual([]);
604
+ });
605
+
606
+ it.each([
607
+ ["the family", FAMILY],
608
+ ["aidc", AIDC],
609
+ ])("the planted A4 build fails %s on both page dimensions", (_, venue) => {
610
+ const facts = ieeeFacts(
611
+ (f) => ((f.page_w_in = 8.264), (f.page_h_in = 11.694)),
612
+ );
613
+ expect(ids(lint({ venue, facts }))).toEqual([
614
+ "pdf/geometry:dim",
615
+ "pdf/geometry:dim",
616
+ ]);
617
+ });
618
+
619
+ it.each([
620
+ ["the family", FAMILY],
621
+ ["aidc", AIDC],
622
+ ])("the planted Computer Modern build fails %s: no Times", (_, venue) => {
623
+ const facts = ieeeFacts(
624
+ (f) => (f.fonts = type1(["CMBX12", "CMR10", "CMTI10"])),
625
+ );
626
+ const fs = lint({ venue, facts });
627
+ expect(ids(fs)).toEqual(["pdf/fonts:noFamily"]);
628
+ expect(fs[0]?.message).toMatch(/NimbusRomNo9L/);
629
+ });
630
+
631
+ it("a body size off the family's 10 pt fails pdf/body-size", () => {
632
+ expect(
633
+ ids(lint({ venue: AIDC, facts: ieeeFacts((f) => (f.body_pt = 9.3)) })),
634
+ ).toEqual(["pdf/body-size:body"]);
635
+ });
636
+
637
+ it("🔴 AIDC's page limit is not gated: banal's body count of 13 — a correct paper with its LLM Usage Statement before the references — is not a finding", () => {
638
+ expect(
639
+ lint({
640
+ venue: AIDC,
641
+ facts: ieeeFacts((f) => ((f.body_pages = 13), (f.ref_pages = 0))),
642
+ }),
643
+ ).toEqual([]);
644
+ });
645
+
646
+ it("an AIDC kind that does not exist names the two that do", () => {
647
+ const fs = lint({ venue: { extends: "paperlint:aidc", kind: "wip" } });
648
+ expect(ids(fs)).toContain("pdf/profile:kindUnknown");
649
+ expect(fs.find((f) => f.rule === "pdf/profile")?.message).toMatch(
650
+ /regular, short/,
651
+ );
652
+ });
653
+ });
654
+
563
655
  describe("a project's own preset, and the facts' other spellings", () => {
564
656
  // A standalone preset (no `extends`, so it carries a `tex` block) that sets a body size with no
565
657
  // tolerance, a reference range, two columns — and no page size and no font families.
@@ -89,7 +89,7 @@ export type Assessment =
89
89
 
90
90
  export interface VenueRuleDeps {
91
91
  readonly files: Files;
92
- /** The package's venues directory (`packageVenuesDir()`): the shipped presets and their schema. */
92
+ /** The package's presets directory (`presetsDir()`): the shipped presets and their schema. */
93
93
  readonly venuesDir: string;
94
94
  }
95
95
 
@@ -1,5 +1,6 @@
1
1
  % Created by `paperlint new`. A stub: replace all of it. The only thing `paperlint lint` needs is that
2
- % this file exists; the document class is yours. `paperlint build` compiles it to paper.pdf.
2
+ % this file exists. With `--venue`, the class below is the venue's (`tex/template` checks it);
3
+ % without, it is yours. `paperlint build` compiles it to paper.pdf.
3
4
  \documentclass{article}
4
5
  \title{Working title for {{name}}}
5
6
  \begin{document}