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
@@ -0,0 +1,63 @@
1
+ /**
2
+ * WHICH TOKEN A PORTAL COMMAND USES — the policy, in one place. Pure.
3
+ *
4
+ * The site's own variable, and nothing else: there is NO fallback to a site-less variable. With one,
5
+ * which token a command used is not visible, and a token from another site reads as "the portal
6
+ * refused". A site-less variable that is set while the site's own is not is refused by name. The
7
+ * names themselves are the portal's (`src/adapters/hotcrp/token.ts`).
8
+ */
9
+ import { err, ok, type Result } from "./result.ts";
10
+
11
+ /**
12
+ * The site's host as written in its URL, with its port when it is not the scheme's default
13
+ * (`review.example.org:8443`): two sites on one host differ by port. The URL itself when it does
14
+ * not parse.
15
+ */
16
+ export const hostOf = (url: string): string =>
17
+ URL.canParse(url) ? new URL(url).host : url;
18
+
19
+ /** The variables a lookup reads: the site's own, and the site-less one it refuses. */
20
+ export interface TokenNames {
21
+ readonly variable: string;
22
+ readonly siteLess: string;
23
+ }
24
+
25
+ /** A token found, and the variable it came from. */
26
+ export interface FoundToken {
27
+ readonly variable: string;
28
+ readonly token: string;
29
+ }
30
+
31
+ /** Why there is no token to use. */
32
+ export type TokenProblem =
33
+ /** The site's variable is not set (or blank), and neither is the site-less one. */
34
+ | { readonly kind: "missing"; readonly variable: string }
35
+ /** The site's variable is not set, but the site-less one, no longer read, is. */
36
+ | {
37
+ readonly kind: "site-less";
38
+ readonly variable: string;
39
+ readonly siteLess: string;
40
+ };
41
+
42
+ const value = (
43
+ read: (name: string) => string | undefined,
44
+ name: string,
45
+ ): string | null => {
46
+ const v = read(name)?.trim();
47
+ return v === undefined || v === "" ? null : v;
48
+ };
49
+
50
+ /**
51
+ * The token for a portal, read through `read` (the root reads the environment): the site's variable
52
+ * only. A set site-less variable never stands in for it — it is named as the problem instead.
53
+ */
54
+ export function lookUpToken(
55
+ { variable, siteLess }: TokenNames,
56
+ read: (name: string) => string | undefined,
57
+ ): Result<FoundToken, TokenProblem> {
58
+ const token = value(read, variable);
59
+ if (token !== null) return ok({ variable, token });
60
+ return value(read, siteLess) === null
61
+ ? err({ kind: "missing", variable })
62
+ : err({ kind: "site-less", variable, siteLess });
63
+ }
@@ -22,6 +22,7 @@ import {
22
22
  type StudyPlan,
23
23
  type StudyRegistry,
24
24
  type IndexInput,
25
+ folderKey,
25
26
  } from "./study-registry.ts";
26
27
 
27
28
  /** No plan and no history: the index with every mode column `—`. */
@@ -110,10 +111,10 @@ const input = (
110
111
  folders: r.studies.map((s) => s.folder),
111
112
  registry: { kind: "read", registry: r },
112
113
  readme: withIndex("# Reproduction index\n", r, NO_FACTS),
113
- existingResults: new Set(),
114
+ results: new Map(),
114
115
  plans: new Map(
115
116
  r.studies.flatMap((s) =>
116
- s.plan === undefined ? [] : [[`${s.folder}/${s.plan}`, read(PLAN)]],
117
+ s.plan === undefined ? [] : [[folderKey(s.folder, s.plan), read(PLAN)]],
117
118
  ),
118
119
  ),
119
120
  ...over,
@@ -385,7 +386,10 @@ describe("plans — the parser decides ready or draft", () => {
385
386
  });
386
387
  const plans = new Map([[`P1-S01-census/${PLAN_FILE}`, read(PLAN)]]);
387
388
  expect(
388
- findings(r, { plans, existingResults: new Set(["P1-S01-census/r.tsv"]) }),
389
+ findings(r, {
390
+ plans,
391
+ results: new Map([["P1-S01-census/r.tsv", { kind: "file" as const }]]),
392
+ }),
389
393
  ).toEqual([]);
390
394
  });
