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
package/src/cli.ts CHANGED
@@ -100,10 +100,10 @@ import { texOutput } from "./adapters/tex-output/index.ts";
100
100
  import { BIB_RULE_LEVELS, bibRules } from "./bib-rules.ts";
101
101
  import { bibliographyReader } from "./references.ts";
102
102
  import { onlineReferences } from "./adapters/references/index.ts";
103
- import { hotcrpPortal } from "./adapters/hotcrp/index.ts";
103
+ import { hotcrpSite } from "./adapters/hotcrp/index.ts";
104
104
  import { runSubmission } from "./submission.ts";
105
105
  import type { PortalKind, SupportedPortal } from "./domain/submission.ts";
106
- import type { SubmissionPortal } from "./ports/submission-portal.ts";
106
+ import type { PortalSite } from "./ports/submission-portal.ts";
107
107
  import type { CheckReferences } from "./ports/check-references.ts";
108
108
  import {
109
109
  narrowToOwners,
@@ -234,6 +234,11 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
234
234
  into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
235
235
  run does nothing. --check: report what is missing, change nothing
236
236
  npx paperlint doctor say what is actually wired — and what only LOOKS wired
237
+ npx paperlint submission new [paper] [--abstract <file>] [--save]
238
+ create the paper's submission on the venue's portal: the title
239
+ and abstract from the build, the authors from paperlint.json,
240
+ the PDF. A DRY RUN unless --save; a save records the new id in
241
+ paperlint.json
237
242
  npx paperlint submission show [paper]
238
243
  the paper's submission on the venue's portal: status, title,
239
244
  type, topics, abstract length, the PDF the portal holds, and
@@ -285,13 +290,18 @@ studies:
285
290
 
286
291
  submission:
287
292
  The venue preset declares the portal — "portal": { "kind": "hotcrp", "url": "<site>" } — and the
288
- paper's paperlint.json its submission there — "submission": { "id": <number> }. The token comes
289
- only from the environment: HOTCRP_TOKEN (HotCRP: Account settings → Developer).
293
+ paper's paperlint.json its submission there — "submission": { "id": <number> }, written by new
294
+ — and, for new, its "authors": [{ "name", "email", "affiliation"? }]. The token comes only from
295
+ the environment, one variable per site: HOTCRP_TOKEN_<subdomain> on *.hotcrp.com, else
296
+ HOTCRP_TOKEN_<host and path> — e.g. HOTCRP_TOKEN_MSR2027 for msr2027.hotcrp.com,
297
+ HOTCRP_TOKEN_AIDC_SUBMIT_ACSAC_ORG for aidc.submit.acsac.org (HotCRP: Account settings →
298
+ Developer). A plain HOTCRP_TOKEN is not read.
290
299
  [paper] the paper's folder; default the current directory
291
300
  --pdf <file> the PDF to compare (show) or send (update) instead of the paper's own
292
- --abstract <file> update: a plain-text file whose text replaces the abstract
301
+ --abstract <file> new, update: a plain-text file whose text is sent as the abstract
293
302
  --submit update: mark the submission submitted
294
- --save update: really change the submission. Without it, nothing is saved
303
+ --save new, update: really create or change the submission. Without it, nothing
304
+ is saved
295
305
 
296
306
  lint:
297
307
  npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
@@ -1737,7 +1747,9 @@ const SIMPLE: Readonly<
1737
1747
  presets: PRESET_DEPS,
1738
1748
  cwd,
1739
1749
  env: (name) => process.env[name],
1740
- portalFor,
1750
+ site: portalSite,
1751
+ latex: latexReader,
1752
+ codec: sourcesCodec,
1741
1753
  log,
1742
1754
  err,
1743
1755
  },
@@ -1745,13 +1757,10 @@ const SIMPLE: Readonly<
1745
1757
  };
1746
1758
 
1747
1759
  /** The adapter of each portal kind — one per kind, chosen here at the root. */
