paperlint 10.4.0 → 11.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 (232) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/hotcrp/index.d.ts +6 -4
  7. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  8. package/dist/adapters/hotcrp/index.js +6 -4
  9. package/dist/adapters/hotcrp/index.js.map +1 -1
  10. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  11. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  12. package/dist/adapters/hotcrp/portal.io.js +36 -11
  13. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  14. package/dist/adapters/hotcrp/response.d.ts +6 -1
  15. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  16. package/dist/adapters/hotcrp/response.js +17 -0
  17. package/dist/adapters/hotcrp/response.js.map +1 -1
  18. package/dist/adapters/hotcrp/site.d.ts +9 -0
  19. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  20. package/dist/adapters/hotcrp/site.js +22 -0
  21. package/dist/adapters/hotcrp/site.js.map +1 -0
  22. package/dist/adapters/hotcrp/token.d.ts +13 -0
  23. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  24. package/dist/adapters/hotcrp/token.js +48 -0
  25. package/dist/adapters/hotcrp/token.js.map +1 -0
  26. package/dist/adapters/latex/abstract.d.ts +22 -0
  27. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  28. package/dist/adapters/latex/abstract.js +187 -0
  29. package/dist/adapters/latex/abstract.js.map +1 -0
  30. package/dist/adapters/latex/captions.d.ts +9 -0
  31. package/dist/adapters/latex/captions.d.ts.map +1 -0
  32. package/dist/adapters/latex/captions.js +40 -0
  33. package/dist/adapters/latex/captions.js.map +1 -0
  34. package/dist/adapters/latex/headings.d.ts +6 -0
  35. package/dist/adapters/latex/headings.d.ts.map +1 -1
  36. package/dist/adapters/latex/headings.js +5 -4
  37. package/dist/adapters/latex/headings.js.map +1 -1
  38. package/dist/adapters/latex/index.d.ts +1 -0
  39. package/dist/adapters/latex/index.d.ts.map +1 -1
  40. package/dist/adapters/latex/index.js +3 -0
  41. package/dist/adapters/latex/index.js.map +1 -1
  42. package/dist/adapters/latex/parse.d.ts +15 -0
  43. package/dist/adapters/latex/parse.d.ts.map +1 -1
  44. package/dist/adapters/latex/parse.js +64 -1
  45. package/dist/adapters/latex/parse.js.map +1 -1
  46. package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
  47. package/dist/adapters/node/list-entries.io.js +3 -1
  48. package/dist/adapters/node/list-entries.io.js.map +1 -1
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +20 -8
  51. package/dist/cli.js.map +1 -1
  52. package/dist/cycle-rules.d.ts.map +1 -1
  53. package/dist/cycle-rules.js +3 -4
  54. package/dist/cycle-rules.js.map +1 -1
  55. package/dist/domain/deadline.d.ts +8 -1
  56. package/dist/domain/deadline.d.ts.map +1 -1
  57. package/dist/domain/deadline.js +26 -12
  58. package/dist/domain/deadline.js.map +1 -1
  59. package/dist/domain/decide-first.d.ts +11 -1
  60. package/dist/domain/decide-first.d.ts.map +1 -1
  61. package/dist/domain/decide-first.js +6 -0
  62. package/dist/domain/decide-first.js.map +1 -1
  63. package/dist/domain/index-columns.d.ts.map +1 -1
  64. package/dist/domain/index-columns.js +2 -0
  65. package/dist/domain/index-columns.js.map +1 -1
  66. package/dist/domain/portal-token.d.ts +44 -0
  67. package/dist/domain/portal-token.d.ts.map +1 -0
  68. package/dist/domain/portal-token.js +32 -0
  69. package/dist/domain/portal-token.js.map +1 -0
  70. package/dist/domain/study-registry.d.ts +65 -8
  71. package/dist/domain/study-registry.d.ts.map +1 -1
  72. package/dist/domain/study-registry.js +63 -19
  73. package/dist/domain/study-registry.js.map +1 -1
  74. package/dist/domain/submission.d.ts +30 -0
  75. package/dist/domain/submission.d.ts.map +1 -1
  76. package/dist/domain/submission.js +68 -0
  77. package/dist/domain/submission.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts +1 -1
  79. package/dist/lib/paper-config.d.ts.map +1 -1
  80. package/dist/lib/paper-config.js +6 -4
  81. package/dist/lib/paper-config.js.map +1 -1
  82. package/dist/outputs-before-decision-rule.d.ts +1 -1
  83. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  84. package/dist/outputs-before-decision-rule.js +21 -17
  85. package/dist/outputs-before-decision-rule.js.map +1 -1
  86. package/dist/paper-settings.d.ts +3 -1
  87. package/dist/paper-settings.d.ts.map +1 -1
  88. package/dist/paper-settings.js +11 -5
  89. package/dist/paper-settings.js.map +1 -1
  90. package/dist/ports/latex.d.ts +23 -0
  91. package/dist/ports/latex.d.ts.map +1 -1
  92. package/dist/ports/list-entries.d.ts +7 -4
  93. package/dist/ports/list-entries.d.ts.map +1 -1
  94. package/dist/ports/study-deps.d.ts +14 -0
  95. package/dist/ports/study-deps.d.ts.map +1 -0
  96. package/dist/ports/study-deps.js +2 -0
  97. package/dist/ports/study-deps.js.map +1 -0
  98. package/dist/ports/study-folder.d.ts +43 -0
  99. package/dist/ports/study-folder.d.ts.map +1 -0
  100. package/dist/ports/study-folder.js +2 -0
  101. package/dist/ports/study-folder.js.map +1 -0
  102. package/dist/ports/submission-portal.d.ts +39 -2
  103. package/dist/ports/submission-portal.d.ts.map +1 -1
  104. package/dist/ports/venue-deadlines.d.ts +1 -1
  105. package/dist/presets.d.ts +1 -2
  106. package/dist/presets.d.ts.map +1 -1
  107. package/dist/presets.js +1 -2
  108. package/dist/presets.js.map +1 -1
  109. package/dist/studies.d.ts.map +1 -1
  110. package/dist/studies.js +10 -7
  111. package/dist/studies.js.map +1 -1
  112. package/dist/study-folder.d.ts +14 -0
  113. package/dist/study-folder.d.ts.map +1 -0
  114. package/dist/study-folder.js +123 -0
  115. package/dist/study-folder.js.map +1 -0
  116. package/dist/study-gate.d.ts +1 -1
  117. package/dist/study-gate.d.ts.map +1 -1
  118. package/dist/study-gate.js +65 -28
  119. package/dist/study-gate.js.map +1 -1
  120. package/dist/study-index-input.d.ts +1 -1
  121. package/dist/study-index-input.d.ts.map +1 -1
  122. package/dist/study-index-input.js +8 -3
  123. package/dist/study-index-input.js.map +1 -1
  124. package/dist/study-modes.d.ts +38 -27
  125. package/dist/study-modes.d.ts.map +1 -1
  126. package/dist/study-modes.js +42 -53
  127. package/dist/study-modes.js.map +1 -1
  128. package/dist/study-registry-rule.d.ts +2 -6
  129. package/dist/study-registry-rule.d.ts.map +1 -1
  130. package/dist/study-registry-rule.js +32 -41
  131. package/dist/study-registry-rule.js.map +1 -1
  132. package/dist/submission.d.ts +49 -11
  133. package/dist/submission.d.ts.map +1 -1
  134. package/dist/submission.js +332 -44
  135. package/dist/submission.js.map +1 -1
  136. package/docs/configuration.md +2 -1
  137. package/docs/design/study-modes.md +46 -28
  138. package/docs/rules/cycle/record.md +9 -6
  139. package/docs/rules/repro/outputs-before-decision.md +3 -0
  140. package/docs/rules/repro/study-registered.md +3 -2
  141. package/docs/rules/tex/heading-case.md +1 -1
  142. package/docs/submission.md +47 -48
  143. package/eslint.config.mjs +39 -24
  144. package/lib/paper-config.ts +7 -4
  145. package/package.json +6 -1
  146. package/presets/msr.jsonc +9 -7
  147. package/presets/msr.md +14 -10
  148. package/scripts/check.ts +1 -1
  149. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  150. package/scripts/refresh-deadlines/compare.ts +101 -0
  151. package/scripts/refresh-deadlines/main.test.ts +140 -0
  152. package/scripts/refresh-deadlines/main.ts +56 -0
  153. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  154. package/scripts/refresh-deadlines/refresh.ts +183 -0
  155. package/skills/grade-paper-writing/SKILL.md +4 -2
  156. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  158. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  159. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  160. package/skills/submission-portal/SKILL.md +60 -44
  161. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  162. package/skills/submission-portal/submission-portal.test.ts +4 -73
  163. package/src/CLAUDE.md +7 -4
  164. package/src/CLAUDE.md.spec.ts +6 -3
  165. package/src/adapters/hotcrp/cassette/README.md +4 -4
  166. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  167. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  168. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  169. package/src/adapters/hotcrp/index.ts +15 -3
  170. package/src/adapters/hotcrp/portal.io.ts +61 -17
  171. package/src/adapters/hotcrp/portal.test.ts +105 -1
  172. package/src/adapters/hotcrp/response.ts +21 -0
  173. package/src/adapters/hotcrp/site.ts +34 -0
  174. package/src/adapters/hotcrp/token.test.ts +69 -0
  175. package/src/adapters/hotcrp/token.ts +51 -0
  176. package/src/adapters/latex/abstract.test.ts +280 -0
  177. package/src/adapters/latex/abstract.ts +272 -0
  178. package/src/adapters/latex/captions.test.ts +57 -0
  179. package/src/adapters/latex/captions.ts +56 -0
  180. package/src/adapters/latex/headings.ts +5 -4
  181. package/src/adapters/latex/index.test.ts +31 -1
  182. package/src/adapters/latex/index.ts +3 -0
  183. package/src/adapters/latex/parse.ts +71 -3
  184. package/src/adapters/node/list-entries.io.ts +2 -1
  185. package/src/adapters/node/list-entries.test.ts +18 -0
  186. package/src/cli.test.ts +20 -8
  187. package/src/cli.ts +22 -13
  188. package/src/cycle-rules.test.ts +6 -6
  189. package/src/cycle-rules.ts +7 -4
  190. package/src/domain/deadline.test.ts +31 -2
  191. package/src/domain/deadline.ts +42 -16
  192. package/src/domain/decide-first.test.ts +33 -0
  193. package/src/domain/decide-first.ts +16 -1
  194. package/src/domain/index-columns.test.ts +49 -4
  195. package/src/domain/index-columns.ts +2 -0
  196. package/src/domain/portal-token.test.ts +55 -0
  197. package/src/domain/portal-token.ts +63 -0
  198. package/src/domain/study-measurements.test.ts +7 -3
  199. package/src/domain/study-registry.test.ts +34 -6
  200. package/src/domain/study-registry.ts +123 -31
  201. package/src/domain/submission.test.ts +88 -0
  202. package/src/domain/submission.ts +99 -0
  203. package/src/new-paper.test.ts +1 -0
  204. package/src/outputs-before-decision-rule.test.ts +47 -2
  205. package/src/outputs-before-decision-rule.ts +31 -22
  206. package/src/paper-settings.test.ts +20 -0
  207. package/src/paper-settings.ts +17 -5
  208. package/src/ports/latex.ts +25 -0
  209. package/src/ports/list-entries.ts +7 -4
  210. package/src/ports/study-deps.ts +14 -0
  211. package/src/ports/study-folder.ts +38 -0
  212. package/src/ports/submission-portal.ts +43 -1
  213. package/src/ports/venue-deadlines.ts +1 -1
  214. package/src/presets.test.ts +8 -5
  215. package/src/presets.ts +1 -2
  216. package/src/studies-index-modes.test.ts +2 -2
  217. package/src/studies.ts +19 -10
  218. package/src/study-folder.test.ts +237 -0
  219. package/src/study-folder.ts +176 -0
  220. package/src/study-gate.test.ts +158 -7
  221. package/src/study-gate.ts +127 -54
  222. package/src/study-index-input.ts +12 -4
  223. package/src/study-modes.ts +67 -80
  224. package/src/study-registry-rule.test.ts +87 -0
  225. package/src/study-registry-rule.ts +58 -66
  226. package/src/submission-new.test.ts +569 -0
  227. package/src/submission-show.test.ts +210 -0
  228. package/src/submission-update.test.ts +160 -0
  229. package/src/submission.test.ts +140 -518
  230. package/src/submission.ts +494 -61
  231. package/scripts/refresh-deadlines.test.ts +0 -431
  232. package/scripts/refresh-deadlines.ts +0 -387
