paperlint 6.2.3 → 7.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 (234) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +10 -3
  3. package/dist/adapters/hotcrp/index.d.ts +7 -0
  4. package/dist/adapters/hotcrp/index.d.ts.map +1 -0
  5. package/dist/adapters/hotcrp/index.js +7 -0
  6. package/dist/adapters/hotcrp/index.js.map +1 -0
  7. package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
  8. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
  9. package/dist/adapters/hotcrp/portal.io.js +90 -0
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -0
  11. package/dist/adapters/hotcrp/response.d.ts +13 -0
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -0
  13. package/dist/adapters/hotcrp/response.js +138 -0
  14. package/dist/adapters/hotcrp/response.js.map +1 -0
  15. package/dist/adapters/latex/conditionals.d.ts +21 -0
  16. package/dist/adapters/latex/conditionals.d.ts.map +1 -0
  17. package/dist/adapters/latex/conditionals.js +75 -0
  18. package/dist/adapters/latex/conditionals.js.map +1 -0
  19. package/dist/adapters/latex/headings.d.ts +10 -0
  20. package/dist/adapters/latex/headings.d.ts.map +1 -0
  21. package/dist/adapters/latex/headings.js +129 -0
  22. package/dist/adapters/latex/headings.js.map +1 -0
  23. package/dist/adapters/latex/index.d.ts +1 -0
  24. package/dist/adapters/latex/index.d.ts.map +1 -1
  25. package/dist/adapters/latex/index.js +3 -0
  26. package/dist/adapters/latex/index.js.map +1 -1
  27. package/dist/adapters/latex/parse.d.ts +1 -1
  28. package/dist/adapters/latex/parse.d.ts.map +1 -1
  29. package/dist/adapters/latex/parse.js +3 -2
  30. package/dist/adapters/latex/parse.js.map +1 -1
  31. package/dist/build.d.ts +1 -1
  32. package/dist/build.d.ts.map +1 -1
  33. package/dist/build.js +1 -1
  34. package/dist/build.js.map +1 -1
  35. package/dist/cli.d.ts +4 -2
  36. package/dist/cli.d.ts.map +1 -1
  37. package/dist/cli.js +126 -37
  38. package/dist/cli.js.map +1 -1
  39. package/dist/domain/heading-case.d.ts +100 -0
  40. package/dist/domain/heading-case.d.ts.map +1 -0
  41. package/dist/domain/heading-case.js +365 -0
  42. package/dist/domain/heading-case.js.map +1 -0
  43. package/dist/domain/result.d.ts +4 -0
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +2 -0
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/domain/submission.d.ts +106 -0
  48. package/dist/domain/submission.d.ts.map +1 -0
  49. package/dist/domain/submission.js +39 -0
  50. package/dist/domain/submission.js.map +1 -0
  51. package/dist/domain/tex-document.d.ts +32 -0
  52. package/dist/domain/tex-document.d.ts.map +1 -1
  53. package/dist/domain/tex-document.js +11 -0
  54. package/dist/domain/tex-document.js.map +1 -1
  55. package/dist/domain/venue-name.d.ts +38 -0
  56. package/dist/domain/venue-name.d.ts.map +1 -0
  57. package/dist/domain/venue-name.js +71 -0
  58. package/dist/domain/venue-name.js.map +1 -0
  59. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  60. package/dist/eslint-rules/latex-language.js +16 -3
  61. package/dist/eslint-rules/latex-language.js.map +1 -1
  62. package/dist/facts-file.d.ts +3 -3
  63. package/dist/facts-file.d.ts.map +1 -1
  64. package/dist/facts-file.js +3 -3
  65. package/dist/facts-file.js.map +1 -1
  66. package/dist/folder-venue-rule.d.ts +51 -0
  67. package/dist/folder-venue-rule.d.ts.map +1 -0
  68. package/dist/folder-venue-rule.js +96 -0
  69. package/dist/folder-venue-rule.js.map +1 -0
  70. package/dist/heading-case.d.ts +48 -0
  71. package/dist/heading-case.d.ts.map +1 -0
  72. package/dist/heading-case.js +191 -0
  73. package/dist/heading-case.js.map +1 -0
  74. package/dist/init.d.ts +6 -0
  75. package/dist/init.d.ts.map +1 -1
  76. package/dist/init.js +3 -3
  77. package/dist/init.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts.map +1 -1
  79. package/dist/lib/paper-config.js +5 -1
  80. package/dist/lib/paper-config.js.map +1 -1
  81. package/dist/new-paper.d.ts +19 -5
  82. package/dist/new-paper.d.ts.map +1 -1
  83. package/dist/new-paper.js +23 -4
  84. package/dist/new-paper.js.map +1 -1
  85. package/dist/paper-settings.d.ts +3 -0
  86. package/dist/paper-settings.d.ts.map +1 -1
  87. package/dist/paper-settings.js +20 -4
  88. package/dist/paper-settings.js.map +1 -1
  89. package/dist/ports/latex.d.ts +7 -1
  90. package/dist/ports/latex.d.ts.map +1 -1
  91. package/dist/ports/submission-portal.d.ts +19 -0
  92. package/dist/ports/submission-portal.d.ts.map +1 -0
  93. package/dist/ports/submission-portal.js +2 -0
  94. package/dist/ports/submission-portal.js.map +1 -0
  95. package/dist/preset-card-rule.d.ts +100 -0
  96. package/dist/preset-card-rule.d.ts.map +1 -0
  97. package/dist/preset-card-rule.js +221 -0
  98. package/dist/preset-card-rule.js.map +1 -0
  99. package/dist/presets.d.ts +43 -2
  100. package/dist/presets.d.ts.map +1 -1
  101. package/dist/presets.js +130 -63
  102. package/dist/presets.js.map +1 -1
  103. package/dist/rules-config.js +1 -1
  104. package/dist/submission.d.ts +61 -0
  105. package/dist/submission.d.ts.map +1 -0
  106. package/dist/submission.js +211 -0
  107. package/dist/submission.js.map +1 -0
  108. package/dist/tex-requirements.d.ts +36 -3
  109. package/dist/tex-requirements.d.ts.map +1 -1
  110. package/dist/tex-requirements.js +30 -2
  111. package/dist/tex-requirements.js.map +1 -1
  112. package/dist/tex-venue-rules.d.ts +7 -8
  113. package/dist/tex-venue-rules.d.ts.map +1 -1
  114. package/dist/tex-venue-rules.js +10 -14
  115. package/dist/tex-venue-rules.js.map +1 -1
  116. package/dist/types.d.ts +13 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/docs/configuration.md +30 -11
  119. package/docs/optional-rules.md +4 -3
  120. package/docs/rules/format/page-limit.md +1 -1
  121. package/docs/rules/paper/folder-venue-leftover.md +104 -0
  122. package/docs/rules/talk/captions-cover.md +1 -1
  123. package/docs/rules/talk/duration-floor.md +1 -1
  124. package/docs/rules/talk/profile.md +1 -1
  125. package/docs/rules/tex/heading-case.md +424 -0
  126. package/docs/rules.md +64 -53
  127. package/docs/skills.md +5 -4
  128. package/docs/submission.md +64 -0
  129. package/docs/talk.md +1 -1
  130. package/eslint-rules/latex-language.test.mjs +12 -0
  131. package/eslint-rules/latex-language.ts +17 -4
  132. package/eslint-rules/paper-typography.test.mjs +12 -0
  133. package/eslint.config.mjs +59 -1
  134. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  135. package/fixtures/build-e2e/fallback/expect.json +3 -3
  136. package/lib/paper-config.ts +6 -1
  137. package/lib/skill-checks.d.mts +7 -0
  138. package/lib/skill-checks.mjs +31 -0
  139. package/lib/skill-trigger-cases.mjs +11 -0
  140. package/package.json +8 -2
  141. package/presets/acm-sigconf.jsonc +5 -0
  142. package/presets/acm-sigconf.md +43 -0
  143. package/presets/agenticdev.jsonc +27 -6
  144. package/presets/agenticdev.md +151 -0
  145. package/presets/aidc.jsonc +12 -0
  146. package/presets/aidc.md +75 -0
  147. package/presets/aisec.jsonc +5 -1
  148. package/presets/aisec.md +117 -0
  149. package/presets/ieee-conference.jsonc +5 -0
  150. package/presets/ieee-conference.md +38 -0
  151. package/presets/realm.jsonc +7 -2
  152. package/presets/realm.md +166 -0
  153. package/presets/tex/agenticdev.tex +1 -1
  154. package/presets/tex-base.jsonc +6 -0
  155. package/presets/tex-base.md +30 -0
  156. package/presets/venue-profile.schema.json +57 -2
  157. package/skills/find-venue/SKILL.md +6 -5
  158. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  159. package/skills/paper-pipeline/SKILL.md +5 -6
  160. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  161. package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
  162. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  163. package/skills/study-accepted-papers/SKILL.md +3 -3
  164. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  165. package/skills/submission-portal/SKILL.md +69 -0
  166. package/skills/submission-portal/SKILL.md.spec.ts +71 -0
  167. package/skills/submission-portal/submission-portal.eval.mjs +15 -0
  168. package/skills/submission-portal/submission-portal.test.ts +81 -0
  169. package/skills/submit-paper/SKILL.md +27 -22
  170. package/skills/submit-paper/SKILL.md.spec.ts +26 -21
  171. package/skills/submit-paper/references/publishers/acm.md +1 -1
  172. package/src/adapters/hotcrp/cassette/README.md +24 -0
  173. package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
  174. package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
  175. package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
  176. package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
  177. package/src/adapters/hotcrp/index.ts +11 -0
  178. package/src/adapters/hotcrp/portal.io.ts +130 -0
  179. package/src/adapters/hotcrp/portal.test.ts +503 -0
  180. package/src/adapters/hotcrp/response.ts +181 -0
  181. package/src/adapters/latex/conditionals.ts +130 -0
  182. package/src/adapters/latex/headings.ts +163 -0
  183. package/src/adapters/latex/index.test.ts +65 -0
  184. package/src/adapters/latex/index.ts +3 -0
  185. package/src/adapters/latex/parse.ts +3 -2
  186. package/src/build.ts +2 -2
  187. package/src/cli.test.ts +155 -3
  188. package/src/cli.ts +161 -25
  189. package/src/domain/heading-case.test.ts +660 -0
  190. package/src/domain/heading-case.ts +538 -0
  191. package/src/domain/result.test.ts +9 -1
  192. package/src/domain/result.ts +5 -0
  193. package/src/domain/submission.test.ts +45 -0
  194. package/src/domain/submission.ts +141 -0
  195. package/src/domain/tex-document.ts +36 -0
  196. package/src/domain/venue-name.test.ts +121 -0
  197. package/src/domain/venue-name.ts +129 -0
  198. package/src/facts-file.test.ts +4 -4
  199. package/src/facts-file.ts +6 -6
  200. package/src/folder-venue-rule.test.ts +182 -0
  201. package/src/folder-venue-rule.ts +141 -0
  202. package/src/heading-case.test.ts +500 -0
  203. package/src/heading-case.ts +275 -0
  204. package/src/init.test.ts +22 -0
  205. package/src/init.ts +9 -2
  206. package/src/new-paper.harness.mjs +11 -9
  207. package/src/new-paper.test.ts +135 -10
  208. package/src/new-paper.ts +38 -7
  209. package/src/paper-settings-commands.test.ts +25 -10
  210. package/src/paper-settings.test.ts +12 -1
  211. package/src/paper-settings.ts +25 -5
  212. package/src/parse-args.test.ts +19 -0
  213. package/src/ports/latex.ts +7 -0
  214. package/src/ports/submission-portal.ts +26 -0
  215. package/src/preset-card-rule.test.ts +266 -0
  216. package/src/preset-card-rule.ts +384 -0
  217. package/src/presets.test.ts +212 -37
  218. package/src/presets.ts +183 -71
  219. package/src/rule-cases.test.ts +147 -42
  220. package/src/rules-config.ts +1 -1
  221. package/src/submission.test.ts +497 -0
  222. package/src/submission.ts +311 -0
  223. package/src/talk-rules.test.ts +6 -3
  224. package/src/tex-requirements.harness.ts +25 -11
  225. package/src/tex-requirements.test.ts +127 -1
  226. package/src/tex-requirements.ts +78 -8
  227. package/src/tex-venue-rules.test.ts +36 -22
  228. package/src/tex-venue-rules.ts +14 -26
  229. package/src/types.ts +13 -0
  230. package/src/venue-rules.test.ts +7 -6
  231. package/templates/paper/paper.tex +1 -1
  232. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  233. package/skills/submit-paper/references/venues/aisec.md +0 -105
  234. package/skills/submit-paper/references/venues/realm.md +0 -155
