paperlint 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +31 -11
  3. package/CONTRIBUTING.md +23 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +22 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +102 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +29 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -0
  16. package/dist/adapters/latex/index.js +18 -0
  17. package/dist/adapters/latex/index.js.map +1 -0
  18. package/dist/adapters/latex/nodes.d.ts +54 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  20. package/dist/adapters/latex/nodes.js +93 -0
  21. package/dist/adapters/latex/nodes.js.map +1 -0
  22. package/dist/adapters/latex/outline.d.ts +10 -0
  23. package/dist/adapters/latex/outline.d.ts.map +1 -0
  24. package/dist/adapters/latex/outline.js +39 -0
  25. package/dist/adapters/latex/outline.js.map +1 -0
  26. package/dist/adapters/latex/parse.d.ts +42 -0
  27. package/dist/adapters/latex/parse.d.ts.map +1 -0
  28. package/dist/adapters/latex/parse.js +94 -0
  29. package/dist/adapters/latex/parse.js.map +1 -0
  30. package/dist/adapters/latex/rendered.d.ts +10 -0
  31. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  32. package/dist/adapters/latex/rendered.js +39 -0
  33. package/dist/adapters/latex/rendered.js.map +1 -0
  34. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  35. package/dist/adapters/pdfjs/fill.js +12 -12
  36. package/dist/adapters/pdfjs/fill.js.map +1 -1
  37. package/dist/build.js +4 -4
  38. package/dist/build.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +29 -5
  41. package/dist/cli.js.map +1 -1
  42. package/dist/domain/tex-document.d.ts +84 -0
  43. package/dist/domain/tex-document.d.ts.map +1 -0
  44. package/dist/domain/tex-document.js +43 -0
  45. package/dist/domain/tex-document.js.map +1 -0
  46. package/dist/facts-file.d.ts.map +1 -1
  47. package/dist/facts-file.js +2 -2
  48. package/dist/facts-file.js.map +1 -1
  49. package/dist/new-paper.d.ts +5 -0
  50. package/dist/new-paper.d.ts.map +1 -1
  51. package/dist/new-paper.js +3 -0
  52. package/dist/new-paper.js.map +1 -1
  53. package/dist/package-dirs.d.ts +5 -0
  54. package/dist/package-dirs.d.ts.map +1 -0
  55. package/dist/package-dirs.js +14 -0
  56. package/dist/package-dirs.js.map +1 -0
  57. package/dist/ports/latex.d.ts +24 -0
  58. package/dist/ports/latex.d.ts.map +1 -0
  59. package/dist/ports/latex.js +2 -0
  60. package/dist/ports/latex.js.map +1 -0
  61. package/dist/presets.d.ts +15 -2
  62. package/dist/presets.d.ts.map +1 -1
  63. package/dist/presets.js +19 -6
  64. package/dist/presets.js.map +1 -1
  65. package/dist/tex-requirements.d.ts +11 -0
  66. package/dist/tex-requirements.d.ts.map +1 -1
  67. package/dist/tex-requirements.js +14 -8
  68. package/dist/tex-requirements.js.map +1 -1
  69. package/dist/tex-venue-rules.d.ts +83 -0
  70. package/dist/tex-venue-rules.d.ts.map +1 -0
  71. package/dist/tex-venue-rules.js +254 -0
  72. package/dist/tex-venue-rules.js.map +1 -0
  73. package/dist/venue-rules.d.ts +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/docs/configuration.md +3 -2
  76. package/docs/e2e.md +69 -40
  77. package/docs/install.md +1 -1
  78. package/docs/prior-art/README.md +1 -1
  79. package/docs/prior-art/package-location.md +5 -5
  80. package/docs/prior-art/repro/README.md +1 -1
  81. package/docs/prior-art/test-tooling.md +21 -10
  82. package/docs/rules/tex/required-section.md +73 -0
  83. package/docs/rules/tex/template.md +62 -0
  84. package/docs/rules/tex/venue-leftover.md +71 -0
  85. package/docs/rules.md +70 -48
  86. package/docs/testing.md +17 -4
  87. package/docs/texlive-install-decision.md +1 -1
  88. package/docs/toolchain.md +2 -2
  89. package/eslint.config.mjs +155 -15
  90. package/fixtures/build-e2e/acmart/expect.json +26 -0
  91. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  92. package/fixtures/build-e2e/aidc/expect.json +14 -0
  93. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  94. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  95. package/fixtures/build-e2e/broken/expect.json +11 -0
  96. package/fixtures/build-e2e/cite/expect.json +27 -0
  97. package/fixtures/build-e2e/empty/expect.json +12 -0
  98. package/fixtures/build-e2e/fallback/expect.json +53 -0
  99. package/fixtures/build-e2e/guards/expect.json +6 -0
  100. package/fixtures/build-e2e/no-source/expect.json +10 -0
  101. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.ts +1 -1
  104. package/fixtures/render-paper/build-clean.sh +3 -5
  105. package/package.json +13 -2
  106. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  107. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  108. package/presets/aidc.jsonc +73 -0
  109. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  110. package/presets/ieee-conference.jsonc +66 -0
  111. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  112. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  113. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  114. package/scripts/check.harness.ts +64 -10
  115. package/scripts/check.test.ts +60 -13
  116. package/scripts/check.ts +83 -30
  117. package/scripts/test.test.ts +1 -1
  118. package/scripts/test.ts +1 -1
  119. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  120. package/skills/paper-pipeline/SKILL.md +2 -2
  121. package/skills/paper-pipeline/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  123. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  124. package/skills/render-paper/SKILL.md +2 -2
  125. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  126. package/skills/render-paper/check-render.sh +16 -63
  127. package/skills/render-paper/render-paper.harness.mjs +17 -59
  128. package/src/adapters/banal/index.test.ts +1 -1
  129. package/src/adapters/banal/invocation.ts +1 -1
  130. package/src/adapters/banal/output.test.ts +1 -1
  131. package/src/adapters/banal/run.test.ts +1 -1
  132. package/src/adapters/banal/xml.test.ts +1 -1
  133. package/src/adapters/latex/document-class.ts +147 -0
  134. package/src/adapters/latex/hidden.ts +70 -0
  135. package/src/adapters/latex/index.test.ts +361 -0
  136. package/src/adapters/latex/index.ts +55 -0
  137. package/src/adapters/latex/nodes.ts +141 -0
  138. package/src/adapters/latex/outline.ts +64 -0
  139. package/src/adapters/latex/parse.ts +136 -0
  140. package/src/adapters/latex/rendered.ts +59 -0
  141. package/src/adapters/pdfjs/fill.test.ts +1 -1
  142. package/src/adapters/pdfjs/fill.ts +18 -10
  143. package/src/build.harness.mjs +5 -7
  144. package/src/build.ts +4 -4
  145. package/src/cli.harness.mjs +1 -1
  146. package/src/cli.test.ts +2 -8
  147. package/src/cli.ts +33 -8
  148. package/src/domain/tex-document.test.ts +80 -0
  149. package/src/domain/tex-document.ts +110 -0
  150. package/src/engine.harness.mjs +1 -1
  151. package/src/facts-file.test.ts +4 -7
  152. package/src/facts-file.ts +2 -2
  153. package/src/latex-loop.harness.ts +1 -1
  154. package/src/new-paper.test.ts +28 -3
  155. package/src/new-paper.ts +8 -0
  156. package/src/package-dirs.ts +17 -0
  157. package/src/paper-settings-commands.test.ts +6 -1
  158. package/src/ports/latex.ts +29 -0
  159. package/src/presets.test.ts +65 -4
  160. package/src/presets.ts +38 -15
  161. package/src/rule-cases.test.ts +474 -0
  162. package/src/rule-docs.test.ts +159 -0
  163. package/src/tex-requirements.harness.ts +13 -23
  164. package/src/tex-requirements.ts +35 -8
  165. package/src/tex-venue-rules.test.ts +659 -0
  166. package/src/tex-venue-rules.ts +429 -0
  167. package/src/toolchain.harness.mjs +1 -1
  168. package/src/venue-rules.test.ts +96 -4
  169. package/src/venue-rules.ts +1 -1
  170. package/templates/paper/paper.tex +2 -1
  171. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  172. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,141 @@