@@ -10,7 +10,6 @@ import { expect, it } from "vitest";
10
10
  import { gitHistory } from "./adapters/git/index.ts";
11
11
  import {
12
12
  nodeFiles,
13
- nodeListDir,
14
13
  nodeListEntries,
15
14
  spawnProcess,
16
15
  } from "./adapters/node/index.ts";
@@ -42,7 +41,6 @@ const T = (h: number): string =>
42
41
  const lint = (repo: string, file = "p/PIPELINE-STATUS.md"): string[] => {
43
42
  const rule = outputsBeforeDecisionRules({
44
43
  files: nodeFiles,
45
- listDir: nodeListDir,
46
44
  entries: nodeListEntries,
47
45
  codec: studyRegistryCodec,
48
46
  git: gitHistory(spawnProcess(), { PATH: String(process.env.PATH) }),
@@ -204,3 +202,50 @@ it("never reads through a study folder that is a symbolic link", () => {
204
202
  // Guards: the outputs behind the link are not this paper's study; nothing is read through it.
205
203
  expect(lint(repo)).toEqual([]);
206
204
  });
205
+
206
+ it("a plan that is a symbolic link declares no mode: the order of its study is not judged", () => {
207
+ const repo = join(root, "plan-link");
208
+ initRepo(repo);
209
+ mkdirSync(join(repo, "p/elsewhere"), { recursive: true });
210
+ writeFileSync(join(repo, "p/elsewhere/plan.json"), planText(planOf(DECIDED)));
211
+ mkdirSync(join(repo, S), { recursive: true });
212
+ symlinkSync("../../elsewhere/plan.json", join(repo, S, "plan.json"));
213
+ commit(
214
+ repo,
215
+ {
216
+ ...Object.fromEntries(
217
+ Object.entries(PAPER).filter(([k]) => k !== `${S}/plan.json`),
218
+ ),
219
+ [`${S}/RESULTS.md`]: "41 of 60\n",
220
+ },
221
+ T(9),
222
+ );
223
+ commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
224
+ // Guards: read through the link, the plan is a decide-first one and its output precedes its
225
+ // decision (`output-before-decision`); a link is read through by nothing, so the study declares no mode.
226
+ expect(lint(repo)).toEqual([]);
227
+ });
228
+
229
+ it("loud on a decision under a linked directory: git records the link, not the files behind it", () => {
230
+ const repo = join(root, "decision-dir-link");
231
+ initRepo(repo);
232
+ mkdirSync(join(repo, "p/elsewhere"), { recursive: true });
233
+ writeFileSync(join(repo, "p/elsewhere/decision.md"), "exclude run 7\n");
234
+ const file = "repro/P1-S01-outcome/sub/decision.md";
235
+ commit(
236
+ repo,
237
+ {
238
+ ...PAPER,
239
+ [`${S}/plan.json`]: planText(
240
+ planOf({ ...DECIDED, decision: { file, note: "n" } }),
241
+ ),
242
+ },
243
+ T(9),
244
+ );
245
+ symlinkSync("../../elsewhere", join(repo, S, "sub"));
246
+ commit(repo, {}, T(10));
247
+ commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
248
+ // Guards: the decision file exists, and is committed as a path through a link; the order proof
249
+ // would be read from the link's history. The study is reported instead.
250
+ expect(lint(repo)).toEqual(["decision-linked"]);
251
+ });
@@ -14,13 +14,9 @@
14
14
  * Reported on `PIPELINE-STATUS.md`, first line, like the other `repro/` rules.
15
15
  */
16
16
  import { basename, dirname, join } from "node:path";
17
+ import { filter, map, pipe } from "remeda";
17
18
  import { callerPath } from "./caller-path.ts";
18
- import {
19
- filesUnder,
20
- modeStudies,
21
- onDisk,
22
- type StudyDeps,
23
- } from "./study-modes.ts";
19
+ import { decisionEntry, modeStudies, type ModeStudy } from "./study-modes.ts";
24
20
  import {
25
21
  judgeOrder,
26
22
  orderMessage,
@@ -28,11 +24,15 @@ import {
28
24
  type OrderFinding,
29
25
  } from "./domain/decide-first.ts";
30
26
  import { REPRO_DIR } from "./domain/repro-layout.ts";
27
+ import type { DecideFirst } from "./domain/study-mode.ts";
31
28
  import type {
32
29
  FolderRuleContext,
33
30
  FolderRuleModule,
34
31
  } from "./folder-venue-rule.ts";
35
32
  import type { GitOrder } from "./ports/git-history.ts";
33
+ import type { StudyDeps } from "./ports/study-deps.ts";
34
+ import { studyFolder } from "./study-folder.ts";
35
+ import { registryOf } from "./study-registry-rule.ts";
36
36
  import { rulePageUrl } from "./venue-rules.ts";
37
37
 
38
38
  const STATUS_FILE = "PIPELINE-STATUS.md";
@@ -46,32 +46,41 @@ export interface OrderDeps extends StudyDeps {
46
46
  export const decideFirstStudies = (
47
47
  paper: string,
48
48
  deps: OrderDeps,
49
- ): readonly DecideFirstStudy[] =>
50
- modeStudies(paper, deps).flatMap(({ row, plan, mode, empty }) => {
51
- // Only a ready plan decides: a draft may name no outputs, or an empty decision file.
52
- if (mode.kind !== "decide_before_run" || empty.length > 0) return [];
53
- const folder = join(paper, REPRO_DIR, row.folder);
54
- const decision = join(paper, mode.decision.file);
55
- const on = onDisk(decision, deps);
56
- return [
57
- {
49
+ ): readonly DecideFirstStudy[] => {
50
+ const reader = studyFolder(paper, deps);
51
+ return pipe(
52
+ modeStudies(registryOf(paper, deps), reader, deps.codec),
53
+ filter(isDecidedAndReady),
54
+ map(({ row, plan, mode }): DecideFirstStudy => {
55
+ const folder = join(paper, REPRO_DIR, row.folder);
56
+ const decision = join(paper, mode.decision.file);
57
+ const on = decisionEntry(reader, mode.decision.file);
58
+ return {
58
59
  id: row.id,
59
60
  folder: row.folder,
60
61
  plan,
61
62
  mode,
62
- tree: filesUnder(folder, deps),
63
+ tree: reader.list(row.folder),
63
64
  facts:
64
- on === "directory" || on === "symbolic link"
65
- ? { kind: "not-a-file", what: on }
66
- : deps.git.order(callerPath(folder), callerPath(decision)),
67
- },
68
- ];
69
- });
65
+ on.kind === "ok"
66
+ ? deps.git.order(callerPath(folder), callerPath(decision))
67
+ : on,
68
+ };
69
+ }),
70
+ );
71
+ };
72
+
73
+ /** Only a ready plan decides: a draft may name no outputs, or an empty decision file. */
74
+ const isDecidedAndReady = (
75
+ s: ModeStudy,
76
+ ): s is ModeStudy & { readonly mode: DecideFirst } =>
77
+ s.mode.kind === "decide_before_run" && s.empty.length === 0;
70
78
 
71
79
  const PROBLEMS: readonly OrderFinding["problem"][] = [
72
80
  "decision-is-output",
73
81
  "decision-deleted",
74
82
  "decision-not-file",
83
+ "decision-linked",
75
84
  "no-history",
76
85
  "shallow",
77
86
  "undecided-output",
@@ -33,6 +33,7 @@ describe("parsePaperSettings", () => {
33
33
  identity: null,
34
34
  talk: null,
35
35
  submission: null,
36
+ authors: null,
36
37
  cycles: null,
37
38
  },
38
39
  });
@@ -46,6 +47,7 @@ describe("parsePaperSettings", () => {
46
47
  identity: null,
47
48
  talk: null,
48
49
  submission: null,
50
+ authors: null,
49
51
  cycles: null,
50
52
  },
51
53
  });
@@ -62,6 +64,7 @@ describe("parsePaperSettings", () => {
62
64
  identity: null,
63
65
  talk: null,
64
66
  submission: null,
67
+ authors: null,
65
68
  cycles: null,
66
69
  },
67
70
  });
@@ -69,6 +72,15 @@ describe("parsePaperSettings", () => {
69
72
  });
70
73
 
71
74
  describe("parsePaperSettings — optional keys", () => {
75
+ it("reads authors, in order, an absent affiliation as null", () => {
76
+ const r = parsePaperSettings({
77
+ authors: [{ name: "Ada", email: "ada@example.org" }],
78
+ });
79
+ expect(r.ok && r.value.authors).toEqual([
80
+ { name: "Ada", email: "ada@example.org", affiliation: null },
81
+ ]);
82
+ });
83
+
72
84
  it("accepts $comment, JSON Schema's comment keyword, and ignores it", () => {
73
85
  expect(
74
86
  parsePaperSettings({ extends: "paperlint:aisec", $comment: "why" }).ok,
@@ -107,6 +119,11 @@ describe("parsePaperSettings — optional keys", () => {
107
119
  /"papersDir" is a project setting — set it in the root paperlint\.json/,
108
120
  ],
109
121
  ["not an object at all", ["aisec"], /must be a JSON object/],
122
+ [
123
+ "authors that are not a list of authors",
124
+ { authors: [{ name: "A" }] },
125
+ /"authors"\[0\]: "email" must be an email address/,
126
+ ],
110
127
  ])("refuses %s", (_, json, why) => {
111
128
  const r = parsePaperSettings(json);
112
129
  expect(r.ok).toBe(false);
@@ -167,6 +184,7 @@ const UNSET = {
167
184
  identity: null,
168
185
  talk: null,
169
186
  submission: null,
187
+ authors: null,
170
188
  cycles: null,
171
189
  };
172
190
 
@@ -224,6 +242,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
224
242
  identity: null,
225
243
  talk: null,
226
244
  submission: null,
245
+ authors: null,
227
246
  cycles: null,
228
247
  });
229
248
  });
@@ -274,6 +293,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
274
293
  identity: null,
275
294
  talk: null,
276
295
  submission: null,
296
+ authors: null,
277
297
  cycles: null,
278
298
  });
279
299
 
@@ -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, identity, talk, submission } —
7
+ * <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission, authors } —
8
8
  * or `cycles`, from whose current attempt extends, kind and submission