391
395
 
@@ -19,6 +19,7 @@ import {
19
19
  studyIndex,
20
20
  studyMessage,
21
21
  studyReadme,
22
+ studyById,
22
23
  studySlug,
23
24
  withIndex,
24
25
  type RegistryState,
@@ -61,7 +62,7 @@ const input = (
61
62
  folders: r.studies.map((s) => s.folder),
62
63
  registry: read(r),
63
64
  readme: withIndex("# Reproduction index\n", r, NO_FACTS),
64
- existingResults: new Set(),
65
+ results: new Map(),
65
66
  plans: new Map(),
66
67
  ...over,
67
68
  });
@@ -88,7 +89,9 @@ describe("judgeStudies — silent", () => {
88
89
  expect(
89
90
  judgeStudies(
90
91
  input(r, {
91
- existingResults: new Set(["P1-S02-ablation/out/results.tsv"]),
92
+ results: new Map([
93
+ ["P1-S02-ablation/out/results.tsv", { kind: "file" as const }],
94
+ ]),
92
95
  }),
93
96
  ),
94
97
  ).toEqual([]);
@@ -100,7 +103,7 @@ describe("judgeStudies — silent", () => {
100
103
  folders: [],
101
104
  registry: { kind: "absent" },
102
105
  readme: null,
103
- existingResults: new Set(),
106
+ results: new Map(),
104
107
  plans: new Map(),
105
108
  }),
106
109
  ).toEqual([]);
@@ -129,7 +132,7 @@ describe("judgeStudies — the registry itself", () => {
129
132
  folders: ["a-2026-10-01", "b-2026-10-02"],
130
133
  registry: { kind: "absent" },
131
134
  readme: null,
132
- existingResults: new Set(),
135
+ results: new Map(),
133
136
  plans: new Map(),
134
137
  }),
135
138
  ).toEqual([
@@ -143,7 +146,7 @@ describe("judgeStudies — the registry itself", () => {
143
146
  folders: ["a-2026-10-01"],
144
147
  registry: { kind: "unreadable", why: "prefix: required" },
145
148
  readme: null,
146
- existingResults: new Set(),
149
+ results: new Map(),
147
150
  plans: new Map(),
148
151
  }),
149
152
  ).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
@@ -259,7 +262,11 @@ describe("judgeStudies — status", () => {
259
262
  const r = reg(study({ status: "running", result_file: "r.tsv" }));
260
263
  expect(
261
264
  judgeStudies(
262
- input(r, { existingResults: new Set(["P1-S01-baseline/r.tsv"]) }),
265
+ input(r, {
266
+ results: new Map([
267
+ ["P1-S01-baseline/r.tsv", { kind: "file" as const }],
268
+ ]),
269
+ }),
263
270
  ),
264
271
  ).toEqual([]);
265
272
  });
@@ -671,3 +678,24 @@ describe("studyReadme", () => {
671
678
  );
672
679
  });
673
680
  });
681
+
682
+ describe("studyById — the one way to address a study by its id", () => {
683
+ const a = study({ id: "P1-S01", folder: "P1-S01-a" });
684
+ const b = study({ id: "P1-S01", folder: "P1-S01-b" });
685
+ const c = study({ id: "P1-S02", folder: "P1-S02-c" });
686
+
687
+ it("exactly one row: that row; no row: none; two or more: all of them, in registry order", () => {
688
+ // Guards: a caller can take a row only from `one`, never "the first" of an ambiguous id.
689
+ expect([
690
+ studyById([a, c], "P1-S01"),
691
+ studyById([a, c], "P1-S09"),
692
+ studyById([], "P1-S01"),
693
+ studyById([a, c, b], "P1-S01"),
694
+ ]).toEqual([
695
+ { kind: "one", row: a },
696
+ { kind: "none" },
697
+ { kind: "none" },
698
+ { kind: "ambiguous", rows: [a, b] },
699
+ ]);
700
+ });
701
+ });
@@ -248,8 +248,22 @@ export type ParsedPlan =
248
248
  }
