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
package/src/new-paper.ts CHANGED
@@ -27,6 +27,11 @@ import { join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { CONFIG_FILE } from "#lib/paper-config";
29
29
  import { isRecord } from "./domain/record.ts";
30
+ import {
31
+ venuesNamedBy,
32
+ withoutVenues,
33
+ type NamedVenue,
34
+ } from "./domain/venue-name.ts";
30
35
 
31
36
  export type PaperFormat = "tex" | "md";
32
37
  export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
@@ -50,6 +55,9 @@ export const SOURCE_FILE: Readonly<Record<PaperFormat, string>> = {
50
55
  md: "paper.md",
51
56
  };
52
57
 
58
+ /** The flag that lets `paperlint new` create a folder whose name names a venue. */
59
+ export const ALLOW_VENUE_NAME = "--allow-venue-name";
60
+
53
61
  export const isFormat = (v: unknown): v is PaperFormat =>
54
62
  FORMATS.some((f) => f === v);
55
63
 
@@ -57,13 +65,30 @@ export const isFormat = (v: unknown): v is PaperFormat =>
57
65
  * Why a name is refused, or null. `[a-z0-9._-]+` is the charset; a LEADING dot is refused on top
58
66
  * of it, because discovery skips dot-directories — a paper named `.x` would be created and then
59
67
  * never linted, which is worse than a refusal.
68
+ *
69
+ * A name that names a venue — one of `venues`' names as whole words (`aisec-2026`) — is refused
70
+ * too, the paper's own venue included: a paper that is rejected goes to another venue and keeps its
71
+ * folder, so the name goes stale, and nothing at creation time knows the paper will not move. A
72
+ * folder made by hand, and one made with `--allow-venue-name` (a whole word can be the work's own:
73
+ * a paper about realms), is what `paper/folder-venue-leftover` judges once the venue changes. The
74
+ * flag passes no `venues`, so it skips this refusal and no other.
60
75
  */
61
- export function nameProblem(name: string): string | null {
76
+ export function nameProblem(
77
+ name: string,
78
+ venues: readonly NamedVenue[],
79
+ ): string | null {
62
80
  if (!/^[a-z0-9._-]+$/.test(name))
63
81
  return `\`${name}\` — a paper name may hold only a-z, 0-9, dot, underscore and hyphen (it becomes a path and a shell argument)`;
64
82
  if (name.startsWith("."))
65
83
  return `\`${name}\` — a name starting with a dot is skipped by paper discovery, so it would never be linted`;
66
- return null;
84
+ const named = venuesNamedBy(name, venues);
85
+ if (named.length === 0) return null;
86
+ const which = named.map((n) => `"${n.name}"`).join(", ");
87
+ const instead = withoutVenues(name, venues);
88
+ return [
89
+ `\`${name}\` — the paper folder names a venue (${which}); venues change on resubmission, so name it after the work${instead === null ? "" : ` (e.g. "${instead}")`}`,
90
+ ` if the venue really belongs in the name, rerun \`paperlint new\` with ${ALLOW_VENUE_NAME}`,
91
+ ].join("\n");
67
92
  }
68
93
 
69
94
  export interface FileOutcome {
@@ -180,17 +205,23 @@ function fromTemplate(
180
205
  : edited;
181
206
  }
182
207
 
208
+ /** What `newPaper` is told besides the name: the venues a name may not name, and what to write. */
209
+ export interface NewPaperOptions {
210
+ readonly packageTemplates?: string;
211
+ readonly venue?: VenueSetting | null;
212
+ /** Every venue paperlint knows (`shippedVenueNames`): a name naming one is refused. */
213
+ readonly venues: readonly NamedVenue[];
214
+ }
215
+
183
216
  // Documented in README.md#getting-started — update it when this changes.
184
217
  export function newPaper(
185
218
  papersRoot: string,
186
219
  name: string,
187
220
  format: PaperFormat,
188
- {
189
- packageTemplates = PACKAGE_TEMPLATES,
190
- venue = null,
191
- }: { packageTemplates?: string; venue?: VenueSetting | null } = {},
221
+ opts: NewPaperOptions,
192
222
  ): NewPaperResult {
193
- const problem = nameProblem(name);
223
+ const { packageTemplates = PACKAGE_TEMPLATES, venue = null } = opts;
224
+ const problem = nameProblem(name, opts.venues);
194
225
  if (problem) return { ok: false, reason: problem };
195
226
  const dir = join(papersRoot, name);
196
227
  const fresh = !existsSync(dir);
@@ -14,6 +14,7 @@ import { join } from "node:path";
14
14
  import { afterEach, describe, expect, it } from "vitest";
15
15
  import { run, toolchainTex } from "./cli.ts";
16
16
  import { lintReport } from "../test/lint-report.ts";
17
+ import { venuePreset } from "../test/support.ts";
17
18
 
18
19
  const dirs: string[] = [];
19
20
  afterEach(() => {
@@ -203,7 +204,7 @@ describe("papersDir — optional, `papers` by default", () => {
203
204
  });
204
205
 
205
206
  describe("paperlint lint — the venue preset's rules", () => {
206
- it("agenticdev's preset turns pdf/last-page-balance on for its paper alone", async () => {
207
+ it("agenticdev's preset turns pdf/last-page-balance and tex/heading-case on for its paper alone", async () => {
207
208
  const root = project({
208
209
  "papers/a/paperlint.json": JSON.stringify({
209
210
  extends: "paperlint:agenticdev",
@@ -219,7 +220,13 @@ describe("paperlint lint — the venue preset's rules", () => {
219
220
  // Scoped to the paper by `basePath` alone: which of its files the rule reaches is decided
220
221
  // once, by narrowing to paperlint's owned scopes (#101), not by a copy of their globs.
221
222
  basePath: join(root, "papers/a"),
222
- rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
223
+ rules: {
224
+ "pdf/last-page-balance": ["error", { tolerancePt: 120 }],
225
+ "tex/heading-case": [
226
+ "error",
227
+ { title: "chicago-headline", headings: "chicago-headline" },
228
+ ],
229
+ },
223
230
  },
224
231
  ],
225
232
  own: [],
@@ -256,6 +263,10 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
256
263
  // The preset's block comes first and the paper's own after it: the later block wins.
257
264
  expect(blocks.ok && blocks.value.preset[0]?.rules).toEqual({
258
265
  "pdf/last-page-balance": ["error", { tolerancePt: 120 }],
266
+ "tex/heading-case": [
267
+ "error",
268
+ { title: "chicago-headline", headings: "chicago-headline" },
269
+ ],
259
270
  });
260
271
  expect(blocks.ok && blocks.value.own[0]?.rules).toEqual({
261
272
  "pdf/last-page-balance": "off",
@@ -264,10 +275,12 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
264
275
 
265
276
  it("a project's own preset, by relative path: its rules apply, an unknown rule id is refused", async () => {
266
277
  const root = project({
267
- "venues/usenix-sec.jsonc": JSON.stringify({
268
- extends: "paperlint:acm-sigconf",
269
- rules: { "pdf/no-such-rule": "error" },
270
- }),
278
+ "venues/usenix-sec.jsonc": JSON.stringify(
279
+ venuePreset("usenix-sec", {
280
+ extends: "paperlint:acm-sigconf",
281
+ rules: { "pdf/no-such-rule": "error" },
282
+ }),
283
+ ),
271
284
  "papers/a/paperlint.json": JSON.stringify({
272
285
  extends: "../../venues/usenix-sec.jsonc",
273
286
  }),
@@ -294,10 +307,12 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
294
307
  describe("paperlint toolchain — installs what the project's own presets need", () => {
295
308
  it("the shipped union plus a relative preset's tex packages", () => {
296
309
  const root = project({
297
- "venues/usenix-sec.jsonc": JSON.stringify({
298
- extends: "paperlint:acm-sigconf",
299
- tex: { packages: { usenix: ["usenix.sty"] } },
300
- }),
310
+ "venues/usenix-sec.jsonc": JSON.stringify(
311
+ venuePreset("usenix-sec", {
312
+ extends: "paperlint:acm-sigconf",
313
+ tex: { packages: { usenix: ["usenix.sty"] } },
314
+ }),
315
+ ),
301
316
  "papers/a/paperlint.json": JSON.stringify({
302
317
  extends: "../../venues/usenix-sec.jsonc",
303
318
  }),
@@ -32,6 +32,7 @@ describe("parsePaperSettings", () => {
32
32
  rules: null,
33
33
  identity: null,
34
34
  talk: null,
35
+ submission: null,
35
36
  },
36
37
  });
37
38
  expect(parsePaperSettings({})).toEqual({
@@ -43,6 +44,7 @@ describe("parsePaperSettings", () => {
43
44
  rules: null,
44
45
  identity: null,
45
46
  talk: null,
47
+ submission: null,
46
48
  },
47
49
  });
48
50
  });
@@ -57,6 +59,7 @@ describe("parsePaperSettings", () => {
57
59
  rules: null,
58
60
  identity: null,
59
61
  talk: null,
62
+ submission: null,
60
63
  },
61
64
  });
62
65
  });
@@ -155,7 +158,13 @@ describe("readPaperSettings — a root paperlint.json that cannot be used", () =
155
158
  });
156
159
 
157
160
  /** The fields a case does not set. */
158
- const UNSET = { pdf: null, rules: null, identity: null, talk: null };
161
+ const UNSET = {
162
+ pdf: null,
163
+ rules: null,
164
+ identity: null,
165
+ talk: null,
166
+ submission: null,
167
+ };
159
168
 
160
169
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
161
170
  const ROOT = "/work";
@@ -210,6 +219,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
210
219
  rules: { "pdf/fonts": "off" },
211
220
  identity: null,
212
221
  talk: null,
222
+ submission: null,
213
223
  });
214
224
  });
215
225
 
@@ -253,6 +263,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
253
263
  rules,
254
264
  identity: null,
255
265
  talk: null,
266
+ submission: null,
256
267
  });
257
268
 
258
269
  it("no rules: none", () => {
@@ -4,7 +4,7 @@
4
4
  * One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
5
5
  *
6
6
  * paperlint.json the project (optional): papersDir, rules, defaults
7
- * <paper>/paperlint.json this paper: { extends, kind, pdf, rules }
7
+ * <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission }
8
8
  * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
9
9
  *
10
10
  * The paper's `extends`, `kind` and `pdf` win over the root's; with no own value, the root's is
@@ -32,6 +32,10 @@ import {
32
32
  } from "./rules-config.ts";
33
33
  import { messageOf } from "./domain/text.ts";
34
34
  import { parsePaperTalk, type PaperTalk } from "./domain/talk.ts";
35
+ import {
36
+ parsePaperSubmission,
37
+ type PaperSubmission,
38
+ } from "./domain/submission.ts";
35
39
 
36
40
  /** What one paper declares. Every absent field is null. */
37
41
  export interface PaperSettings {
@@ -54,6 +58,8 @@ export interface PaperSettings {
54
58
  readonly identity: readonly string[] | null;
55
59
  /** How this paper is presented (`talk/*`); null when it declares no talk. Never the root's. */
56
60
  readonly talk: PaperTalk | null;
61
+ /** Which submission on the venue's portal is this paper's; null when it says none. Never the root's. */
62
+ readonly submission: PaperSubmission | null;
57
63
  }
58
64
 
59
65
  /** Why a paper's settings cannot be read. */
@@ -112,17 +118,25 @@ export function identityField(
112
118
  );
113
119
  }
114
120
 
115
- /** `rules` (checked for shape only) and `talk`, each null when absent. */
121
+ /** `rules` (checked for shape only), `talk` and `submission`, each null when absent. */
116
122
  function rulesAndTalk(
117
123
  json: Readonly<Record<string, unknown>>,
118
- ): Result<Pick<PaperSettings, "rules" | "talk">, string> {
124
+ ): Result<Pick<PaperSettings, "rules" | "talk" | "submission">, string> {
119
125
  const rules = json["rules"];
120
126
  if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
121
127
  return err(
122
128
  `"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
123
129
  );
124
130
  const talk = parsePaperTalk(json["talk"]);
125
- return talk.ok ? ok({ rules: rules ?? null, talk: talk.value }) : talk;
131
+ if (!talk.ok) return talk;
132
+ const submission = parsePaperSubmission(json["submission"]);
133
+ return submission.ok
134
+ ? ok({
135
+ rules: rules ?? null,
136
+ talk: talk.value,
137
+ submission: submission.value,
138
+ })
139
+ : submission;
126
140
  }
127
141
 
128
142
  /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
@@ -232,7 +246,12 @@ function merge(
232
246
  paper: PaperSettings | null,
233
247
  ): PaperSettings | null {
234
248
  const r = root ?? NO_DEFAULTS;
235
- const p = paper ?? { ...NO_DEFAULTS, rules: null, talk: null };
249
+ const p = paper ?? {
250
+ ...NO_DEFAULTS,
251
+ rules: null,
252
+ talk: null,
253
+ submission: null,
254
+ };
236
255
  const merged: PaperSettings = {
237
256
  extends: p.extends ?? r.extends,
238
257
  kind: p.kind ?? r.kind,
@@ -240,6 +259,7 @@ function merge(
240
259
  rules: p.rules,
241
260
  identity: joined(r.identity, p.identity),
242
261
  talk: p.talk,
262
+ submission: p.submission,
243
263
  };
244
264
  return paper === null && Object.values(merged).every((v) => v === null)
245
265
  ? null
@@ -35,6 +35,25 @@ describe("parseArgs — `--flag=value` is the same as `--flag value`", () => {
35
35
  });
36
36
  });
37
37
 
38
+ describe("parseArgs — the switches, each turning one field on", () => {
39
+ it.each([
40
+ ["--json", "json"],
41
+ ["--fix", "fix"],
42
+ ["--all", "all"],
43
+ ["--dry-run", "dryRun"],
44
+ ["--check", "check"],
45
+ ["--yes", "yes"],
46
+ ["-y", "yes"],
47
+ ["--no-hooks", "noHooks"],
48
+ ["--allow-venue-name", "allowVenueName"],
49
+ ] as const)("%s → %s", (flag, key) => {
50
+ const off = parseArgs(["new", "x"]);
51
+ const on = parseArgs(["new", "x", flag]);
52
+ expect(off[key]).toBe(false);
53
+ expect(on).toEqual({ ...off, [key]: true });
54
+ });
55
+ });
56
+
38
57
  describe("an unknown flag is refused, not read as a path", () => {
39
58
  it("exits 2 and names the flag", async () => {
40
59
  const err: string[] = [];
@@ -12,6 +12,7 @@ import type {
12
12
  Passage,
13
13
  Span,
14
14
  TextRun,
15
+ TitledHeading,
15
16
  } from "../domain/tex-document.ts";
16
17
 
17
18
  export interface LatexReader {
@@ -19,6 +20,12 @@ export interface LatexReader {
19
20
  readonly documentClass: (src: string) => ClassLine;
20
21
  /** Its sections, where the back matter begins, and where the document ends. */
21
22
  readonly outline: (src: string) => Outline;
23
+ /**
24
+ * Its `\title` and the titles of its `\section`, `\subsection`, `\subsubsection`, `\paragraph`
25
+ * and `\subparagraph` (starred or not, and each optional short title), each as the text the case
26
+ * judge reads: in document order.
27
+ */
28
+ readonly headings: (src: string) => readonly TitledHeading[];
22
29
  /** The text a reader sees: prose, the author block, footnotes — not comments, keys, math, code. */
23
30
  readonly renderedRuns: (src: string) => readonly TextRun[];
24
31
  /**
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A conference portal holding a paper's submission — the port `paperlint submission` talks through.
3
+ * Shaped by what the command needs (read one submission, send one change), not by any portal's API;
4
+ * one adapter per portal kind (`src/adapters/hotcrp/`).
5
+ */
6
+ import type { Result } from "../domain/result.ts";
7
+ import type {
8
+ PortalFailure,
9
+ SubmissionChange,
10
+ SubmissionView,
11
+ UpdateOutcome,
12
+ } from "../domain/submission.ts";
13
+
14
+ export interface SubmissionPortal {
15
+ /** The submission as the portal holds it now. */
16
+ show(id: number): Promise<Result<SubmissionView, PortalFailure>>;
17
+ /**
18
+ * Send a change. Without `save` the portal only checks it (a dry run) and keeps nothing; an
19
+ * answer the portal gave — valid or not — is an outcome, not a failure.
20
+ */
21
+ update(
22
+ id: number,
23
+ change: SubmissionChange,
24
+ o: { readonly save: boolean },
25
+ ): Promise<Result<UpdateOutcome, PortalFailure>>;
26
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * `preset/card-rules` through real ESLint, on a copy of the shipped presets and the rule pages: the
3
+ * section a card must hold, the findings for each way a card can be wrong, and what `--fix` writes.
4
+ */
5
+ import {
6
+ cpSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { ESLint } from "eslint";
14
+ import markdown from "@eslint/markdown";
15
+ import { describe, expect, it } from "vitest";
16
+ import { nodeFiles } from "./adapters/node/index.ts";
17
+ import { eslintConfig } from "./cli.ts";
18
+ import { presetsDir } from "./package-dirs.ts";
19
+ import {
20
+ entryParts,
21
+ presetCardRules,
22
+ SECTION_END,
23
+ SECTION_START,
24
+ } from "./preset-card-rule.ts";
25
+ import { useTempDir } from "../test/support.ts";
26
+
27
+ const REPO = join(presetsDir(), "..");
28
+ const root = useTempDir("paperlint-card-rule-");
29
+ cpSync(presetsDir(), join(root, "presets"), { recursive: true });
30
+ cpSync(join(REPO, "docs", "rules"), join(root, "docs", "rules"), {
31
+ recursive: true,
32
+ });
33
+ cpSync(join(REPO, "docs", "rules.md"), join(root, "docs", "rules.md"));
34
+ const P = join(root, "presets");
35
+
36
+ const eslint = (fix = false) =>
37
+ new ESLint({
38
+ cwd: root,
39
+ fix,
40
+ overrideConfigFile: true,
41
+ // Through cli.ts's one adapter from paperlint's blocks to ESLint's type (`eslintConfig`).
42
+ overrideConfig: eslintConfig([
43
+ {
44
+ files: ["presets/*.md"],
45
+ plugins: {
46
+ markdown,
47
+ preset: {
48
+ rules: presetCardRules({
49
+ files: nodeFiles,
50
+ venuesDir: P,
51
+ docsRoot: root,
52
+ }),
53
+ },
54
+ },
55
+ language: "markdown/gfm",
56
+ rules: { "preset/card-rules": "error" },
57
+ },
58
+ ]),
59
+ });
60
+
61
+ /** The findings for one card, as `messageId`-free text: rule id and message. */
62
+ async function findings(card: string): Promise<string[]> {
63
+ const [r] = await eslint().lintFiles([join(P, card)]);
64
+ return (r?.messages ?? []).map((m) => `${String(m.ruleId)}: ${m.message}`);
65
+ }
66
+
67
+ /** The card after `eslint --fix`, written to disk as ESLint would. */
68
+ async function fixed(card: string): Promise<string> {
69
+ const results = await eslint(true).lintFiles([join(P, card)]);
70
+ await ESLint.outputFixes(results);
71
+ return readFileSync(join(P, card), "utf8");
72
+ }
73
+
74
+ /** The generated section of a card's text, markers included. */
75
+ const sectionOf = (text: string): string =>
76
+ text.slice(
77
+ text.indexOf(SECTION_START),
78
+ text.indexOf(SECTION_END) + SECTION_END.length,
79
+ );
80
+
81
+ const STALE =
82
+ "preset/card-rules: the rules section does not match the preset beside this card — run `npx eslint --fix` on this file to rewrite it from the preset";
83
+
84
+ describe("the shipped cards", () => {
85
+ it("every shipped card holds the section its preset resolves to", async () => {
86
+ const results = await eslint().lintFiles([join(P, "*.md")]);
87
+ // Guards: a glob that matched nothing lints nothing and reports clean.
88
+ expect(results.map((r) => r.filePath.slice(P.length + 1)).sort()).toEqual([
89
+ "acm-sigconf.md",
90
+ "agenticdev.md",
91
+ "aidc.md",
92
+ "aisec.md",
93
+ "ieee-conference.md",
94
+ "realm.md",
95
+ "tex-base.md",
96
+ ]);
97
+ expect(results.flatMap((r) => r.messages)).toEqual([]);
98
+ });
99
+
100
+ it("agenticdev: its own rules, linked to their page or to the index, and what it extends", async () => {
101
+ const text = readFileSync(join(P, "agenticdev.md"), "utf8");
102
+ writeFileSync(
103
+ join(P, "agenticdev.md"),
104
+ text.replace(sectionOf(text), `${SECTION_START}\n${SECTION_END}`),
105
+ );
106
+ expect(await findings("agenticdev.md")).toEqual([STALE]);
107
+ // Guards: --fix writes the resolved preset — the section is the generated output, compared whole.
108
+ expect(sectionOf(await fixed("agenticdev.md"))).toBe(
109
+ [
110
+ SECTION_START,
111
+ "<!-- prettier-ignore-start -->",
112
+ "<!-- Generated from agenticdev.jsonc by `npx eslint --fix` (rule preset/card-rules). Edit the preset, not this section. -->",
113
+ "",
114
+ "**`paperlint:agenticdev`** — venue preset for AgenticDev · call for papers: <https://conf.researchr.org/home/ase-2026/agenticdev-2026> · extends `paperlint:acm-sigconf`",
115
+ "",
116
+ "| rule | severity | options | set in |",
117
+ "| --- | --- | --- | --- |",
118
+ '| [`pdf/last-page-balance`](../docs/rules.md) | error | `{"tolerancePt":120}` | this preset |',
119
+ '| [`tex/heading-case`](../docs/rules/tex/heading-case.md) | error | `{"title":"chicago-headline","headings":"chicago-headline"}` | this preset |',
120
+ "",
121
+ "<!-- prettier-ignore-end -->",
122
+ SECTION_END,
123
+ ].join("\n"),
124
+ );
125
+ expect(await findings("agenticdev.md")).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe("the origin column: set here, or inherited from the file of the chain that set it", () => {
130
+ it("a venue over agenticdev: its own rule is its own, agenticdev's are inherited", async () => {
131
+ writeFileSync(
132
+ join(P, "fixture-venue.jsonc"),
133
+ JSON.stringify({
134
+ type: "venue",
135
+ name: "Fixture",
136
+ url: "https://example.org/cfp",
137
+ extends: "paperlint:agenticdev",
138
+ rules: { "pdf/body-size": "off", "tex/register": ["warn", "a|b"] },
139
+ }),
140
+ );
141
+ writeFileSync(join(P, "fixture-venue.md"), "# Fixture\n");
142
+ expect(await findings("fixture-venue.md")).toEqual([
143
+ `preset/card-rules: this card has no generated rules section — run \`npx eslint --fix\` on this file to append one (between ${SECTION_START} and ${SECTION_END})`,
144
+ ]);
145
+ const text = await fixed("fixture-venue.md");
146
+ // Guards: an inherited rule names its parent, an own rule says so; an entry without options
147
+ // shows a dash, and a `|` in the options is escaped so the table keeps its columns.
148
+ expect(text.split("\n").filter((l) => l.startsWith("| ["))).toEqual([
149
+ "| [`pdf/body-size`](../docs/rules/pdf/body-size.md) | off | — | this preset |",
150
+ '| [`pdf/last-page-balance`](../docs/rules.md) | error | `{"tolerancePt":120}` | inherited from `paperlint:agenticdev` |',
151
+ '| [`tex/heading-case`](../docs/rules/tex/heading-case.md) | error | `{"title":"chicago-headline","headings":"chicago-headline"}` | inherited from `paperlint:agenticdev` |',
152
+ '| [`tex/register`](../docs/rules/tex/register.md) | warn | `"a\\|b"` | this preset |',
153
+ ]);
154
+ // Appended after one blank line to a card that ended with a newline.
155
+ expect(text.startsWith(`# Fixture\n\n${SECTION_START}\n`)).toBe(true);
156
+ expect(await findings("fixture-venue.md")).toEqual([]);
157
+ });
158
+
159
+ it("a card without a final newline gets one before the appended section", async () => {
160
+ writeFileSync(join(P, "fixture-venue.md"), "# Fixture");
161
+ expect(
162
+ (await fixed("fixture-venue.md")).startsWith(
163
+ `# Fixture\n\n${SECTION_START}\n`,
164
+ ),
165
+ ).toBe(true);
166
+ });
167
+ });
168
+
169
+ describe("a chain that leaves presets/", () => {
170
+ it("a rule set by a preset outside presets/ names that file by its path from the card", async () => {
171
+ mkdirSync(join(root, "shared"), { recursive: true });
172
+ writeFileSync(
173
+ join(root, "shared", "fixture-base.jsonc"),
174
+ JSON.stringify({
175
+ type: "family",
176
+ extends: "paperlint:acm-sigconf",
177
+ rules: { "pdf/body-size": "warn" },
178
+ }),
179
+ );
180
+ writeFileSync(
181
+ join(P, "fixture-outside.jsonc"),
182
+ JSON.stringify({
183
+ type: "venue",
184
+ name: "Outside",
185
+ url: "https://example.org/cfp",
186
+ extends: "../shared/fixture-base.jsonc",
187
+ }),
188
+ );
189
+ writeFileSync(join(P, "fixture-outside.md"), "# Outside\n");
190
+ // Guards: a file of the chain that is not a shipped preset is not given a `paperlint:` name
191
+ // that would resolve nowhere; it is the path a reader can open from the card.
192
+ expect(
193
+ (await fixed("fixture-outside.md"))
194
+ .split("\n")
195
+ .filter((l) => l.startsWith("| [")),
196
+ ).toEqual([
197
+ "| [`pdf/body-size`](../docs/rules/pdf/body-size.md) | warn | — | inherited from `../shared/fixture-base.jsonc` |",
198
+ ]);
199
+ rmSync(join(P, "fixture-outside.jsonc"));
200
+ rmSync(join(P, "fixture-outside.md"));
201
+ });
202
+ });
203
+
204
+ describe("a family the shipped presets do not extend", () => {
205
+ it("says so, and that its chain sets no rule", async () => {
206
+ writeFileSync(
207
+ join(P, "fixture-family.jsonc"),
208
+ JSON.stringify({ type: "family", extends: "paperlint:acm-sigconf" }),
209
+ );
210
+ writeFileSync(join(P, "fixture-family.md"), "# Family\n");
211
+ expect(
212
+ sectionOf(await fixed("fixture-family.md"))
213
+ .split("\n")
214
+ .slice(4, 7),
215
+ ).toEqual([
216
+ "**`paperlint:fixture-family`** — template family, no venue of its own · extends `paperlint:acm-sigconf` · extended by no shipped preset",
217
+ "",
218
+ "No preset of this chain sets a rule: under it, every rule keeps paperlint's default.",
219
+ ]);
220
+ rmSync(join(P, "fixture-family.jsonc"));
221
+ rmSync(join(P, "fixture-family.md"));
222
+ });
223
+ });
224
+
225
+ describe("a card that cannot be judged, or whose markers are not a pair", () => {
226
+ it("one marker without the other is reported, and not fixed", async () => {
227
+ for (const marker of [SECTION_START, SECTION_END]) {
228
+ writeFileSync(join(P, "fixture-venue.md"), `# Fixture\n\n${marker}\n`);
229
+ expect(await findings("fixture-venue.md")).toEqual([
230
+ `preset/card-rules: the generated rules section needs both markers, ${SECTION_START} and ${SECTION_END}, each on its own line`,
231
+ ]);
232
+ expect(await fixed("fixture-venue.md")).toBe(`# Fixture\n\n${marker}\n`);
233
+ }
234
+ });
235
+
236
+ it("a card with no preset beside it names the file it looked for", async () => {
237
+ writeFileSync(join(P, "orphan.md"), "# Orphan\n");
238
+ expect(await findings("orphan.md")).toEqual([
239
+ `preset/card-rules: a preset card describes the preset beside it, and there is none: ${join(P, "orphan.jsonc")}`,
240
+ ]);
241
+ rmSync(join(P, "orphan.md"));
242
+ });
243
+
244
+ it("a card beside a preset that does not resolve says why", async () => {
245
+ writeFileSync(
246
+ join(P, "broken.jsonc"),
247
+ JSON.stringify({ type: "family", extends: "x" }),
248
+ );
249
+ writeFileSync(join(P, "broken.md"), "# Broken\n");
250
+ expect(await findings("broken.md")).toEqual([
251
+ `preset/card-rules: the preset beside this card does not resolve, so its rules cannot be listed: "extends": "x" — a preset is \`paperlint:<name>\` (shipped) or a path starting with ./ or ../ (your own); npm presets: not yet supported. Did you mean "paperlint:x"?`,
252
+ ]);
253
+ rmSync(join(P, "broken.jsonc"));
254
+ rmSync(join(P, "broken.md"));
255
+ });
256
+ });
257
+
258
+ describe("entryParts", () => {
259
+ it.each<[unknown, { severity: string; options: string }]>([
260
+ ["error", { severity: "error", options: "" }],
261
+ [2, { severity: "2", options: "" }],
262
+ [["warn", { a: 1 }, "b"], { severity: "warn", options: '{"a":1}, "b"' }],
263
+ ])("%j", (entry, want) => {
264
+ expect(entryParts(entry)).toEqual(want);
265
+ });
266
+ });