paperlint 4.0.0 → 4.2.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 (236) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +55 -11
  3. package/CONTRIBUTING.md +30 -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 +23 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +110 -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/includes.d.ts +12 -0
  15. package/dist/adapters/latex/includes.d.ts.map +1 -0
  16. package/dist/adapters/latex/includes.js +42 -0
  17. package/dist/adapters/latex/includes.js.map +1 -0
  18. package/dist/adapters/latex/index.d.ts +33 -0
  19. package/dist/adapters/latex/index.d.ts.map +1 -0
  20. package/dist/adapters/latex/index.js +25 -0
  21. package/dist/adapters/latex/index.js.map +1 -0
  22. package/dist/adapters/latex/nodes.d.ts +58 -0
  23. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  24. package/dist/adapters/latex/nodes.js +97 -0
  25. package/dist/adapters/latex/nodes.js.map +1 -0
  26. package/dist/adapters/latex/outline.d.ts +10 -0
  27. package/dist/adapters/latex/outline.d.ts.map +1 -0
  28. package/dist/adapters/latex/outline.js +39 -0
  29. package/dist/adapters/latex/outline.js.map +1 -0
  30. package/dist/adapters/latex/parse.d.ts +42 -0
  31. package/dist/adapters/latex/parse.d.ts.map +1 -0
  32. package/dist/adapters/latex/parse.js +95 -0
  33. package/dist/adapters/latex/parse.js.map +1 -0
  34. package/dist/adapters/latex/prose.d.ts +8 -0
  35. package/dist/adapters/latex/prose.d.ts.map +1 -0
  36. package/dist/adapters/latex/prose.js +353 -0
  37. package/dist/adapters/latex/prose.js.map +1 -0
  38. package/dist/adapters/latex/rendered.d.ts +10 -0
  39. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  40. package/dist/adapters/latex/rendered.js +39 -0
  41. package/dist/adapters/latex/rendered.js.map +1 -0
  42. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  43. package/dist/adapters/pdfjs/fill.js +12 -12
  44. package/dist/adapters/pdfjs/fill.js.map +1 -1
  45. package/dist/build.js +4 -4
  46. package/dist/build.js.map +1 -1
  47. package/dist/claim-provenance.d.ts +34 -0
  48. package/dist/claim-provenance.d.ts.map +1 -0
  49. package/dist/claim-provenance.js +146 -0
  50. package/dist/claim-provenance.js.map +1 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +49 -5
  53. package/dist/cli.js.map +1 -1
  54. package/dist/domain/paper-source.d.ts +58 -0
  55. package/dist/domain/paper-source.d.ts.map +1 -0
  56. package/dist/domain/paper-source.js +109 -0
  57. package/dist/domain/paper-source.js.map +1 -0
  58. package/dist/domain/sentences.d.ts +26 -0
  59. package/dist/domain/sentences.d.ts.map +1 -0
  60. package/dist/domain/sentences.js +48 -0
  61. package/dist/domain/sentences.js.map +1 -0
  62. package/dist/domain/tex-document.d.ts +119 -0
  63. package/dist/domain/tex-document.d.ts.map +1 -0
  64. package/dist/domain/tex-document.js +43 -0
  65. package/dist/domain/tex-document.js.map +1 -0
  66. package/dist/eslint-rules/paper-typography.d.ts +7 -0
  67. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  68. package/dist/eslint-rules/paper-typography.js +53 -1
  69. package/dist/eslint-rules/paper-typography.js.map +1 -1
  70. package/dist/facts-file.d.ts.map +1 -1
  71. package/dist/facts-file.js +2 -2
  72. package/dist/facts-file.js.map +1 -1
  73. package/dist/new-paper.d.ts +5 -0
  74. package/dist/new-paper.d.ts.map +1 -1
  75. package/dist/new-paper.js +3 -0
  76. package/dist/new-paper.js.map +1 -1
  77. package/dist/package-dirs.d.ts +5 -0
  78. package/dist/package-dirs.d.ts.map +1 -0
  79. package/dist/package-dirs.js +14 -0
  80. package/dist/package-dirs.js.map +1 -0
  81. package/dist/ports/latex.d.ts +35 -0
  82. package/dist/ports/latex.d.ts.map +1 -0
  83. package/dist/ports/latex.js +2 -0
  84. package/dist/ports/latex.js.map +1 -0
  85. package/dist/presets.d.ts +15 -2
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -6
  88. package/dist/presets.js.map +1 -1
  89. package/dist/register.d.ts +40 -0
  90. package/dist/register.d.ts.map +1 -0
  91. package/dist/register.js +134 -0
  92. package/dist/register.js.map +1 -0
  93. package/dist/tex-paper.d.ts +19 -0
  94. package/dist/tex-paper.d.ts.map +1 -0
  95. package/dist/tex-paper.js +105 -0
  96. package/dist/tex-paper.js.map +1 -0
  97. package/dist/tex-requirements.d.ts +11 -0
  98. package/dist/tex-requirements.d.ts.map +1 -1
  99. package/dist/tex-requirements.js +14 -8
  100. package/dist/tex-requirements.js.map +1 -1
  101. package/dist/tex-venue-rules.d.ts +88 -0
  102. package/dist/tex-venue-rules.d.ts.map +1 -0
  103. package/dist/tex-venue-rules.js +268 -0
  104. package/dist/tex-venue-rules.js.map +1 -0
  105. package/dist/venue-rules.d.ts +1 -1
  106. package/dist/venue-rules.d.ts.map +1 -1
  107. package/docs/configuration.md +3 -2
  108. package/docs/e2e.md +69 -40
  109. package/docs/install.md +1 -1
  110. package/docs/prior-art/README.md +1 -1
  111. package/docs/prior-art/package-location.md +5 -5
  112. package/docs/prior-art/repro/README.md +1 -1
  113. package/docs/prior-art/test-tooling.md +21 -10
  114. package/docs/rules/tex/claim-provenance.md +83 -0
  115. package/docs/rules/tex/missing-input.md +58 -0
  116. package/docs/rules/tex/register.md +77 -0
  117. package/docs/rules/tex/required-section.md +75 -0
  118. package/docs/rules/tex/template.md +74 -0
  119. package/docs/rules/tex/venue-leftover.md +73 -0
  120. package/docs/rules.md +73 -48
  121. package/docs/testing.md +17 -4
  122. package/docs/texlive-install-decision.md +1 -1
  123. package/docs/toolchain.md +2 -2
  124. package/eslint-rules/paper-typography.test.mjs +48 -1
  125. package/eslint-rules/paper-typography.ts +72 -1
  126. package/eslint.config.mjs +156 -15
  127. package/fixtures/build-e2e/acmart/expect.json +26 -0
  128. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  129. package/fixtures/build-e2e/aidc/expect.json +14 -0
  130. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  131. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  132. package/fixtures/build-e2e/broken/expect.json +11 -0
  133. package/fixtures/build-e2e/cite/expect.json +27 -0
  134. package/fixtures/build-e2e/empty/expect.json +12 -0
  135. package/fixtures/build-e2e/fallback/expect.json +53 -0
  136. package/fixtures/build-e2e/guards/expect.json +6 -0
  137. package/fixtures/build-e2e/no-source/expect.json +10 -0
  138. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  139. package/fixtures/pdf-facts/README.md +1 -1
  140. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  141. package/fixtures/render-paper/build-clean.sh +3 -5
  142. package/package.json +14 -2
  143. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  144. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  145. package/presets/aidc.jsonc +73 -0
  146. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  147. package/presets/ieee-conference.jsonc +66 -0
  148. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  149. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  150. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  151. package/scripts/check.harness.ts +64 -10
  152. package/scripts/check.test.ts +60 -13
  153. package/scripts/check.ts +83 -30
  154. package/scripts/test.test.ts +1 -1
  155. package/scripts/test.ts +1 -1
  156. package/skills/cold-read-diff/SKILL.md +31 -10
  157. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  158. package/skills/draft-paper/SKILL.md +2 -2
  159. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  160. package/skills/grade-paper-writing/SKILL.md +109 -44
  161. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  162. package/skills/harden-paper/SKILL.md +35 -36
  163. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  164. package/skills/paper-adversarial-review/SKILL.md +2 -2
  165. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  166. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  167. package/skills/paper-pipeline/SKILL.md +4 -4
  168. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -3
  169. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  170. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  171. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  172. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  173. package/skills/pc-panel-review/SKILL.md +36 -11
  174. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  175. package/skills/render-paper/SKILL.md +2 -2
  176. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  177. package/skills/render-paper/check-render.sh +16 -63
  178. package/skills/render-paper/render-paper.harness.mjs +17 -59
  179. package/src/adapters/banal/index.test.ts +1 -1
  180. package/src/adapters/banal/invocation.ts +1 -1
  181. package/src/adapters/banal/output.test.ts +1 -1
  182. package/src/adapters/banal/run.test.ts +1 -1
  183. package/src/adapters/banal/xml.test.ts +1 -1
  184. package/src/adapters/latex/document-class.ts +155 -0
  185. package/src/adapters/latex/hidden.ts +70 -0
  186. package/src/adapters/latex/includes.ts +66 -0
  187. package/src/adapters/latex/index.test.ts +535 -0
  188. package/src/adapters/latex/index.ts +65 -0
  189. package/src/adapters/latex/nodes.ts +150 -0
  190. package/src/adapters/latex/outline.ts +64 -0
  191. package/src/adapters/latex/parse.ts +137 -0
  192. package/src/adapters/latex/prose.ts +484 -0
  193. package/src/adapters/latex/rendered.ts +59 -0
  194. package/src/adapters/pdfjs/fill.test.ts +1 -1
  195. package/src/adapters/pdfjs/fill.ts +18 -10
  196. package/src/build.harness.mjs +5 -7
  197. package/src/build.ts +4 -4
  198. package/src/claim-provenance.test.ts +352 -0
  199. package/src/claim-provenance.ts +217 -0
  200. package/src/cli.harness.mjs +1 -1
  201. package/src/cli.test.ts +2 -8
  202. package/src/cli.ts +53 -8
  203. package/src/domain/paper-source.test.ts +156 -0
  204. package/src/domain/paper-source.ts +233 -0
  205. package/src/domain/sentences.ts +67 -0
  206. package/src/domain/tex-document.test.ts +80 -0
  207. package/src/domain/tex-document.ts +143 -0
  208. package/src/engine.harness.mjs +1 -1
  209. package/src/facts-file.test.ts +4 -7
  210. package/src/facts-file.ts +2 -2
  211. package/src/latex-loop.harness.ts +1 -1
  212. package/src/new-paper.test.ts +28 -3
  213. package/src/new-paper.ts +8 -0
  214. package/src/package-dirs.ts +17 -0
  215. package/src/paper-settings-commands.test.ts +6 -1
  216. package/src/ports/latex.ts +42 -0
  217. package/src/presets.test.ts +65 -4
  218. package/src/presets.ts +38 -15
  219. package/src/register.test.ts +165 -0
  220. package/src/register.ts +177 -0
  221. package/src/rule-cases.test.ts +524 -0
  222. package/src/rule-docs.test.ts +159 -0
  223. package/src/tex-paper.test.ts +131 -0
  224. package/src/tex-paper.ts +152 -0
  225. package/src/tex-requirements.harness.ts +13 -23
  226. package/src/tex-requirements.ts +35 -8
  227. package/src/tex-venue-rules.test.ts +741 -0
  228. package/src/tex-venue-rules.ts +461 -0
  229. package/src/toolchain.harness.mjs +1 -1
  230. package/src/venue-rules.test.ts +96 -4
  231. package/src/venue-rules.ts +1 -1
  232. package/templates/paper/paper.tex +2 -1
  233. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  234. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  235. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  236. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * What a paper's LaTeX source says, in the paper's vocabulary — its document class, its sections,