249
249
  | { readonly kind: "unreadable"; readonly why: string };
250
250
 
251
+ /**
252
+ * What a path inside a study's folder is, found without reading it or following a link: a regular
253
+ * file, nothing, or a path that passes through a symbolic link (`link`: the first one, relative to
254
+ * the folder).
255
+ */
256
+ export type Located =
257
+ | { readonly kind: "file" }
258
+ | { readonly kind: "absent" }
259
+ | { readonly kind: "linked"; readonly link: string };
260
+
251
261
  /** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
252
- export type PlanState = { readonly kind: "absent" } | ParsedPlan;
262
+ export type PlanState =
263
+ | { readonly kind: "absent" }
264
+ /** The plan's path passes through a symbolic link, `link` (relative to the folder); never read. */
265
+ | { readonly kind: "linked"; readonly link: string }
266
+ | ParsedPlan;
253
267
 
254
268
  /** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
255
269
  export const decisionPath = (folder: string): string =>
@@ -303,8 +317,8 @@ export interface StudyInput {
303
317
  readonly registry: RegistryState;
304
318
  /** `repro/README.md`'s text; null when there is none. */
305
319
  readonly readme: string | null;
306
- /** Each `<folder>/<result_file>` of the registry that is a file. */
307
- readonly existingResults: ReadonlySet<string>;
320
+ /** What each `<folder>/<result_file>` of the registry is, as `locate` found it; one not listed is absent. */
321
+ readonly results: ReadonlyMap<string, Located>;
308
322
  /** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
309
323
  readonly plans: ReadonlyMap<string, PlanState>;
310
324
  }
@@ -351,6 +365,13 @@ export type StudyFinding =
351
365
  readonly path: string;
352
366
  readonly why: "missing" | "outside";
353
367
  }
368
+ | {
369
+ readonly problem: "result-file";
370
+ readonly id: string;
371
+ readonly path: string;
372
+ readonly why: "linked";
373
+ readonly link: string;
374
+ }
354
375
  | {
355
376
  readonly problem: "no-successor";
356
377
  readonly id: string;
@@ -390,6 +411,13 @@ export type StudyFinding =
390
411
  readonly path: string;
391
412
  readonly why: "missing" | "outside";
392
413
  }
414
+ | {
415
+ readonly problem: "bad-plan";
416
+ readonly id: string;
417
+ readonly path: string;
418
+ readonly why: "linked";
419
+ readonly link: string;
420
+ }
393
421
  | {
394
422
  readonly problem: "bad-plan";
395
423
  readonly id: string;
@@ -479,24 +507,66 @@ export const isInside = (path: string): boolean => {
479
507
  export const isFolderName = (folder: string): boolean =>
480
508
  folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
481
509
 
510
+ /**
511
+ * The rows of a registry that carry an id: exactly one, none, or several. The one way to address a
512
+ * study by its id — a caller that wants "the study P1-S01" gets a row only when there is exactly one,
513
+ * so no command can act on the first of two rows by accident. A registry may still hold two rows of
514
+ * one id (`repro/study-registered` reports it); this says so instead of choosing.
515
+ */
516
+ export type StudyLookup =
517
+ | { readonly kind: "one"; readonly row: Study }
518
+ | { readonly kind: "none" }
519
+ | { readonly kind: "ambiguous"; readonly rows: readonly Study[] };
520
+
521
+ /** The rows of a registry that was read; none when there is no registry, or it does not parse. */
522
+ export const studiesOf = (state: RegistryState): readonly Study[] =>
523
+ state.kind === "read" ? state.registry.studies : [];
524
+
525
+ /** The rows of `studies` whose id is `id`, as a `StudyLookup`. */
526
+ export const studyById = (
527
+ studies: readonly Study[],
528
+ id: string,
529
+ ): StudyLookup => {
530
+ const [first, ...rest] = studies.filter((s) => s.id === id);
531
+ if (first === undefined) return { kind: "none" };
532
+ return rest.length === 0
533
+ ? { kind: "one", row: first }
534
+ : { kind: "ambiguous", rows: [first, ...rest] };
535
+ };
536
+
537
+ /**
538
+ * The key a path inside a study's folder is held under: `<folder>/<path>`. A plan (`StudyInput.plans`,
539
+ * `IndexInput.plans`), the history of its study (`IndexInput.history`) and a result file
540
+ * (`StudyInput.results`) are all found by it.
541
+ */
542
+ export const folderKey = (folder: string, path: string): string =>
543
+ `${folder}/${path}`;
544
+
482
545
  /**
483
546
  * The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
484
547
  * (`StudyInput.plans`). A plan outside its folder is reported without being read.
485
548
  */
