paperlint 4.2.0 → 6.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 (251) hide show
  1. package/CLAUDE.md +15 -2
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +23 -12
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/bin/paperlint.mjs +5 -1
  7. package/dist/adapters/latex/includes.d.ts +5 -1
  8. package/dist/adapters/latex/includes.d.ts.map +1 -1
  9. package/dist/adapters/latex/includes.js +8 -2
  10. package/dist/adapters/latex/includes.js.map +1 -1
  11. package/dist/adapters/latex/index.d.ts +1 -0
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +3 -0
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/layout.d.ts +27 -0
  16. package/dist/adapters/latex/layout.d.ts.map +1 -0
  17. package/dist/adapters/latex/layout.js +62 -0
  18. package/dist/adapters/latex/layout.js.map +1 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +11 -1
  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 +5 -1
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +7 -3
  27. package/dist/build.js.map +1 -1
  28. package/dist/cli.d.ts +2 -8
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +92 -42
  31. package/dist/cli.js.map +1 -1
  32. package/dist/domain/anonymity.d.ts +68 -0
  33. package/dist/domain/anonymity.d.ts.map +1 -0
  34. package/dist/domain/anonymity.js +94 -0
  35. package/dist/domain/anonymity.js.map +1 -0
  36. package/dist/domain/body-pages.d.ts +69 -0
  37. package/dist/domain/body-pages.d.ts.map +1 -0
  38. package/dist/domain/body-pages.js +141 -0
  39. package/dist/domain/body-pages.js.map +1 -0
  40. package/dist/domain/paper-source.js +2 -2
  41. package/dist/domain/tex-document.d.ts +9 -0
  42. package/dist/domain/tex-document.d.ts.map +1 -1
  43. package/dist/eslint-rules/latex-language.d.ts +5 -1
  44. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  45. package/dist/eslint-rules/latex-language.js +6 -3
  46. package/dist/eslint-rules/latex-language.js.map +1 -1
  47. package/dist/eslint-rules/paper-context.d.ts +20 -0
  48. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  49. package/dist/eslint-rules/paper-context.js +44 -0
  50. package/dist/eslint-rules/paper-context.js.map +1 -0
  51. package/dist/eslint-rules/paper-typography.d.ts +1 -1
  52. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  53. package/dist/eslint-rules/paper-typography.js +32 -18
  54. package/dist/eslint-rules/paper-typography.js.map +1 -1
  55. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  56. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  57. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  58. package/dist/eslint-rules/tex-build.js +7 -1
  59. package/dist/eslint-rules/tex-build.js.map +1 -1
  60. package/dist/facts-file.d.ts +28 -2
  61. package/dist/facts-file.d.ts.map +1 -1
  62. package/dist/facts-file.js +54 -9
  63. package/dist/facts-file.js.map +1 -1
  64. package/dist/init.d.ts +4 -19
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +24 -29
  67. package/dist/init.js.map +1 -1
  68. package/dist/lib/paper-config.d.ts.map +1 -1
  69. package/dist/lib/paper-config.js +2 -1
  70. package/dist/lib/paper-config.js.map +1 -1
  71. package/dist/package-dirs.d.ts +2 -0
  72. package/dist/package-dirs.d.ts.map +1 -1
  73. package/dist/package-dirs.js +8 -0
  74. package/dist/package-dirs.js.map +1 -1
  75. package/dist/paper-files.d.ts +2 -2
  76. package/dist/paper-includes.d.ts +17 -0
  77. package/dist/paper-includes.d.ts.map +1 -0
  78. package/dist/paper-includes.js +51 -0
  79. package/dist/paper-includes.js.map +1 -0
  80. package/dist/paper-settings.d.ts +11 -0
  81. package/dist/paper-settings.d.ts.map +1 -1
  82. package/dist/paper-settings.js +38 -4
  83. package/dist/paper-settings.js.map +1 -1
  84. package/dist/pdf-content.d.ts +40 -0
  85. package/dist/pdf-content.d.ts.map +1 -0
  86. package/dist/pdf-content.js +97 -0
  87. package/dist/pdf-content.js.map +1 -0
  88. package/dist/pdf-facts.d.ts +30 -0
  89. package/dist/pdf-facts.d.ts.map +1 -1
  90. package/dist/pdf-facts.js +37 -1
  91. package/dist/pdf-facts.js.map +1 -1
  92. package/dist/ports/latex.d.ts +3 -1
  93. package/dist/ports/latex.d.ts.map +1 -1
  94. package/dist/presets.d.ts +2 -0
  95. package/dist/presets.d.ts.map +1 -1
  96. package/dist/presets.js +4 -0
  97. package/dist/presets.js.map +1 -1
  98. package/dist/rules-config.d.ts +1 -0
  99. package/dist/rules-config.d.ts.map +1 -1
  100. package/dist/rules-config.js.map +1 -1
  101. package/dist/structure.js +1 -1
  102. package/dist/structure.js.map +1 -1
  103. package/dist/tex-paper.d.ts +17 -3
  104. package/dist/tex-paper.d.ts.map +1 -1
  105. package/dist/tex-paper.js +36 -38
  106. package/dist/tex-paper.js.map +1 -1
  107. package/dist/tex-requirements.d.ts +4 -0
  108. package/dist/tex-requirements.d.ts.map +1 -1
  109. package/dist/tex-requirements.js +3 -0
  110. package/dist/tex-requirements.js.map +1 -1
  111. package/dist/tex-venue-rules.d.ts +7 -1
  112. package/dist/tex-venue-rules.d.ts.map +1 -1
  113. package/dist/tex-venue-rules.js +40 -0
  114. package/dist/tex-venue-rules.js.map +1 -1
  115. package/dist/types.d.ts +2 -0
  116. package/dist/types.d.ts.map +1 -1
  117. package/dist/venue-rules.d.ts +38 -8
  118. package/dist/venue-rules.d.ts.map +1 -1
  119. package/dist/venue-rules.js +179 -41
  120. package/dist/venue-rules.js.map +1 -1
  121. package/docs/configuration.md +23 -20
  122. package/docs/e2e.md +32 -11
  123. package/docs/install.md +8 -8
  124. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  125. package/docs/rules/anonymity/identity.md +111 -0
  126. package/docs/rules/format/layout-override.md +61 -0
  127. package/docs/rules/format/page-limit.md +108 -0
  128. package/docs/rules/pdf/body-size.md +87 -0
  129. package/docs/rules.md +55 -46
  130. package/eslint-rules/latex-language.ts +10 -4
  131. package/eslint-rules/paper-context.test.ts +47 -0
  132. package/eslint-rules/paper-context.ts +57 -0
  133. package/eslint-rules/paper-typography.ts +67 -23
  134. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  135. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  136. package/eslint-rules/tex-build.ts +8 -1
  137. package/fixtures/build-e2e/aidc/expect.json +1 -1
  138. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  139. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  140. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  141. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  142. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  143. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  144. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  145. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  146. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  147. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  148. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  149. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  150. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  151. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  152. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  153. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  154. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  155. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  156. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  157. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  158. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  159. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  160. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  161. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  162. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  163. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  164. package/fixtures/real-markdown-paper/baseline.json +1 -2
  165. package/lib/paper-config.ts +3 -1
  166. package/package.json +1 -1
  167. package/presets/acm-sigconf.jsonc +3 -1
  168. package/presets/aidc.jsonc +16 -17
  169. package/presets/ieee-conference.jsonc +8 -0
  170. package/presets/venue-profile.schema.json +9 -0
  171. package/scripts/check.ts +2 -2
  172. package/scripts/eslint-report-guard.mjs +5 -2
  173. package/scripts/harness-api.frozen.json +0 -1
  174. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  175. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  176. package/src/adapters/latex/includes.ts +10 -2
  177. package/src/adapters/latex/index.test.ts +8 -0
  178. package/src/adapters/latex/index.ts +3 -0
  179. package/src/adapters/latex/layout.ts +106 -0
  180. package/src/adapters/latex/nodes.ts +11 -3
  181. package/src/adapters/latex/parse.ts +5 -1
  182. package/src/build.harness.mjs +6 -4
  183. package/src/build.test.ts +23 -1
  184. package/src/build.ts +7 -3
  185. package/src/cli.harness.mjs +22 -54
  186. package/src/cli.test.ts +15 -18
  187. package/src/cli.ts +114 -46
  188. package/src/domain/anonymity.test.ts +163 -0
  189. package/src/domain/anonymity.ts +129 -0
  190. package/src/domain/body-pages.test.ts +300 -0
  191. package/src/domain/body-pages.ts +223 -0
  192. package/src/domain/paper-source.ts +2 -2
  193. package/src/domain/tex-document.ts +10 -0
  194. package/src/facts-file.test.ts +100 -4
  195. package/src/facts-file.ts +91 -9
  196. package/src/init.test.ts +33 -13
  197. package/src/init.ts +25 -48
  198. package/src/linted-set.test.ts +11 -1
  199. package/src/new-paper.harness.mjs +1 -5
  200. package/src/new-paper.test.ts +7 -1
  201. package/src/package-dirs.ts +9 -0
  202. package/src/paper-files.ts +2 -2
  203. package/src/paper-includes.test.ts +161 -0
  204. package/src/paper-includes.ts +77 -0
  205. package/src/paper-settings.test.ts +63 -6
  206. package/src/paper-settings.ts +53 -5
  207. package/src/pdf-content.test.ts +71 -0
  208. package/src/pdf-content.ts +125 -0
  209. package/src/pdf-facts.test.ts +110 -2
  210. package/src/pdf-facts.ts +106 -8
  211. package/src/ports/latex.ts +3 -0
  212. package/src/presets.ts +8 -8
  213. package/src/rule-cases.test.ts +29 -33
  214. package/src/rule-docs.test.ts +0 -3
  215. package/src/rules-config.ts +1 -0
  216. package/src/structure.harness.ts +1 -3
  217. package/src/structure.ts +1 -1
  218. package/src/tex-paper.test.ts +78 -61
  219. package/src/tex-paper.ts +57 -48
  220. package/src/tex-requirements.ts +9 -0
  221. package/src/tex-venue-rules.test.ts +111 -6
  222. package/src/tex-venue-rules.ts +57 -2
  223. package/src/types.ts +2 -0
  224. package/src/venue-rules.test.ts +351 -39
  225. package/src/venue-rules.ts +247 -47
  226. package/templates/paper/PIPELINE-STATUS.md +0 -4
  227. package/dist/action-ref.d.ts +0 -12
  228. package/dist/action-ref.d.ts.map +0 -1
  229. package/dist/action-ref.js +0 -16
  230. package/dist/action-ref.js.map +0 -1
  231. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  232. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  233. package/dist/eslint-rules/paper-research-question.js +0 -188
  234. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  235. package/docs/rules/tex/missing-input.md +0 -58
  236. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  237. package/eslint-rules/paper-research-question.ts +0 -199
  238. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  239. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  240. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  241. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  242. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  243. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  244. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  245. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  246. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  247. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  248. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  249. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  250. package/src/action-ref.test.ts +0 -26
  251. package/src/action-ref.ts +0 -15