@@ -0,0 +1,181 @@
1
+ /**
2
+ * HotCRP's answers, parsed at the boundary into paperlint's submission values. Pure.
3
+ *
4
+ * Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
5
+ * names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
6
+ * `GET /api/paper` answers `{ ok, paper, message_list? }`, `POST /api/paper` answers
7
+ * `{ ok, valid, change_list, message_list, dry_run?, pid }`. A document's `hash` is
8
+ * `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
9
+ */
10
+ import { z } from "zod";
11
+ import { err, ok, type Result } from "../../domain/result.ts";
12
+ import { parseSha256, type Sha256 } from "../../domain/sha256.ts";
13
+ import type {
14
+ PortalDocument,
15
+ PortalFailure,
16
+ PortalMessage,
17
+ SubmissionView,
18
+ UpdateOutcome,
19
+ } from "../../domain/submission.ts";
20
+
21
+ const Message = z.object({
22
+ message: z.string().optional(),
23
+ field: z.string().optional(),
24
+ status: z.number().optional(),
25
+ });
26
+ const Messages = z.array(Message).optional();
27
+
28
+ const Document = z.object({
29
+ hash: z.string().optional(),
30
+ size: z.number().optional(),
31
+ mimetype: z.string().optional(),
32
+ timestamp: z.number().optional(),
33
+ });
34
+
35
+ const Paper = z.object({
36
+ pid: z.number(),
37
+ status: z.string(),
38
+ title: z.string().optional(),
39
+ abstract: z.string().optional(),
40
+ paper_type: z.unknown().optional(),
41
+ topics: z.array(z.string()).optional(),
42
+ submission: Document.nullable().optional(),
43
+ submitted_at: z.number().optional(),
44
+ modified_at: z.number().optional(),
45
+ });
46
+
47
+ const ShowBody = z.object({
48
+ ok: z.literal(true),
49
+ paper: Paper,
50
+ message_list: Messages,
51
+ });
52
+
53
+ const UpdateBody = z.object({
54
+ valid: z.boolean(),
55
+ change_list: z.array(z.string()).optional(),
56
+ message_list: Messages,
57
+ dry_run: z.boolean().optional(),
58
+ });
59
+
60
+ const Envelope = z.object({ message_list: Messages });
61
+
62
+ const SHA2 = /^sha2-([0-9a-f]{64})$/;
63
+
64
+ /** HotCRP's `sha2-<hex>` → the sha256; null for any other hash it may name. */
65
+ export function sha256OfHash(hash: string): Sha256 | null {
66
+ const hex = SHA2.exec(hash)?.[1];
67
+ return hex === undefined ? null : parseSha256(hex);
68
+ }
69
+
70
+ /** HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest. */
71
+ const textOf = (message: string | undefined): string =>
72
+ (message ?? "").replace(/^<\d+>/, "").trim();
73
+
74
+ /** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
75
+ const messagesOf = (
76
+ list: Readonly<z.infer<typeof Messages>>,
77
+ ): readonly PortalMessage[] =>
78
+ (list ?? [])
79
+ .map((m) => ({
80
+ message: textOf(m.message),
81
+ field: m.field ?? null,
82
+ status: m.status ?? null,
83
+ }))
84
+ .filter((m) => m.message !== "");
85
+
86
+ const documentOf = (
87
+ d: Readonly<z.infer<typeof Document>> | null | undefined,
88
+ ): PortalDocument | null =>
89
+ d?.hash === undefined
90
+ ? null
91
+ : {
92
+ sha256: sha256OfHash(d.hash),
93
+ hash: d.hash,
94
+ size: d.size ?? null,
95
+ mimetype: d.mimetype ?? null,
96
+ uploadedAt: d.timestamp ?? null,
97
+ };
98
+
99
+ /** A paper type as text: HotCRP sends the option's name, or a value of another shape. */
100
+ const typeText = (v: unknown): string | null =>
101
+ v === undefined || v === null
102
+ ? null
103
+ : typeof v === "string"
104
+ ? v
105
+ : JSON.stringify(v);
106
+
107
+ function viewOf(b: Readonly<z.infer<typeof ShowBody>>): SubmissionView {
108
+ const p = b.paper;
109
+ return {
110
+ id: p.pid,
111
+ status: p.status,
112
+ title: p.title ?? null,
113
+ paperType: typeText(p.paper_type),
114
+ topics: p.topics ?? [],
115
+ abstract: p.abstract ?? null,
116
+ pdf: documentOf(p.submission),
117
+ submittedAt: p.submitted_at ?? null,
118
+ modifiedAt: p.modified_at ?? null,
119
+ messages: messagesOf(b.message_list),
120
+ };
121
+ }
122
+
123
+ const is2xx = (status: number): boolean => status >= 200 && status < 300;
124
+
125
+ const StatusCode = z.object({ status_code: z.number() });
126
+
127
+ /**
128
+ * The status HotCRP meant. Its error envelope repeats the status in the body (`status_code: 401`
129
+ * beside `ok: false`, recorded in `cassette/get-noauth.json`); when something between us and HotCRP
130
+ * turns the transport status into a 2xx, the body's code still decides.
131
+ */
132
+ function statusOf(transport: number, body: unknown): number {
133
+ const c = StatusCode.safeParse(body);
134
+ return is2xx(transport) && c.success ? c.data.status_code : transport;
135
+ }
136
+
137
+ /** A body that is no answer of HotCRP's: the portal's messages when it sent any, else "malformed". */
138
+ function refusal(
139
+ httpStatus: number,
140
+ body: unknown,
141
+ what: string,
142
+ ): PortalFailure {
143
+ const e = Envelope.safeParse(body);
144
+ const messages = e.success ? messagesOf(e.data.message_list) : [];
145
+ return messages.length > 0 || !is2xx(httpStatus)
146
+ ? { kind: "refused", httpStatus, messages }
147
+ : { kind: "malformed", httpStatus, detail: what };
148
+ }
149
+
150
+ /** `GET /api/paper` → the submission, or why there is none. */
151
+ export function parseShow(
152
+ httpStatus: number,
153
+ body: unknown,
154
+ ): Result<SubmissionView, PortalFailure> {
155
+ const status = statusOf(httpStatus, body);
156
+ const b = ShowBody.safeParse(body);
157
+ return b.success && is2xx(status)
158
+ ? ok(viewOf(b.data))
159
+ : err(refusal(status, body, "the answer has no submission in `paper`"));
160
+ }
161
+
162
+ /**
163
+ * `POST /api/paper` → the outcome, valid or not. A body carrying `valid` is HotCRP's verdict on
164
+ * the change whatever the HTTP status; the caller decides the exit from both.
165
+ */
166
+ export function parseUpdate(
167
+ httpStatus: number,
168
+ body: unknown,
169
+ ): Result<UpdateOutcome, PortalFailure> {
170
+ const status = statusOf(httpStatus, body);
171
+ const b = UpdateBody.safeParse(body);
172
+ if (!b.success)
173
+ return err(refusal(status, body, "the answer has no `valid`"));
174
+ return ok({
175
+ httpStatus: status,
176
+ valid: b.data.valid,
177
+ changes: b.data.change_list ?? [],
178
+ messages: messagesOf(b.data.message_list),
179
+ dryRun: b.data.dry_run ?? false,
180
+ });
181
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `\iffalse … \fi`: text TeX never reads. Authors park old titles, sections and paragraphs there,
3
+ * and every reader of the tree — the outline, the rendered text, the headings the case judge reads,
4
+ * the includes — would otherwise take them for the paper. So the branch is cut out of the tree once,
5
+ * when the source is parsed (`parseLatex`), rather than pruned per walk like `hidden.ts` prunes: a
6
+ * walk can skip a node, but the rendered text reads the strings of a node list directly, and they
7
+ * would come back.
8
+ *
9
+ * What is cut: from `\iffalse` to its matching `\fi`, both included, counting the conditionals
10
+ * nested in between. An `\else` at the same depth ends the dead part: what follows it is read, and
11
+ * the matching `\fi` is cut. A conditional is a macro whose name starts with `if`, except
12
+ * `\ifthenelse` (the `ifthen` package's command, which takes arguments and has no `\fi`). An
13
+ * `\iffalse` with no `\fi` cuts to the end of its list, as TeX would read to the end of the file.
14
+ *
15
+ * Only `\iffalse` is decided. `\iftrue`, `\if\venue1` and every switch made with `\newif` depend on
16
+ * a value this reader does not compute, so both of their branches stay.
17
+ */
18
+ import type * as Ast from "@unified-latex/unified-latex-types";
19
+
20
+ type Node = Readonly<Ast.Node>;
21
+
22
+ /** Where the scan of one node list stands: reading, inside a dead branch, or in its `\else`. */
23
+ type Mode =
24
+ | { readonly kind: "live" }
25
+ | { readonly kind: "dead"; readonly depth: number }
26
+ | { readonly kind: "else"; readonly depth: number };
27
+
28
+ /** One node against the mode before it: the mode after it, and whether the node is kept. */
29
+ interface Step {
30
+ readonly mode: Mode;
31
+ readonly keep: boolean;
32
+ }
33
+
34
+ const LIVE: Mode = { kind: "live" };
35
+
36
+ const isMacro = (n: Node, name: string): boolean =>
37
+ n.type === "macro" && n.content === name;
38
+
39
+ /** A macro that opens a conditional TeX closes with `\fi`. */
40
+ const opensConditional = (n: Node): boolean =>
41
+ n.type === "macro" &&
42
+ n.content.startsWith("if") &&
43
+ n.content !== "ifthenelse";
44
+
45
+ /** Inside the dead branch: everything is cut; nesting is counted, `\else` at depth 1 ends it. */
46
+ function stepDead(depth: number, n: Node): Step {
47
+ if (opensConditional(n))
48
+ return { mode: { kind: "dead", depth: depth + 1 }, keep: false };
49
+ if (isMacro(n, "fi"))
50
+ return {
51
+ mode: depth === 1 ? LIVE : { kind: "dead", depth: depth - 1 },
52
+ keep: false,
53
+ };
54
+ if (isMacro(n, "else") && depth === 1)
55
+ return { mode: { kind: "else", depth }, keep: false };
56
+ return { mode: { kind: "dead", depth }, keep: false };
57
+ }
58
+
59
+ /** In the `\else` branch: everything is kept but the `\fi` that closes the `\iffalse`. */
60
+ function stepElse(depth: number, n: Node): Step {
61
+ if (opensConditional(n))
62
+ return { mode: { kind: "else", depth: depth + 1 }, keep: true };
63
+ if (isMacro(n, "fi"))
64
+ return depth === 1
65
+ ? { mode: LIVE, keep: false }
66
+ : { mode: { kind: "else", depth: depth - 1 }, keep: true };
67
+ return { mode: { kind: "else", depth }, keep: true };
68
+ }
69
+
70
+ function step(mode: Mode, n: Node): Step {
71
+ switch (mode.kind) {
72
+ case "live":
73
+ return isMacro(n, "iffalse")
74
+ ? { mode: { kind: "dead", depth: 1 }, keep: false }
75
+ : { mode, keep: true };
76
+ case "dead":
77
+ return stepDead(mode.depth, n);
78
+ case "else":
79
+ return stepElse(mode.depth, n);
80
+ }
81
+ }
82
+
83
+ /** A node list without its `\iffalse` branches, each kept node cleaned the same way. */
84
+ // eslint-disable-next-line functional/prefer-immutable-types -- the tree's own content type is a mutable array
85
+ function liveList(nodes: readonly Node[]): Ast.Node[] {
86
+ const { kept } = nodes.reduce<{
87
+ readonly mode: Mode;
88
+ readonly kept: readonly Ast.Node[];
89
+ }>(
90
+ (acc, n) => {
91
+ const next = step(acc.mode, n);
92
+ return {
93
+ mode: next.mode,
94
+ kept: next.keep ? [...acc.kept, liveNode(n)] : acc.kept,
95
+ };
96
+ },
97
+ { mode: LIVE, kept: [] },
98
+ );
99
+ return [...kept];
100
+ }
101
+
102
+ // eslint-disable-next-line functional/prefer-immutable-types -- the tree's own argument type is mutable
103
+ const liveArgs = (args: readonly Readonly<Ast.Argument>[]): Ast.Argument[] =>
104
+ args.map((a) => ({ ...a, content: liveList(a.content) }));
105
+
106
+ /** A node with its content and its arguments' content cleaned; a node with neither, as it is. */
107
+ // eslint-disable-next-line functional/prefer-immutable-types -- the tree's own node type is mutable
108
+ function liveNode(n: Node): Ast.Node {
109
+ if (n.type === "macro")
110
+ return n.args === undefined ? n : { ...n, args: liveArgs(n.args) };
111
+ if (n.type === "environment" || n.type === "mathenv")
112
+ return {
113
+ ...n,
114
+ content: liveList(n.content),
115
+ ...(n.args === undefined ? {} : { args: liveArgs(n.args) }),
116
+ };
117
+ if (n.type === "group" || n.type === "inlinemath" || n.type === "displaymath")
118
+ return { ...n, content: liveList(n.content) };
119
+ return n;
120
+ }
121
+
122
+ /** The tree without what `\iffalse … \fi` hides; the same tree when the source has no `\iffalse`. */
123
+ export const withoutFalseBranches = (
124
+ src: string,
125
+ root: Readonly<Ast.Root>,
126
+ // eslint-disable-next-line functional/prefer-immutable-types -- `ParsedTex.root` is the tree's own mutable type
127
+ ): Ast.Root =>
128
+ src.includes("\\iffalse")
129
+ ? { ...root, content: liveList(root.content) }
130
+ : root;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The paper's `\title` and the titles of its headings as the case judge reads them: each as one run
3
+ * of text, every character with the source offset it came from.
4
+ *
5
+ * What a title says is its strings and spaces, and the words a formatting command wraps
6
+ * (`\emph{very}` says "very"). A label, a footnote or an index entry says nothing in the title and
7
+ * is left out. Anything else — math, `\texttt`, `\cite`, `\textsc`, an accent, an environment — is
8
+ * something the reader sees and the judge does not read, so it stands as `OPAQUE`, one character,
9
+ * where it is: the judge then knows a word is first or last only when nothing stands before or
10
+ * after it, and never reads a word that touches one.
11
+ */
12
+ import { hasAtLeast } from "remeda";
13
+ import type {
14
+ HeadingLevel,
15
+ Segment,
16
+ TitleArgument,
17
+ TitledCommand,
18
+ TitledHeading,
19
+ } from "../../domain/tex-document.ts";
20
+ import { LINE_BREAK, OPAQUE } from "../../domain/tex-document.ts";
21
+ import { isDefinition, isUnrendered } from "./hidden.ts";
22
+ import {
23
+ inPlace,
24
+ isNode,
25
+ mandatory,
26
+ optional,
27
+ placeOf,
28
+ stringText,
29
+ visited,
30
+ type Macro,
31
+ type Node,
32
+ } from "./nodes.ts";
33
+ import type { ParsedTex } from "./parse.ts";
34
+
35
+ const LEVELS: ReadonlyMap<string, HeadingLevel> = new Map<string, HeadingLevel>(
36
+ [
37
+ ["section", "section"],
38
+ ["subsection", "subsection"],
39
+ ["subsubsection", "subsubsection"],
40
+ ["paragraph", "paragraph"],
41
+ ["subparagraph", "subparagraph"],
42
+ ],
43
+ );
44
+
45
+ /** Formatting commands: they say what their argument says. */
46
+ const TRANSPARENT: ReadonlySet<string> = new Set([
47
+ "emph",
48
+ "textit",
49
+ "textbf",
50
+ "textsl",
51
+ "textup",
52
+ "textmd",
53
+ "textnormal",
54
+ "textsf",
55
+ "textrm",
56
+ ]);
57
+
58
+ /** Commands that break the line: the title goes on as a second part, a subtitle. */
59
+ const LINE_BREAKS: ReadonlySet<string> = new Set([
60
+ "\\\\",
61
+ "newline",
62
+ "linebreak",
63
+ ]);
64
+
65
+ /** Commands that put nothing in the title's words: a label, a footnote, an index entry. */
66
+ const INVISIBLE: ReadonlySet<string> = new Set([
67
+ "label",
68
+ "footnote",
69
+ "thanks",
70
+ "index",
71
+ ]);
72
+
73
+ /**
74
+ * The command a macro is, for the case judge: the paper's `\title`, a heading at its level, or none.
75
+ * `\title` is read wherever it stands — in the preamble, where acmart and IEEEtran put it, or in a
76
+ * file the paper includes.
77
+ */
78
+ const commandOf = (m: Macro): readonly TitledCommand[] => {
79
+ if (m.content === "title") return [{ kind: "title" }];
80
+ const level = LEVELS.get(m.content);
81
+ return level === undefined ? [] : [{ kind: "heading", level }];
82
+ };
83
+
84
+ /** A macro that is neither left out of the title nor read as the words it wraps. */
85
+ const isOpaqueMacro = (m: Macro): boolean =>
86
+ !INVISIBLE.has(m.content) &&
87
+ !TRANSPARENT.has(m.content) &&
88
+ !LINE_BREAKS.has(m.content);
89
+
90
+ /** What one macro says in a title. */
91
+ function macroSegments(m: Macro): readonly Segment[] {
92
+ if (INVISIBLE.has(m.content)) return [];
93
+ if (LINE_BREAKS.has(m.content)) return textAt(m, LINE_BREAK);
94
+ if (TRANSPARENT.has(m.content))
95
+ return segmentsOf(mandatory(m).at(-1)?.content);
96
+ return textAt(m, OPAQUE);
97
+ }
98
+
99
+ /** A piece of text at the start of a node. */
100
+ const textAt = (n: Node, text: string): readonly Segment[] =>
101
+ inPlace(placeOf(n), (s) => [{ text, at: s.start }]);
102
+
103
+ /** What one node says in a title: text as itself, a formatting command's words, anything else opaque. */
104
+ function nodeSegments(n: Node): readonly Segment[] {
105
+ if (n.type === "string") return textAt(n, stringText(n.content));
106
+ if (n.type === "whitespace" || n.type === "parbreak") return textAt(n, " ");
107
+ // A comment ends its line: the words on either side of it are two words.
108
+ if (n.type === "comment") return textAt(n, " ");
109
+ if (n.type === "group") return segmentsOf(n.content);
110
+ if (n.type === "macro") return macroSegments(n);
111
+ return textAt(n, OPAQUE);
112
+ }
113
+
114
+ /**
115
+ * A group that is the very next node after a command the parser has no signature for (`\ours{…}`, a macro of the
116
+ * author's own): the parser leaves such a group beside the macro, but TeX hands it to the macro as an
117
+ * argument, so it is part of what the macro prints and not words of the title. A chain of groups
118
+ * (`\findingdes{label}{a sentence}`) is all argument.
119
+ */
120
+ function isArgumentGroup(nodes: readonly Node[], n: Node, i: number): boolean {
121
+ const prev = nodes[i - 1];
122
+ if (prev === undefined || n.type !== "group") return false;
123
+ return prev.type === "macro"
124
+ ? isOpaqueMacro(prev)
125
+ : isArgumentGroup(nodes, prev, i - 1);
126
+ }
127
+
128
+ const segmentsOf = (nodes: readonly Node[] | undefined): readonly Segment[] => {
129
+ const list = nodes ?? [];
130
+ return list.flatMap((n, i) =>
131
+ isArgumentGroup(list, n, i) ? [] : nodeSegments(n),
132
+ );
133
+ };
134
+
135
+ /** One argument's contents → the heading it makes, or none when it says nothing. */
136
+ function titled(
137
+ command: TitledCommand,
138
+ argument: TitleArgument,
139
+ nodes: readonly Node[] | undefined,
140
+ ): readonly TitledHeading[] {
141
+ const segments = segmentsOf(nodes);
142
+ return hasAtLeast(segments, 1)
143
+ ? [{ command, argument, title: { segments } }]
144
+ : [];
145
+ }
146
+
147
+ /** One titled command → the short title (`[…]`) and the title (the last `{…}`), each judged. */
148
+ const headingsOfMacro = (m: Macro): readonly TitledHeading[] =>
149
+ commandOf(m).flatMap((command) => [
150
+ ...titled(command, "short", optional(m)?.content),
151
+ ...titled(command, "title", mandatory(m).at(-1)?.content),
152
+ ]);
153
+
154
+ /**
155
+ * The paper's `\title` and every `\section`, `\subsection`, `\subsubsection`, `\paragraph` and
156
+ * `\subparagraph`, starred or not, each with its optional short title, in document order. What the
157
+ * PDF never shows is pruned (`isUnrendered`), and so is a macro definition's body: a heading in a
158
+ * comment environment or in a definition is not a heading where it is written.
159
+ */
160
+ export const headingsOf = (t: ParsedTex): readonly TitledHeading[] =>
161
+ visited(t.root, (v, parent) => isUnrendered(v, parent) || isDefinition(v))
162
+ .filter(isNode)
163
+ .flatMap((n) => (n.type === "macro" ? headingsOfMacro(n) : []));
@@ -324,6 +324,71 @@ describe("outlineOf — a heading that is never rendered is not in the outline",
324
324
  });
325
325
  });