486
549
  export const planKeys = (r: StudyRegistry): readonly string[] =>
487
- planRefs(r).map((p) => p.key);
550
+ planRefs(r).map((p) => folderKey(p.folder, p.plan));
488
551
 
489
- /** Each plan `planKeys` names, with the kind of the study whose row names it: what its parser needs. */
490
- export const planRefs = (
491
- r: StudyRegistry,
492
- ): readonly {
493
- readonly key: string;
552
+ /** A plan a row names: where it is read from, and the kind of the study — what its parser needs. */
553
+ export interface PlanRef {
494
554
  readonly folder: string;
555
+ readonly plan: string;
495
556
  readonly kind: StudyKind;
496
- }[] =>
557
+ }
558
+
559
+ /** Each plan `planKeys` names. */
560
+ export const planRefs = (r: StudyRegistry): readonly PlanRef[] =>
497
561
  r.studies.flatMap((s) =>
498
562
  s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
499
- ? [{ key: `${s.folder}/${s.plan}`, folder: s.folder, kind: s.kind }]
563
+ ? [
564
+ {
565
+ folder: s.folder,
566
+ plan: s.plan,
567
+ kind: s.kind,
568
+ },
569
+ ]
500
570
  : [],
501
571
  );
502
572
 
@@ -522,7 +592,7 @@ const badId: RowCheck = ({ r, s }) =>
522
592
 
523
593
  /** Reported once, on the first row that holds the id. */
524
594
  const duplicateId: RowCheck = (row) =>
525
- row.r.studies.filter((o) => o.id === row.s.id).length > 1 &&
595
+ studyById(row.r.studies, row.s.id).kind === "ambiguous" &&
526
596
  isFirst(row, (o) => o.id)
527
597
  ? { problem: "duplicate-id", id: row.s.id }
528
598
  : null;
@@ -572,9 +642,23 @@ const resultFile: RowCheck = ({ s, input }) => {
572
642
  if (path === undefined) return null;
573
643
  if (!isInside(path))
574
644
  return { problem: "result-file", id: s.id, path, why: "outside" };
575
- return input.existingResults.has(`${s.folder}/${path}`)
576
- ? null
577
- : { problem: "result-file", id: s.id, path, why: "missing" };
645
+ const found = input.results.get(folderKey(s.folder, path)) ?? {
646
+ kind: "absent",
647
+ };
648
+ switch (found.kind) {
649
+ case "file":
650
+ return null;
651
+ case "linked":
652
+ return {
653
+ problem: "result-file",
654
+ id: s.id,
655
+ path,
656
+ why: "linked",
657
+ link: found.link,
658
+ };
659
+ case "absent":
660
+ return { problem: "result-file", id: s.id, path, why: "missing" };
661
+ }
578
662
  };