@@ -31,6 +31,7 @@ import { outlineOf } from "./outline.ts";
31
31
  import { parseLatex } from "./parse.ts";
32
32
  import { bodyProse } from "./prose.ts";
33
33
  import { renderedRuns } from "./rendered.ts";
34
+ import { layoutOverridesOf } from "./layout.ts";
34
35
 
35
36
  export {
36
37
  documentClassOf,
@@ -51,6 +52,7 @@ export { outlineOf } from "./outline.ts";
51
52
  export { parseLatex, type ParsedTex } from "./parse.ts";
52
53
  export { bodyProse } from "./prose.ts";
53
54
  export { renderedRuns } from "./rendered.ts";
55
+ export { layoutOverridesOf } from "./layout.ts";
54
56
 
55
57
  /** The `LatexReader` port over unified-latex: each call parses its source once. */
56
58
  export const latexReader: LatexReader = {
@@ -62,4 +64,5 @@ export const latexReader: LatexReader = {
62
64
  documentBody: (src) => documentBodyOf(parseLatex(src)),
63
65
  template: parseTemplate,
64
66
  withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
67
+ layoutOverrides: (src) => layoutOverridesOf(parseLatex(src)),
65
68
  };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Where a source changes the page layout its class sets for the whole document — the input of
3
+ * `format/layout-override`. Read from the parse tree, never the text: a command in a comment is not
4
+ * a command.
5
+ *
6
+ * What counts, each by its node:
7
+ *
8
+ * \usepackage{geometry} · \RequirePackage{…,geometry} the package that re-sets the margins
9
+ * \geometry{…} · \newgeometry{…} its commands
10
+ * \setlength / \addtolength on a layout length \textheight \textwidth \topmargin …
11
+ * \linespread{…} · \renewcommand{\baselinestretch} the line spacing
12
+ *
13
+ * Local spacing — a negative `\vspace` or `\vskip` around one float — is not a layout the template
14
+ * sets, and no venue's call names it; it is not read.
15
+ *
16
+ * A comment and a `comment` environment are not read — the parser hands both back as text, not
17
+ * macros. Everything else is — the bibliography, math, floats — and a macro definition too: a
18
+ * `\newcommand` whose body sets a layout length does so wherever it is used. Conditionals are not
19
+ * evaluated.
20
+ */
21
+ import type { LayoutOverride } from "../../domain/tex-document.ts";
22
+ import {
23
+ collapse,
24
+ isList,
25
+ macroPlace,
26
+ mandatory,
27
+ textOf,
28
+ visited,
29
+ type Macro,
30
+ } from "./nodes.ts";
31
+ import type { ParsedTex } from "./parse.ts";
32
+
33
+ /** The lengths the class sets for the page and the line; changing one changes the venue's layout. */
34
+ export const LAYOUT_LENGTHS: ReadonlySet<string> = new Set([
35
+ "textheight",
36
+ "textwidth",
37
+ "topmargin",
38
+ "oddsidemargin",
39
+ "evensidemargin",
40
+ "columnsep",
41
+ "headsep",
42
+ "headheight",
43
+ "footskip",
44
+ "baselineskip",
45
+ ]);
46
+
47
+ /** The macro a mandatory argument holds first, or undefined: `{\textheight}` → `textheight`. */
48
+ function firstMacro(m: Macro, i: number): string | undefined {
49
+ const first = mandatory(m)[i]?.content.find((n) => n.type !== "whitespace");
50
+ return first?.type === "macro" ? first.content : undefined;
51
+ }
52
+
53
+ const argText = (m: Macro, i: number): string =>
54
+ collapse(textOf(mandatory(m)[i]?.content));
55
+
56
+ /** What names a macro that overrides the layout; null: this use of it does not. */
57
+ type Namer = (m: Macro) => string | null;
58
+
59
+ const usesGeometry: Namer = (m) =>
60
+ argText(m, 0)
61
+ .split(",")
62
+ .map((p) => p.trim())
63
+ .includes("geometry")
64
+ ? `\\${m.content}{geometry}`
65
+ : null;
66
+
67
+ const setsLayoutLength: Namer = (m) => {
68
+ const length = firstMacro(m, 0);
69
+ return length !== undefined && LAYOUT_LENGTHS.has(length)
70
+ ? `\\${m.content}{\\${length}}`
71
+ : null;
72
+ };
73
+
74
+ /** Each macro that may override the layout, and how to tell whether this use of it does. */
75
+ const OVERRIDES: ReadonlyMap<string, Namer> = new Map<string, Namer>([
76
+ ["usepackage", usesGeometry],
77
+ ["RequirePackage", usesGeometry],
78
+ ["geometry", (m) => `\\${m.content}`],
79
+ ["newgeometry", (m) => `\\${m.content}`],
80
+ ["linespread", (m) => `\\linespread{${argText(m, 0)}}`],
81
+ ["setlength", setsLayoutLength],
82
+ ["addtolength", setsLayoutLength],
83
+ [
84
+ "renewcommand",
85
+ (m) =>
86
+ firstMacro(m, 0) === "baselinestretch"
87
+ ? "\\renewcommand{\\baselinestretch}"
88
+ : null,
89
+ ],
90
+ ]);
91
+
92
+ /** How a message names a macro that overrides the layout, or null when it does not. */
93
+ const overrideOf: Namer = (m) => OVERRIDES.get(m.content)?.(m) ?? null;
94
+
95
+ /** Every layout override in the source, in document order. */
96
+ export function layoutOverridesOf(t: ParsedTex): readonly LayoutOverride[] {
97
+ // Nothing is pruned: a comment, and a `comment` environment, reach the tree as leaves of text.
98
+ const lists = visited(t.root, () => false).filter(isList);
99
+ return lists.flatMap((list) =>
100
+ list.flatMap((n) => {
101
+ if (n.type !== "macro") return [];
102
+ const command = overrideOf(n);
103
+ return command === null ? [] : [{ command, place: macroPlace(n) }];
104
+ }),
105
+ );
106
+ }
@@ -63,9 +63,17 @@ const NONE: Shown = { kind: "none" };
63
63
  */
64
64
  const SHOWN_ARGUMENT: ReadonlyMap<string, Shown> = new Map<string, Shown>([
65
65
  ["texorpdfstring", { kind: "argument", at: 0 }],
66
- ...["color", "fontsize", "vspace", "hspace", "setlength", "addtolength"].map(
67
- (m) => [m, NONE] as const,
68
- ),
66
+ ...[
67
+ "color",
68
+ "fontsize",
69
+ "vspace",
70
+ "hspace",
71
+ "setlength",
72
+ "addtolength",
73
+ "linespread",
74
+ "geometry",
75
+ "newgeometry",
76
+ ].map((m) => [m, NONE] as const),
69
77
  ]);
70
78
 
71
79
  /** The text a (non-key) macro typesets: see `SHOWN_ARGUMENT`. */
@@ -82,7 +82,11 @@ const LINK_SIGNATURES = Object.fromEntries(signed(["href"], "m m"));
82
82
  * Declarations unified-latex has no signature for: signed so their arguments (sizes, not text)
83
83
  * attach to them instead of standing beside them as groups a reader would seem to see.
84
84
  */
85
- const DECLARATION_SIGNATURES = Object.fromEntries(signed(["fontsize"], "m m"));
85
+ const DECLARATION_SIGNATURES = Object.fromEntries([
86
+ ...signed(["fontsize"], "m m"),
87
+ // The layout commands `format/layout-override` reads: a spacing factor, geometry's key list.
88
+ ...signed(["linespread", "geometry", "newgeometry"], "m"),
89
+ ]);
86
90
 
87
91
  /**
88
92
  * Macros that define other macros or environments. A heading in a body is no section where it is
@@ -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 ────────────────────────────────────────
@@ -287,8 +289,8 @@ try {
287
289
  if (existsSync(factsFile)) {
288
290
  const f = JSON.parse(readFileSync(factsFile, "utf8"));
289
291
  check(
290
- "measure: the facts are schema 2, about paper.pdf, with the last page's heights",
291
- f.schema === 2 &&
292
+ "measure: the facts are schema 3, about paper.pdf, with the last page's heights",
293
+ f.schema === 3 &&
292
294
  f.pdf === "paper.pdf" &&
293
295
  JSON.stringify(f.last_page_cols_pt) === "[400,400]",
294
296
  );
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 {
@@ -114,6 +115,11 @@ const reader =
114
115
  },
115
116
  last,
116
117
  layout: [],
118
+ pageTexts: [],
119
+ links: [],
120
+ bibAnchorPage: null,
121
+ appendixAnchorPage: null,
122
+ metadata: {},
117
123
  },
118
124
  });
119
125
  const quiet = () => {};
@@ -344,6 +350,22 @@ test("paths: a paper that IS the working directory is named by its full path", a
344
350
  assert.equal(existsSync(join(dir, "paper.pdf")), false);
345
351
  });
346
352
 
353
+ test("🔴 pdflatex looks for an \\input in the paper's directory, then paperlint's inputs, then the system tree", async () => {
354
+ const dir = paper();
355
+ const f: Fake = {};
356
+ const inner = fakeRun(f);
357
+ const seen: (string | undefined)[] = [];
358
+ await build(dir, f, {
359
+ run: (bin, args, opts) => {
360
+ if (bin === "pdflatex") seen.push(opts.env?.["TEXINPUTS"]);
361
+ return inner(bin, args, opts);
362
+ },
363
+ });
364
+ // The same search path the lint rules read includes through (`texSearchPath`).
365
+ // A pass runs in the paper's directory, where `.` is that directory.
366
+ assert.equal(seen[0], [".", texInputsDir(), ""].join(delimiter));
367
+ });
368
+
347
369
  test("facts that cannot be read fail the build at `facts`", async () => {
348
370
  const { line } = await build(
349
371
  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
 
@@ -132,8 +132,8 @@ check(
132
132
  Array.isArray(cfg) && ruleBlocks(cfg).length === 4,
133
133
  );
134
134
  check(
135
- "with a LaTeX language a fifth block is added",
136
- ruleBlocks(buildConfig({}, {})).length === 5,
135
+ "with a LaTeX language two blocks are added: paper.tex, and the files it includes",
136
+ ruleBlocks(buildConfig({}, {})).length === 6,
137
137
  );
138
138
  check(
139
139
  "🔴 the project's paper TEMPLATE directory is ignored — flat config does not skip dot-directories",
@@ -181,7 +181,7 @@ check(
181
181
  writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
182
182
  writeFileSync(
183
183
  join(paper, "PIPELINE-STATUS.md"),
184
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
184
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
185
185
  );
186
186
  writeFileSync(
187
187
  join(root, "package.json"),
@@ -441,32 +441,14 @@ check(
441
441
  !existsSync(join(dir, WORKFLOW_PATH)),
442
442
  );
443
443
  check(
444
- "and the CI step is still printed — it just gets pasted in by hand",
445
- /uses: zernie\/paperlint@/.test(out.text()) &&
446
- /paths: writing/.test(out.text()),
447
- );
448
- }
449
- {
450
- // Guards: the printed step carries the tag of the running release, not a placeholder.
451
- const dir = project("ci-printed-pin", { papers: ["writing"] });
452
- const out = say();
453
- await init(dir, {
454
- log: out.log,
455
- err: out.log,
456
- interactive: false,
457
- run: haveAll,
458
- version: "1.2.3",
459
- });
460
- check(
461
- "🔴 a released version — the printed step is pinned to its tag, no <commit-sha>",
462
- /uses: zernie\/paperlint@v1\.2\.3\b/.test(out.text()) &&
463
- !/<commit-sha>/.test(out.text()),
444
+ "and the CI steps are still printed — they just get pasted in by hand",
445
+ out.text().includes("- uses: ./node_modules/paperlint"),
464
446
  );
465
447
  }
466
448
  {
467
449
  const dir = project("ci-yes", { papers: ["writing"] });
468
450
  const asked = [];
469
- const wf = await offerWorkflow(dir, "writing", {
451
+ const wf = await offerWorkflow(dir, {
470
452
  interactive: true,
471
453
  ask: async (q) => {
472
454
  asked.push(q);
@@ -477,16 +459,22 @@ check(
477
459
  "saying yes WRITES the workflow",
478
460
  wf === "written" && existsSync(join(dir, WORKFLOW_PATH)),
479
461
  );
462
+ const steps =
463
+ yamlLoad(readFileSync(join(dir, WORKFLOW_PATH), "utf8"))?.jobs?.papers
464
+ ?.steps ?? [];
480
465
  check(
481
- "and the workflow carries THE directory that was actually discussed",
482
- /paths: writing/.test(readFileSync(join(dir, WORKFLOW_PATH), "utf8")),
466
+ "🔴 the workflow runs the action from the installed package — one revision, set by the lockfile",
467
+ steps.some((st) => st.uses === "./node_modules/paperlint") &&
468
+ !steps.some((st) =>
469
+ String(st.uses ?? "").startsWith("zernie/paperlint"),
470
+ ),
483
471
  );
484
472
  check(
485
- "without a known release the action keeps the obvious placeholder, never a guessed tag",
486
- /@<commit-sha>/.test(readFileSync(join(dir, WORKFLOW_PATH), "utf8")),
473
+ "and it repeats no papers directory — the action reads papersDir from paperlint.json",
474
+ steps.every((st) => st.with?.paths === undefined),
487
475
  );
488
476
  check("the question is asked exactly once", asked.length === 1);
489
- const again = await offerWorkflow(dir, "writing", {
477
+ const again = await offerWorkflow(dir, {
490
478
  interactive: true,
491
479
  ask: async () => "y",
492
480
  });
@@ -495,29 +483,9 @@ check(
495
483
  again === "kept",
496
484
  );
497
485
  }
498
- {
499
- const dir = project("ci-pinned", { papers: ["writing"] });
500
- const wf = await offerWorkflow(dir, "writing", {
501
- interactive: true,
502
- ask: async () => "y",
503
- version: "1.2.3",
504
- });
505
- const yaml = existsSync(join(dir, WORKFLOW_PATH))
506
- ? readFileSync(join(dir, WORKFLOW_PATH), "utf8")
507
- : "";
508
- const uses = (yamlLoad(yaml)?.jobs?.papers?.steps ?? []).map(
509
- (s) => s.uses,
510
- );
511
- check(
512
- "🔴 a released version — the written workflow is pinned to its tag, no <commit-sha> left",
513
- wf === "written" &&
514
- uses.includes("zernie/paperlint@v1.2.3") &&
515
- !yaml.includes("<commit-sha>"),
516
- );
517
- }
518
486
  {
519
487
  const dir = project("ci-no", { papers: ["writing"] });
520
- const no = await offerWorkflow(dir, "writing", {
488
+ const no = await offerWorkflow(dir, {
521
489
  interactive: true,
522
490
  ask: async () => "",
523
491
  });
@@ -532,7 +500,7 @@ check(
532
500
  // 🔴 THE `.catch` HERE IS LOAD-BEARING, NOT CAUTION. A leaked exception is a PROPERTY that
533
501
  // gets asserted; without it, the harness crashes instead of failing a named assertion,
534
502
  // i.e. the real defect would look like a hole in the test rather than a caught one.
535
- const aborted = await offerWorkflow(dir, "writing", {
503
+ const aborted = await offerWorkflow(dir, {
536
504
  interactive: true,
537
505
  ask: async () => {
538
506
  throw new Error("Aborted with Ctrl+D");
@@ -733,7 +701,7 @@ check(
733
701
  "x".repeat(100),
734
702
  );
735
703
  const status = (bytes) =>
736
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
704
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
737
705
  writeFileSync(join(paper, "versions", "s.tex"), "abcd");
738
706
  writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
739
707
 
@@ -822,7 +790,7 @@ check(
822
790
  );
823
791
  writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
824
792
  const status = (bytes) =>
825
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
793
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
826
794
  writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(100));
827
795
 
828
796
  // findProjectRoot — kept separate from the run so the failure is distinguishable
@@ -934,7 +902,7 @@ check(
934
902
  );
935
903
  writeFileSync(
936
904
  join(paper, "PIPELINE-STATUS.md"),
937
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
905
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
938
906
  );
939
907
  writeFileSync(
940
908
  join(root, "paperlint.json"),
package/src/cli.test.ts CHANGED
@@ -14,7 +14,6 @@ import type { runToolchain } from "./toolchain.ts";
14
14
  import {
15
15
  chooseVenue,
16
16
  initTexLive,
17
- ownVersion,
18
17
  paperRuleBlocks,
19
18
  parseArgs,
20
19
  parseSettings,
@@ -259,7 +258,7 @@ test("build: a markdown-only paper is refused", async () => {
259
258
  code: 1,
260
259
  out: [
261
260
  "papers/md",
262
- ` inputs: TEXINPUTS += ${venues}`,
261
+ ` inputs: TEXINPUTS += the paper's directory, then ${venues}`,
263
262
  " compile: refused — no paper.tex; paperlint compiles LaTeX, and this paper has none",
264
263
  " measure: skipped — nothing is compiled",
265
264
  " references: skipped — nothing is compiled",
@@ -288,7 +287,7 @@ test("build: with no TeX Live a dry run says where a real run stops, and a real
288
287
  out: [
289
288
  `engine: none — a real run would stop here: paperlint build: no TeX Live with every package these papers need — run \`npx paperlint toolchain\` (${missing})`,
290
289
  "papers/tex",
291
- ` inputs: TEXINPUTS += ${venues}`,
290
+ ` inputs: TEXINPUTS += the paper's directory, then ${venues}`,
292
291
  " compile: paper.tex (\\documentclass{article})",
293
292
  " measure: pdf.js → _build/paper.facts.json (facts for the lint rules; nothing is judged here)",
294
293
  " references: online: citations exist, titles and authors match → _build/references.json (never fails the build)",
@@ -529,21 +528,6 @@ test("chooseVenue: a question that fails (the stream ended) takes the default, n
529
528
  assert.deepEqual(r, { ok: true, value: null });
530
529
  });
531
530
 
532
- test("ownVersion: the manifest's version; an unreadable manifest or a non-string version is undefined", () => {
533
- assert.deepEqual(
534
- [
535
- ownVersion(() => '{"version": "1.2.3"}'),
536
- ownVersion(() => "{ nope"),
537
- ownVersion(() => '{"version": 3}'),
538
- ownVersion(() => {
539
- throw new Error("ENOENT");
540
- }),
541
- typeof ownVersion(),
542
- ],
543
- ["1.2.3", undefined, undefined, undefined, "string"],
544
- );
545
- });
546
-
547
531
  test("init's TeX Live install runs the toolchain over the project's papers, never a check", () => {
548
532
  const dir = writeTree(join(root, "init-tex"), { "package.json": "{}" });
549
533
  const calls: Parameters<typeof runToolchain>[0][] = [];
@@ -594,3 +578,16 @@ test("`paperlint hook` with no name: exit 2, and the usage names an example", as
594
578
  err: "`hook` needs a name, e.g. `paperlint hook paper-edit-guard`",
595
579
  });
596
580
  });
581
+
582
+ test("parseSettings refuses a root `identity` that is not a list of words, and takes one that is", () => {
583
+ const bad = parseSettings({ identity: "Ada" }, "paperlint.json", "/r");
584
+ assert.deepEqual(bad, {
585
+ ok: false,
586
+ error:
587
+ 'paperlint.json: "identity" must be a list of strings, each with a letter or digit, got "Ada"',
588
+ });
589
+ assert.equal(
590
+ parseSettings({ identity: ["Ada Example"] }, "paperlint.json", "/r").ok,
591
+ true,
592
+ );
593
+ });