9
9
  * are DERIVED (src/domain/cycle.ts); the two forms do not mix
10
10
  * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
@@ -35,7 +35,9 @@ import {
35
35
  import { messageOf } from "./domain/text.ts";
36
36
  import { parsePaperTalk, type PaperTalk } from "./domain/talk.ts";
37
37
  import {
38
+ parsePaperAuthors,
38
39
  parsePaperSubmission,
40
+ type Author,
39
41
  type PaperSubmission,
40
42
  } from "./domain/submission.ts";
41
43
  import {
@@ -69,6 +71,8 @@ export interface PaperSettings {
69
71
  readonly talk: PaperTalk | null;
70
72
  /** Which submission on the venue's portal is this paper's; null when it says none. Never the root's. */
71
73
  readonly submission: PaperSubmission | null;
74
+ /** The authors as the venue's portal should record them, in order; null when the paper declares none. Never the root's. */
75
+ readonly authors: readonly Author[] | null;
72
76
  /**
73
77
  * The paper's attempts at venues and the current one, when the file declares `cycles`; null for a
74
78
  * file in the flat form. With cycles, `extends`, `kind` and `submission` above are the current
@@ -133,10 +137,13 @@ export function identityField(
133
137
  );
134
138
  }
135
139
 
136
- /** `rules` (checked for shape only), `talk` and `submission`, each null when absent. */
140
+ /** `rules` (checked for shape only), `talk`, `submission` and `authors`, each null when absent. */
137
141
  function rulesAndTalk(
138
142
  json: Readonly<Record<string, unknown>>,
139
- ): Result<Pick<PaperSettings, "rules" | "talk" | "submission">, string> {
143
+ ): Result<
144
+ Pick<PaperSettings, "rules" | "talk" | "submission" | "authors">,
145
+ string
146
+ > {
140
147
  const rules = json["rules"];
141
148
  if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
142
149
  return err(
@@ -145,13 +152,16 @@ function rulesAndTalk(
145
152
  const talk = parsePaperTalk(json["talk"]);
146
153
  if (!talk.ok) return talk;
147
154
  const submission = parsePaperSubmission(json["submission"]);
148
- return submission.ok
155
+ if (!submission.ok) return submission;
156
+ const authors = parsePaperAuthors(json["authors"]);
157
+ return authors.ok
149
158
  ? ok({
150
159
  rules: rules ?? null,
151
160
  talk: talk.value,
152
161
  submission: submission.value,
162
+ authors: authors.value,
153
163
  })
154
- : submission;
164
+ : authors;
155
165
  }
156
166
 
157
167
  /** The keys the current cycle supplies: writing one beside `cycles` is a second source for it. */
@@ -323,6 +333,7 @@ function merge(
323
333
  rules: null,
324
334
  talk: null,
325
335
  submission: null,
336
+ authors: null,
326
337
  cycles: null,
327
338
  };
328
339
  const merged: PaperSettings = {
@@ -333,6 +344,7 @@ function merge(
333
344
  identity: joined(r.identity, p.identity),
334
345
  talk: p.talk,
335
346
  submission: p.submission,
347
+ authors: p.authors,
336
348
  cycles: p.cycles,
337
349
  };
338
350
  return paper === null && Object.values(merged).every((v) => v === null)
@@ -3,6 +3,7 @@
3
3
  * parser. The one implementation is `src/adapters/latex/` (unified-latex); `src/cli.ts` wires it.
4
4
  */
5
5
  import type { Include } from "../domain/paper-source.ts";
6
+ import type { Result } from "../domain/result.ts";
6
7
  import type {
7
8
  ClassLine,
8
9
  DocumentClass,
@@ -25,6 +26,24 @@ export interface Filecontents {
25
26
  readonly body: Span;
26
27
  }
27
28
 
29
+ /** Why a source's abstract cannot be read as plain text. */
30
+ export type AbstractProblem =
31
+ | { readonly kind: "none" }
32
+ | {
33
+ readonly kind: "unreadable";
34
+ /** What it is, in words: `\toolname, which no \newcommand … defines`. */
35
+ readonly what: string;
36
+ /** Its offset in the source. */
37
+ readonly at: number;
38
+ }
39
+ | {
40
+ /** A macro the abstract uses that the source defines more than once: which one TeX uses is not guessed. */
41
+ readonly kind: "redefined";
42
+ readonly name: string;
43
+ /** Each definition's offset in the source, in order. */
44
+ readonly at: readonly number[];
45
+ };
46
+
28
47
  export interface LatexReader {
29
48
  /** The source's `\documentclass`, as one of its states. */
30
49
  readonly documentClass: (src: string) => ClassLine;
@@ -74,4 +93,10 @@ export interface LatexReader {
74
93
  src: string,
75
94
  jobname: string,
76
95
  ) => readonly Filecontents[];
96
+ /**
97
+ * The body of `src`'s `abstract` environment as one paragraph of plain text — the author's
98
+ * argument-less macros expanded, TeX's dashes and quotes, citations and comments dropped — or
99
+ * what stops it, with its offset in `src`.
100
+ */
101
+ readonly abstractText: (src: string) => Result<string, AbstractProblem>;
77
102
  }
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * A directory's entries with what each one is, read without following a symbolic link — so a walk
3
3
  * that descends only into `dir` entries can neither loop on a link to an ancestor nor wander into a
4
- * tree outside through one. The walker of a study's folder (`src/study-modes.ts`) reads with it.
4
+ * tree outside through one. The reader of a study's folder
5
+ * (`src/study-folder.ts`), outputs listing included (`StudyFolder.list`), reads with it.
5
6
  */
6
7
  import type { AbsolutePath } from "../domain/paths.ts";
7
8
 
8
9
  /**
9
- * One entry of a directory: a directory, a symbolic link (not followed), or a file — anything else on
10
- * disk (a socket, a FIFO) reads as a file: a name, never descended into.
10
+ * One entry of a directory: a regular file, a directory, a symbolic link (not followed), or `other` —
11
+ * anything that is none of those (a FIFO, a socket, a device). An `other` entry is a name and nothing
12
+ * more: it is never read (opening a FIFO blocks) and never descended into, and an outputs listing
13
+ * still names it.
11
14
  */
12
15
  export interface DirEntry {
13
16
  readonly name: string;
14
- readonly kind: "file" | "dir" | "symlink";
17
+ readonly kind: "file" | "dir" | "symlink" | "other";
15
18
  }
16
19
 
17
20
  /** The entries of `dir`; none when it does not exist or is not a directory. */
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What the readers of a paper's studies are handed: the disk, the entries of a directory with what
3
+ * each is (no symbolic link followed), and the parsers of the registry and its plans. The study folder's
4
+ * reader (`src/study-folder.ts`), the gate and the order rule are built from it.
5
+ */
6
+ import type { Files } from "./files.ts";
7
+ import type { ListEntries } from "./list-entries.ts";
8
+ import type { StudyRegistryCodec } from "./study-registry-codec.ts";
9
+
10
+ export interface StudyDeps {
11
+ readonly files: Files;
12
+ readonly entries: ListEntries;
13
+ readonly codec: StudyRegistryCodec;
14
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The reads a paper's study folders allow, shaped by what the study readers need: a row's folder is
3
+ * entered or refused, a path inside it is located or read, the files under it are listed, and the
4
+ * decision file's path is walked — none of it by following a symbolic link. The reader of every plan,
5
+ * result file, output listing and decision (`src/study-folder.ts`) is this port's one implementation.
6
+ */
7
+ import type { Located } from "../domain/study-registry.ts";
8
+
9
+ /** What reading a path inside a study's folder gave. */
10
+ export type FolderRead =
11
+ | { readonly kind: "text"; readonly text: string }
12
+ | { readonly kind: "absent" }
13
+ | { readonly kind: "linked"; readonly link: string };
14
+
15
+ /** What a path in the paper is: `Located`, a directory, or something that is no regular file (a FIFO, a socket). */
16
+ export type PathEntry =
17
+ Located | { readonly kind: "dir" } | { readonly kind: "other" };
18
+
19
+ /** Why a row's folder may not be entered: it is no one folder inside `repro/`, or it is a link. */
20
+ export type FolderRefusal = "not-a-folder" | "symbolic-link";
21
+
22
+ /** The reads a paper's study folders allow. */
23
+ export interface StudyFolder {
24
+ /** Why `folder` may not be entered; null when it may. */
25
+ readonly refusal: (folder: string) => FolderRefusal | null;
26
+ /** What `path` in `folder` is; nothing is read. */
27
+ readonly locate: (folder: string, path: string) => Located;
28
+ /** The text of the file at `path` in `folder`. */
29
+ readonly read: (folder: string, path: string) => FolderRead;
30
+ /**
31
+ * Every file under `folder`, relative to it and `/`-separated: a symbolic link is listed as a file
32
+ * (git stores it as one) and never descended into; a FIFO or a socket is listed by name too (it is
33
+ * never read). None for a folder that may not be entered.
34
+ */
35
+ readonly list: (folder: string) => readonly string[];
36
+ /** What `path`, relative to the paper, is: a decision file is held to be a regular file. */
37
+ readonly entryAt: (path: string) => PathEntry;
38
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
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;
3
+ * Shaped by what the command needs (read one submission, send one change, create one), not by any
4
+ * portal's API;
4
5
  * one adapter per portal kind (`src/adapters/hotcrp/`).
5
6
  */
6
7
  import type { Result } from "../domain/result.ts";
7
8
  import type {
9
+ CreateOutcome,
10
+ NewSubmission,
8
11
  PortalFailure,
9
12
  SubmissionChange,
10
13
  SubmissionView,
@@ -23,4 +26,43 @@ export interface SubmissionPortal {
23
26
  change: SubmissionChange,
24
27
  o: { readonly save: boolean },
25
28
  ): Promise<Result<UpdateOutcome, PortalFailure>>;
29
+ /**
30
+ * Create a submission. Without `save` the portal only checks it (a dry run), keeps nothing and
31
+ * chooses no id; with it, the outcome carries the id the portal chose.
32
+ */
33
+ create(
34
+ paper: NewSubmission,
35
+ o: { readonly save: boolean },
36
+ ): Promise<Result<CreateOutcome, PortalFailure>>;
37
+ }
38
+
39
+ /** How an author makes a token for a portal site, in the words the command prints. */
40
+ export interface CredentialHelp {
41
+ /** The site's page where tokens are made. */
42
+ readonly page: string;
43
+ /** Where that page is in the site's menus. */
44
+ readonly where: string;
45
+ /** The permissions to give the token, per subcommand. */
46
+ readonly scopes: string;
47
+ }
48
+
49
+ /**
50
+ * A portal site before any token — what the command needs to find one, to say how to make one, and
51
+ * to say what is left to do on the site — and the portal itself once there is a token. One adapter
52
+ * per portal kind supplies it; the app prints what it returns.
53
+ */
54
+ export interface PortalSite {
55
+ /** The environment variable this site's token is read from. */
56
+ readonly tokenVariable: string;
57
+ /** The site-less variable no longer read: named when still set, never used. */
58
+ readonly siteLessVariable: string;
59
+ /** The shape of a site's variable, for a message about another site's. */
60
+ readonly variablePattern: string;
61
+ readonly credentials: CredentialHelp;
62
+ /** What `submission new` does not send: done on the site by the author. */
63
+ readonly siteOnly: readonly string[];
64
+ /** A submission's page on the site; null: the one just created, before it has a number. */
65
+ readonly submissionPage: (id: number | null) => string;
66
+ /** The portal, read and changed with `token`. */
67
+ readonly connect: (token: string) => SubmissionPortal;
26
68
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A venue's deadlines as its portal publishes them — the port the scheduled refresh of the shipped
3
- * presets reads through (`scripts/refresh-deadlines.ts`). Shaped by what the refresh needs (the
3
+ * presets reads through (`scripts/refresh-deadlines/`). Shaped by what the refresh needs (the
4
4
  * portal's deadlines, once), not by any portal's page; one adapter per portal kind
5
5
  * (`src/adapters/hotcrp/`).
6
6
  */
@@ -103,23 +103,26 @@ describe("shipped presets: paperlint:msr", () => {
103
103
  kind: "hotcrp",
104
104
  url: "https://msr2027.hotcrp.com",
105
105
  });
106
- // The portal's deadlines, read 2026-10-05 off cassette/deadlines-msr2027.html's page.
106
+ // The portal's deadlines, read 2026-10-19 18:05 UTC off https://msr2027.hotcrp.com/deadlines:
107
+ // «Wednesday Oct 21, 2026, 7:59:59 AM EDT» (`"sub":1792583999`) and «Saturday Oct 24, 2026,
108
+ // 7:59:59 AM EDT» (#235).
107
109
  expect(r.value.deadlines).toEqual([
108
110
  {
109
111
  what: "submission",
110
- at: "2026-10-20T04:00:00Z",
112
+ at: "2026-10-21T11:59:59Z",
111
113
  source: "portal",
112
114
  url: "https://msr2027.hotcrp.com/deadlines",
113
- read: "2026-10-05",
115
+ read: "2026-10-19",
114
116
  },
115
117
  {
116
118
  what: "resubmission",
117
- at: "2026-10-23T04:00:00Z",
119
+ at: "2026-10-24T11:59:59Z",
118
120
  source: "portal",
119
121
  url: "https://msr2027.hotcrp.com/deadlines",
120
- read: "2026-10-05",
122
+ read: "2026-10-19",
121
123
  },
122
124
  ]);
125
+ expect(Date.parse("2026-10-21T11:59:59Z") / 1000).toBe(1792583999);
123
126
  // No rule turned on: the IEEE capitalization rule has not been read and quoted.
124
127
  expect(r.value.rules).toEqual({});
125
128
  expect(r.value.registerAnchors).toEqual([]);
package/src/presets.ts CHANGED
@@ -406,8 +406,7 @@ export type ShippedVenue = Pick<
406
406
  >;
407
407
 
408
408
  /**
409
- * A resolved preset as a name knows it: its label, its aliases, its chain. Pure. (Not Remeda's
410
- * `pick`: the app layer may not import it — `boundaries/dependencies`, `APP_EXTERNALS`.)
409
+ * A resolved preset as a name knows it: its label, its aliases, its chain. Pure.
411
410
  */
412
411
  export const venueOf = ({
413
412
  label,
@@ -98,7 +98,7 @@ it("refuses a registry whose row names a folder outside repro/, for index and fo
98
98
  { ...ROW, folder: "../x" },
99
99
  );
100
100
  const line =
101
- "P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
101
+ "P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside papers/example/repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
102
102
  const index = studies("index", escaping, { title: undefined });
103
103
  const added = studies("new", escaping, { title: "Second" });
104
104
  // Guards: nothing is written — no README, no new folder — when a row could lead outside the paper.
@@ -122,7 +122,7 @@ it("refuses a registry whose study folder is a symbolic link, for index", () =>
122
122
  expect([r.code, r.err, README in r.after]).toEqual([
123
123
  1,
124
124
  [
125
- "P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.",
125
+ "P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is a symbolic link: nothing is read or written through it. Give the row a real folder inside papers/example/repro/, then run this again.",
126
126
  ],
127
127
  false,
128
128
  ]);
package/src/studies.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * registry is refused with the file to write rather than given a guessed one.
17
17
  */
18
18
  import { join, relative, resolve } from "node:path";
19
+ import { find, map, pipe } from "remeda";
19
20
  import { callerPath } from "./caller-path.ts";
20
21
  import type { AbsolutePath } from "./domain/paths.ts";
21
22
  import { README, REPRO_DIR } from "./domain/repro-layout.ts";
@@ -37,10 +38,12 @@ import {
37
38
  type StudyRegistry,
38
39
  } from "./domain/study-registry.ts";
39
40
  import type { Files } from "./ports/files.ts";
41
+ import type { FolderRefusal } from "./ports/study-folder.ts";
40
42
  import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
41
43
  import type { ListEntries } from "./ports/list-entries.ts";
42
44
  import type { ListDir } from "./ports/talk-media.ts";
43
- import { readableFolders, registryOf } from "./study-registry-rule.ts";
45
+ import { registryOf } from "./study-registry-rule.ts";
46
+ import { folderRefusalText, studyFolder } from "./study-folder.ts";
44
47
  import { runGate } from "./study-gate.ts";
45
48
  import { indexInputOf } from "./study-index-input.ts";
46
49
  import type { GitHistory, GitOrder } from "./ports/git-history.ts";
@@ -106,20 +109,26 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
106
109
  }
107
110
  }
108
111
 
109
- /** The refusal when a row names a folder that is not one inside `repro/`; null when none does. */
112
+ /** The refusal when a row names a folder that may not be entered; null when none does. */
110
113
  function escaping(
111
114
  p: Paper,
112
115
  r: StudyRegistry,
113
116
  deps: StudiesDeps,
114
117
  ): Refusal | null {
115
- const readable = readableFolders(p.dir, deps);
116
- const s = r.studies.find((row) => !readable(row.folder));
117
- return s === undefined
118
- ? null
119
- : refuse(
120
- 1,
121
- `${s.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${s.folder}\`, which is not one folder inside ${REPRO_DIR}/: nothing is read or written through it. Give the row its own folder's name, then run this again.`,
122
- );
118
+ const folder = studyFolder(p.dir, deps);
119
+ const found = pipe(
120
+ r.studies,
121
+ map((row) => ({ row, why: folder.refusal(row.folder) })),
122
+ find(
123
+ (x): x is typeof x & { readonly why: FolderRefusal } => x.why !== null,
124
+ ),
125
+ );
126
+ if (found === undefined) return null;
127
+ const { reason, fix } = folderRefusalText(found.why, p.shown);
128
+ return refuse(
129
+ 1,
130
+ `${found.row.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${found.row.folder}\`, which ${reason}: nothing is read or written through it. ${fix}, then run this again.`,
131
+ );
123
132
  }
124
133
 
125
134
  const isRefusal = (v: unknown): v is Refusal =>