3
+ * the text a reader sees — as values. No parser here: the LaTeX adapter (`src/adapters/latex/`)
4
+ * produces these, and the rules and `paperlint new` read them through the `LatexReader` port.
5
+ */
6
+
7
+ /** A span of the source: `[start, end)` offsets. */
8
+ export interface Span {
9
+ readonly start: number;
10
+ readonly end: number;
11
+ }
12
+
13
+ /** Where something stands in the source, or that the parser gave it no position. */
14
+ export type Place =
15
+ { readonly kind: "at"; readonly span: Span } | { readonly kind: "unplaced" };
16
+
17
+ /** Whitespace collapsed to single spaces and trimmed — how a title or a class name is compared. */
18
+ export const collapse = (s: string): string =>
19
+ s.split(/\s+/u).filter(Boolean).join(" ");
20
+
21
+ /** A LaTeX document class and the options it is loaded with, in source order. */
22
+ export interface DocumentClass {
23
+ readonly cls: string;
24
+ readonly options: readonly string[];
25
+ }
26
+
27
+ /** One `\documentclass` line: one that names no class, or a class with its options. */
28
+ export type ClassCandidate =
29
+ | { readonly kind: "empty"; readonly place: Place }
30
+ | ({ readonly kind: "class"; readonly place: Place } & DocumentClass);
31
+
32
+ /**
33
+ * A paper's `\documentclass`: none, exactly one, or several — a source that picks its class behind
34
+ * a TeX switch (`\if\venue1 \documentclass{article} \fi …`). Which branch builds is not
35
+ * evaluated, so several lines are candidates, in source order, and none of them is THE class.
36
+ */
37
+ export type ClassLine =
38
+ | { readonly kind: "missing" }
39
+ | ClassCandidate
40
+ | {
41
+ readonly kind: "ambiguous";
42
+ readonly candidates: readonly [
43
+ ClassCandidate,
44
+ ClassCandidate,
45
+ ...ClassCandidate[],
46
+ ];
47
+ };
48
+
49
+ /** The options of `want` that `got` does not carry — every one, in `want`'s order. */
50
+ export const missingOptions = (
51
+ got: DocumentClass,
52
+ want: DocumentClass,
53
+ ): readonly string[] => want.options.filter((o) => !got.options.includes(o));
54
+
55
+ /** What a paper's class line needs to satisfy a venue: nothing, options added, or the venue's line. */
56
+ export type ClassChange =
57
+ | { readonly kind: "keep" }
58
+ | { readonly kind: "add"; readonly options: readonly string[] }
59
+ | { readonly kind: "replace"; readonly by: DocumentClass };
60
+
61
+ /**
62
+ * What a paper's class must change to satisfy a venue's `want`: another class is replaced by `want`;
63
+ * the same class keeps its options and gains the ones it lacks, in `want`'s order. This decides WHAT
64
+ * changes; writing it into a source is the LaTeX adapter's.
65
+ */
66
+ export function venueClass(
67
+ have: DocumentClass,
68
+ want: DocumentClass,
69
+ ): ClassChange {
70
+ if (have.cls !== want.cls) return { kind: "replace", by: want };
71
+ const missing = missingOptions(have, want);
72
+ return missing.length === 0
73
+ ? { kind: "keep" }
74
+ : { kind: "add", options: missing };
75
+ }
76
+
77
+ /** A class as a `\documentclass` line, for a message and for the line `paperlint new` writes. */
78
+ export const documentClassLine = (d: DocumentClass): string =>
79
+ `\\documentclass${d.options.length > 0 ? `[${d.options.join(",")}]` : ""}{${d.cls}}`;
80
+
81
+ /** One `\section` or `\section*`, its title's text with whitespace collapsed. */
82
+ export interface Heading {
83
+ readonly title: string;
84
+ readonly place: Place;
85
+ }
86
+
87
+ /** The document's sections in source order, where its back matter begins, and where it ends. */
88
+ export interface Outline {
89
+ readonly sections: readonly Heading[];
90
+ /** The first `\appendix` or bibliography, or null: sections from here on are not body. */
91
+ readonly backMatter: number | null;
92
+ /** Where `\end{document}` stands, or null when there is no document environment. */
93
+ readonly end: number | null;
94
+ }
95
+
96
+ /** Text a reader sees, copied from one place: `text[i]` stands at source offset `at + i`. */
97
+ export interface Segment {
98
+ readonly text: string;
99
+ readonly at: number;
100
+ }
101
+
102
+ /** A run of rendered text: contiguous segments, never empty. */
103
+ export interface TextRun {
104
+ readonly segments: readonly [Segment, ...Segment[]];
105
+ }
106
+
107
+ /** The run's text. */
108
+ export const runText = (run: TextRun): string =>
109
+ run.segments.map((s) => s.text).join("");
110
+
111
+ /** The source offset of the `i`-th character of the text these segments make, or null outside it. */
112
+ function offsetIn(segments: readonly Segment[], i: number): number | null {
113
+ const [s, ...rest] = segments;
114
+ if (s === undefined || i < 0) return null;
115
+ return i < s.text.length ? s.at + i : offsetIn(rest, i - s.text.length);
116
+ }
117
+
118
+ /**
119
+ * The source span of `runText(run).slice(from, to)`: from its first character to past its last.
120
+ * Null when the range is empty or reaches outside the run — never an invented position.
121
+ */
122
+ export function spanIn(run: TextRun, from: number, to: number): Span | null {
123
+ const start = offsetIn(run.segments, from);
124
+ const last = to > from ? offsetIn(run.segments, to - 1) : null;
125
+ return start === null || last === null ? null : { start, end: last + 1 };
126
+ }
127
+
128
+ /** What a mark in prose says about where a claim comes from: a work outside the paper, or a place in it. */
129
+ export type Owner = "citation" | "reference";
130
+
131
+ /**
132
+ * One piece of body prose, in source order: text a reader sees, a mark the markup leaves in the
133
+ * sentence (a `\cite`, a link, a `\ref`), or math, with its source.
134
+ */
135
+ export type ProsePiece =
136
+ | { readonly kind: "text"; readonly segment: Segment }
137
+ | { readonly kind: "owner"; readonly owner: Owner; readonly span: Span }
138
+ | { readonly kind: "math"; readonly tex: string; readonly span: Span };
139
+
140
+ /** A stretch of body prose no sentence crosses — a paragraph, a list item, a footnote. Never empty. */
141
+ export interface Passage {
142
+ readonly pieces: readonly [ProsePiece, ...ProsePiece[]];
143
+ }
@@ -5,7 +5,7 @@
5
5
  * 1. `resolveEngine` over `EngineFacts` — one row per branch of the order;