1748
- const PORTALS: Readonly<
1749
- Record<PortalKind, (url: string, token: string) => SubmissionPortal>
1750
- > = {
1751
- hotcrp: (url, token) => hotcrpPortal({ url, token }),
1760
+ const PORTALS: Readonly<Record<PortalKind, (url: string) => PortalSite>> = {
1761
+ hotcrp: hotcrpSite,
1752
1762
  };
1753
- const portalFor = (p: SupportedPortal, token: string): SubmissionPortal =>
1754
- PORTALS[p.kind](p.url, token);
1763
+ const portalSite = (p: SupportedPortal): PortalSite => PORTALS[p.kind](p.url);
1755
1764
 
1756
1765
  /** banal's installer, wired from this process's environment: the composition root's work. */
1757
1766
  function hostBanalInstaller(): ToolInstaller {
@@ -155,8 +155,8 @@ describe("cycle/record", () => {
155
155
 
156
156
  describe("cycle/record — the deadlines, over the venue preset's readings", () => {
157
157
  it("MSR 2027 as a newcomer records the call: abstract → registration, paper → submission; the preset's portal readings are derived — silent", () => {
158
- // The portal's submission (2026-10-20T04:00Z, from the preset) is earlier than the call's
159
- // registration (the end of 2026-10-20 AoE): two sources disagreeing, not a typo.
158
+ // The portal's submission (2026-10-21T11:59:59Z, from the preset) is the end of the call's
159
+ // registration day (2026-10-20 AoE), before the call's submission: no contradiction.
160
160
  const fs = lint({
161
161
  cycles: [
162
162
  msr([
@@ -185,7 +185,7 @@ describe("cycle/record — the deadlines, over the venue preset's readings", ()
185
185
  });
186
186
  expect(fs.map((f) => f.messageId)).toEqual(["order"]);
187
187
  expect(fs[0]?.message).toBe(
188
- 'paperlint.json, cycle «msr-2027»: the call\'s "registration" (2026-10-26T11:59:59Z) is after its "submission" (2026-10-24T11:59:59Z) — a date typed wrong, or an AoE day miscounted (write a call\'s date as "YYYY-MM-DD AoE" and paperlint converts it)',
188
+ 'paperlint.json, cycle «msr-2027»: the call\'s "registration" (2026-10-26T11:59:59Z, the call\'s reading on 2026-10-05 from https://2027.msrconf.org/track/msr-2027-technical-papers) is after its "submission" (2026-10-24T11:59:59Z, the call\'s reading on 2026-10-05 from https://2027.msrconf.org/track/msr-2027-technical-papers) — a date typed wrong, or an AoE day miscounted (write a call\'s date as "YYYY-MM-DD AoE" and paperlint converts it)',
189
189
  );
190
190
  });
191
191
  });
@@ -199,10 +199,10 @@ describe("cycle/record — a portal reading beside the preset's", () => {
199
199
  what: "submission",
200
200
  observed: [
201
201
  {
202
- at: "2026-10-20T04:00:00Z",
202
+ at: "2026-10-21T11:59:59Z",
203
203
  source: "portal",
204
204
  url: "https://msr2027.hotcrp.com/deadlines",
205
- read: "2026-10-01",
205
+ read: "2026-10-19",
206
206
  },
207
207
  ],
208
208
  },
@@ -210,7 +210,7 @@ describe("cycle/record — a portal reading beside the preset's", () => {
210
210
  ],
211
211
  });
212
212
  expect(fs.map((f) => f.message)).toEqual([
213
- "paperlint.json, cycle «msr-2027»: the \"submission\" deadline records a portal reading, and the preset paperlint:msr carries the portal's reading of it (2026-10-20T04:00:00Z, read 2026-10-05) — a second copy drifts when paperlint updates the preset: delete this one (the call's reading and an override stay)",
213
+ "paperlint.json, cycle «msr-2027»: the \"submission\" deadline records a portal reading, and the preset paperlint:msr carries it (2026-10-21T11:59:59Z, the portal's reading on 2026-10-19 from https://msr2027.hotcrp.com/deadlines — venues move dates: re-check there before relying on it) — a second copy drifts when paperlint updates the preset: delete this one (the call's reading and an override stay)",
214
214
  ]);
215
215
  });
216
216
 
@@ -41,7 +41,11 @@ import {
41
41
  type Cycle,
42
42
  type Cycles,
43
43
  } from "./domain/cycle.ts";
44
- import { deadlineOrderProblems, type Reading } from "./domain/deadline.ts";
44
+ import {
45
+ deadlineOrderProblems,
46
+ readingText,
47
+ type Reading,
48
+ } from "./domain/deadline.ts";
45
49
  import { resolvePreset } from "./presets.ts";
46
50
  import { fieldOf } from "./domain/record.ts";
47
51
  import { err, ok, type Result } from "./domain/result.ts";
@@ -104,8 +108,7 @@ function presetCopies(c: Cycle, preset: PresetOfCycle): readonly Finding[] {
104
108
  id: c.id,
105
109
  what: d.what,
106
110
  preset: preset.spec,
107
- at: theirs.at,
108
- read: theirs.read,
111
+ reading: readingText(theirs),
109
112
  },
110
113
  },
111
114
  ]
@@ -190,7 +193,7 @@ const META: Readonly<Record<CycleRuleName, FolderRuleModule["meta"]>> = {
190
193
  messages: {
191
194
  broken: `${CONFIG_FILE}: {{why}}`,
192
195
  order: `${CONFIG_FILE}, cycle «{{id}}»: {{why}} — a date typed wrong, or an AoE day miscounted (write a call's date as "YYYY-MM-DD AoE" and paperlint converts it)`,
193
- presetCopy: `${CONFIG_FILE}, cycle «{{id}}»: the "{{what}}" deadline records a portal reading, and the preset {{preset}} carries the portal's reading of it ({{at}}, read {{read}}) — a second copy drifts when paperlint updates the preset: delete this one (the call's reading and an override stay)`,
196
+ presetCopy: `${CONFIG_FILE}, cycle «{{id}}»: the "{{what}}" deadline records a portal reading, and the preset {{preset}} carries it ({{reading}}) — a second copy drifts when paperlint updates the preset: delete this one (the call's reading and an override stay)`,
194
197
  },
195
198
  },
196
199
  evidence: {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { describe, expect, it } from "vitest";
6
6
  import {
7
+ readingText,
7
8
  deadlineOrderProblems,
8
9
  deadlinesInForce,
9
10
  instantOf,
@@ -325,8 +326,8 @@ describe("deadlineOrderProblems — within ONE source; across sources is the dis
325
326
  reading("notification", "2027-02-01T00:00:00Z"),
326
327
  ];
327
328
  expect(deadlineOrderProblems(own, preset)).toEqual([
328
- 'the portal\'s "notification" (2027-02-01T00:00:00Z) is after its "camera-ready" (2027-01-01T00:00:00Z)',
329
- 'the call\'s "registration" (2026-10-26T11:59:59Z) is after its "submission" (2026-10-24T11:59:59Z)',
329
+ 'the portal\'s "notification" (2027-02-01T00:00:00Z, the portal\'s reading on 2026-10-05 from https://example.org/portal — venues move dates: re-check there before relying on it) is after its "camera-ready" (2027-01-01T00:00:00Z, the portal\'s reading on 2026-10-05 from https://example.org/portal — venues move dates: re-check there before relying on it)',
330
+ 'the call\'s "registration" (2026-10-26T11:59:59Z, the call\'s reading on 2026-10-05 from https://beta2027.example.org/call) is after its "submission" (2026-10-24T11:59:59Z, the call\'s reading on 2026-10-05 from https://beta2027.example.org/call)',
330
331
  ]);
331
332
  });
332
333
 
@@ -384,3 +385,31 @@ describe("instantOf", () => {
384
385
  }
385
386
  });
386
387
  });
388
+
389
+ describe("readingText — the one way a reading is printed", () => {
390
+ it("🔴 a portal's reading says when and where it was read, that venues move dates, and to re-check", () => {
391
+ expect(
392
+ readingText({
393
+ at: "2026-10-21T11:59:59Z",
394
+ source: "portal",
395
+ url: "https://msr2027.hotcrp.com/deadlines",
396
+ read: "2026-10-19",
397
+ }),
398
+ ).toBe(
399
+ "2026-10-21T11:59:59Z, the portal's reading on 2026-10-19 from https://msr2027.hotcrp.com/deadlines — venues move dates: re-check there before relying on it",
400
+ );
401
+ });
402
+
403
+ it("a call's reading: when and where it was read", () => {
404
+ expect(
405
+ readingText({
406
+ at: "2026-10-24T11:59:59Z",
407
+ source: "call",
408
+ url: "https://2027.msrconf.org/cfp",
409
+ read: "2026-10-05",
410
+ }),
411
+ ).toBe(
412
+ "2026-10-24T11:59:59Z, the call's reading on 2026-10-05 from https://2027.msrconf.org/cfp",
413
+ );
414
+ });
415
+ });
@@ -75,6 +75,17 @@ export interface Observation {
75
75
  readonly read: IsoDate;
76
76
  }
77
77
 
78
+ /**
79
+ * THE ONE WAY a deadline reading is printed: the instant, and where and when it was read. A portal's
80
+ * reading adds that venues move dates and to re-check the page before relying on it — MSR moved both
81
+ * its deadlines after the preset read them, and the preset kept the old ones for two weeks (#235).
82
+ * Used by `submission show` and the `cycle/record` messages. Pure.
83
+ */
84
+ export const readingText = (o: Observation): string =>
85
+ o.source === "portal"
86
+ ? `${o.at}, the portal's reading on ${o.read} from ${o.url} — venues move dates: re-check there before relying on it`
87
+ : `${o.at}, the ${o.source}'s reading on ${o.read} from ${o.url}`;
88
+
78
89
  /** A human decision that another instant binds, with why and the file it rests on. */
79
90
  export interface Override {
80
91
  readonly at: Instant;
@@ -110,7 +121,7 @@ export interface InForce {
110
121
 
111
122
  /**
112
123
  * What a venue's portal says about its deadlines, in paperlint's words — what the scheduled refresh
113
- * of the shipped presets reads (`scripts/refresh-deadlines.ts`). One adapter per portal kind.
124
+ * of the shipped presets reads (`scripts/refresh-deadlines/`). One adapter per portal kind.
114
125
  */
115
126
  export interface PortalDeadlines {
116
127
  /** The portal's clock when it answered, Unix seconds. */
@@ -406,40 +417,51 @@ export function deadlinesInForce(
406
417
  });
407
418
  }
408
419
 
409
- /** One instant per kind, the earliest a layer holds for it, in the order of the kinds. */
410
- type Layer = readonly { readonly what: DeadlineWhat; readonly at: Instant }[];
420
+ /** One instant per kind — and the reading it came from, an override's none. */
421
+ type Entry = {
422
+ readonly what: DeadlineWhat;
423
+ readonly at: Instant;
424
+ readonly reading: Observation | null;
425
+ };
426
+ /** One entry per kind, the earliest a layer holds for it, in the order of the kinds. */
427
+ type Layer = readonly Entry[];
411
428
 
412
429
  const layerOf = (entries: Layer): Layer =>
413
430
  DEADLINE_WHATS.flatMap((what) => {
414
- const ats = entries.filter((e) => e.what === what).map((e) => e.at);
415
- const first = ats.toSorted()[0];
416
- return first === undefined ? [] : [{ what, at: first }];
431
+ const first = entries
432
+ .filter((e) => e.what === what)
433
+ .toSorted((a, b) => a.at.localeCompare(b.at))[0];
434
+ return first === undefined ? [] : [first];
417
435
  });
418
436
 
419
437
  /** The pairs of a layer out of the order registration ≤ submission ≤ resubmission < notification < camera-ready. */
420
438
  const inversions = (
421
439
  layer: Layer,
422
- says: (what: DeadlineWhat, at: Instant, later: boolean) => string,
440
+ says: (e: Entry, later: boolean) => string,
423
441
  ): readonly string[] =>
424
442
  layer.flatMap((d, i) => {
425
443
  const before = layer[i - 1];
426
444
  return before !== undefined && before.at > d.at
427
- ? [
428
- `${says(before.what, before.at, false)} is after ${says(d.what, d.at, true)}`,
429
- ]
445
+ ? [`${says(before, false)} is after ${says(d, true)}`]
430
446
  : [];
431
447
  });
432
448
 
449
+ /** An entry's instant, with where and when it was read when it is a reading. */
450
+ const entryText = (e: Entry): string =>
451
+ e.reading === null ? e.at : readingText(e.reading);
452
+
433
453
  const readingsOf = (
434
454
  own: readonly Deadline[],
435
455
  preset: readonly Reading[],
436
456
  source: DeadlineSource,
437
457
  ): Layer => [
438
- ...preset.filter((r) => r.source === source),
458
+ ...preset
459
+ .filter((r) => r.source === source)
460
+ .map((r) => ({ what: r.what, at: r.at, reading: r })),
439
461
  ...own.flatMap((d) =>
440
462
  d.observed
441
463
  .filter((o) => o.source === source)
442
- .map((o) => ({ what: d.what, at: o.at })),
464
+ .map((o) => ({ what: d.what, at: o.at, reading: o })),
443
465
  ),
444
466
  ];
445
467
 
@@ -453,20 +475,24 @@ export function deadlineOrderProblems(
453
475
  preset: readonly Reading[],
454
476
  ): readonly string[] {
455
477
  const overrides = own.flatMap((d) =>
456
- d.override === null ? [] : [{ what: d.what, at: d.override.at }],
478
+ d.override === null
479
+ ? []
480
+ : [{ what: d.what, at: d.override.at, reading: null }],
457
481
  );
458
482
  return [
459
483
  ...inversions(
460
484
  layerOf(readingsOf(own, preset, "portal")),
461
- (w, at, later) => `${later ? "its" : "the portal's"} "${w}" (${at})`,
485
+ (e, later) =>
486
+ `${later ? "its" : "the portal's"} "${e.what}" (${entryText(e)})`,
462
487
  ),
463
488
  ...inversions(
464
489
  layerOf(readingsOf(own, preset, "call")),
465
- (w, at, later) => `${later ? "its" : "the call's"} "${w}" (${at})`,
490
+ (e, later) =>
491
+ `${later ? "its" : "the call's"} "${e.what}" (${entryText(e)})`,
466
492
  ),
467
493
  ...inversions(
468
494
  layerOf(overrides),
469
- (w, at) => `the override of "${w}" (${at})`,
495
+ (e) => `the override of "${e.what}" (${entryText(e)})`,
470
496
  ),
471
497
  ];
472
498
  }
@@ -180,6 +180,23 @@ it("a decision that names a directory is reported, and no history is judged", ()
180
180
  ]);
181
181
  });