579
663
 
580
664
  const noSuccessor: RowCheck = ({ r, s }) => {
@@ -593,13 +677,15 @@ const unknownMeasurement: RowCheck = ({ r, s }) =>
593
677
  ? null
594
678
  : { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
595
679
 
596
- /** The state of the plan the row names (absent when outside its folder); null when it names none. */
597
- const planOf = ({ s, input }: Row): PlanState | null => {
598
- if (s.plan === undefined) return null;
599
- return isInside(s.plan)
600
- ? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
680
+ /** The state of the plan `plan` of the row `s` (absent when outside its folder): the one place a plan is looked up. */
681
+ const planStateOf = (input: StudyInput, s: Study, plan: string): PlanState =>
682
+ isInside(plan)
683
+ ? (input.plans.get(folderKey(s.folder, plan)) ?? { kind: "absent" })
601
684
  : { kind: "absent" };
602
- };
685
+
686
+ /** The state of the plan the row names; null when it names none. */
687
+ const planOf = ({ s, input }: Row): PlanState | null =>
688
+ s.plan === undefined ? null : planStateOf(input, s, s.plan);
603
689
 
604
690
  const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
605
691
 
@@ -613,6 +699,8 @@ const planFinding = (
613
699
  switch (state.kind) {
614
700
  case "absent":
615
701
  return { ...at, why: "missing" };
702
+ case "linked":
703
+ return { ...at, why: "linked", link: state.link };
616
704
  case "unreadable":
617
705
  return { ...at, why: "unreadable", reasons: state.why };
618
706
  case "draft":
@@ -630,11 +718,7 @@ const badPlan: RowCheck = ({ s, input }) => {
630
718
  if (path === undefined) return null;
631
719
  if (!isInside(path))
632
720
  return { problem: "bad-plan", id: s.id, path, why: "outside" };
633
- return planFinding(
634
- s,
635
- path,
636
- input.plans.get(`${s.folder}/${path}`) ?? { kind: "absent" },
637
- );
721
+ return planFinding(s, path, planStateOf(input, s, path));
638
722
  };
639
723
 
640
724
  /** A plan under its old name: read, and named for renaming. */
@@ -801,8 +885,8 @@ const cell = (s: string): string =>
801
885
 
802
886
  /**
803
887
  * What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
804
- * `StudyInput.plans`), and the history of each decide-first study by id. A study with no history
805
- * listed shows `not read` — the lint, which never compares those columns, asks none.
888
+ * `StudyInput.plans`), and the history of each decide-first study by the same key. A study with no
889
+ * history listed shows `not read` — the lint, which never compares those columns, asks none.
806
890
  */
807
891
  export interface IndexInput {
808
892
  readonly plans: ReadonlyMap<string, PlanState>;
@@ -816,10 +900,11 @@ const declaredMode = (
816
900
  ): StudyMode | null => {
817
901
  const state =
818
902
  s.plan !== undefined && isInside(s.plan)
819
- ? plans.get(`${s.folder}/${s.plan}`)
903
+ ? plans.get(folderKey(s.folder, s.plan))
820
904
  : undefined;
821
905
  return state === undefined ||
822
906
  state.kind === "absent" ||
907
+ state.kind === "linked" ||
823
908
  state.kind === "unreadable"
824
909
  ? null
825
910
  : (state.plan.mode ?? null);
@@ -828,7 +913,10 @@ const declaredMode = (
828
913
  /** What the index shows of `s` beyond its row. */
829
914
  const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
830
915
  mode: declaredMode(s, at.plans),
831
- history: at.history.get(s.id) ?? null,
916
+ history:
917
+ (s.plan === undefined
918
+ ? undefined
919
+ : at.history.get(folderKey(s.folder, s.plan))) ?? null,
832
920
  });
833
921
 
834
922
  /**
@@ -1039,7 +1127,9 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
1039
1127
  "no-result": (f) =>
1040
1128
  `${REGISTRY}: ${tick(f.id)} is \`done\` without ${f.missing.map(tick).join(" and ")}. What to do: write the one-line result and the file it is read from (\`result\`, \`result_file\`), or set the status it really has.`,
1041
1129
  "result-file": (f) =>
1042
- `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, ${f.why === "missing" ? "is not a file in its folder" : "is outside its folder"}. What to do: name a file that exists, relative to the study's folder.`,
1130
+ f.why === "linked"
1131
+ ? `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: name a regular file inside the study's folder, or replace the link with the file itself.`
1132
+ : `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, ${f.why === "missing" ? "is not a file in its folder" : "is outside its folder"}. What to do: name a file that exists, relative to the study's folder.`,
1043
1133
  "no-successor": (f) =>
1044
1134
  `${REGISTRY}: ${tick(f.id)} is \`superseded\` ${f.superseded_by === null ? "without `superseded_by`" : `by ${tick(f.superseded_by)}, which is no study in the registry`}. What to do: name the id of the study that replaced it.`,
1045
1135
  "stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
@@ -1059,6 +1149,8 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
1059
1149
  switch (f.why) {
1060
1150
  case "missing":
1061
1151
  return `${REGISTRY}: ${of} is not a file in its folder. What to do: write the plan there (its fields are on the rule's page), or correct the row's \`plan\`.`;
1152
+ case "linked":
1153
+ return `${REGISTRY}: ${of} passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: replace the link with the plan file itself.`;
1062
1154
  case "outside":
1063
1155
  return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
1064
1156
  case "unreadable":
@@ -2,7 +2,9 @@
2
2
  import assert from "node:assert/strict";
3
3
  import { test } from "vitest";
4
4
  import {
5
+ parsePaperAuthors,
5
6
  parsePaperSubmission,
7
+ withSubmissionId,
6
8
  supportedPortal,
7
9
  wordCount,
8
10
  } from "./submission.ts";
@@ -43,3 +45,89 @@ test("wordCount: runs of non-space, any whitespace between", () => {
43
45
  assert.equal(wordCount(""), 0);
44
46
  assert.equal(wordCount(" one\ttwo\n\nthree "), 3);
45
47
  });
48
+
49
+ test("parsePaperAuthors: absent is null; each author trimmed, in order, affiliation optional", () => {
50
+ assert.deepEqual(parsePaperAuthors(undefined), { ok: true, value: null });
51
+ assert.deepEqual(
52
+ parsePaperAuthors([
53
+ { name: " Ada Lovelace ", email: "ada@example.org", affiliation: "AE" },
54
+ { name: "Bob", email: " bob@example.org " },
55
+ ]),
56
+ {
57
+ ok: true,
58
+ value: [
59
+ { name: "Ada Lovelace", email: "ada@example.org", affiliation: "AE" },
60
+ { name: "Bob", email: "bob@example.org", affiliation: null },
61
+ ],
62
+ },
63
+ );
64
+ });
65
+
66
+ test("🔴 parsePaperAuthors: every malformed list refused with what to fix — never a partial list", () => {
67
+ const shape = /^"authors" must be a non-empty list of \{ "name"/;
68
+ const ada = { name: "Ada", email: "ada@example.org" };
69
+ const cases: readonly (readonly [unknown, RegExp])[] = [
70
+ [[], shape],
71
+ ["Ada", shape],
72
+ [["Ada"], shape],
73
+ [
74
+ [ada, { ...ada, orcid: "x" }],
75
+ /^"authors"\[1\] has an unknown key "orcid" — known: name, email, affiliation$/,
76
+ ],
77
+ [
78
+ [{ ...ada, name: " " }],
79
+ /^"authors"\[0\]: "name" must be a non-empty string$/,
80
+ ],
81
+ [
82
+ [{ ...ada, email: "ada" }],
83
+ /^"authors"\[0\]: "email" must be an email address, got "ada"$/,
84
+ ],
85
+ [
86
+ [{ name: "Ada" }, ada],
87
+ /^"authors"\[0\]: "email" must be an email address, got undefined$/,
88
+ ],
89
+ [
90
+ [{ ...ada, affiliation: "" }],
91
+ /^"authors"\[0\]: "affiliation", when given, must be a non-empty string$/,
92
+ ],
93
+ [
94
+ [ada, { name: "Ada 2", email: "ADA@example.org" }],
95
+ /^"authors": the email ada@example\.org is given twice$/,
96
+ ],
97
+ ];
98
+ for (const [v, why] of cases) {
99
+ const r = parsePaperAuthors(v);
100
+ assert.ok(!r.ok, JSON.stringify(v));
101
+ assert.match(r.error, why);
102
+ }
103
+ });
104
+
105
+ test("withSubmissionId: the flat form at the top level; the cycles form in the last cycle, the others untouched", () => {
106
+ assert.deepEqual(withSubmissionId({ extends: "x" }, 4), {
107
+ ok: true,
108
+ value: { extends: "x", submission: { id: 4 } },
109
+ });
110
+ assert.deepEqual(
111
+ withSubmissionId({ cycles: [{ id: "a" }, { id: "b" }] }, 4),
112
+ {
113
+ ok: true,
114
+ value: { cycles: [{ id: "a" }, { id: "b", submission: { id: 4 } }] },
115
+ },
116
+ );
117
+ });
118
+
119
+ test("🔴 withSubmissionId: nowhere to record it — refused, never guessed", () => {
120
+ assert.deepEqual(withSubmissionId([], 4), {
121
+ ok: false,
122
+ error: "must be a JSON object",
123
+ });
124
+ for (const cycles of [[], "x"])
125
+ assert.deepEqual(withSubmissionId({ cycles }, 4), {
126
+ ok: false,
127
+ error: `"cycles" has no attempt to record the submission in`,
128
+ });
129
+ assert.deepEqual(withSubmissionId({ cycles: [{ id: "a" }, 3] }, 4), {
130
+ ok: false,
131
+ error: `the last entry of "cycles" is not an object`,
132
+ });
133
+ });
@@ -139,3 +139,102 @@ export type PortalFailure =
139
139
  /** Words in a text, as a reader counts them: runs of non-space. Pure. */
140
140
  export const wordCount = (s: string): number =>
141
141
  s.split(/\s+/).filter((w) => w !== "").length;
142
+
143
+ // ── a new submission ────────────────────────────────────────────────────────────────────
144
+
145
+ /** One author as the portal records it. Never guessed: the paper's `authors` key declares it. */
146
+ export interface Author {
147
+ readonly name: string;
148
+ readonly email: string;
149
+ /** Null when the paper does not declare one. */
150
+ readonly affiliation: string | null;
151
+ }
152
+
153
+ const AUTHOR_KEYS = ["name", "email", "affiliation"] as const;
154
+ const AUTHORS_SHAPE = `"authors" must be a non-empty list of { "name": "<as the portal should show it>", "email": "<the address the portal knows the author by>", "affiliation"?: "<…>" }, in the paper's order`;
155
+
156
+ const nonEmpty = (v: unknown): v is string =>
157
+ typeof v === "string" && v.trim() !== "";
158
+
159
+ /** One entry of `authors` → the author, or what is wrong with it. */
160
+ function authorOf(v: unknown, i: number): Result<Author, string> {
161
+ const at = `"authors"[${String(i)}]`;
162
+ if (!isRecord(v)) return err(AUTHORS_SHAPE);
163
+ const unknown = Object.keys(v).find((k) => !AUTHOR_KEYS.some((a) => a === k));
164
+ if (unknown !== undefined)
165
+ return err(
166
+ `${at} has an unknown key "${unknown}" — known: ${AUTHOR_KEYS.join(", ")}`,
167
+ );
168
+ const { name, email, affiliation } = v;
169
+ if (!nonEmpty(name)) return err(`${at}: "name" must be a non-empty string`);
170
+ if (!nonEmpty(email) || !/^[^\s@]+@[^\s@]+$/.test(email.trim()))
171
+ return err(
172
+ `${at}: "email" must be an email address, got ${JSON.stringify(email)}`,
173
+ );
174
+ if (affiliation !== undefined && !nonEmpty(affiliation))
175
+ return err(`${at}: "affiliation", when given, must be a non-empty string`);
176
+ return ok({
177
+ name: name.trim(),
178
+ email: email.trim(),
179
+ affiliation: affiliation === undefined ? null : affiliation.trim(),
180
+ });
181
+ }
182
+
183
+ /** A paper's `authors` key → the list, null when absent, or one line saying what is wrong. Pure. */
184
+ export function parsePaperAuthors(
185
+ v: unknown,
186
+ ): Result<readonly Author[] | null, string> {
187
+ if (v === undefined) return ok(null);
188
+ if (!Array.isArray(v) || v.length === 0) return err(AUTHORS_SHAPE);
189
+ const list: readonly unknown[] = v;
190
+ const parsed = list.reduce<Result<readonly Author[], string>>((acc, x, i) => {
191
+ if (!acc.ok) return acc;
192
+ const a = authorOf(x, i);
193
+ return a.ok ? ok([...acc.value, a.value]) : a;
194
+ }, ok([]));
195
+ if (!parsed.ok) return parsed;
196
+ const authors = parsed.value;
197
+ const emails = authors.map((a) => a.email.toLowerCase());
198
+ const twice = emails.find((e, i) => emails.indexOf(e) !== i);
199
+ return twice === undefined
200
+ ? ok(authors)
201
+ : err(`"authors": the email ${twice} is given twice`);
202
+ }
203
+
204
+ /** What creating a submission sends. */
205
+ export interface NewSubmission {
206
+ readonly title: string;
207
+ readonly abstract: string;
208
+ readonly authors: readonly Author[];
209
+ readonly pdf: { readonly name: string; readonly bytes: Uint8Array };
210
+ }
211
+
212
+ /** What the portal answered to a creation: an update's answer, and the id it chose when it saved. */
213
+ export interface CreateOutcome extends UpdateOutcome {
214
+ /** The new submission's id; null on a dry run, or when nothing was created. */
215
+ readonly id: number | null;
216
+ }
217
+
218
+ /**
219
+ * The parsed `paperlint.json` with the new submission's id recorded where the paper's submission is
220
+ * read from: the last cycle (the open attempt) in the cycles form, the top level in the flat form.
221
+ * Pure; the caller writes the result and reads it back.
222
+ */
223
+ export function withSubmissionId(
224
+ json: unknown,
225
+ id: number,
226
+ ): Result<Readonly<Record<string, unknown>>, string> {
227
+ if (!isRecord(json)) return err("must be a JSON object");
228
+ const cycles = json["cycles"];
229
+ if (cycles === undefined) return ok({ ...json, submission: { id } });
230
+ if (!Array.isArray(cycles) || cycles.length === 0)
231
+ return err(`"cycles" has no attempt to record the submission in`);
232
+ const list: readonly unknown[] = cycles;
233
+ const last = list.at(-1);
234
+ return isRecord(last)
235
+ ? ok({
236
+ ...json,
237
+ cycles: [...list.slice(0, -1), { ...last, submission: { id } }],
238
+ })
239
+ : err(`the last entry of "cycles" is not an object`);
240
+ }
@@ -85,6 +85,7 @@ describe("paperlint new — paperlint.json", () => {
85
85
  identity: null,
86
86
  talk: null,
87
87
  submission: null,
88
+ authors: null,
88
89
  cycles: null,
89
90
  },
90
91
  });