6
6
  * 2. the fact readers: `missingPackages` over kpsewhich output, `missingTools`, `whichOnPath`,
7
7
  * `probeTree` through a fake runner.
8
- * All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/build.ts`.
8
+ * All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/tex/build.e2e.ts`.
9
9
  */
10
10
  import {
11
11
  chmodSync,
@@ -5,12 +5,12 @@
5
5
  * in-memory files with a FAKE pdf.js reader and a FAKE measurer, so each outcome — read failed, not
6
6
  * measured, measured — is chosen rather than hoped for. Reading real PDFs is
7
7
  * `pdf-facts.harness.ts`'s job; banal as the measurer is `adapters/banal/index.test.ts`, and the
8
- * real banal is `test/e2e/banal.ts`.
8
+ * real banal is `test/e2e/tex/banal.e2e.ts`.
9
9
  */
10
10
  import assert from "node:assert/strict";
11
11
  import { readdirSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
13
+ import { presetsDir } from "./package-dirs.ts";
14
14
  import { test } from "vitest";
15
15
  import { memoryFiles } from "./adapters/memory/index.ts";
16
16
  import {
@@ -154,12 +154,9 @@ const good: PdfReader = () =>
154
154
  });
155
155
  /** The shipped presets at their real paths: `extends` resolves through the same Files port. */
156
156
  const SHIPPED_PRESETS = Object.fromEntries(
157
- readdirSync(packageVenuesDir())
157
+ readdirSync(presetsDir())
158
158
  .filter((f) => /\.jsonc?$/.test(f))
159
- .map((f) => [
160
- join(packageVenuesDir(), f),
161
- readFileSync(join(packageVenuesDir(), f)),
162
- ]),
159
+ .map((f) => [join(presetsDir(), f), readFileSync(join(presetsDir(), f))]),
163
160
  );
164
161
 
165
162
  /** Options over in-memory files: the PDF and paperlint.json on "disk", a measurer answering `geometry`. */
package/src/facts-file.ts CHANGED
@@ -49,7 +49,7 @@ import { callerPath } from "./caller-path.ts";
49
49
  import type { Files } from "./ports/files.ts";
50
50
  import { err, ok, type Result } from "./domain/result.ts";
51
51
  import { paperPreset, paperPresetProblem } from "./presets.ts";
52
- import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
52
+ import { presetsDir } from "./package-dirs.ts";
53
53
  import { messageOf } from "./domain/text.ts";
54
54
 
55
55
  export const FACTS_SCHEMA = 2;
@@ -76,7 +76,7 @@ export interface VenueDecl {
76
76
  export function declaredVenue(
77
77
  files: Files,
78
78
  paperDir: string,
79
- venuesDir: string = packageVenuesDir(),
79
+ venuesDir: string = presetsDir(),
80
80
  ): VenueDecl | null {
81
81
  const p = paperPreset(paperDir, { files, venuesDir });
82
82
  const problem = paperPresetProblem(paperDir, p);
@@ -6,7 +6,7 @@
6
6
  * A defect in `nextStep` fails in table 1, one in `summarize` in table 2, before table 3 runs.
7
7
  *
8
8
  * The decision is pure, so none of this needs TeX. The real-pdflatex half lives in
9
- * `test/e2e/build.ts`.
9
+ * `test/e2e/tex/build.e2e.ts`.
10
10
  */
11
11
  import { createChecker } from "../lib/check.ts";
12
12
  import type {
@@ -19,7 +19,7 @@ import { newPaper, OVERRIDE_DIR, reportNewPaper } from "./new-paper.ts";
19
19
  import { parsePaperSettings } from "./paper-settings.ts";
20
20
  import { chooseVenue, run } from "./cli.ts";
21
21
  import { shippedPresets } from "./presets.ts";
22
- import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
22
+ import { presetsDir } from "./package-dirs.ts";
23
23
  import { lintReport } from "../test/lint-report.ts";
24
24
  import { z } from "zod";
25
25
 
@@ -177,6 +177,31 @@ describe("paperlint new --venue / --kind", () => {
177
177
  expect(r.code).toBe(0);
178
178
  });
179
179
 
180
+ it("🔴 the new paper.tex is set in the venue's class, so tex/template passes on what new wrote; the required section is named", async () => {
181
+ const r = await newIn(["--venue", "aidc", "--kind", "regular"]);
182
+ const tex = readFileSync(join(r.dir, "paper.tex"), "utf8");
183
+ expect(tex).toContain("\\documentclass[conference,compsoc]{IEEEtran}\n");
184
+ expect(tex).not.toContain("{article}");
185
+ expect(r.out).not.toMatch(/tex\/template/);
186
+ // What the stub still lacks is what AIDC requires of the text, and lint says so right away.
187
+ expect(r.out).toMatch(/tex\/required-section/);
188
+ expect(r.code).toBe(1);
189
+ });
190
+
191
+ it("a preset that names no template, or one no reader can read, leaves the template's class as it is", async () => {
192
+ for (const template of [undefined, "\\documentclass[a]{}"]) {
193
+ const r = await newIn(["--venue", "./venues/bare.jsonc"], {
194
+ "venues/bare.jsonc": JSON.stringify({
195
+ ...(template === undefined ? {} : { template }),
196
+ tex: { packages: { x: ["x.sty"] } },
197
+ }),
198
+ });
199
+ expect(readFileSync(join(r.dir, "paper.tex"), "utf8")).toContain(
200
+ "\\documentclass{article}",
201
+ );
202
+ }
203
+ });
204
+
180
205
  it("🔴 a path is relative to where you run it, and written relative to the paper's file", async () => {
181
206
  const r = await newIn(
182
207
  ["--venue", "./venues/my-workshop.jsonc", "--kind", "short"],
@@ -197,7 +222,7 @@ describe("paperlint new --venue / --kind", () => {
197
222
  const r = await newIn(["--venue", "icse"]);
198
223
  expect(r.code).toBe(2);
199
224
  // Guards: the list is read from the shipped presets, the same list lint prints.
200
- expect(r.err).toContain(shippedPresets(packageVenuesDir()).join(", "));
225
+ expect(r.err).toContain(shippedPresets(presetsDir()).join(", "));
201
226
  expect(r.err).toMatch(/--venue icse: no such venue preset/);
202
227
  expect(existsSync(r.dir)).toBe(false);
203
228
  });
@@ -277,7 +302,7 @@ describe("chooseVenue — on a terminal", () => {
277
302
  value: { extends: "paperlint:agenticdev", kind: "full" },
278
303
  });
279
304
  expect(t.asked[0]).toContain(
280
- [...shippedPresets(packageVenuesDir()), "none"].join(" / "),
305
+ [...shippedPresets(presetsDir()), "none"].join(" / "),
281
306
  );
282
307
  expect(t.asked[1]).toMatch(/kind: short \/ full \/ demo \/ later/);
283
308
  });
package/src/new-paper.ts CHANGED
@@ -104,6 +104,11 @@ export interface VenueSetting {
104
104
  /** `paperlint:<name>`, or a path relative to the paper's `paperlint.json`. */
105
105
  readonly extends: string;
106
106
  readonly kind: string | null;
107
+ /**
108
+ * The new `paper.tex` set in the class the preset's template names, so `tex/template` passes on
109
+ * the paper `new` just wrote. Absent or null: the template's class is kept.
110
+ */
111
+ readonly setClass?: ((paperTex: string) => string) | null;
107
112
  }
108
113
 
109
114
  /**
@@ -164,6 +169,9 @@ function fromTemplate(
164
169
  if (!existsSync(src))
165
170
  return { ok: false, reason: `no template for ${file}: ${src} is missing` };
166
171
  const text = readFileSync(src, "utf8").split("{{name}}").join(name);
172
+ const setClass = venue?.setClass ?? null;
173
+ if (file === SOURCE_FILE.tex && setClass !== null)
174
+ return { ok: true, value: { file, text: setClass(text), from } };
167
175
  if (file !== CONFIG_FILE || venue === null)
168
176
  return { ok: true, value: { file, text, from } };
169
177
  const edited = withVenue(text, src, venue);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Where the package's own data lives, wherever the package is installed (its checkout,
3
+ * `node_modules`, a plugin cache). This file sits one level below the package root both as a source
4
+ * (`src/`) and as a build (`dist/`), so `..` is the root in either.
5
+ *
6
+ * presets/ the venue presets (`paperlint:<name>`) and their JSON Schema
7
+ * presets/tex/ the LaTeX inputs `paperlint build` puts on TEXINPUTS
8
+ */
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ /** The shipped venue presets and `venue-profile.schema.json`. */
12
+ export const presetsDir = (): string =>
13
+ fileURLToPath(new URL("../presets", import.meta.url));
14
+
15
+ /** The LaTeX inputs a paper `\input`s by name (`paper-guards`, a venue's numbers). */
16
+ export const texInputsDir = (): string =>
17
+ fileURLToPath(new URL("../presets/tex", import.meta.url));
@@ -232,6 +232,9 @@ describe("paperlint lint — the venue preset's rules", () => {
232
232
  extends: "paperlint:agenticdev",
233
233
  kind: "short",
234
234
  }),
235
+ // In agenticdev's class, or tex/template would be the finding.
236
+ "papers/a/paper.tex":
237
+ "\\documentclass[sigconf]{acmart}\n\\begin{document}x\\end{document}\n",
235
238
  });
236
239
  const r = await lint(root);
237
240
  expect(r.code).toBe(0);
@@ -282,7 +285,9 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
282
285
  });
283
286
  const r = await lint(root);
284
287
  expect(r.code).toBe(1);
285
- expect(r.out).toMatch(/acm-sigconf, agenticdev, aisec, realm/);
288
+ expect(r.out).toMatch(
289
+ /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
290
+ );
286
291
  });