182
182
 
183
+ it("a decision behind a linked directory is reported with the link, and no history is judged", () => {
184
+ // Guards: git records a path through a link as what lies behind the link.
185
+ expect(
186
+ judgeOrder({
187
+ ...STUDY,
188
+ facts: { kind: "linked", link: "repro/P1-S01-x/sub" },
189
+ }),
190
+ ).toEqual([
191
+ {
192
+ problem: "decision-linked",
193
+ id: "P1-S01",
194
+ file: "repro/P1-S01-x/decision.md",
195
+ link: "repro/P1-S01-x/sub",
196
+ },
197
+ ]);
198
+ });
199
+
183
200
  const PAGE =
184
201
  "\nRule repro/outputs-before-decision: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/outputs-before-decision.md";
185
202
 
@@ -192,6 +209,12 @@ const ALL: readonly OrderFinding[] = [
192
209
  glob: "**/*.md",
193
210
  },
194
211
  { problem: "decision-deleted", id: "P1-S01", file: "repro/d.md" },
212
+ {
213
+ problem: "decision-not-file",
214
+ id: "P1-S01",
215
+ file: "repro/p",
216
+ what: "special file",
217
+ },
195
218
  {
196
219
  problem: "decision-not-file",
197
220
  id: "P1-S01",
@@ -204,6 +227,12 @@ const ALL: readonly OrderFinding[] = [
204
227
  file: "repro/l.md",
205
228
  what: "symbolic link",
206
229
  },
230
+ {
231
+ problem: "decision-linked",
232
+ id: "P1-S01",
233
+ file: "repro/sub/d.md",
234
+ link: "repro/sub",
235
+ },
207
236
  { problem: "no-history", id: "P1-S01", why: "git is not installed" },
208
237
  { problem: "shallow", id: "P1-S01" },
209
238
  {
@@ -240,10 +269,14 @@ it("each finding's message: what is wrong, what to do, the rule's page", () => {
240
269
  PAGE,
241
270
  "The decision of `P1-S01`, `repro/d.md`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study `exploratory` with the reason." +
242
271
  PAGE,
272
+ "The decision of `P1-S01`, `repro/p`, is a special file, not a file: git tracks only regular files, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
273
+ PAGE,
243
274
  "The decision of `P1-S01`, `repro/d`, is a directory, not a file: git answers for a directory with the commits of anything under it, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
244
275
  PAGE,
245
276
  "The decision of `P1-S01`, `repro/l.md`, is a symbolic link, not a file: git records the link, not the file it points to, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
246
277
  PAGE,
278
+ "The decision of `P1-S01`, `repro/sub/d.md`, passes through the symbolic link `repro/sub`: git records the link, not the files behind it, so no order can be read from it. What to do: name a decision file that lies in real directories in `mode.decision.file`." +
279
+ PAGE,
247
280
  "`P1-S01` is decided before its run, and git's history is the proof of that order, but git could not answer: git is not installed. What to do: lint the paper inside its git repository, with git installed." +
248
281
  PAGE,
249
282
  "`P1-S01` is decided before its run, and this clone is shallow: every file's first commit is the clone's first, so the order of decision and outputs cannot be read. What to do: fetch the whole history (`git fetch --unshallow`; in GitHub Actions, `actions/checkout` with `fetch-depth: 0`)." +
@@ -23,7 +23,7 @@ import {
23
23
  } from "./study-mode.ts";
24
24
 
25
25
  /** What a decision path names on disk when it is no file git can vouch for. */
26
- export type NotAFile = "directory" | "symbolic link";
26
+ export type NotAFile = "directory" | "symbolic link" | "special file";
27
27
 
28
28
  /** A commit, and its committer date (ISO 8601 with its offset). */
29
29
  export interface CommitAt {
@@ -54,6 +54,8 @@ export interface FirstCommit extends CommitAt {
54
54
  */
55
55
  export type OrderFacts =
56
56
  | { readonly kind: "not-a-file"; readonly what: NotAFile }
57
+ /** The decision's path passes through a symbolic link above the file (`link`: the first, relative to the paper). */
58
+ | { readonly kind: "linked"; readonly link: string }
57
59
  | { readonly kind: "unknown"; readonly why: string }
58
60
  /** A shallow clone: every file's first commit is the clone's first, so no order can be read. */
59
61
  | { readonly kind: "shallow" }
@@ -97,6 +99,12 @@ export type OrderFinding =
97
99
  readonly file: string;
98
100
  readonly what: NotAFile;
99
101
  }
102
+ | {
103
+ readonly problem: "decision-linked";
104
+ readonly id: string;
105
+ readonly file: string;
106
+ readonly link: string;
107
+ }
100
108
  | {
101
109
  readonly problem: "no-history";
102
110
  readonly id: string;
@@ -188,6 +196,10 @@ const historyFindings = (s: DecideFirstStudy): readonly OrderFinding[] => {
188
196
  return [
189
197
  { problem: "decision-not-file", id: s.id, file, what: s.facts.what },
190
198
  ];
199
+ case "linked":
200
+ return [
201
+ { problem: "decision-linked", id: s.id, file, link: s.facts.link },
202
+ ];
191
203
  case "unknown":
192
204
  return [{ problem: "no-history", id: s.id, why: s.facts.why }];
193
205
  case "shallow":
@@ -212,6 +224,7 @@ const NOT_A_FILE: Readonly<Record<NotAFile, string>> = {
212
224
  directory:
213
225
  "git answers for a directory with the commits of anything under it",
214
226
  "symbolic link": "git records the link, not the file it points to",
227
+ "special file": "git tracks only regular files",
215
228
  };
216
229
 
217
230
  const short = (c: CommitAt): string => `${c.commit.slice(0, 12)} (${c.date})`;
@@ -225,6 +238,8 @@ const HEADS: {
225
238
  `The decision of \`${f.id}\`, \`${f.file}\`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study \`exploratory\` with the reason.`,
226
239
  "decision-not-file": (f) =>
227
240
  `The decision of \`${f.id}\`, \`${f.file}\`, is a ${f.what}, not a file: ${NOT_A_FILE[f.what]}, so no order can be read from it. What to do: name the decision file itself in \`mode.decision.file\`.`,
241
+ "decision-linked": (f) =>
242
+ `The decision of \`${f.id}\`, \`${f.file}\`, passes through the symbolic link \`${f.link}\`: git records the link, not the files behind it, so no order can be read from it. What to do: name a decision file that lies in real directories in \`mode.decision.file\`.`,
228
243
  "no-history": (f) =>
229
244
  `\`${f.id}\` is decided before its run, and git's history is the proof of that order, but git could not answer: ${f.why}. What to do: lint the paper inside its git repository, with git installed.`,
230
245
  shallow: (f) =>
@@ -82,11 +82,11 @@ const NINE_AT: IndexInput = {
82
82
  ]),
83
83
  history: new Map([
84
84
  [
85
- "P1-S01",
85
+ "P1-S01-hint/plan.json",
86
86
  { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
87
87
  ],
88
88
  [
89
- "P1-S02",
89
+ "P1-S02-s/plan.json",
90
90
  {
91
91
  kind: "read",
92
92
  decision: null,
@@ -94,7 +94,7 @@ const NINE_AT: IndexInput = {
94
94
  firstOutput: null,
95
95
  },
96
96
  ],
97
- ["P1-S04", { kind: "unread", why: "shallow clone" }],
97
+ ["P1-S04-s/plan.json", { kind: "unread", why: "shallow clone" }],
98
98
  ]),
99
99
  };
100
100
 
@@ -124,7 +124,7 @@ describe("the mode columns of the index", () => {
124
124
  plans,
125
125
  history: new Map([
126
126
  [
127
- "P1-S01",
127
+ "P1-S01-s/plan.json",
128
128
  {
129
129
  kind: "read",
130
130
  decision: D,
@@ -151,6 +151,49 @@ describe("the mode columns of the index", () => {
151
151
  });
152
152
  });
153
153
 
154
+ it("two rows of one id each show their own history, keyed by folder and plan", () => {
155
+ const r: StudyRegistry = {
156
+ prefix: "P1",
157
+ studies: [row(1, { folder: "P1-S01-a" }), row(1, { folder: "P1-S01-b" })],
158
+ };
159
+ const at: IndexInput = {
160
+ plans: new Map([
161
+ ["P1-S01-a/plan.json", plan(DECIDED)],
162
+ ["P1-S01-b/plan.json", plan(DECIDED)],
163
+ ]),
164
+ history: new Map([
165
+ [
166
+ "P1-S01-a/plan.json",
167
+ { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
168
+ ],
169
+ [
170
+ "P1-S01-b/plan.json",
171
+ {
172
+ kind: "read",
173
+ decision: null,
174
+ decisionDeleted: false,
175
+ firstOutput: null,
176
+ },
177
+ ],
178
+ ]),
179
+ };
180
+ // Guards: keyed by id, the second row's history was shown on both rows (the later one wins).
181
+ expect(
182
+ studyIndex(r, at)
183
+ .split("\n")
184
+ .slice(2)
185
+ .map((l) =>
186
+ l
187
+ .split("|")
188
+ .slice(6, 9)
189
+ .map((c) => c.trim()),
190
+ ),
191
+ ).toEqual([
192
+ ["decide_before_run", "dddddddddddd 2026-10-14", "aaaaaaaaaaaa 2026-10-16"],
193
+ ["decide_before_run", "not committed", "none"],
194
+ ]);
195
+ });
196
+
154
197
  it("a decision committed and since deleted shows its commit, and that it is gone", () => {
155
198
  // Guards: the Methods sentence must not cite a decision that no longer stands.
156
199
  expect(
@@ -181,6 +224,7 @@ describe("indexHistory", () => {
181
224
  indexHistory(study({ kind: "unknown", why: "not a repository" })),
182
225
  indexHistory(study({ kind: "shallow" })),
183
226
  indexHistory(study({ kind: "not-a-file", what: "directory" })),
227
+ indexHistory(study({ kind: "linked", link: "repro/sub" })),
184
228
  indexHistory(
185
229
  study({
186
230
  kind: "read",
@@ -204,6 +248,7 @@ describe("indexHistory", () => {
204
248
  { kind: "unread", why: "git could not answer" },
205
249
  { kind: "unread", why: "shallow clone" },
206
250
  { kind: "unread", why: "the decision is a directory" },
251
+ { kind: "unread", why: "the decision is behind a symbolic link" },
207
252
  { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
208
253
  {
209
254
  kind: "read",
@@ -85,6 +85,8 @@ export const indexHistory = (s: DecideFirstStudy): IndexHistory => {
85
85
  switch (s.facts.kind) {
86
86
  case "not-a-file":
87
87
  return { kind: "unread", why: `the decision is a ${s.facts.what}` };
88
+ case "linked":
89
+ return { kind: "unread", why: "the decision is behind a symbolic link" };
88
90
  case "unknown":
89
91
  return { kind: "unread", why: "git could not answer" };
90
92
  case "shallow":
@@ -0,0 +1,55 @@
1
+ /** Which token a portal command uses: the site's variable only, a lone site-less one refused by name. */
2
+ import assert from "node:assert/strict";
3
+ import { test } from "vitest";
4
+ import { hostOf, lookUpToken } from "./portal-token.ts";
5
+
6
+ const NAMES = { variable: "HOTCRP_TOKEN_MSR2027", siteLess: "HOTCRP_TOKEN" };
7
+
8
+ test("hostOf: the URL's host; a text that is no URL stands for itself", () => {
9
+ assert.equal(
10
+ hostOf("https://msr2027.hotcrp.com/deadlines"),
11
+ "msr2027.hotcrp.com",
12
+ );
13
+ assert.equal(hostOf("msr2027.hotcrp.com"), "msr2027.hotcrp.com");
14
+ });
15
+
16
+ test("lookUpToken: the site's variable set → its token, trimmed, and its name", () => {
17
+ const env: Record<string, string> = {
18
+ HOTCRP_TOKEN_MSR2027: " hct_site \n",
19
+ HOTCRP_TOKEN: "hct_other",
20
+ };
21
+ assert.deepEqual(
22
+ lookUpToken(NAMES, (n) => env[n]),
23
+ {
24
+ ok: true,
25
+ value: { variable: "HOTCRP_TOKEN_MSR2027", token: "hct_site" },
26
+ },
27
+ );
28
+ });
29
+
30
+ test("🔴 lookUpToken: only the site-less variable set → refused by name, never used", () => {
31
+ const env: Record<string, string> = { HOTCRP_TOKEN: "hct_other" };
32
+ assert.deepEqual(
33
+ lookUpToken(NAMES, (n) => env[n]),
34
+ {
35
+ ok: false,
36
+ error: {
37
+ kind: "site-less",
38
+ variable: "HOTCRP_TOKEN_MSR2027",
39
+ siteLess: "HOTCRP_TOKEN",
40
+ },
41
+ },
42
+ );
43
+ });
44
+
45
+ test("lookUpToken: neither set, or only blanks → missing, naming the site's variable", () => {
46
+ const blank: Record<string, string> = {
47
+ HOTCRP_TOKEN_MSR2027: " ",
48
+ HOTCRP_TOKEN: "",
49
+ };
50
+ for (const read of [() => undefined, (n: string) => blank[n]])
51
+ assert.deepEqual(lookUpToken(NAMES, read), {
52
+ ok: false,
53
+ error: { kind: "missing", variable: "HOTCRP_TOKEN_MSR2027" },
54
+ });
55
+ });