326
326
 
327
+ describe("\\iffalse … \\fi — a branch TeX never reads is not in the tree any reader walks", () => {
328
+ const dead = [
329
+ "A",
330
+ "\\iffalse",
331
+ "\\section{Old Title}",
332
+ "dead words",
333
+ "\\ifx\\a\\b nested\\fi",
334
+ "still dead",
335
+ "\\fi",
336
+ "B",
337
+ ].join("\n");
338
+ const doc = (body: string) => `\\begin{document}\n${body}\n\\end{document}`;
339
+
340
+ it("🔴 outline, headings and rendered text skip it, nested conditionals included", () => {
341
+ const t = parseLatex(doc(dead));
342
+ expect(outlineOf(t).sections).toEqual([]);
343
+ expect(latexReader.headings(doc(dead))).toEqual([]);
344
+ // Guards: the dead strings are gone from the list itself, so A and B are one run.
345
+ expect(
346
+ renderedRuns(t).map((r) => runText(r).replace(/\s+/gu, " ").trim()),
347
+ ).toEqual(["A B"]);
348
+ });
349
+
350
+ it("the \\else branch of an \\iffalse is read, and the text after its \\fi", () => {
351
+ const src = doc(
352
+ "\\iffalse\n\\section{Old}\n\\else\n\\section{New}\n\\fi\n\\section{After}",
353
+ );
354
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
355
+ "New",
356
+ "After",
357
+ ]);
358
+ });
359
+
360
+ it("inside a group too, and `\\ifthenelse` (which has no \\fi) does not swallow the rest", () => {
361
+ const src = doc(
362
+ "{\\iffalse\\section{Old}\\ifthenelse{x}{y}{z}\\fi}\\section{Real}",
363
+ );
364
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
365
+ "Real",
366
+ ]);
367
+ });
368
+
369
+ it("inside an environment with arguments, in math, and an \\else holding its own conditional", () => {
370
+ const src = doc(
371
+ [
372
+ "\\begin{minipage}{4cm}\\iffalse\\section{Old}\\fi\\section{Kept}\\end{minipage}",
373
+ "$x \\iffalse y \\fi$ \\[ z \\] \\begin{align*} a \\end{align*}",
374
+ "\\iffalse\\section{Old}\\else\\ifx\\a\\b\\section{Inner}\\fi\\section{New}\\fi",
375
+ ].join("\n"),
376
+ );
377
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
378
+ "Kept",
379
+ "Inner",
380
+ "New",
381
+ ]);
382
+ });
383
+
384
+ it("an \\iffalse that is never closed leaves out everything after it, as TeX does", () => {
385
+ const src = doc("\\section{Real}\n\\iffalse\n\\section{Old}");
386
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
387
+ "Real",
388
+ ]);
389
+ });
390
+ });
391
+
327
392
  describe("renderedRuns", () => {
328
393
  it("runs of strings and spaces, each character mapped to its source offset", () => {
329
394
  const src = "A~B \\emph{C D}\n\n E";
@@ -26,6 +26,7 @@ import {
26
26
  parseTemplate,
27
27
  replaceDocumentClass,
28
28
  } from "./document-class.ts";
29
+ import { headingsOf } from "./headings.ts";
29
30
  import { documentBodyOf, includesOf } from "./includes.ts";
30
31
  import { outlineOf } from "./outline.ts";
31
32
  import { parseLatex } from "./parse.ts";
@@ -48,6 +49,7 @@ export {
48
49
  textOf,
49
50
  } from "./nodes.ts";
50
51
  export { documentBodyOf, includesOf } from "./includes.ts";
52
+ export { headingsOf } from "./headings.ts";
51
53
  export { outlineOf } from "./outline.ts";
52
54
  export { parseLatex, type ParsedTex } from "./parse.ts";
53
55
  export { bodyEmphasis, bodyProse } from "./prose.ts";
@@ -58,6 +60,7 @@ export { layoutOverridesOf } from "./layout.ts";
58
60
  export const latexReader: LatexReader = {
59
61
  documentClass: (src) => documentClassOf(parseLatex(src)),
60
62
  outline: (src) => outlineOf(parseLatex(src)),
63
+ headings: (src) => headingsOf(parseLatex(src)),
61
64
  renderedRuns: (src) => renderedRuns(parseLatex(src)),
62
65
  bodyProse: (src) => bodyProse(parseLatex(src)),
63
66
  bodyEmphasis: (src) => bodyEmphasis(parseLatex(src)),
@@ -12,6 +12,7 @@
12
12
  import type * as Ast from "@unified-latex/unified-latex-types";
13
13
  import { getParser } from "@unified-latex/unified-latex-util-parse";
14
14
  import { once } from "remeda";
15
+ import { withoutFalseBranches } from "./conditionals.ts";
15
16
 
16
17
  const signed = (
17
18
  names: readonly string[],
@@ -133,9 +134,9 @@ const parser = once(() =>
133
134
  }),
134
135
  );
135
136
 
136
- /** A LaTeX source → its tree. */
137
+ /** A LaTeX source → its tree, without the branches `\iffalse … \fi` hides (`conditionals.ts`). */
137
138
  export const parseLatex = (src: string): ParsedTex => ({
138
139
  src,
139
- root: parser().parse(src),
140
+ root: withoutFalseBranches(src, parser().parse(src)),
140
141
  [PARSED]: true,
141
142
  });
package/src/build.ts CHANGED
@@ -121,7 +121,7 @@ export interface PaperFacts {
121
121
  readonly name: string;
122
122
  readonly options: readonly string[];
123
123
  } | null;
124
- /** The label of the venue preset `paperlint.json` extends, or null. */
124
+ /** The id of the venue preset `paperlint.json` extends (`agenticdev`), or null. */
125
125
  readonly venue: string | null;
126
126
  /** Paper-supplied build scripts found on disk — reported as ignored. */
127
127
  readonly ignoredScripts: readonly string[];
@@ -239,7 +239,7 @@ export function readFacts(paperDir: string): PaperFacts {
239
239
  return {
240
240
  main,
241
241
  documentclass: documentclassOf(ast),
242
- venue: venue?.label ?? null,
242
+ venue: venue?.id ?? null,
243
243
  ignoredScripts: IGNORED_SCRIPTS.filter((s) =>
244
244
  existsSync(join(paperDir, s)),
245
245
  ),