287
292
  });
288
293
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What paperlint needs from a LaTeX source, shaped by its rules and `paperlint new` — not by the
3
+ * parser. The one implementation is `src/adapters/latex/` (unified-latex); `src/cli.ts` wires it.
4
+ */
5
+ import type { Include } from "../domain/paper-source.ts";
6
+ import type {
7
+ ClassLine,
8
+ DocumentClass,
9
+ Outline,
10
+ Passage,
11
+ Span,
12
+ TextRun,
13
+ } from "../domain/tex-document.ts";
14
+
15
+ export interface LatexReader {
16
+ /** The source's `\documentclass`, as one of its states. */
17
+ readonly documentClass: (src: string) => ClassLine;
18
+ /** Its sections, where the back matter begins, and where the document ends. */
19
+ readonly outline: (src: string) => Outline;
20
+ /** The text a reader sees: prose, the author block, footnotes — not comments, keys, math, code. */
21
+ readonly renderedRuns: (src: string) => readonly TextRun[];
22
+ /**
23
+ * The body's prose, as passages a sentence cannot cross: from the start of the document (the
24
+ * abstract included) to the appendix or the bibliography, without headings, captions, floats,
25
+ * code or the title block. Citations, links and cross-references stay in place as marks.
26
+ */
27
+ readonly bodyProse: (src: string) => readonly Passage[];
28
+ /** Every `\input`, `\include` and `\subfile`, in source order: the path as written, where it stands. */
29
+ readonly includes: (src: string) => readonly Include[];
30
+ /** The span of the `document` environment's body, or null when the source has none. */
31
+ readonly documentBody: (src: string) => Span | null;
32
+ /**
33
+ * A preset's `template` → the class it names: a whole `\documentclass[…]{…}` line, or a bare
34
+ * class name (no option). Null for anything else.
35
+ */
36
+ readonly template: (text: string) => DocumentClass | null;
37
+ /**
38
+ * `src` with its `\documentclass` set to `want` — unless it already is `want`'s class with every
39
+ * option `want` names (the author's own options stay), or it has none.
40
+ */
41
+ readonly withDocumentClass: (src: string, want: DocumentClass) => string;
42
+ }
@@ -10,15 +10,18 @@ import { readdirSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { describe, expect, it } from "vitest";
12
12
  import { memoryFiles } from "./adapters/memory/index.ts";
13
- import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
13
+ import { presetsDir } from "./package-dirs.ts";
14
+ import { parsePreset } from "./tex-requirements.ts";
14
15
  import {
15
16
  MAX_PRESET_DEPTH,
17
+ SHIPPED_PREFIX,
18
+ shippedPresets,
16
19
  labelOf,
17
20
  presetProblemText,
18
21
  resolvePreset,
19
22
  } from "./presets.ts";
20
23
 
21
- const VENUES = packageVenuesDir();
24
+ const VENUES = presetsDir();
22
25
  const shipped = Object.fromEntries(
23
26
  readdirSync(VENUES)
24
27
  .filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
@@ -44,7 +47,11 @@ describe("shipped presets", () => {
44
47
  expect(r.value.format.columns).toBe(2);
45
48
  expect(r.value.format.fontsText).toBe("LinLibertine");
46
49
  expect([...r.value.format.kinds.keys()]).toEqual(["short", "full", "demo"]);
47
- expect(r.value.template).toBe("acmart");
50
+ // The family's template, as its file spells it: the child names none.
51
+ expect(r.value.template).toEqual({
52
+ text: "\\documentclass[sigconf]{acmart}",
53
+ file: join(VENUES, "acm-sigconf.jsonc"),
54
+ });
48
55
  expect("acmart" in r.value.tex.packages).toBe(true);
49
56
  expect(r.value.label).toBe("agenticdev");
50
57
  });
@@ -70,6 +77,45 @@ describe("shipped presets", () => {
70
77
  });
71
78
  });
