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,311 @@
1
+ /**
2
+ * `paperlint submission show|update [paper-dir]` — read and change a paper's submission on the
3
+ * venue's portal, through the portal's API.
4
+ *
5
+ * The command is named by the job, not by the portal: WHERE the submission lives is data. The venue
6
+ * preset declares the portal (`"portal": { "kind": "hotcrp", "url": … }`), the paper's
7
+ * `paperlint.json` declares which submission is its own (`"submission": { "id": 7 }`), and one
8
+ * adapter per portal kind does the talking (`src/adapters/hotcrp/`).
9
+ *
10
+ * show status, id, title, paper type, topics, abstract length, the PDF the portal holds — and
11
+ * whether it is the local `paper.pdf`, by sha256
12
+ * update send the PDF (and, if asked, an abstract, or "submitted"). A DRY RUN unless `--save`:
13
+ * the portal checks the change and keeps nothing
14
+ *
15
+ * 🔴 THE TOKEN IS AN INPUT, AND ONLY AN INPUT. It comes from the environment (the root reads it), is
16
+ * handed to the adapter, and never appears in anything printed here.
17
+ */
18
+ import { basename, join, resolve } from "node:path";
19
+ import { absolutePath } from "./domain/paths.ts";
20
+ import { err, ok, type Result } from "./domain/result.ts";
21
+ import { sha256Hex, type Sha256 } from "./domain/sha256.ts";
22
+ import {
23
+ supportedPortal,
24
+ wordCount,
25
+ type PortalFailure,
26
+ type PortalMessage,
27
+ type SubmissionView,
28
+ type SupportedPortal,
29
+ type UpdateOutcome,
30
+ } from "./domain/submission.ts";
31
+ import type { Files } from "./ports/files.ts";
32
+ import type { SubmissionPortal } from "./ports/submission-portal.ts";
33
+ import { paperPreset, paperPresetProblem, type PresetDeps } from "./presets.ts";
34
+ import { CONFIG_FILE } from "#lib/paper-config";
35
+
36
+ /** The environment variable each portal kind's token is read from. */
37
+ export const TOKEN_ENV: Readonly<Record<SupportedPortal["kind"], string>> = {
38
+ hotcrp: "HOTCRP_TOKEN",
39
+ };
40
+
41
+ /** Where each portal kind lets an author create a token. */
42
+ const TOKEN_HELP: Readonly<Record<SupportedPortal["kind"], string>> = {
43
+ hotcrp:
44
+ "create one on the HotCRP site under Account settings → Developer, with the scopes submeta:read and document:read (show) and submeta:write and document:write (update)",
45
+ };
46
+
47
+ export const SUBCOMMANDS = ["show", "update"] as const;
48
+ export type Subcommand = (typeof SUBCOMMANDS)[number];
49
+ const isSubcommand = (s: string | undefined): s is Subcommand =>
50
+ SUBCOMMANDS.some((x) => x === s);
51
+
52
+ /** What one `submission` run was asked, after the command line. */
53
+ export interface SubmissionArgs {
54
+ readonly sub: string | undefined;
55
+ readonly paperDir: string;
56
+ /** Positional arguments past the paper directory: refused. */
57
+ readonly extra: readonly string[];
58
+ /** `--pdf <file>`, relative to `cwd`; null: the paper's own PDF. */
59
+ readonly pdf: string | null;
60
+ /** `--abstract <file>`, relative to `cwd`; null: leave the abstract as it is. */
61
+ readonly abstract: string | null;
62
+ readonly submit: boolean;
63
+ readonly save: boolean;
64
+ }
65
+
66
+ export interface SubmissionDeps {
67
+ readonly files: Files;
68
+ readonly presets: PresetDeps;
69
+ readonly cwd: string;
70
+ /** The value of an environment variable; the root reads the environment. */
71
+ readonly env: (name: string) => string | undefined;
72
+ readonly portalFor: (p: SupportedPortal, token: string) => SubmissionPortal;
73
+ readonly log: (line: string) => void;
74
+ readonly err: (line: string) => void;
75
+ }
76
+
77
+ /** A paper's portal, its submission there, and the PDF the paper builds. */
78
+ export interface SubmissionTarget {
79
+ readonly portal: SupportedPortal;
80
+ readonly id: number;
81
+ /** The paper's built PDF, absolute. */
82
+ readonly pdf: string;
83
+ }
84
+
85
+ const configOf = (dir: string): string => join(dir, CONFIG_FILE);
86
+
87
+ /** The paper directory → its portal and submission, or the line naming the file and key to set. */
88
+ export function submissionTarget(
89
+ paperDir: string,
90
+ deps: PresetDeps,
91
+ ): Result<SubmissionTarget, string> {
92
+ const p = paperPreset(paperDir, deps);
93
+ const problem = paperPresetProblem(paperDir, p);
94
+ if (problem !== null) return err(problem);
95
+ if (p.kind !== "resolved")
96
+ return err(
97
+ `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`,
98
+ );
99
+ const declared = p.preset.portal;
100
+ if (declared === null)
101
+ return err(
102
+ `the venue preset declares no "portal" (read: ${p.preset.chain.join(", ")}) — add "portal": { "kind": "hotcrp", "url": "<the site>" } to the venue's preset, or upload by hand`,
103
+ );
104
+ const sub = p.settings.submission;
105
+ if (sub === null)
106
+ return err(
107
+ `${configOf(paperDir)}: no "submission" — add "submission": { "id": <the submission number on ${declared.url}> }`,
108
+ );
109
+ const portal = supportedPortal(declared);
110
+ if (!portal.ok) return portal;
111
+ const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
112
+ return ok({ portal: portal.value, id: sub.id, pdf });
113
+ }
114
+
115
+ /** The token from the environment, or the line naming the variable and where to get one. */
116
+ export function tokenFor(
117
+ portal: SupportedPortal,
118
+ env: (name: string) => string | undefined,
119
+ ): Result<string, string> {
120
+ const name = TOKEN_ENV[portal.kind];
121
+ const v = env(name)?.trim();
122
+ return v
123
+ ? ok(v)
124
+ : err(
125
+ `${name} is not set — the ${portal.kind} API needs a token: ${TOKEN_HELP[portal.kind]}. Then: export ${name}=…`,
126
+ );
127
+ }
128
+
129
+ const iso = (seconds: number | null): string =>
130
+ seconds === null ? "—" : new Date(seconds * 1000).toISOString();
131
+
132
+ const messageLine = (m: PortalMessage): string =>
133
+ ` portal says: ${m.field === null ? "" : `${m.field}: `}${m.message}`;
134
+
135
+ /** A failure in words: HTTP status and the portal's own messages. Never the token. */
136
+ export function failureText(f: PortalFailure): string {
137
+ switch (f.kind) {
138
+ case "refused": {
139
+ const said = f.messages.map((m) => m.message).filter(Boolean);
140
+ return `the portal refused (HTTP ${String(f.httpStatus)})${said.length ? `: ${said.join("; ")}` : ""}`;
141
+ }
142
+ case "unreachable":
143
+ return `the portal cannot be reached: ${f.detail}`;
144
+ case "malformed":
145
+ return `the portal answered HTTP ${String(f.httpStatus)}, but not as its API does: ${f.detail}`;
146
+ }
147
+ }
148
+
149
+ /** Whether the portal holds the local build, in one line. */
150
+ function matchLine(view: SubmissionView, local: Sha256 | null): string {
151
+ if (local === null) return "match unknown — no local PDF to compare";
152
+ if (view.pdf === null) return "match NO — the portal holds no PDF";
153
+ if (view.pdf.sha256 === null)
154
+ return `match unknown — the portal names a ${view.pdf.hash.replace(/-.*$/s, "")} hash, not a sha256`;
155
+ return view.pdf.sha256 === local
156
+ ? "match yes — the portal holds this build"
157
+ : "match NO — the portal holds a different PDF than the local one";
158
+ }
159
+
160
+ const pdfLine = (view: SubmissionView): string =>
161
+ view.pdf === null
162
+ ? "portal PDF none"
163
+ : `portal PDF ${view.pdf.hash}, ${view.pdf.size === null ? "?" : String(view.pdf.size)} bytes, uploaded ${iso(view.pdf.uploadedAt)}`;
164
+
165
+ /** `show`'s report. Pure. */
166
+ export function showLines(
167
+ t: SubmissionTarget,
168
+ view: SubmissionView,
169
+ local: { readonly shown: string; readonly sha256: Sha256 | null },
170
+ ): readonly string[] {
171
+ return [
172
+ `submission ${String(view.id)} on ${t.portal.url} (${t.portal.kind})`,
173
+ `status ${view.status}${view.submittedAt === null ? "" : `, submitted ${iso(view.submittedAt)}`}`,
174
+ `title ${view.title ?? "—"}`,
175
+ `paper type ${view.paperType ?? "—"}`,
176
+ `topics ${view.topics.length ? view.topics.join("; ") : "—"}`,
177
+ `abstract ${view.abstract === null ? "—" : `${String(wordCount(view.abstract))} words`}`,
178
+ pdfLine(view),
179
+ `local PDF ${local.shown} ${local.sha256 === null ? "(missing)" : `sha2-${local.sha256}`}`,
180
+ matchLine(view, local.sha256),
181
+ ...view.messages.map(messageLine),
182
+ ];
183
+ }
184
+
185
+ /** `update`'s report. Pure. */
186
+ export function updateLines(
187
+ o: UpdateOutcome,
188
+ local: { readonly shown: string; readonly sha256: Sha256 },
189
+ save: boolean,
190
+ ): readonly string[] {
191
+ const mode = save
192
+ ? o.dryRun
193
+ ? "SAVE WITHHELD by the portal — nothing changed"
194
+ : "saved"
195
+ : "dry run — the portal checked the change and kept nothing; --save sends it for real";
196
+ return [
197
+ mode,
198
+ `HTTP ${String(o.httpStatus)}`,
199
+ `valid ${o.valid ? "yes" : "NO"}`,
200
+ `changes ${o.changes.length ? o.changes.join(", ") : "—"}`,
201
+ `local PDF ${local.shown} sha2-${local.sha256}`,
202
+ ...o.messages.map(messageLine),
203
+ ];
204
+ }
205
+
206
+ const shownPath = (cwd: string, p: string): string =>
207
+ p.startsWith(`${cwd}/`) ? p.slice(cwd.length + 1) : p;
208
+
209
+ /** The file's bytes, or null when there is none. */
210
+ const bytesAt = (files: Files, p: string): Uint8Array | null =>
211
+ files.readBytes(absolutePath(p));
212
+
213
+ /** `show`: read the submission and compare its PDF with the local one. */
214
+ async function runShow(
215
+ t: SubmissionTarget,
216
+ portal: SubmissionPortal,
217
+ pdfPath: string,
218
+ deps: SubmissionDeps,
219
+ ): Promise<number> {
220
+ const view = await portal.show(t.id);
221
+ if (!view.ok) return (deps.err(failureText(view.error)), 1);
222
+ const bytes = bytesAt(deps.files, pdfPath);
223
+ const local = {
224
+ shown: shownPath(deps.cwd, pdfPath),
225
+ sha256: bytes === null ? null : sha256Hex(bytes),
226
+ };
227
+ showLines(t, view.value, local).forEach((l) => {
228
+ deps.log(l);
229
+ });
230
+ return 0;
231
+ }
232
+
233
+ /** The abstract file's text, or the line saying it is missing; null when none was asked for. */
234
+ function abstractOf(
235
+ a: SubmissionArgs,
236
+ deps: SubmissionDeps,
237
+ ): Result<string | null, string> {
238
+ if (a.abstract === null) return ok(null);
239
+ const p = resolve(deps.cwd, a.abstract);
240
+ const bytes = bytesAt(deps.files, p);
241
+ return bytes === null
242
+ ? err(`--abstract: no file at ${shownPath(deps.cwd, p)}`)
243
+ : ok(new TextDecoder().decode(bytes).trim());
244
+ }
245
+
246
+ /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
247
+ async function runUpdate(
248
+ t: SubmissionTarget,
249
+ portal: SubmissionPortal,
250
+ a: SubmissionArgs & { readonly pdfPath: string },
251
+ deps: SubmissionDeps,
252
+ ): Promise<number> {
253
+ const bytes = bytesAt(deps.files, a.pdfPath);
254
+ const shown = shownPath(deps.cwd, a.pdfPath);
255
+ if (bytes === null)
256
+ return (
257
+ deps.err(`no PDF at ${shown} — build the paper, or name one with --pdf`),
258
+ 2
259
+ );
260
+ const abstract = abstractOf(a, deps);
261
+ if (!abstract.ok) return (deps.err(abstract.error), 2);
262
+ const change = {
263
+ pdf: { name: basename(a.pdfPath), bytes },
264
+ abstract: abstract.value,
265
+ submit: a.submit,
266
+ };
267
+ const r = await portal.update(t.id, change, { save: a.save });
268
+ if (!r.ok) return (deps.err(failureText(r.error)), 1);
269
+ const local = { shown, sha256: sha256Hex(bytes) };
270
+ updateLines(r.value, local, a.save).forEach((l) => {
271
+ deps.log(l);
272
+ });
273
+ const fine =
274
+ r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
275
+ return fine ? 0 : 1;
276
+ }
277
+
278
+ /** Why the command line cannot be run as asked; null when it can. Pure. */
279
+ export function usageProblem(a: SubmissionArgs): string | null {
280
+ if (!isSubcommand(a.sub))
281
+ return `\`submission\` needs a subcommand: ${SUBCOMMANDS.join(" or ")} — got \`${a.sub ?? ""}\``;
282
+ if (a.extra.length > 0)
283
+ return `\`submission ${a.sub}\` takes one paper folder — got also \`${a.extra.join(" ")}\``;
284
+ const updateOnly = [
285
+ a.abstract === null ? null : "--abstract",
286
+ a.submit ? "--submit" : null,
287
+ a.save ? "--save" : null,
288
+ ].filter((f) => f !== null);
289
+ return a.sub === "show" && updateOnly.length > 0
290
+ ? `${updateOnly.join(", ")}: only \`submission update\` changes anything; \`show\` reads`
291
+ : null;
292
+ }
293
+
294
+ /** `paperlint submission <show|update> [paper-dir]` — exit 0, 1 (the portal said no), or 2 (usage). */
295
+ export async function runSubmission(
296
+ a: SubmissionArgs,
297
+ deps: SubmissionDeps,
298
+ ): Promise<number> {
299
+ const usage = usageProblem(a);
300
+ if (usage !== null) return (deps.err(usage), 2);
301
+ const paperDir = resolve(deps.cwd, a.paperDir);
302
+ const t = submissionTarget(paperDir, deps.presets);
303
+ if (!t.ok) return (deps.err(t.error), 2);
304
+ const token = tokenFor(t.value.portal, deps.env);
305
+ if (!token.ok) return (deps.err(token.error), 2);
306
+ const portal = deps.portalFor(t.value.portal, token.value);
307
+ const pdfPath = a.pdf === null ? t.value.pdf : resolve(deps.cwd, a.pdf);
308
+ return a.sub === "show"
309
+ ? runShow(t.value, portal, pdfPath, deps)
310
+ : runUpdate(t.value, portal, { ...a, pdfPath }, deps);
311
+ }
@@ -23,7 +23,7 @@ import {
23
23
  talkRules,
24
24
  type TalkRuleName,
25
25
  } from "./talk-rules.ts";