1
+ /** What the three readers share about unified-latex nodes: places, text, and the walk. */
2
+ import type * as Ast from "@unified-latex/unified-latex-types";
3
+ import { CONTINUE, SKIP, visit } from "@unified-latex/unified-latex-util-visit";
4
+ import type { Place, Span } from "../../domain/tex-document.ts";
5
+ import { KEY_SIGNATURES } from "./parse.ts";
6
+
7
+ export { collapse } from "../../domain/tex-document.ts";
8
+
9
+ /** unified-latex's node types, read-only: nothing here writes to a tree. */
10
+ export type Node = Readonly<Ast.Node>;
11
+ export type Macro = Readonly<Ast.Macro>;
12
+ export type Argument = Readonly<Ast.Argument>;
13
+
14
+ /** Anything the parser may have given a position: offsets of its first and past its last character. */
15
+ interface Positioned {
16
+ readonly position?: {
17
+ readonly start: { readonly offset: number };
18
+ readonly end: { readonly offset: number };
19
+ };
20
+ }
21
+
22
+ /** Where a node stands, or that it has no position. */
23
+ export const placeOf = (n: Positioned): Place =>
24
+ n.position === undefined
25
+ ? { kind: "unplaced" }
26
+ : {
27
+ kind: "at",
28
+ span: { start: n.position.start.offset, end: n.position.end.offset },
29
+ };
30
+
31
+ /** The first mandatory (`{…}`) and optional (`[…]`) arguments' contents, and the last mandatory one's. */
32
+ export const mandatory = (m: Macro): readonly Argument[] =>
33
+ (m.args ?? []).filter((a) => a.openMark === "{");
34
+ export const optional = (m: Macro): Argument | undefined =>
35
+ (m.args ?? []).find((a) => a.openMark === "[");
36
+
37
+ /** What a string node reads as: itself, except a tie (`~`), which is a space the reader sees. */
38
+ export const stringText = (content: string): string =>
39
+ content === "~" ? " " : content;
40
+
41
+ /** Which of a macro's mandatory arguments it typesets: one of them, or none at all. */
42
+ type Shown =
43
+ | { readonly kind: "argument"; readonly at: number }
44
+ | { readonly kind: "none" };
45
+
46
+ const LAST: Shown = { kind: "argument", at: -1 };
47
+ const NONE: Shown = { kind: "none" };
48
+
49
+ /**
50
+ * Macros that do not typeset their last mandatory argument, as data. `\texorpdfstring{shown}{bookmark}`
51
+ * typesets the first; a declaration (`\color{red}`, `\fontsize{10}{12}`, `\vspace{1em}`,
52
+ * `\setlength{\x}{0pt}`) typesets none. Every other macro typesets its last (`\textcolor{red}{x}`,
53
+ * `\colorbox{yellow}{x}`, `\href{url}{x}`).
54
+ */
55
+ const SHOWN_ARGUMENT: ReadonlyMap<string, Shown> = new Map<string, Shown>([
56
+ ["texorpdfstring", { kind: "argument", at: 0 }],
57
+ ...["color", "fontsize", "vspace", "hspace", "setlength", "addtolength"].map(
58
+ (m) => [m, NONE] as const,
59
+ ),
60
+ ]);
61
+
62
+ /** The text a (non-key) macro typesets: see `SHOWN_ARGUMENT`. */
63
+ function shownText(m: Macro): string {
64
+ const shown = SHOWN_ARGUMENT.get(m.content) ?? LAST;
65
+ return shown.kind === "none"
66
+ ? ""
67
+ : textOf(mandatory(m).at(shown.at)?.content);
68
+ }
69
+
70
+ /**
71
+ * The text of a node list with markup dropped: strings, spaces, what groups hold, and the argument a
72
+ * formatting macro typesets — its last mandatory one (`\textbf{Usage}`, `\textcolor{red}{Usage}`,
73
+ * `\href{url}{Usage}` → `Usage`) unless `SHOWN_ARGUMENT` says otherwise. A key macro's arguments
74
+ * (`\label{…}`, `\cite{…}`) are not text.
75
+ */
76
+ export const textOf = (nodes: readonly Node[] | undefined): string =>
77
+ (nodes ?? [])
78
+ .map((n) => {
79
+ if (n.type === "string") return stringText(n.content);
80
+ if (n.type === "whitespace" || n.type === "parbreak") return " ";
81
+ if (n.type === "group") return textOf(n.content);
82
+ return n.type === "macro" && !(n.content in KEY_SIGNATURES)
83
+ ? shownText(n)
84
+ : "";
85
+ })
86
+ .join("");
87
+
88
+ /**
89
+ * A macro's place with its arguments: from its name to past the closing mark of the last argument
90
+ * that holds text. unified-latex positions the name and the arguments' contents, never the
91
+ * arguments themselves, so an empty trailing argument is not covered.
92
+ */
93
+ export function macroPlace(m: Macro): Place {
94
+ const name = placeOf(m);
95
+ if (name.kind === "unplaced") return name;
96
+ const end = (m.args ?? []).reduce((e, a) => {
97
+ const last = a.content.at(-1)?.position?.end.offset;
98
+ return last === undefined ? e : last + a.closeMark.length;
99
+ }, name.span.end);
100
+ const span: Span = { start: name.span.start, end };
101
+ return { kind: "at", span };
102
+ }
103
+
104
+ /** What visit reaches: a node, an argument, or a node list (a content array, an argument list). */
105
+ export type Visited = Readonly<Ast.Ast> | readonly (Node | Argument)[];
106
+
107
+ /**
108
+ * Everything visit reaches under `tree`, in document order, node lists included; `skip` prunes what
109
+ * it is given (a node or an argument, with the node that holds it) and everything under it. visit
110
+ * reports through a callback and returns nothing, so the one collection point of this adapter is here.
111
+ */
112
+ export function visited(
113
+ // eslint-disable-next-line functional/prefer-immutable-types -- visit's own parameter type is mutable
114
+ tree: Ast.Ast,
115
+ skip: (v: Visited, parent: Visited | undefined) => boolean,
116
+ ): readonly Visited[] {
117
+ // eslint-disable-next-line functional/prefer-immutable-types -- the one accumulator, see above
118
+ const seen: Visited[] = [];
119
+ visit(
120
+ tree,
121
+ (node, info) => {
122
+ // eslint-disable-next-line functional/immutable-data -- visit is callback-only; see above
123
+ seen.push(node);
124
+ return skip(node, info.parents[0]) ? SKIP : CONTINUE;
125
+ },
126
+ { includeArrays: true },
127
+ );
128
+ return seen;
129
+ }
130
+
131
+ /** A node list visit reached. `Array.isArray` alone does not narrow a readonly array. */
132
+ export const isList = (v: Visited): v is readonly (Node | Argument)[] =>
133
+ Array.isArray(v);
134
+
135
+ /** A macro's argument, as visit reports it. */
136
+ export const isArgument = (v: Visited): v is Argument =>
137
+ !isList(v) && v.type === "argument";
138
+
139
+ /** A node, as opposed to a node list or an argument (which visit also reports). */
140
+ export const isNode = (v: Visited): v is Node =>
141
+ !isList(v) && v.type !== "argument" && v.type !== "root";
@@ -0,0 +1,64 @@
1
+ /** The document's sections, where its back matter starts, and where it ends. */
2
+ import type * as Ast from "@unified-latex/unified-latex-types";
3
+ import { isDefined } from "remeda";
4
+ import type { Heading, Outline } from "../../domain/tex-document.ts";
5
+ import {
6
+ collapse,
7
+ isNode,
8
+ macroPlace,
9
+ mandatory,
10
+ textOf,
11
+ visited,
12
+ type Macro,
13
+ type Node,
14
+ } from "./nodes.ts";
15
+ import { isDefinition, isUnrendered } from "./hidden.ts";
16
+ import type { ParsedTex } from "./parse.ts";
17
+
18
+ /** Macros and the environment that start the back matter: the appendix and the bibliography. */
19
+ const BACK_MATTER_MACROS: ReadonlySet<string> = new Set([
20
+ "appendix",
21
+ "bibliography",
22
+ "printbibliography",
23
+ ]);
24
+
25
+ const isBackMatter = (n: Node): boolean =>
26
+ (n.type === "macro" && BACK_MATTER_MACROS.has(n.content)) ||
27
+ (n.type === "environment" && n.env === "thebibliography");
28
+
29
+ const isSection = (n: Node): n is Macro =>
30
+ n.type === "macro" && n.content === "section";
31
+
32
+ const isDocument = (n: Node): n is Readonly<Ast.Environment> =>
33
+ n.type === "environment" && n.env === "document";
34
+
35
+ /** One `\section`/`\section*`: the last mandatory argument is its title. */
36
+ const headingOf = (m: Macro): Heading => ({
37
+ title: collapse(textOf(mandatory(m).at(-1)?.content)),
38
+ place: macroPlace(m),
39
+ });
40
+
41
+ /**
42
+ * The body's `\section`s (starred or not) in document order, the first back-matter node, and the
43
+ * end of the document. A source with no `document` environment is read whole, as a fragment. What
44
+ * the PDF never shows is pruned (`isUnrendered`), and so is a macro definition's body
45
+ * (`isDefinition`): a heading in a comment environment or in a definition is not a section.
46
+ */
47
+ export function outlineOf(t: ParsedTex): Outline {
48
+ const doc = t.root.content.find(isDocument);
49
+ const nodes = visited(
50
+ doc?.content ?? t.root.content,
51
+ (v, parent) => isUnrendered(v, parent) || isDefinition(v),
52
+ ).filter(isNode);
53
+ const backMatter =
54
+ nodes
55
+ .filter(isBackMatter)
56
+ .map((n) => n.position?.start.offset)
57
+ .filter(isDefined)[0] ?? null;
58
+ const docEnd = doc?.position?.end.offset;
59
+ return {
60
+ sections: nodes.filter(isSection).map(headingOf),
61
+ backMatter,
62
+ end: docEnd === undefined ? null : docEnd - 1,
63
+ };
64
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A LaTeX source → its unified-latex tree, as one `ParsedTex` that keeps the two together.
3
+ *
4
+ * ── CITATION KEYS ARE ARGUMENTS, NOT TEXT ────────────────────────────────────────
5
+ * unified-latex attaches arguments only to macros it has a signature for. natbib's and biblatex's
6
+ * citation commands have none, so `\citep{aisec2025}` came back with its key as a separate group —
7
+ * readable as prose. `KEY_SIGNATURES` declares them, and the other macros whose arguments name a
8
+ * file, a label or a URL rather than say something, so their arguments are attached and the
9
+ * rendered text skips them structurally. `\href` says something in one argument and names a URL in
10
+ * the other, so it is signed too, and `KEY_ARGUMENT` says which of its arguments is not text.
11
+ */
12
+ import type * as Ast from "@unified-latex/unified-latex-types";
13
+ import { getParser } from "@unified-latex/unified-latex-util-parse";
14
+ import { once } from "remeda";
15
+
16
+ const signed = (
17
+ names: readonly string[],
18
+ signature: string,
19
+ ): readonly (readonly [string, { readonly signature: string }])[] =>
20
+ names.map((m) => [m, { signature }] as const);
21
+
22
+ /** Citation commands: their `{…}` argument is a key, their `[…]` ones are notes the reader sees. */
23
+ export const CITATION_MACROS: ReadonlySet<string> = new Set([
24
+ "cite",
25
+ "citep",
26
+ "citet",
27
+ "citealp",
28
+ "citealt",
29
+ "citeauthor",
30
+ "citeyear",
31
+ "citeyearpar",
32
+ "Citep",
33
+ "Citet",
34
+ "nocite",
35
+ "parencite",
36
+ "textcite",
37
+ "autocite",
38
+ "footcite",
39
+ ]);
40
+
41
+ /** Macros whose arguments are keys, labels, files or URLs — never text a reader sees. */
42
+ export const KEY_SIGNATURES: Readonly<
43
+ Record<string, { readonly signature: string }>
44
+ > = Object.fromEntries([
45
+ ...signed([...CITATION_MACROS], "o o m"),
46
+ ...signed(
47
+ [
48
+ "label",
49
+ "ref",
50
+ "eqref",
51
+ "pageref",
52
+ "autoref",
53
+ "cref",
54
+ "Cref",
55
+ "url",
56
+ "input",
57
+ "include",
58
+ "bibliography",
59
+ "bibliographystyle",
60
+ "addbibresource",
61
+ ],
62
+ "m",
63
+ ),
64
+ ...signed(["includegraphics"], "s o o m"),
65
+ ...signed(["usepackage", "RequirePackage", "documentclass"], "o m o"),
66
+ ]);
67
+
68
+ /**
69
+ * Macros that print some arguments and not others: the index of the one argument that is a key or
70
+ * a URL. A citation (`o o m`) prints its `[…]` notes, not its `{…}` key; `\href` (`m m`) prints its
71
+ * link text, not its URL.
72
+ */
73
+ export const KEY_ARGUMENT: ReadonlyMap<string, number> = new Map([
74
+ ...[...CITATION_MACROS].map((m) => [m, 2] as const),
75
+ ["href", 0],
76
+ ]);
77
+
78
+ const LINK_SIGNATURES = Object.fromEntries(signed(["href"], "m m"));
79
+
80
+ /**
81
+ * Declarations unified-latex has no signature for: signed so their arguments (sizes, not text)
82
+ * attach to them instead of standing beside them as groups a reader would seem to see.
83
+ */
84
+ const DECLARATION_SIGNATURES = Object.fromEntries(signed(["fontsize"], "m m"));
85
+
86
+ /**
87
+ * Macros that define other macros or environments. A heading in a body is no section where it is
88
+ * written, so the outline skips these; the rendered text reads a body as written, since nothing here
89
+ * expands macros. `\def` and its kin get a signature so the name and the body attach to them; a
90
+ * parameter text (`\def\x#1{…}`) is not modelled.
91
+ */
92
+ export const DEFINITION_MACROS: ReadonlySet<string> = new Set([
93
+ "newcommand",
94
+ "renewcommand",
95
+ "providecommand",
96
+ "DeclareRobustCommand",
97
+ "newenvironment",
98
+ "renewenvironment",
99
+ "def",
100
+ "gdef",
101
+ "edef",
102
+ "xdef",
103
+ ]);
104
+
105
+ const DEF_SIGNATURES = Object.fromEntries(
106
+ signed(["def", "gdef", "edef", "xdef"], "m m"),
107
+ );
108
+
109
+ // Not exported: a `ParsedTex` carries this property, and the one way to write it is `parseLatex`.
110
+ const PARSED = Symbol("ParsedTex");
111
+
112
+ /** A source and ITS tree: only `parseLatex` makes one, so a tree cannot be paired with another source. */
113
+ export interface ParsedTex {
114
+ readonly src: string;
115
+ readonly root: Ast.Root;
116
+ readonly [PARSED]: true;
117
+ }
118
+
119
+ /** One parser for the process, made on first use: building one compiles its grammar. */
120
+ const parser = once(() =>
121
+ getParser({
122
+ macros: {
123
+ ...KEY_SIGNATURES,
124
+ ...LINK_SIGNATURES,
125
+ ...DECLARATION_SIGNATURES,
126
+ ...DEF_SIGNATURES,
127
+ },
128
+ }),
129
+ );
130
+
131
+ /** A LaTeX source → its tree. */
132
+ export const parseLatex = (src: string): ParsedTex => ({
133
+ src,
134
+ root: parser().parse(src),
135
+ [PARSED]: true,
136
+ });
@@ -0,0 +1,59 @@
1
+ /** The text a reader sees, each character with the source offset it came from. */
2
+ import { hasAtLeast, sortBy } from "remeda";
3
+ import type { Segment, TextRun } from "../../domain/tex-document.ts";
4
+ import { isUnrendered } from "./hidden.ts";
5
+ import {
6
+ isList,
7
+ stringText,
8
+ visited,
9
+ type Argument,
10
+ type Node,
11
+ } from "./nodes.ts";
12
+ import type { ParsedTex } from "./parse.ts";
13
+
14
+ /** A node's rendered text and where it starts, or null when it is not text (it ends a run). */
15
+ function segmentOf(n: Node | Argument): Segment | null {
16
+ const at = n.position?.start.offset;
17
+ if (at === undefined) return null;
18
+ if (n.type === "whitespace" || n.type === "parbreak")
19
+ return { text: " ", at };
20
+ if (n.type !== "string") return null;
21
+ return { text: stringText(n.content), at };
22
+ }
23
+
24
+ /** One node list → its runs: stretches of sibling text, split wherever anything else stands. */
25
+ function runsOf(list: readonly (Node | Argument)[]): readonly TextRun[] {
26
+ const { done, current } = list.reduce<{
27
+ readonly done: readonly (readonly Segment[])[];
28
+ readonly current: readonly Segment[];
29
+ }>(
30
+ (acc, n) => {
31
+ const seg = segmentOf(n);
32
+ return seg === null
33
+ ? { done: [...acc.done, acc.current], current: [] }
34
+ : { done: acc.done, current: [...acc.current, seg] };
35
+ },
36
+ { done: [], current: [] },
37
+ );
38
+ return [...done, current].flatMap((g) =>
39
+ hasAtLeast(g, 1) &&
40
+ g
41
+ .map((x) => x.text)
42
+ .join("")
43
+ .trim() !== ""
44
+ ? [{ segments: g }]
45
+ : [],
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Runs of rendered text, in document order: stretches of sibling strings and spaces. Everything
51
+ * else ends a run and is entered — a group's text, a macro's arguments — except what a reader never
52
+ * sees (`isUnrendered`): comments, math, code, the bibliography, keys and URLs. A macro definition's
53
+ * body is read where it is written.
54
+ */
55
+ export const renderedRuns = (t: ParsedTex): readonly TextRun[] =>
56
+ sortBy(
57
+ visited(t.root, isUnrendered).filter(isList).flatMap(runsOf),
58
+ (r) => r.segments[0].at,
59
+ );
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `fill.ts` — what is recovered from pdf.js's exports for each text item: whether it is upright, and
3
3
  * the fill it was drawn in (the operator-list walk). Operator lists here are written by hand; the
4
- * real-PDF half runs in `pdf-facts.harness.ts` and `test/e2e/banal.ts`.
4
+ * real-PDF half runs in `pdf-facts.harness.ts` and `test/e2e/tex/banal.e2e.ts`.
5
5
  */
6
6
  import assert from "node:assert/strict";
7
7
  import { test } from "vitest";
@@ -154,14 +154,22 @@ export function fillsFor<T>(
154
154
  textOf: (item: T) => string,
155
155
  ): { readonly item: T; readonly fill: Fill }[] {
156
156
  const drawn = drawnCharacters(ops, list);
157
- let at = 0;
158
- const fillOf = (text: string): Fill => {
159
- const want = Array.from(squeeze(text));
160
- if (want.length === 0) return { kind: "unknown" };
161
- const hit = findRun(drawn, want, at);
162
- if (hit === null) return { kind: "unknown" };
163
- at = hit.at + want.length;
164
- return hit.fill;
165
- };
166
- return items.map((item) => ({ item, fill: fillOf(textOf(item)) }));
157
+ // Each item is searched for after the previous one's match: the offset threads through.
158
+ const { out } = items.reduce<{
159
+ readonly at: number;
160
+ readonly out: readonly { readonly item: T; readonly fill: Fill }[];
161
+ }>(
162
+ (acc, item) => {
163
+ const want = Array.from(squeeze(textOf(item)));
164
+ const hit = want.length === 0 ? null : findRun(drawn, want, acc.at);
165
+ return hit === null
166
+ ? { at: acc.at, out: [...acc.out, { item, fill: { kind: "unknown" } }] }
167
+ : {
168
+ at: hit.at + want.length,
169
+ out: [...acc.out, { item, fill: hit.fill }],
170
+ };
171
+ },
172
+ { at: 0, out: [] },
173
+ );
174
+ return [...out];
167
175
  }
@@ -4,7 +4,7 @@
4
4
  * The process runner is replaced by a fake that writes what pdflatex and bibtex would write, so
5
5
  * these assertions check the SHELL's decisions: what is run, with which environment, in which
6
6
  * order, and what is left on disk. Whether a real pdflatex produces a real PDF is the other half,
7
- * `test/e2e/build.ts`.
7
+ * `test/e2e/tex/build.e2e.ts`.
8
8
  *
9
9
  * 🔴 THE TWO THINGS PINNED DOWN HERE THAT A RETURNED OBJECT CANNOT SHOW: a paper-supplied
10
10
  * `build.sh` is never executed (checked by the trace it would leave on disk and by the list of
@@ -40,9 +40,7 @@ const {
40
40
  STEPS,
41
41
  IGNORED_SCRIPTS,
42
42
  } = await import(join(HERE, "build.ts"));
43
- const { packageVenuesDir } = await import(
44
- join(HERE, "..", "skills", "paper-pipeline", "scripts", "consumer.mjs")
45
- );
43
+ const { texInputsDir } = await import(join(HERE, "package-dirs.ts"));
46
44
 
47
45
  const check = createChecker();
48
46
 
@@ -137,12 +135,12 @@ const fakeRead = async (pdf) => {
137
135
  const CLEAN_TEX = "\\documentclass{article}\\begin{document}x\\end{document}";
138
136
 
139
137
  try {
140
- // ── inputs: paperlint's own venues directory, no configuration ───────────────────────────────
138
+ // ── inputs: paperlint's own TeX inputs directory, no configuration ────────────────────────────
141
139
  // First: the facts below resolve a paper's venue preset in this same directory, so a wrong one
142
140
  // must be named HERE, not as a missing venue label two checks later.
143
- const venues = packageVenuesDir();
141
+ const venues = texInputsDir();
144
142
  check(
145
- "the venues directory is paperlint's own and holds paper-guards.tex",
143
+ "the TeX inputs directory is paperlint's own and holds paper-guards.tex",
146
144
  existsSync(join(venues, "paper-guards.tex")),
147
145
  );
148
146
  check(
package/src/build.ts CHANGED
@@ -34,7 +34,7 @@
34
34
  * The decision of the LaTeX loop is `latex-loop.ts`, reading the log is `latex-log.ts`; both are
35
35
  * pure. This file is the shell: it runs processes through the injected `run` (the existing port —
36
36
  * `spawnSync` by default) and reads the files a pass left behind. Where paperlint's own files live is
37
- * answered by `consumer.mjs`, the one module allowed to know it (rule 10).
37
+ * answered by `package-dirs.ts`, the one module allowed to know it (rule 10).
38
38
  */
39
39
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
40
40
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
@@ -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 { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
47
+ import { texInputsDir } from "./package-dirs.ts";
48
48
  import { CONFIG_FILE } from "#lib/paper-config";
49
49
  import {
50
50
  declaredVenue,
@@ -268,11 +268,11 @@ export const inputsStep: BuildStep = {
268
268
  required: false,
269
269
  applies: () => ({
270
270
  yes: true,
271
- why: `TEXINPUTS += ${packageVenuesDir()}`,
271
+ why: `TEXINPUTS += ${texInputsDir()}`,
272
272
  }),
273
273
  run: (ctx) => ({
274
274
  ok: true,
275
- env: withTexInputs(ctx.env, [packageVenuesDir()]),
275
+ env: withTexInputs(ctx.env, [texInputsDir()]),
276
276
  }),
277
277
  };
278
278
 
@@ -1148,7 +1148,7 @@ check(
1148
1148
  // pnpm: node_modules/vigiles/dist/cli.js DOES NOT
1149
1149
  // The old wiring addressed the runtime from the project root and did not resolve under pnpm,
1150
1150
  // and `|| exit 2` on PreToolUse(Bash) turned that into a block on ANY command. The end-to-end
1151
- // half (both installs, real processes) lives in `test/e2e/install.ts`; here — the verdicts.
1151
+ // half (both installs, real processes) lives in `test/e2e/install/install.e2e.ts`; here — the verdicts.
1152
1152
  {
1153
1153
  const calls = [];
1154
1154
  const fake = (code) => (bin, args, opts) => {
package/src/cli.test.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  silentOptionalRules,
24
24
  toolchainTex,
25
25
  } from "./cli.ts";
26
+ import { texInputsDir } from "./package-dirs.ts";
26
27
 
27
28
  const root = useTempDir("cli-test-");
28
29
 
@@ -248,14 +249,7 @@ writeTree(noTexDir, {
248
249
  "papers/tex/paper.tex":
249
250
  "\\documentclass{article}\\begin{document}x\\end{document}\n",
250
251
  });
251
- const venues = join(
252
- import.meta.dirname,
253
- "..",
254
- "skills",
255
- "submit-paper",
256
- "references",
257
- "venues",
258
- );
252
+ const venues = texInputsDir();
259
253
 
260
254
  test("build: a markdown-only paper is refused", async () => {
261
255
  const md = await withoutTex(noTexDir, () =>
package/src/cli.ts CHANGED
@@ -32,10 +32,8 @@ import { join, dirname, resolve, relative, basename, sep } from "node:path";
32
32
  import markdown from "@eslint/markdown";
33
33
  import { z } from "zod";
34
34
  // Types come from consumer.d.mts beside it.
35
- import {
36
- isMain,
37
- packageVenuesDir,
38
- } from "../skills/paper-pipeline/scripts/consumer.mjs";
35
+ import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
36
+ import { presetsDir } from "./package-dirs.ts";
39
37
  export { isMain };
40
38
  import type { Args, PaperlintConfig, ConfigRead } from "./types.ts";
41
39
  import {
@@ -48,8 +46,10 @@ import { prepareEngine } from "./build-engine.ts";
48
46
  import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
49
47
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
50
48
  import { curlDownload } from "./adapters/curl/index.ts";
49
+ import { latexReader } from "./adapters/latex/index.ts";
51
50
  import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
52
51
  import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
52
+ import { TEX_VENUE_RULE_LEVELS, texVenueRules } from "./tex-venue-rules.ts";
53
53
  import {
54
54
  paperRules,
55
55
  stringFields,
@@ -72,6 +72,7 @@ import {
72
72
  shippedPresets,
73
73
  SHIPPED_PREFIX,
74
74
  type PaperPreset,
75
+ type PresetTemplate,
75
76
  } from "./presets.ts";
76
77
  import type { ToolInstaller } from "./ports/tool-installer.ts";
77
78
  import {
@@ -132,7 +133,7 @@ import { messageOf } from "./domain/text.ts";
132
133
  import { isRecord } from "./domain/record.ts";
133
134
 
134
135
  /** The shipped venue presets, read from the package's venues directory — the one list. */
135
- const SHIPPED_VENUES = (): string[] => shippedPresets(packageVenuesDir());
136
+ const SHIPPED_VENUES = (): string[] => shippedPresets(presetsDir());
136
137
 
137
138
  const USAGE = `paperlint — machine-checkable gates for a paper kept in git
138
139
 
@@ -257,7 +258,7 @@ export function buildConfig(
257
258
  pdf: {
258
259
  rules: {
259
260
  ...pdfRules.rules,
260
- ...venueRules({ files: nodeFiles, venuesDir: packageVenuesDir() }),
261
+ ...venueRules({ files: nodeFiles, venuesDir: presetsDir() }),
261
262
  },
262
263
  },
263
264
  },
@@ -302,7 +303,17 @@ export function buildConfig(
302
303
  cfg.push({
303
304
  files: ["**/paper.tex"],
304
305
  plugins: {
305
- tex: { languages: { latex: texLanguage }, rules: texBuild },
306
+ tex: {
307
+ languages: { latex: texLanguage },
308
+ rules: {
309
+ ...texBuild,
310
+ ...texVenueRules({
311
+ files: nodeFiles,
312
+ venuesDir: presetsDir(),
313
+ latex: latexReader,
314
+ }),
315
+ },
316
+ },
306
317
  paper: { rules: texPaperRules },
307
318
  bib: bibReachable,
308
319
  },
@@ -316,6 +327,7 @@ export function buildConfig(
316
327
  "tex/acm-frontmatter-override": "error",
317
328
  // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
318
329
  ...VENUE_RULE_LEVELS,
330
+ ...TEX_VENUE_RULE_LEVELS,
319
331
  },
320
332
  });
321
333
  // 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
@@ -1037,12 +1049,25 @@ export async function chooseVenue(
1037
1049
  value: {
1038
1050
  extends: spec.value,
1039
1051
  kind: kind !== null && kinds.includes(kind) ? kind : null,
1052
+ setClass: classSetter(r.value.template),
1040
1053
  label,
1041
1054
  kinds,
1042
1055
  },
1043
1056
  };
1044
1057
  }
1045
1058
 
1059
+ /**
1060
+ * What `paperlint new` does to the template's paper.tex for a venue: set it in the class the
1061
+ * preset's template names. Null when the preset names no template, or one the reader cannot read
1062
+ * (then `tex/template` says so on the new paper).
1063
+ */
1064
+ function classSetter(
1065
+ t: PresetTemplate | null,
1066
+ ): ((paperTex: string) => string) | null {
1067
+ const cls = t === null ? null : latexReader.template(t.text);
1068
+ return cls === null ? null : (src) => latexReader.withDocumentClass(src, cls);
1069
+ }
1070
+
1046
1071
  const bad = (error: string): { ok: false; error: string } => ({
1047
1072
  ok: false,
1048
1073
  error,
@@ -1217,7 +1242,7 @@ async function runBuild(
1217
1242
  }
1218
1243
 
1219
1244
  /** The presets' deps, wired to the disk and the package's own venues directory. */
1220
- const PRESET_DEPS = { files: nodeFiles, venuesDir: packageVenuesDir() };
1245
+ const PRESET_DEPS = { files: nodeFiles, venuesDir: presetsDir() };
1221
1246
 
1222
1247
  /**
1223
1248
  * What one paper needs from TeX Live: the base set plus its preset chain's `tex`. A paper whose