paperlint 4.1.0 → 5.0.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 (202) hide show
  1. package/CLAUDE.md +26 -2
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +15 -4
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/dist/adapters/latex/document-class.d.ts +6 -5
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -1
  8. package/dist/adapters/latex/document-class.js +18 -10
  9. package/dist/adapters/latex/document-class.js.map +1 -1
  10. package/dist/adapters/latex/includes.d.ts +16 -0
  11. package/dist/adapters/latex/includes.d.ts.map +1 -0
  12. package/dist/adapters/latex/includes.js +48 -0
  13. package/dist/adapters/latex/includes.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +9 -5
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +8 -1
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/nodes.d.ts +5 -1
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +6 -2
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +1 -0
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/adapters/latex/prose.d.ts +8 -0
  26. package/dist/adapters/latex/prose.d.ts.map +1 -0
  27. package/dist/adapters/latex/prose.js +353 -0
  28. package/dist/adapters/latex/prose.js.map +1 -0
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +7 -3
  31. package/dist/build.js.map +1 -1
  32. package/dist/claim-provenance.d.ts +34 -0
  33. package/dist/claim-provenance.d.ts.map +1 -0
  34. package/dist/claim-provenance.js +146 -0
  35. package/dist/claim-provenance.js.map +1 -0
  36. package/dist/cli.d.ts +2 -8
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +81 -31
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/paper-source.d.ts +58 -0
  41. package/dist/domain/paper-source.d.ts.map +1 -0
  42. package/dist/domain/paper-source.js +109 -0
  43. package/dist/domain/paper-source.js.map +1 -0
  44. package/dist/domain/sentences.d.ts +26 -0
  45. package/dist/domain/sentences.d.ts.map +1 -0
  46. package/dist/domain/sentences.js +48 -0
  47. package/dist/domain/sentences.js.map +1 -0
  48. package/dist/domain/tex-document.d.ts +39 -4
  49. package/dist/domain/tex-document.d.ts.map +1 -1
  50. package/dist/domain/tex-document.js.map +1 -1
  51. package/dist/eslint-rules/latex-language.d.ts +5 -1
  52. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  53. package/dist/eslint-rules/latex-language.js +6 -3
  54. package/dist/eslint-rules/latex-language.js.map +1 -1
  55. package/dist/eslint-rules/paper-context.d.ts +20 -0
  56. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  57. package/dist/eslint-rules/paper-context.js +44 -0
  58. package/dist/eslint-rules/paper-context.js.map +1 -0
  59. package/dist/eslint-rules/paper-typography.d.ts +8 -1
  60. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  61. package/dist/eslint-rules/paper-typography.js +85 -19
  62. package/dist/eslint-rules/paper-typography.js.map +1 -1
  63. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  64. package/dist/eslint-rules/tex-build.js +7 -1
  65. package/dist/eslint-rules/tex-build.js.map +1 -1
  66. package/dist/init.d.ts +4 -19
  67. package/dist/init.d.ts.map +1 -1
  68. package/dist/init.js +24 -29
  69. package/dist/init.js.map +1 -1
  70. package/dist/package-dirs.d.ts +2 -0
  71. package/dist/package-dirs.d.ts.map +1 -1
  72. package/dist/package-dirs.js +8 -0
  73. package/dist/package-dirs.js.map +1 -1
  74. package/dist/paper-files.d.ts +2 -2
  75. package/dist/paper-includes.d.ts +17 -0
  76. package/dist/paper-includes.d.ts.map +1 -0
  77. package/dist/paper-includes.js +51 -0
  78. package/dist/paper-includes.js.map +1 -0
  79. package/dist/ports/latex.d.ts +12 -1
  80. package/dist/ports/latex.d.ts.map +1 -1
  81. package/dist/register.d.ts +40 -0
  82. package/dist/register.d.ts.map +1 -0
  83. package/dist/register.js +134 -0
  84. package/dist/register.js.map +1 -0
  85. package/dist/rules-config.d.ts +1 -0
  86. package/dist/rules-config.d.ts.map +1 -1
  87. package/dist/rules-config.js.map +1 -1
  88. package/dist/structure.js +1 -1
  89. package/dist/structure.js.map +1 -1
  90. package/dist/tex-paper.d.ts +33 -0
  91. package/dist/tex-paper.d.ts.map +1 -0
  92. package/dist/tex-paper.js +103 -0
  93. package/dist/tex-paper.js.map +1 -0
  94. package/dist/tex-venue-rules.d.ts +7 -2
  95. package/dist/tex-venue-rules.d.ts.map +1 -1
  96. package/dist/tex-venue-rules.js +31 -17
  97. package/dist/tex-venue-rules.js.map +1 -1
  98. package/docs/configuration.md +5 -4
  99. package/docs/install.md +8 -8
  100. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  101. package/docs/rules/tex/claim-provenance.md +83 -0
  102. package/docs/rules/tex/register.md +77 -0
  103. package/docs/rules/tex/required-section.md +3 -1
  104. package/docs/rules/tex/template.md +12 -0
  105. package/docs/rules/tex/venue-leftover.md +4 -2
  106. package/docs/rules.md +17 -10
  107. package/eslint-rules/latex-language.ts +10 -4
  108. package/eslint-rules/paper-context.test.ts +47 -0
  109. package/eslint-rules/paper-context.ts +57 -0
  110. package/eslint-rules/paper-typography.test.mjs +48 -1
  111. package/eslint-rules/paper-typography.ts +139 -24
  112. package/eslint-rules/tex-build.ts +8 -1
  113. package/eslint.config.mjs +1 -0
  114. package/fixtures/real-markdown-paper/baseline.json +1 -2
  115. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  116. package/package.json +2 -1
  117. package/scripts/check.ts +2 -2
  118. package/scripts/eslint-report-guard.mjs +5 -2
  119. package/scripts/harness-api.frozen.json +0 -1
  120. package/skills/cold-read-diff/SKILL.md +31 -10
  121. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  122. package/skills/draft-paper/SKILL.md +2 -2
  123. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  124. package/skills/grade-paper-writing/SKILL.md +109 -44
  125. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  126. package/skills/harden-paper/SKILL.md +35 -36
  127. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  128. package/skills/paper-adversarial-review/SKILL.md +2 -2
  129. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  130. package/skills/paper-pipeline/SKILL.md +3 -3
  131. package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
  132. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  133. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  134. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  135. package/skills/pc-panel-review/SKILL.md +36 -11
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  137. package/src/adapters/latex/document-class.ts +20 -12
  138. package/src/adapters/latex/includes.ts +74 -0
  139. package/src/adapters/latex/index.test.ts +183 -1
  140. package/src/adapters/latex/index.ts +14 -4
  141. package/src/adapters/latex/nodes.ts +11 -2
  142. package/src/adapters/latex/parse.ts +1 -0
  143. package/src/adapters/latex/prose.ts +484 -0
  144. package/src/build.harness.mjs +4 -2
  145. package/src/build.test.ts +18 -1
  146. package/src/build.ts +7 -3
  147. package/src/claim-provenance.test.ts +352 -0
  148. package/src/claim-provenance.ts +217 -0
  149. package/src/cli.harness.mjs +22 -54
  150. package/src/cli.test.ts +2 -18
  151. package/src/cli.ts +92 -36
  152. package/src/domain/paper-source.test.ts +156 -0
  153. package/src/domain/paper-source.ts +233 -0
  154. package/src/domain/sentences.ts +67 -0
  155. package/src/domain/tex-document.ts +36 -3
  156. package/src/init.test.ts +33 -13
  157. package/src/init.ts +25 -48
  158. package/src/linted-set.test.ts +11 -1
  159. package/src/new-paper.harness.mjs +1 -5
  160. package/src/package-dirs.ts +9 -0
  161. package/src/paper-files.ts +2 -2
  162. package/src/paper-includes.test.ts +161 -0
  163. package/src/paper-includes.ts +77 -0
  164. package/src/ports/latex.ts +13 -0
  165. package/src/register.test.ts +165 -0
  166. package/src/register.ts +177 -0
  167. package/src/rule-cases.test.ts +43 -23
  168. package/src/rule-docs.test.ts +0 -1
  169. package/src/rules-config.ts +1 -0
  170. package/src/structure.harness.ts +1 -3
  171. package/src/structure.ts +1 -1
  172. package/src/tex-paper.test.ts +148 -0
  173. package/src/tex-paper.ts +161 -0
  174. package/src/tex-venue-rules.test.ts +87 -5
  175. package/src/tex-venue-rules.ts +62 -30
  176. package/templates/paper/PIPELINE-STATUS.md +0 -4
  177. package/dist/action-ref.d.ts +0 -12
  178. package/dist/action-ref.d.ts.map +0 -1
  179. package/dist/action-ref.js +0 -16
  180. package/dist/action-ref.js.map +0 -1
  181. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  182. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  183. package/dist/eslint-rules/paper-research-question.js +0 -188
  184. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  185. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  186. package/eslint-rules/paper-research-question.ts +0 -199
  187. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  188. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  189. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  190. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  191. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  192. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  193. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  194. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  195. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  196. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  197. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  198. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  199. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  200. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  201. package/src/action-ref.test.ts +0 -26
  202. package/src/action-ref.ts +0 -15