72
79
 
80
+ describe("the naming convention of shipped presets (docs/rules.md)", () => {
81
+ // Each shipped preset, parsed: its file name and what it extends. The families are DERIVED — a
82
+ // shipped preset another shipped preset extends — so a new family is checked the day it ships.
83
+ const presets = shippedPresets(VENUES).map((name) => {
84
+ const file = join(VENUES, `${name}.jsonc`);
85
+ return {
86
+ name,
87
+ extends: parsePreset(readFileSync(file, "utf8"), file, VENUES).extends,
88
+ };
89
+ });
90
+ const families = presets.filter((p) =>
91
+ presets.some((q) => q.extends === `${SHIPPED_PREFIX}${p.name}`),
92
+ );
93
+ const publishers = families.map((f) => f.name.slice(0, f.name.indexOf("-")));
94
+
95
+ it("there are families to check (else the checks below see nothing)", () => {
96
+ expect(families.map((f) => f.name).sort()).toEqual([
97
+ "acm-sigconf",
98
+ "ieee-conference",
99
+ ]);
100
+ });
101
+
102
+ it.each(families.map((f) => [f.name]))(
103
+ "the family %s is <publisher>-<template variant>",
104
+ (name) => {
105
+ expect(name).toMatch(/^[a-z0-9]+-[a-z0-9-]+$/);
106
+ },
107
+ );
108
+
109
+ it.each(presets.filter((p) => p.extends !== null).map((p) => [p.name]))(
110
+ "the venue %s carries no publisher prefix — its template is said by `extends`",
111
+ (name) => {
112
+ expect(publishers.filter((pub) => name.startsWith(`${pub}-`))).toEqual(
113
+ [],
114
+ );
115
+ },
116
+ );
117
+ });
118
+
73
119
  describe("a project's own preset, by relative path", () => {
74
120
  const usenix = JSON.stringify({
75
121
  extends: "paperlint:acm-sigconf",
@@ -149,7 +195,7 @@ describe("what does not resolve, and says why", () => {
149
195
  "a shipped name with a typo",
150
196
  "paperlint:agenticdve",
151
197
  "not-found",
152
- /acm-sigconf, agenticdev, aisec, realm/,
198
+ /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
153
199
  ],
154
200
  ["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
155
201
  [
@@ -225,6 +271,21 @@ describe("chains that do not resolve", () => {
225
271
  });
226
272
  });
227
273
 
274
+ describe("a preset's template: kept as its file spells it, with that file", () => {
275
+ it("a child's template replaces its parent's, and names its own file", () => {
276
+ const r = resolve("./own.jsonc", {
277
+ "/work/papers/p/own.jsonc": JSON.stringify({
278
+ extends: "paperlint:aidc",
279
+ template: "\\documentclass[conference]{IEEEtran}",
280
+ }),
281
+ });
282
+ expect(r.ok && r.value.template).toEqual({
283
+ text: "\\documentclass[conference]{IEEEtran}",
284
+ file: "/work/papers/p/own.jsonc",
285
+ });
286
+ });
287
+ });
288
+
228
289
  describe("labelOf — display only, never used to resolve", () => {
229
290
  it.each([
230
291
  ["paperlint:agenticdev", "agenticdev"],
package/src/presets.ts CHANGED
@@ -19,7 +19,8 @@
19
19
  * tex union — a child never removes a package its parent needs
20
20
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
21
21
  * rules per rule id, the child wins
22
- * template the child wins; so does `name`
22
+ * template the child wins; so does `name`, and `required_sections` (the whole list)
23
+ * aliases union, with every `name` — what the venue is called along the chain
23
24
  *
24
25
  * ── THE LABEL ─────────────────────────────────────────────────────────────────────
25
26
  * Messages, the facts file and the build plan need a word for the venue. It is the most derived
@@ -36,6 +37,7 @@ import {
36
37
  profileFileOf,
37
38
  venueNames,
38
39
  type PresetFile,
40
+ type RequiredSection,
39
41
  type TexRequirements,
40
42
  type VenueFormat,
41
43
  } from "./tex-requirements.ts";
@@ -61,13 +63,27 @@ export interface PresetDeps {
61
63
  readonly venuesDir: string;
62
64
  }
63
65
 
66
+ /** A preset's `template`, as its file spells it. */
67
+ export interface PresetTemplate {
68
+ readonly text: string;
69
+ readonly file: string;
70
+ }
71
+
64
72
  /** A resolved chain, merged. */
65
73
  export interface Preset {
66
74
  /** The word for this venue in messages. */
67
75
  readonly label: string;
68
76
  /** Every file of the chain, root first. */
69
77
  readonly chain: readonly string[];
70
- readonly template: string | null;
78
+ /**
79
+ * The `\\documentclass` the venue's template uses, as the preset spells it, and the file that
80
+ * spells it; null when no file of the chain names one. Read by the rules and `paperlint new`,
81
+ * through the LaTeX reader.
82
+ */
83
+ readonly template: PresetTemplate | null;
84
+ /** Every `name` and `aliases` along the chain: what this venue is called in a paper's text. */
85
+ readonly aliases: readonly string[];
86
+ readonly requiredSections: readonly RequiredSection[];
71
87
  readonly tex: TexRequirements;
72
88
  readonly format: VenueFormat;
73
89
  readonly rules: Readonly<Record<string, unknown>>;
@@ -187,26 +203,37 @@ export function mergeFormat(
187
203
  /** The chain, root first, merged into one preset. Pure. */
188
204
  function merged(
189
205
  spec: string,
190
- rootFirst: readonly PresetFile[],
191
- files: readonly string[],
206
+ rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
192
207
  ): Preset {
193
208
  const base: {
194
209
  name: string | null;
195
- template: string | null;
210
+ template: PresetTemplate | null;
211
+ aliases: readonly string[];
212
+ requiredSections: readonly RequiredSection[];
196
213
  tex: TexRequirements;
197
214
  format: VenueFormat;
198
215
  rules: Readonly<Record<string, unknown>>;
199
216
  } = {
200
217
  name: null,
201
218
  template: null,
219
+ aliases: [],
220
+ requiredSections: [],
202
221
  tex: NO_REQUIREMENTS,
203
222
  format: NO_FORMAT,
204
223
  rules: {},
205
224
  };
206
225
  const m = rootFirst.reduce(
207
- (acc, p) => ({
226
+ (acc, { preset: p, file }) => ({
208
227
  name: p.name ?? acc.name,
209
- template: p.template ?? acc.template,
228
+ template: p.template === null ? acc.template : { text: p.template, file },
229
+ aliases: [
230
+ ...new Set([
231
+ ...acc.aliases,
232
+ ...(p.name === null ? [] : [p.name]),
233
+ ...p.aliases,
234
+ ]),
235
+ ],
236
+ requiredSections: p.requiredSections ?? acc.requiredSections,
210
237
  tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
211
238
  format: mergeFormat(acc.format, p.format),
212
239
  rules: { ...acc.rules, ...p.rules },
@@ -215,8 +242,10 @@ function merged(
215
242
  );
216
243
  return {
217
244
  label: m.name ?? labelOf(spec),
218
- chain: files,
245
+ chain: rootFirst.map((x) => x.file),
219
246
  template: m.template,
247
+ aliases: m.aliases,
248
+ requiredSections: m.requiredSections,
220
249
  tex: m.tex,
221
250
  format: m.format,
222
251
  rules: m.rules,
@@ -238,13 +267,7 @@ export function resolvePreset(
238
267
  ? err({ ...chain.error, shipped: shippedPresets(deps.venuesDir) })
239
268
  : chain;
240
269
  const rootFirst = [...chain.value].reverse();
241
- return ok(
242
- merged(
243
- spec,
244
- rootFirst.map((x) => x.preset),
245
- rootFirst.map((x) => x.file),
246
- ),
247
- );
270
+ return ok(merged(spec, rootFirst));
248
271
  }
249
272
 
250
273
  /** The shipped presets' names (each is spelled `paperlint:<name>`). */