26
- import { useTempDir, writeTree } from "../test/support.ts";
26
+ import { useTempDir, venuePreset, writeTree } from "../test/support.ts";
27
27
 
28
28
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
29
  const media = (name: string): Uint8Array =>
@@ -62,8 +62,11 @@ function run(
62
62
  const dir = useTempDir("paperlint-talk-rules-");
63
63
  const venue =
64
64
  o.venue === null
65
- ? { extends: "paperlint:acm-sigconf" }
66
- : { extends: "paperlint:agenticdev", talk: { ...VENUE, ...o.venue } };
65
+ ? { type: "family", extends: "paperlint:acm-sigconf" }
66
+ : venuePreset("venue", {
67
+ extends: "paperlint:agenticdev",
68
+ talk: { ...VENUE, ...o.venue },
69
+ });
67
70
  writeTree(dir, {
68
71
  "package.json": "{}",
69
72
  "p/paper.tex": "x",
@@ -130,32 +130,40 @@ copyFileSync(join(VENUES, R.SCHEMA_FILE), join(tmp, R.SCHEMA_FILE));
130
130
  const bad = (text: string) =>
131
131
  throws(() => R.parseProfile(text, "bad.jsonc", tmp));
132
132
  const BAD: [string, string, string][] = [
133
- ["no `tex` block at all", `{ "template": "acmart" }`, "tex"],
133
+ [
134
+ "no `tex` block at all",
135
+ `{ "type": "family", "template": "acmart" }`,
136
+ "tex",
137
+ ],
134
138
  // Guards: typo detection — `templat` would be accepted and the field silently unread.
135
139
  [
136
140
  "an unknown top-level key (a typo)",
137
- `{ "tex": { "packages": { "a": ["a.sty"] } }, "templat": "x" }`,
141
+ `{ "type": "family", "tex": { "packages": { "a": ["a.sty"] } }, "templat": "x" }`,
138
142
  "additional properties",
139
143
  ],
140
144
  [
141
145
  "a package with NO proof file",
142
- `{ "tex": { "packages": { "acmart": [] } } }`,
146
+ `{ "type": "family", "tex": { "packages": { "acmart": [] } } }`,
143
147
  ".tex.packages['acmart']",
144
148
  ],
145
149
  [
146
150
  "a package name tlmgr would not know (upper case)",
147
- `{ "tex": { "packages": { "Acmart": ["acmart.cls"] } } }`,
151
+ `{ "type": "family", "tex": { "packages": { "Acmart": ["acmart.cls"] } } }`,
148
152
  "property name",
149
153
  ],
150
154
  [
151
155
  "a proof that is a path, not a file name",
152
- `{ "tex": { "packages": { "a": ["tex/a.sty"] } } }`,
156
+ `{ "type": "family", "tex": { "packages": { "a": ["tex/a.sty"] } } }`,
153
157
  "pattern",
154
158
  ],
155
- ["an empty packages map", `{ "tex": { "packages": {} } }`, "fewer than 1"],
159
+ [
160
+ "an empty packages map",
161
+ `{ "type": "family", "tex": { "packages": {} } }`,
162
+ "fewer than 1",
163
+ ],
156
164
  [
157
165
  "an unknown key inside tex",
158
- `{ "tex": { "packages": { "a": ["a.sty"] }, "pkgs": {} } }`,
166
+ `{ "type": "family", "tex": { "packages": { "a": ["a.sty"] }, "pkgs": {} } }`,
159
167
  "additional properties",
160
168
  ],
161
169
  ];
@@ -174,7 +182,9 @@ check(
174
182
  );
175
183
  check(
176
184
  "comments and trailing commas ARE JSONC",
177
- bad(`// quote\n{ "tex": { "packages": { "a": ["a.sty"], }, }, }`) === "",
185
+ bad(
186
+ `// quote\n{ "type": "family", "tex": { "packages": { "a": ["a.sty"], }, }, }`,
187
+ ) === "",
178
188
  );
179
189
  rmSync(tmp, { recursive: true, force: true });
180
190
 
@@ -239,7 +249,7 @@ rmSync(tmp, { recursive: true, force: true });
239
249
  // ── 5. the parser's tolerances: an optional block left out, and Ajv's optional fields ───
240
250
  {
241
251
  const bare = R.parsePreset(
242
- '{ "extends": "paperlint:aisec" }',
252
+ '{ "type": "venue", "name": "X", "url": "https://x.org/", "extends": "paperlint:aisec" }',
243
253
  "bare.jsonc",
244
254
  VENUES,
245
255
  );
@@ -254,9 +264,13 @@ rmSync(tmp, { recursive: true, force: true });
254
264
  check(
255
265
  "a tex block without `packages` is refused by the schema, before anything reads it",
256
266
  /tex\.jsonc: \.tex should have required property 'packages'/.test(
257
- throws(() => R.parsePreset('{ "tex": {} }', "tex.jsonc", VENUES)),
267
+ throws(() =>
268
+ R.parsePreset('{ "type": "family", "tex": {} }', "tex.jsonc", VENUES),
269
+ ),
270
+ ),
271
+ throws(() =>
272
+ R.parsePreset('{ "type": "family", "tex": {} }', "tex.jsonc", VENUES),
258
273
  ),
259
- throws(() => R.parsePreset('{ "tex": {} }', "tex.jsonc", VENUES)),
260
274
  );
261
275
  check(
262
276
  "violations: Ajv's null `errors` is no violation; a message Ajv left out is an empty one",
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "vitest";
3
- import { loadTypescript } from "./tex-requirements.ts";
3
+ import {
4
+ identityHint,
5
+ identityHints,
6
+ loadTypescript,
7
+ parsePreset,
8
+ } from "./tex-requirements.ts";
4
9
 
5
10
  test("loadTypescript refuses a module without the two functions a profile is read with", () => {
6
11
  assert.throws(
@@ -19,3 +24,124 @@ test("loadTypescript hands back what `require` loaded when it has them", () => {
19
24
  ts,
20
25
  );
21
26
  });
27
+
28
+ // ── what a preset is: the identity its `type` declares ─────────────────────────────────────
29
+
30
+ /** What `parsePreset` throws for `preset`, as text; "" when it parses. */
31
+ function refusalOf(name: string, preset: object): string {
32
+ try {
33
+ parsePreset(JSON.stringify(preset), name);
34
+ return "";
35
+ } catch (e) {
36
+ return e instanceof Error ? e.message : String(e);
37
+ }
38
+ }
39
+
40
+ test("a venue without the link to its call for papers is refused at load, saying what to add", () => {
41
+ // Guards: the wrong state the identity exists to make unrepresentable — a venue nobody can check.
42
+ assert.equal(
43
+ refusalOf("v.jsonc", {
44
+ type: "venue",
45
+ name: "X",
46
+ extends: "paperlint:acm-sigconf",
47
+ }),
48
+ [
49
+ "v.jsonc does not match venue-profile.schema.json:",
50
+ ' a venue preset names its venue and links its call for papers: "name": "<the venue, as its call spells it>", "url": "https://<the call for papers>"',
51
+ " v.jsonc: (top level) should have required property 'url'",
52
+ ' v.jsonc: (top level) should match "then" schema',
53
+ ].join("\n"),
54
+ );
55
+ });
56
+
57
+ test("a template family with a venue's name and url is refused, naming each key", () => {
58
+ assert.equal(
59
+ refusalOf("f.jsonc", {
60
+ type: "family",
61
+ name: "ACM",
62
+ url: "https://acm.org/",
63
+ extends: "paperlint:acm-sigconf",
64
+ }),
65
+ [
66
+ "f.jsonc does not match venue-profile.schema.json:",
67
+ ' a template family names no venue: drop "name" — "name" and "url" belong to the venue presets that extend it',
68
+ ' a template family names no venue: drop "url" — "name" and "url" belong to the venue presets that extend it',
69
+ " f.jsonc: .name boolean schema is false",
70
+ " f.jsonc: .url boolean schema is false",
71
+ ' f.jsonc: (top level) should match "then" schema',
72
+ ].join("\n"),
73
+ );
74
+ });
75
+
76
+ test("a preset that does not say what it is is refused, with the three answers", () => {
77
+ assert.equal(
78
+ refusalOf("n.jsonc", { extends: "paperlint:acm-sigconf" }),
79
+ [
80
+ "n.jsonc does not match venue-profile.schema.json:",
81
+ ' say what this preset is: "type": "venue" (one venue\'s call for papers), "family" (a publisher\'s template that venues extend) or "base" (the TeX base set)',
82
+ " n.jsonc: (top level) should have required property 'type'",
83
+ ].join("\n"),
84
+ );
85
+ });
86
+
87
+ test("the TeX base set holds its packages and nothing else", () => {
88
+ assert.equal(
89
+ refusalOf("b.jsonc", {
90
+ type: "base",
91
+ tex: { packages: { a: ["a.sty"] } },
92
+ format: { columns: 2 },
93
+ }),
94
+ [
95
+ "b.jsonc does not match venue-profile.schema.json:",
96
+ ' the TeX base set holds "type" and "tex" only — a venue\'s or a family\'s settings go in its own preset',
97
+ " b.jsonc: (top level) should NOT have more than 2 properties",
98
+ ' b.jsonc: (top level) should match "then" schema',
99
+ ].join("\n"),
100
+ );
101
+ });
102
+
103
+ test("a venue is parsed into its identity; a family and the base set carry none of a venue's fields", () => {
104
+ const read = (p: object) =>
105
+ parsePreset(JSON.stringify(p), "p.jsonc").identity;
106
+ assert.deepEqual(
107
+ [
108
+ read({
109
+ type: "venue",
110
+ name: "X",
111
+ url: "https://x.org/cfp",
112
+ extends: "paperlint:acm-sigconf",
113
+ }),
114
+ read({ type: "family", extends: "paperlint:acm-sigconf" }),
115
+ read({ type: "base", tex: { packages: { a: ["a.sty"] } } }),
116
+ ],
117
+ [
118
+ { type: "venue", name: "X", url: "https://x.org/cfp" },
119
+ { type: "family" },
120
+ { type: "base" },
121
+ ],
122
+ );
123
+ });
124
+
125
+ test("identityHint has nothing to add to a violation that is not about the identity", () => {
126
+ assert.equal(
127
+ identityHint({
128
+ keyword: "additionalProperties",
129
+ dataPath: "",
130
+ params: { additionalProperty: "colums" },
131
+ }),
132
+ null,
133
+ );
134
+ });
135
+
136
+ test("identityHints: Ajv's null errors give none; a hint two violations share is given once", () => {
137
+ assert.deepEqual(identityHints({ errors: null }), []);
138
+ const missing = {
139
+ keyword: "required",
140
+ dataPath: "",
141
+ schemaPath: "#/required",
142
+ params: { missingProperty: "type" },
143
+ };
144
+ const hints = identityHints({ errors: [missing, missing] });
145
+ assert.equal(hints.length, 1);
146
+ assert.deepEqual(hints, [identityHint(missing)]);
147
+ });