@@ -0,0 +1,484 @@
1
+ /**
2
+ * The body's prose as passages a sentence cannot cross — for a rule that judges SENTENCES, where the
3
+ * rendered runs (`rendered.ts`) are too fine: a run ends at every macro, so one sentence around a
4
+ * `\cite` or an `\emph{…}` is several runs there and one passage here.
5
+ *
6
+ * Kept: the text a reader sees, from the start of the document (the abstract included) to the
7
+ * appendix or the bibliography; the marks that say where a claim comes from — a citation, a link, a
8
+ * cross-reference — where they stand; math, as its source.
9
+ *
10
+ * Left out: headings — a run-in one too, a bold or italic phrase ending in `.` or `:` that opens a
11
+ * paragraph or an item (`\textbf{Threats.} …`) — captions and list-item labels (each also ends a passage), the title block,
12
+ * floats and tables, code, comments, macro definitions, the groups that follow a macro the parser
13
+ * gave no signature (`\institution{…}`), and everything from the back matter on. A footnote is its
14
+ * own passage; one that cites or links leaves a citation mark in the sentence it hangs from.
15
+ */
16
+ import type * as Ast from "@unified-latex/unified-latex-types";
17
+ import { hasAtLeast, sortBy } from "remeda";
18
+ import type { Owner, Passage, ProsePiece } from "../../domain/tex-document.ts";
19
+ import {
20
+ argumentsOf,
21
+ inPlace,
22
+ macroPlace,
23
+ mandatory,
24
+ placeOf,
25
+ stringText,
26
+ type Macro,
27
+ type Node,
28
+ } from "./nodes.ts";
29
+ import { CITATION_MACROS, DEFINITION_MACROS, type ParsedTex } from "./parse.ts";
30
+
31
+ /** What the walk yields: a piece, the end of a passage, the start of the back matter, a footnote. */
32
+ type Event =
33
+ | { readonly kind: "piece"; readonly piece: ProsePiece }
34
+ | { readonly kind: "break" }
35
+ | { readonly kind: "end" }
36
+ | { readonly kind: "aside"; readonly events: readonly Event[] };
37
+
38
+ const BREAK: Event = { kind: "break" };
39
+ const END: Event = { kind: "end" };
40
+
41
+ /** Environments whose body is not running prose: floats, tables, algorithms, drawings, code. */
42
+ const SKIPPED_ENVS: ReadonlySet<string> = new Set([
43
+ "figure",
44
+ "figure*",
45
+ "table",
46
+ "table*",
47
+ "tabular",
48
+ "tabular*",
49
+ "tabularx",
50
+ "longtable",
51
+ "wrapfigure",
52
+ "wraptable",
53
+ "subfigure",
54
+ "subtable",
55
+ "algorithm",
56
+ "algorithm*",
57
+ "algorithmic",
58
+ "tikzpicture",
59
+ "lstlisting",
60
+ "verbatim",
61
+ "Verbatim",
62
+ "minted",
63
+ "comment",
64
+ "CCSXML",
65
+ ]);
66
+
67
+ /** Macros that start the back matter: nothing after them is the body. */
68
+ const BACK_MATTER: ReadonlySet<string> = new Set([
69
+ "appendix",
70
+ "bibliography",
71
+ "printbibliography",
72
+ ]);
73
+
74
+ /** Macros that point at a numbered place in the paper. */
75
+ const REFERENCE_MACROS: ReadonlySet<string> = new Set([
76
+ "ref",
77
+ "cref",
78
+ "Cref",
79
+ "autoref",
80
+ "Autoref",
81
+ "eqref",
82
+ "pageref",
83
+ "nameref",
84
+ "vref",
85
+ "Vref",
86
+ "cpageref",
87
+ ]);
88
+
89
+ /** Macros that name a source outside the paper: a citation (`\nocite` prints nothing), or a link. */
90
+ const SOURCE_MACROS: ReadonlySet<string> = new Set([
91
+ ...[...CITATION_MACROS].filter((m) => m !== "nocite"),
92
+ "url",
93
+ ]);
94
+
95
+ /** Macros that end a passage and whose arguments are not prose: headings, captions, the title block. */
96
+ const BREAK_MACROS: ReadonlySet<string> = new Set([
97
+ "part",
98
+ "chapter",
99
+ "section",
100
+ "subsection",
101
+ "subsubsection",
102
+ "paragraph",
103
+ "subparagraph",
104
+ "caption",
105
+ "captionof",
106
+ "bibitem",
107
+ "maketitle",
108
+ "title",
109
+ "subtitle",
110
+ "author",
111
+ "date",
112
+ "newpage",
113
+ "clearpage",
114
+ ]);
115
+
116
+ /** Formatting macros whose last mandatory argument is prose, read where it stands. */
117
+ const INLINE_MACROS: ReadonlySet<string> = new Set([
118
+ "emph",
119
+ "textbf",
120
+ "textit",
121
+ "texttt",
122
+ "textsc",
123
+ "textsf",
124
+ "textrm",
125
+ "textsl",
126
+ "textup",
127
+ "textmd",
128
+ "textnormal",
129
+ "underline",
130
+ "uline",
131
+ "mbox",
132
+ "hbox",
133
+ "text",
134
+ "enquote",
135
+ "textcolor",
136
+ "colorbox",
137
+ "fbox",
138
+ "textsuperscript",
139
+ "textsubscript",
140
+ ]);
141
+
142
+ /** Macros that typeset one character or a space. */
143
+ const CHARACTER_MACROS: ReadonlyMap<string, string> = new Map([
144
+ ["%", "%"],
145
+ ["&", "&"],
146
+ ["$", "$"],
147
+ ["#", "#"],
148
+ ["_", "_"],
149
+ ["{", "{"],
150
+ ["}", "}"],
151
+ ["S", "§"],
152
+ ["P", "¶"],
153
+ ["textpercent", "%"],
154
+ ["ldots", "…"],
155
+ ["dots", "…"],
156
+ ["textellipsis", "…"],
157
+ ["textendash", "–"],
158
+ ["textemdash", "—"],
159
+ ...[
160
+ ",",
161
+ " ",
162
+ ";",
163
+ ":",
164
+ "quad",
165
+ "qquad",
166
+ "enspace",
167
+ "thinspace",
168
+ "\\",
169
+ "newline",
170
+ ].map((m) => [m, " "] as const),
171
+ ]);
172
+
173
+ const piece = (p: ProsePiece): Event => ({ kind: "piece", piece: p });
174
+
175
+ /** `s`, typeset where `n` starts. */
176
+ const textAt = (n: Node, s: string): readonly Event[] =>
177
+ inPlace(placeOf(n), (span) => [
178
+ piece({ kind: "text", segment: { text: s, at: span.start } }),
179
+ ]);
180
+
181
+ /**
182
+ * The character a macro typesets, placed at the macro's LAST source character: a sentence that ends
183
+ * on `\%` then spans the whole macro, not its backslash.
184
+ */
185
+ const characterAt = (m: Macro, ch: string): readonly Event[] =>
186
+ inPlace(placeOf(m), (span) => [
187
+ piece({ kind: "text", segment: { text: ch, at: span.end - 1 } }),
188
+ ]);
189
+
190
+ /** A mark over a macro and its arguments. */
191
+ const mark = (m: Macro, owner: Owner): readonly Event[] =>
192
+ inPlace(macroPlace(m), (span) => [piece({ kind: "owner", owner, span })]);
193
+
194
+ /** Math, as its source, where it stands. */
195
+ const math = (t: ParsedTex, n: Node): readonly Event[] =>
196
+ inPlace(placeOf(n), (span) => [
197
+ piece({ kind: "math", tex: t.src.slice(span.start, span.end), span }),
198
+ ]);
199
+
200
+ /** The content of a macro's last mandatory argument: what a formatting macro or a footnote typesets. */
201
+ const lastArgument = (m: Macro): readonly Node[] =>
202
+ mandatory(m).at(-1)?.content ?? [];
203
+
204
+ /**
205
+ * Whether a walk yielded a citation or a link. A footnote nested inside that cites has already left
206
+ * its own citation mark among these events, so the top level is enough.
207
+ */
208
+ const cites = (events: readonly Event[]): boolean =>
209
+ events.some(
210
+ (e) =>
211
+ e.kind === "piece" &&
212
+ e.piece.kind === "owner" &&
213
+ e.piece.owner === "citation",
214
+ );
215
+
216
+ /**
217
+ * What a list item typesets: the parser hands an `\item` its text as the last argument (the
218
+ * `[label]` before it is not prose).
219
+ */
220
+ const itemBody = (m: Macro): readonly Node[] =>
221
+ argumentsOf(m)
222
+ .slice(-1)
223
+ .flatMap((a) => a.content);
224
+
225
+ /** A footnote: its own passage, and a citation mark in the host sentence when it cites or links. */
226
+ function footnote(t: ParsedTex, m: Macro): readonly Event[] {
227
+ const inside = walkList(t, lastArgument(m));
228
+ const aside: Event = { kind: "aside", events: inside };
229
+ return cites(inside) ? [...mark(m, "citation"), aside] : [aside];
230
+ }
231
+
232
+ /**
233
+ * Macros with events of their own: `\href{url}{text}` is a link whose text is prose; a footnote is a
234
+ * passage of its own; a list item ends a passage and opens the next with its text.
235
+ */
236
+ const OWN_EVENTS: ReadonlyMap<
237
+ string,
238
+ (t: ParsedTex, m: Macro) => readonly Event[]
239
+ > = new Map([
240
+ [
241
+ "href",
242
+ (t: ParsedTex, m: Macro) => [
243
+ ...mark(m, "citation"),
244
+ ...walkList(t, lastArgument(m)),
245
+ ],
246
+ ],
247
+ ["footnote", footnote],
248
+ [
249
+ "item",
250
+ (t: ParsedTex, m: Macro) => [BREAK, ...walkList(t, itemBody(m), true)],
251
+ ],
252
+ ]);
253
+
254
+ function macroEvents(t: ParsedTex, m: Macro): readonly Event[] {
255
+ const name = m.content;
256
+ if (DEFINITION_MACROS.has(name)) return [];
257
+ if (BACK_MATTER.has(name)) return [END];
258
+ if (REFERENCE_MACROS.has(name)) return mark(m, "reference");
259
+ if (SOURCE_MACROS.has(name)) return mark(m, "citation");
260
+ const own = OWN_EVENTS.get(name);
261
+ if (own !== undefined) return own(t, m);
262
+ if (BREAK_MACROS.has(name)) return [BREAK];
263
+ if (INLINE_MACROS.has(name)) return walkList(t, lastArgument(m));
264
+ const ch = CHARACTER_MACROS.get(name);
265
+ return ch === undefined ? [] : characterAt(m, ch);
266
+ }
267
+
268
+ function environmentEvents(
269
+ t: ParsedTex,
270
+ e: Readonly<Ast.Environment>,
271
+ ): readonly Event[] {
272
+ if (e.type === "mathenv") return math(t, e);
273
+ if (e.env === "thebibliography") return [END];
274
+ if (SKIPPED_ENVS.has(e.env)) return [BREAK];
275
+ return [BREAK, ...walkList(t, e.content, true), BREAK];
276
+ }
277
+
278
+ type NodeOf<K extends Node["type"]> = Extract<Node, { readonly type: K }>;
279
+
280
+ /**
281
+ * Inline and display math (an equation environment is typed as an environment, and read there),
282
+ * what ends a passage, and what holds no prose.
283
+ */
284
+ const isMath = (n: Node): n is NodeOf<"inlinemath" | "displaymath"> =>
285
+ n.type === "inlinemath" || n.type === "displaymath";
286
+ const isBreak = (n: Node): n is NodeOf<"parbreak" | "verbatim"> =>
287
+ n.type === "parbreak" || n.type === "verbatim";
288
+ const isSilent = (n: Node): n is NodeOf<"comment" | "verb" | "root"> =>
289
+ n.type === "comment" || n.type === "verb" || n.type === "root";
290
+
291
+ function nodeEvents(t: ParsedTex, n: Node): readonly Event[] {
292
+ if (isMath(n)) return math(t, n);
293
+ if (isBreak(n)) return [BREAK];
294
+ if (isSilent(n)) return [];
295
+ switch (n.type) {
296
+ case "string":
297
+ return textAt(n, stringText(n.content));
298
+ case "whitespace":
299
+ return textAt(n, " ");
300
+ case "group":
301
+ return walkList(t, n.content);
302
+ case "environment":
303
+ case "mathenv":
304
+ return environmentEvents(t, n);
305
+ case "macro":
306
+ return macroEvents(t, n);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Where a node list stands with respect to a macro the parser attached no arguments to: in prose,
312
+ * in that macro's arguments, or inside one of its `[…]` arguments.
313
+ */
314
+ type ArgumentState = "prose" | "arguments" | "optional";
315
+
316
+ /** A macro whose `{…}` and `[…]` after it are its arguments, unattached: not a formatting or a character macro. */
317
+ const isUnsignedMacro = (n: Node): boolean =>
318
+ n.type === "macro" &&
319
+ n.args === undefined &&
320
+ !INLINE_MACROS.has(n.content) &&
321
+ !CHARACTER_MACROS.has(n.content);
322
+
323
+ const isString = (n: Node, s: string): boolean =>
324
+ n.type === "string" && n.content === s;
325
+
326
+ /** A `{…}` group, or digits glued to the macro (`\if\conference1`): an argument it takes. */
327
+ const isMandatoryArgument = (n: Node): boolean =>
328
+ n.type === "group" || (n.type === "string" && /^\d+$/u.test(n.content));
329
+
330
+ /** One node's step: whether it is skipped as an argument, and the state after it. */
331
+ function step(
332
+ state: ArgumentState,
333
+ n: Node,
334
+ ): { readonly skip: boolean; readonly state: ArgumentState } {
335
+ if (state === "optional")
336
+ return { skip: true, state: isString(n, "]") ? "arguments" : "optional" };
337
+ if (state === "arguments" && isMandatoryArgument(n))
338
+ return { skip: true, state: "arguments" };
339
+ if (state === "arguments" && isString(n, "["))
340
+ return { skip: true, state: "optional" };
341
+ return { skip: false, state: isUnsignedMacro(n) ? "arguments" : "prose" };
342
+ }
343
+
344
+ /**
345
+ * The nodes of a list that are prose: without the `{…}`, `[…]` and glued digits right after a
346
+ * macro the parser gave no signature — `\institution{…}`, `\ccsdesc[500]{…}`, `\tool{}`,
347
+ * `\if\conference1` — which are its arguments, not text. A formatting macro's group (`\enquote{…}` without csquotes' signature) stays prose.
348
+ */
349
+ const proseNodes = (list: readonly Node[]): readonly Node[] =>
350
+ list.reduce<{
351
+ readonly kept: readonly Node[];
352
+ readonly state: ArgumentState;
353
+ }>(
354
+ (acc, n) => {
355
+ const next = step(acc.state, n);
356
+ return {
357
+ kept: next.skip ? acc.kept : [...acc.kept, n],
358
+ state: next.state,
359
+ };
360
+ },
361
+ { kept: [], state: "prose" },
362
+ ).kept;
363
+
364
+ /** Formatting macros a run-in heading is set in: `\textbf{Correctness gate.} Each task…`. */
365
+ const RUN_IN_MACROS: ReadonlySet<string> = new Set([
366
+ "textbf",
367
+ "textit",
368
+ "emph",
369
+ "textsc",
370
+ "underline",
371
+ ]);
372
+
373
+ /** The text a list of events typesets, marks and math left out. */
374
+ const typeset = (events: readonly Event[]): string =>
375
+ events
376
+ .map((e) =>
377
+ e.kind === "piece" && e.piece.kind === "text" ? e.piece.segment.text : "",
378
+ )
379
+ .join("");
380
+
381
+ /** A formatting macro whose phrase ends in `.` or `:` — a run-in heading, when it opens a paragraph. */
382
+ const isRunInHeading = (t: ParsedTex, n: Node): boolean =>
383
+ n.type === "macro" &&
384
+ RUN_IN_MACROS.has(n.content) &&
385
+ /[.:]$/u.test(typeset(walkList(t, lastArgument(n))).trim());
386
+
387
+ /** Macros that typeset no text, and so leave a paragraph's opening where it was. */
388
+ const SILENT_AT_OPENING: ReadonlySet<string> = new Set([
389
+ "noindent",
390
+ "label",
391
+ "vspace",
392
+ "smallskip",
393
+ "medskip",
394
+ "bigskip",
395
+ "phantomsection",
396
+ ]);
397
+
398
+ /** Nodes that leave a paragraph's opening where it was: spaces, comments, macros that typeset nothing. */
399
+ const keepsOpening = (n: Node): boolean =>
400
+ n.type === "whitespace" ||
401
+ n.type === "comment" ||
402
+ (n.type === "macro" && SILENT_AT_OPENING.has(n.content));
403
+
404
+ /** Nodes after which a paragraph opens: a paragraph break, and a macro that ends a passage (a heading). */
405
+ const opensAfter = (n: Node): boolean =>
406
+ n.type === "parbreak" || (n.type === "macro" && BREAK_MACROS.has(n.content));
407
+
408
+ /**
409
+ * A list's events. `opens` says the list starts a paragraph (the body, an environment's content);
410
+ * a run-in heading that opens a paragraph ends the passage before it and is left out, like a heading.
411
+ */
412
+ function walkList(
413
+ t: ParsedTex,
414
+ list: readonly Node[],
415
+ opens = false,
416
+ ): readonly Event[] {
417
+ return proseNodes(list).reduce<{
418
+ readonly events: readonly Event[];
419
+ readonly opening: boolean;
420
+ }>(
421
+ (acc, n) => ({
422
+ events: [
423
+ ...acc.events,
424
+ ...(acc.opening && isRunInHeading(t, n) ? [BREAK] : nodeEvents(t, n)),
425
+ ],
426
+ opening: opensAfter(n) || (acc.opening && keepsOpening(n)),
427
+ }),
428
+ { events: [], opening: opens },
429
+ ).events;
430
+ }
431
+
432
+ /** The events before the back matter starts. */
433
+ const untilEnd = (events: readonly Event[]): readonly Event[] => {
434
+ const end = events.findIndex((e) => e.kind === "end");
435
+ return end < 0 ? events : events.slice(0, end);
436
+ };
437
+
438
+ /** Events → passages: pieces between breaks, each footnote a passage of its own. */
439
+ function passagesOf(events: readonly Event[]): readonly Passage[] {
440
+ const { done, current } = events.reduce<{
441
+ readonly done: readonly (readonly ProsePiece[])[];
442
+ readonly current: readonly ProsePiece[];
443
+ }>(
444
+ (acc, e) =>
445
+ e.kind === "piece"
446
+ ? { done: acc.done, current: [...acc.current, e.piece] }
447
+ : e.kind === "break"
448
+ ? { done: [...acc.done, acc.current], current: [] }
449
+ : acc,
450
+ { done: [], current: [] },
451
+ );
452
+ const asides = events.flatMap((e) =>
453
+ e.kind === "aside" ? passagesOf(untilEnd(e.events)) : [],
454
+ );
455
+ const own = [...done, current].flatMap((pieces) =>
456
+ hasAtLeast(pieces, 1) ? [{ pieces }] : [],
457
+ );
458
+ return [...own, ...asides];
459
+ }
460
+
461
+ const isDocument = (n: Node): n is Readonly<Ast.Environment> =>
462
+ n.type === "environment" && n.env === "document";
463
+
464
+ /** A passage with something in it besides spaces. */
465
+ const hasProse = (p: Passage): boolean =>
466
+ p.pieces.some((x) => x.kind !== "text" || x.segment.text.trim() !== "");
467
+
468
+ /** Where a passage starts in the source. */
469
+ const startOf = (p: Passage): number => {
470
+ const first = p.pieces[0];
471
+ return first.kind === "text" ? first.segment.at : first.span.start;
472
+ };
473
+
474
+ /**
475
+ * The body's prose, in document order: the content of the `document` environment (the whole
476
+ * source when there is none), up to the first back-matter node.
477
+ */
478
+ export function bodyProse(t: ParsedTex): readonly Passage[] {
479
+ const body = t.root.content.find(isDocument)?.content ?? t.root.content;
480
+ return sortBy(
481
+ passagesOf(untilEnd(walkList(t, body, true))).filter(hasProse),
482
+ startOf,
483
+ );
484
+ }
@@ -264,8 +264,10 @@ try {
264
264
  tex.calls.every((c) => c.cwd === clean),
265
265
  );
266
266
  check(
267
- "🔴 pdflatex got paperlint's venues directory on TEXINPUTS, trailing separator kept",
268
- tex.calls.every((c) => c.texinputs === `${venues}${delimiter}`),
267
+ "🔴 pdflatex got the paper's directory, then paperlint's venues directory, on TEXINPUTS, trailing separator kept",
268
+ tex.calls.every(
269
+ (c) => c.texinputs === `.${delimiter}${venues}${delimiter}`,
270
+ ),
269
271
  );
270
272
 
271
273
  // ── the measure step: facts for the lint rules ────────────────────────────────────────
package/src/build.test.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { existsSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { delimiter, join } from "node:path";
10
+ import { texInputsDir } from "./package-dirs.ts";
10
11
  import { test } from "vitest";
11
12
  import { present, useTempDir, writeTree } from "../test/support.ts";
12
13
  import {
@@ -344,6 +345,22 @@ test("paths: a paper that IS the working directory is named by its full path", a
344
345
  assert.equal(existsSync(join(dir, "paper.pdf")), false);
345
346
  });
346
347
 
348
+ test("🔴 pdflatex looks for an \\input in the paper's directory, then paperlint's inputs, then the system tree", async () => {
349
+ const dir = paper();
350
+ const f: Fake = {};
351
+ const inner = fakeRun(f);
352
+ const seen: (string | undefined)[] = [];
353
+ await build(dir, f, {
354
+ run: (bin, args, opts) => {
355
+ if (bin === "pdflatex") seen.push(opts.env?.["TEXINPUTS"]);
356
+ return inner(bin, args, opts);
357
+ },
358
+ });
359
+ // The same search path the lint rules read includes through (`texSearchPath`).
360
+ // A pass runs in the paper's directory, where `.` is that directory.
361
+ assert.equal(seen[0], [".", texInputsDir(), ""].join(delimiter));
362
+ });
363
+
347
364
  test("facts that cannot be read fail the build at `facts`", async () => {
348
365
  const { line } = await build(
349
366
  paper({ "paper.tex": TEX, "paperlint.json": "{ nope" }),
package/src/build.ts CHANGED
@@ -44,7 +44,7 @@ import { createHash } from "node:crypto";
44
44
  import { delimiter, join, relative } from "node:path";
45
45
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
46
46
  import { getParser } from "@unified-latex/unified-latex-util-parse";
47
- import { texInputsDir } from "./package-dirs.ts";
47
+ import { texInputsDir, texSearchPath } from "./package-dirs.ts";
48
48
  import { CONFIG_FILE } from "#lib/paper-config";
49
49
  import {
50
50
  declaredVenue,
@@ -268,11 +268,15 @@ export const inputsStep: BuildStep = {
268
268
  required: false,
269
269
  applies: () => ({
270
270
  yes: true,
271
- why: `TEXINPUTS += ${texInputsDir()}`,
271
+ why: `TEXINPUTS += the paper's directory, then ${texInputsDir()}`,
272
272
  }),
273
+ // The same search path the lint rules read includes through: one answer to which file is meant.
274
+ // A pass runs IN the paper's directory, so that directory is `.` here. TeX names a file it finds
275
+ // through an absolute entry by that absolute path: with the directory itself on TEXINPUTS, the
276
+ // `broken` build fixture's log said `/tmp/…/paper.tex:6:` instead of `./paper.tex:6:` (measured).
273
277
  run: (ctx) => ({
274
278
  ok: true,
275
- env: withTexInputs(ctx.env, [texInputsDir()]),
279
+ env: withTexInputs(ctx.env, texSearchPath(".")),
276
280
  }),
277
281
  };
278
282