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,287 @@
1
+ /**
2
+ * The refresh's logic: what one preset becomes, what a run prints, writes and returns — over a
3
+ * fake VenueDeadlines port that answers plain domain values (how HotCRP's page is parsed is
4
+ * src/adapters/hotcrp/deadlines.test.ts's) and presets held in memory.
5
+ */
6
+ import { readdirSync, readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { describe, expect, test } from "vitest";
10
+ import type { PortalDeadlines } from "#src/domain/deadline";
11
+ import { err, ok, type Result } from "#src/domain/result";
12
+ import type { PortalFailure } from "#src/domain/submission";
13
+ import type { VenueDeadlines } from "#src/ports/venue-deadlines";
14
+ import { parsePreset } from "#src/tex-requirements";
15
+ import {
16
+ refreshPreset,
17
+ runRefresh,
18
+ withDeadlines,
19
+ type Outcome,
20
+ type PresetFiles,
21
+ } from "./refresh.ts";
22
+
23
+ const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
24
+ const PRESETS = join(ROOT, "presets");
25
+ const MSR = join(PRESETS, "msr.jsonc");
26
+ const msrText = readFileSync(MSR, "utf8");
27
+ const readings = (text: string) => parsePreset(text, MSR).deadlines ?? [];
28
+ /** MSR with its submission deadline two days before the portal's. */
29
+ const earlier = msrText.replace(
30
+ '"at": "2026-10-21T11:59:59Z"',
31
+ '"at": "2026-10-19T04:00:00Z"',
32
+ );
33
+ /** Presets held in memory, by name: what a run reads, and what it writes back. */
34
+ function inMemory(files: Readonly<Record<string, string>>) {
35
+ const written: Record<string, string> = {};
36
+ const presets: PresetFiles = {
37
+ names: Object.keys(files).toSorted(),
38
+ read: (name) => files[name] ?? "",
39
+ write: (name, text) => {
40
+ written[name] = text;
41
+ },
42
+ path: (name) => join(PRESETS, name),
43
+ };
44
+ return { presets, written };
45
+ }
46
+
47
+ /** What MSR 2027's portal lists — the readings presets/msr.jsonc holds. */
48
+ const MSR_PAGE: PortalDeadlines = {
49
+ now: 1792434476,
50
+ open: true,
51
+ blind: true,
52
+ deadlines: [
53
+ {
54
+ what: "submission",
55
+ label: "Submission deadline",
56
+ at: "2026-10-21T11:59:59Z",
57
+ },
58
+ {
59
+ what: "resubmission",
60
+ label: "Resubmission deadline",
61
+ at: "2026-10-24T11:59:59Z",
62
+ },
63
+ ],
64
+ };
65
+ /** What AIDC 2026's portal lists — the readings presets/aidc.jsonc holds. */
66
+ const AIDC_PAGE: PortalDeadlines = {
67
+ now: 1791194489,
68
+ open: true,
69
+ blind: true,
70
+ deadlines: [
71
+ {
72
+ what: "submission",
73
+ label: "Submission deadline",
74
+ at: "2026-10-03T11:59:59Z",
75
+ },
76
+ { what: "camera-ready", label: "camera-ready", at: "2026-10-31T11:59:59Z" },
77
+ ],
78
+ };
79
+ /** A portal that answers `answer`. */
80
+ const answering =
81
+ (answer: Result<PortalDeadlines, PortalFailure>) => (): VenueDeadlines => ({
82
+ read: () => Promise.resolve(answer),
83
+ });
84
+ /** Each site's page, by the URL the preset names. */
85
+ const bySite = (url: string): VenueDeadlines =>
86
+ answering(ok(url.includes("acsac") ? AIDC_PAGE : MSR_PAGE))();
87
+
88
+ /** The outcome, known to be of `kind` — or the test fails naming what it was. */
89
+ function as<K extends Outcome["kind"]>(
90
+ o: Outcome,
91
+ kind: K,
92
+ ): Extract<Outcome, { kind: K }> {
93
+ const isKind = (x: Outcome): x is Extract<Outcome, { kind: K }> =>
94
+ x.kind === kind;
95
+ if (!isKind(o)) throw new Error(`expected ${kind}, got ${JSON.stringify(o)}`);
96
+ return o;
97
+ }
98
+
99
+ describe("withDeadlines — the `deadlines` value replaced by jsonc-parser, everything else as written", () => {
100
+ test("writing back the readings the file holds gives the file's own bytes", async () => {
101
+ expect(await withDeadlines(msrText, MSR, readings(msrText))).toBe(msrText);
102
+ });
103
+ });
104
+
105
+ test("a preset with a portal and no `deadlines` yet: each reading added, the block right after `portal`", async () => {
106
+ const start = msrText.indexOf(" // Read from the portal");
107
+ const end = msrText.indexOf(" ],\n", start) + " ],\n".length;
108
+ const o = as(
109
+ await refreshPreset({
110
+ file: MSR,
111
+ text: msrText.slice(0, start) + msrText.slice(end),
112
+ portal: answering(ok(MSR_PAGE)),
113
+ today: "2026-10-19",
114
+ }),
115
+ "changed",
116
+ );
117
+ expect(o.changes).toEqual([
118
+ "submission: added 2026-10-21T11:59:59Z",
119
+ "resubmission: added 2026-10-24T11:59:59Z",
120
+ ]);
121
+ expect(readings(o.text)).toEqual(readings(msrText));
122
+ expect(o.text).toMatch(
123
+ /"url": "https:\/\/msr2027\.hotcrp\.com",\n {2}\},\n {2}"deadlines": \[/,
124
+ );
125
+ });
126
+
127
+ describe("refreshPreset on MSR 2027's page", () => {
128
+ test("the portal says what the preset says: unchanged", async () => {
129
+ const o = await refreshPreset({
130
+ file: MSR,
131
+ text: msrText,
132
+ portal: answering(ok(MSR_PAGE)),
133
+ today: "2026-10-06",
134
+ });
135
+ expect(o).toEqual({
136
+ kind: "unchanged",
137
+ url: "https://msr2027.hotcrp.com/deadlines",
138
+ notes: [],
139
+ });
140
+ });
141
+
142
+ test("the portal moved the submission: only that reading changes, with today's read day", async () => {
143
+ const o = as(
144
+ await refreshPreset({
145
+ file: MSR,
146
+ text: earlier,
147
+ portal: answering(ok(MSR_PAGE)),
148
+ today: "2026-10-20",
149
+ }),
150
+ "changed",
151
+ );
152
+ expect(o.changes).toEqual([
153
+ "submission: 2026-10-19T04:00:00Z → 2026-10-21T11:59:59Z",
154
+ ]);
155
+ // Only the deadlines block differs: the comments and every other key are the file's own.
156
+ expect(o.text).toBe(
157
+ msrText.replace(
158
+ '"read": "2026-10-19",\n },\n {\n "what": "resubmission"',
159
+ '"read": "2026-10-20",\n },\n {\n "what": "resubmission"',
160
+ ),
161
+ );
162
+ });
163
+ });
164
+
165
+ describe("refreshPreset — what it skips, and a portal it cannot read", () => {
166
+ test("a preset without a portal, or with one paperlint has no adapter for, is skipped", async () => {
167
+ const aisec = join(PRESETS, "aisec.jsonc");
168
+ const skipped = { kind: "skipped" };
169
+ expect(
170
+ await refreshPreset({
171
+ file: aisec,
172
+ text: readFileSync(aisec, "utf8"),
173
+ portal: answering(ok(MSR_PAGE)),
174
+ today: "2026-10-05",
175
+ }),
176
+ ).toEqual(skipped);
177
+ expect(
178
+ await refreshPreset({
179
+ file: MSR,
180
+ text: msrText.replace('"kind": "hotcrp"', '"kind": "easychair"'),
181
+ portal: answering(ok(MSR_PAGE)),
182
+ today: "2026-10-05",
183
+ }),
184
+ ).toEqual(skipped);
185
+ });
186
+
187
+ test.each<[string, PortalFailure, string]>([
188
+ [
189
+ "a site that answers 404",
190
+ {
191
+ kind: "refused",
192
+ httpStatus: 404,
193
+ messages: [
194
+ { message: "No such conference", field: null, status: null },
195
+ ],
196
+ },
197
+ "the portal refused (HTTP 404): No such conference",
198
+ ],
199
+ [
200
+ "a site that does not answer",
201
+ { kind: "unreachable", detail: "connect ECONNREFUSED" },
202
+ "the portal cannot be reached: connect ECONNREFUSED",
203
+ ],
204
+ [
205
+ "a page that is not the one expected",
206
+ { kind: "malformed", httpStatus: 200, detail: "no init" },
207
+ "the portal answered HTTP 200, but not as its API does: no init",
208
+ ],
209
+ ])("%s is reported, never guessed", async (_what, failure, why) => {
210
+ expect(
211
+ await refreshPreset({
212
+ file: MSR,
213
+ text: msrText,
214
+ portal: answering(err(failure)),
215
+ today: "2026-10-05",
216
+ }),
217
+ ).toEqual({
218
+ kind: "unreadable",
219
+ url: "https://msr2027.hotcrp.com/deadlines",
220
+ why,
221
+ });
222
+ });
223
+ });
224
+
225
+ describe("runRefresh — what it prints, writes and returns", () => {
226
+ test("nothing changed: one line, no preset written, exit 0", async () => {
227
+ const { presets, written } = inMemory(
228
+ Object.fromEntries(
229
+ readdirSync(PRESETS)
230
+ .filter((f) => f.endsWith(".jsonc"))
231
+ .map((f) => [f, readFileSync(join(PRESETS, f), "utf8")]),
232
+ ),
233
+ );
234
+ const lines: string[] = [];
235
+ const code = await runRefresh({
236
+ presets,
237
+ portal: bySite,
238
+ today: "2026-10-06",
239
+ log: (l) => lines.push(l),
240
+ summary: null,
241
+ });
242
+ expect([code, written]).toStrictEqual([0, {}]);
243
+ expect(lines.at(-1)).toMatch(
244
+ /^✓ no deadline changed: 2 preset\(s\) with a portal read, \d+ skipped$/,
245
+ );
246
+ });
247
+
248
+ test("a preset that does not parse is a defect of the repository: exit 1, naming it", async () => {
249
+ const lines: string[] = [];
250
+ const summaries: string[] = [];
251
+ const code = await runRefresh({
252
+ presets: inMemory({ "bad.jsonc": "{ nope" }).presets,
253
+ portal: bySite,
254
+ today: "2026-10-06",
255
+ log: (l) => lines.push(l),
256
+ summary: (text) => summaries.push(text),
257
+ });
258
+ expect(code).toBe(1);
259
+ expect(lines[0]).toMatch(
260
+ /^::error title=deadlines::bad\.jsonc does not parse: /,
261
+ );
262
+ expect(summaries[0]).toMatch(/^- 🔴 `bad\.jsonc` does not parse: /m);
263
+ });
264
+ });
265
+
266
+ describe("runRefresh — a changed preset", () => {
267
+ test("a changed preset is written back, and the summary names it", async () => {
268
+ const { presets, written } = inMemory({ "msr.jsonc": earlier });
269
+ const lines: string[] = [];
270
+ const summaries: string[] = [];
271
+ const code = await runRefresh({
272
+ presets,
273
+ portal: bySite,
274
+ today: "2026-10-20",
275
+ log: (l) => lines.push(l),
276
+ summary: (text) => summaries.push(text),
277
+ });
278
+ expect(code).toBe(0);
279
+ expect(readings(written["msr.jsonc"] ?? "")[0]?.at).toBe(
280
+ "2026-10-21T11:59:59Z",
281
+ );
282
+ expect(lines.at(-1)).toBe("1 preset(s) changed");
283
+ expect(summaries[0]).toMatch(
284
+ /^- `msr\.jsonc` \(https:\/\/msr2027\.hotcrp\.com\/deadlines\)\n {2}- submission: 2026-10-19T04:00:00Z → 2026-10-21T11:59:59Z$/m,
285
+ );
286
+ });
287
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * THE SHIPPED PRESETS' DEADLINES, RE-READ FROM THEIR PORTALS — the refresh's logic, behind the
3
+ * VenueDeadlines port and the preset files it is handed (`main.ts` plugs in the real ones). For
4
+ * every preset whose `portal` paperlint has an adapter for, the portal's deadlines are compared
5
+ * with the preset's `deadlines` (`compare.ts`), and a preset that changed is rewritten — the
6
+ * `deadlines` value only, by jsonc-parser, then Prettier as `npm run fmt` would.
7
+ *
8
+ * IT REPORTS, IT DOES NOT BLOCK. A portal that is down, gone or not the page expected is a
9
+ * `::warning::` line and a line in the summary, and the run exits 0 — the venue's outage is not this
10
+ * repository's defect, and the next day's run reads it again. A preset that does not parse IS:
11
+ * `::error::`, exit 1.
12
+ */
13
+ import { applyEdits, modify } from "jsonc-parser";
14
+ import { format } from "prettier";
15
+ import type { IsoDate, Reading } from "#src/domain/deadline";
16
+ import { supportedPortal } from "#src/domain/submission";
17
+ import { messageOf } from "#src/domain/text";
18
+ import type { VenueDeadlines } from "#src/ports/venue-deadlines";
19
+ import { failureText } from "#src/submission";
20
+ import { parsePreset } from "#src/tex-requirements";
21
+ import { refreshReadings } from "./compare.ts";
22
+
23
+ /** What happened to one preset. */
24
+ export type Outcome =
25
+ | { readonly kind: "skipped" }
26
+ | { readonly kind: "broken"; readonly why: string }
27
+ | { readonly kind: "unreadable"; readonly url: string; readonly why: string }
28
+ | {
29
+ readonly kind: "unchanged";
30
+ readonly url: string;
31
+ readonly notes: readonly string[];
32
+ }
33
+ | {
34
+ readonly kind: "changed";
35
+ readonly url: string;
36
+ readonly text: string;
37
+ readonly changes: readonly string[];
38
+ readonly notes: readonly string[];
39
+ };
40
+
41
+ /** The presets of one directory, by file name. */
42
+ export interface PresetFiles {
43
+ /** The `*.jsonc` presets' names, sorted. */
44
+ readonly names: readonly string[];
45
+ readonly read: (name: string) => string;
46
+ readonly write: (name: string, text: string) => void;
47
+ /** Where a preset lives, as a message names it and Prettier infers its format from. */
48
+ readonly path: (name: string) => string;
49
+ }
50
+
51
+ /** The preset's text with its `deadlines` value set to `readings` (inserted after `portal` when absent). */
52
+ export async function withDeadlines(
53
+ text: string,
54
+ file: string,
55
+ readings: readonly Reading[],
56
+ ): Promise<string> {
57
+ const edits = modify(text, ["deadlines"], readings, {
58
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
59
+ getInsertionIndex: (keys) => keys.indexOf("portal") + 1,
60
+ });
61
+ // Prettier, for what jsonc-parser does not print: the trailing commas `npm run fmt:check` requires.
62
+ return format(applyEdits(text, edits), { filepath: file });
63
+ }
64
+
65
+ /** One preset: its portal read, its readings compared, its new text when one changed. */
66
+ export async function refreshPreset(o: {
67
+ readonly file: string;
68
+ readonly text: string;
69
+ readonly portal: (site: string) => VenueDeadlines;
70
+ readonly today: IsoDate;
71
+ }): Promise<Outcome> {
72
+ const parsed = ((): ReturnType<typeof parsePreset> | string => {
73
+ try {
74
+ return parsePreset(o.text, o.file);
75
+ } catch (e) {
76
+ return messageOf(e);
77
+ }
78
+ })();
79
+ if (typeof parsed === "string") return { kind: "broken", why: parsed };
80
+ const portal = parsed.portal === null ? null : supportedPortal(parsed.portal);
81
+ if (portal === null || !portal.ok) return { kind: "skipped" };
82
+ const url = `${portal.value.url}/deadlines`;
83
+ const page = await o.portal(portal.value.url).read();
84
+ if (!page.ok)
85
+ return { kind: "unreadable", url, why: failureText(page.error) };
86
+ const r = refreshReadings(parsed.deadlines ?? [], page.value, url, o.today);
87
+ if (r.changes.length === 0) return { kind: "unchanged", url, notes: r.notes };
88
+ const text = await withDeadlines(o.text, o.file, r.readings);
89
+ return { kind: "changed", url, text, changes: r.changes, notes: r.notes };
90
+ }
91
+
92
+ /** The lines one preset's outcome contributes: to the log (annotations included) and to the summary. */
93
+ function report(
94
+ name: string,
95
+ o: Outcome,
96
+ ): { readonly log: readonly string[]; readonly md: readonly string[] } {
97
+ switch (o.kind) {
98
+ case "skipped":
99
+ return { log: [], md: [] };
100
+ case "broken":
101
+ return {
102
+ log: [`::error title=deadlines::${name} does not parse: ${o.why}`],
103
+ md: [`- 🔴 \`${name}\` does not parse: ${o.why}`],
104
+ };
105
+ case "unreadable":
106
+ return {
107
+ log: [
108
+ `::warning title=deadlines::${name}: could not read ${o.url} — ${o.why}`,
109
+ ],
110
+ md: [`- ⚠️ \`${name}\`: could not read ${o.url} — ${o.why}`],
111
+ };
112
+ case "unchanged":
113
+ return { log: o.notes.map((n) => `${name}: ${n}`), md: [] };
114
+ case "changed": {
115
+ const lines = [...o.changes, ...o.notes];
116
+ return {
117
+ log: lines.map((c) => `${name}: ${c}`),
118
+ md: [`- \`${name}\` (${o.url})`, ...lines.map((c) => ` - ${c}`)],
119
+ };
120
+ }
121
+ }
122
+ }
123
+
124
+ /** The pull request's body. */
125
+ const summaryOf = (today: IsoDate, lines: readonly string[]): string =>
126
+ [
127
+ `The venue presets' deadlines, re-read on ${today} from each preset's portal by \`.github/workflows/deadlines.yml\` (\`scripts/refresh-deadlines/\`). A reading whose value changed carries today's \`read\` day; the others keep theirs.`,
128
+ "",
129
+ ...lines,
130
+ "",
131
+ "Merging releases a patch; papers get the new dates through their normal update of paperlint.",
132
+ "",
133
+ ].join("\n");
134
+
135
+ /** What a refresh run needs: the presets, how to read a portal, the day, where to print. */
136
+ export interface RefreshDeps {
137
+ readonly presets: PresetFiles;
138
+ readonly portal: (site: string) => VenueDeadlines;
139
+ readonly today: IsoDate;
140
+ readonly log: (line: string) => void;
141
+ /** Where the pull request's body goes; null: nowhere. */
142
+ readonly summary: ((text: string) => void) | null;
143
+ }
144
+
145
+ /** Every preset, refreshed — a changed one written back. */
146
+ const outcomesOf = (
147
+ d: RefreshDeps,
148
+ ): Promise<readonly { readonly name: string; readonly out: Outcome }[]> =>
149
+ Promise.all(
150
+ d.presets.names.map(async (name) => {
151
+ const out = await refreshPreset({
152
+ file: d.presets.path(name),
153
+ text: d.presets.read(name),
154
+ portal: d.portal,
155
+ today: d.today,
156
+ });
157
+ if (out.kind === "changed") d.presets.write(name, out.text);
158
+ return { name, out };
159
+ }),
160
+ );
161
+
162
+ /** Every preset refreshed, reported to the log and the summary. 0 unless a preset does not parse. */
163
+ export async function runRefresh(d: RefreshDeps): Promise<number> {
164
+ const outcomes = await outcomesOf(d);
165
+ const reports = outcomes.map(({ name, out }) => report(name, out));
166
+ const count = (k: Outcome["kind"]) =>
167
+ outcomes.filter((x) => x.out.kind === k).length;
168
+ [
169
+ ...reports.flatMap((r) => r.log),
170
+ count("changed") === 0
171
+ ? `✓ no deadline changed: ${String(count("unchanged"))} preset(s) with a portal read, ${String(count("skipped"))} skipped`
172
+ : `${String(count("changed"))} preset(s) changed`,
173
+ ].forEach((l) => {
174
+ d.log(l);
175
+ });
176
+ d.summary?.(
177
+ summaryOf(
178
+ d.today,
179
+ reports.flatMap((r) => r.md),
180
+ ),
181
+ );
182
+ return count("broken") > 0 ? 1 : 0;
183
+ }
@@ -4,7 +4,7 @@ description: Grade how a paper reads and fix it sentence by sentence. Use when a
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:1897016b69f3f9b8 compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:2b7193c739a374e6 compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
8
8
 
9
9
  # grade-paper-writing — grade how the paper READS, then fix it sentence by sentence
10
10
 
@@ -223,7 +223,9 @@ is what happened.
223
223
  ```
224
224
 
225
225
  It measures the prose a reader sees — the files the last build read spliced in, no preamble,
226
- comments, floats or inline `.bib` — so its rates are the paper's, not the source file's. Run
226
+ comments, floats or inline `.bib` — so its rates are the paper's, not the source file's — and, on
227
+ its own line, the figure captions that float outside that prose, flagging a caption sentence over
228
+ 40 words or a caption over 100. Run
227
229
  `npx paperlint build` first: with no current build record it reads `paper.tex` alone and says so. The skill is what must ACT on it.
228
230
  Its thresholds are sourced; a FLAG means the text sits above a published human or machine
229
231
  baseline, which is a defect with a number attached, not an opinion.
@@ -229,7 +229,9 @@ is what happened.
229
229
  \`\`\`
230
230
 
231
231
  It measures the prose a reader sees — the files the last build read spliced in, no preamble,
232
- comments, floats or inline \`.bib\` — so its rates are the paper's, not the source file's. Run
232
+ comments, floats or inline \`.bib\` — so its rates are the paper's, not the source file's — and, on
233
+ its own line, the figure captions that float outside that prose, flagging a caption sentence over
234
+ 40 words or a caption over 100. Run
233
235
  \`npx paperlint build\` first: with no current build record it reads \`paper.tex\` alone and says so. The skill is what must ACT on it.
234
236
  Its thresholds are sourced; a FLAG means the text sits above a published human or machine
235
237
  baseline, which is a defect with a number attached, not an opinion.
@@ -1,10 +1,19 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
+ exports[`--flags-only: a metric over its threshold (not a caption) is a finding too, and the report flags the same line 1`] = `
4
+ "✍️ prose-lint — paper.tex — 1 finding(s):
5
+ sentences whose main claim is a negation: 75%
6
+ run prose-lint.mjs on the file without --flags-only for every metric
7
+ "
8
+ `;
9
+
3
10
  exports[`--flags-only: an over-long caption flags and exits 1 1`] = `
4
11
  "✍️ prose-lint — paper.tex — 2 finding(s):
5
- figures/fig1.tex: caption sentence of 46 words (warn >40) — "Big. word0 word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 wor…"
6
- figures/fig1.tex: caption is 106 words (warn >100) — a caption this long is a section under a picture
7
- run prose-lint.mjs on the file without --flags-only for the sentences
12
+ all metadiscourse/1000w 363.6 (Hyland RA baseline 64.9, warn >90)
13
+ figure captions over the limit: 2
14
+ > figures/fig1.tex: caption sentence of 46 words (warn >40) — "Big. word0 word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 wor…"
15
+ > figures/fig1.tex: caption is 106 words (warn >100) — a caption this long is a section under a picture
16
+ run prose-lint.mjs on the file without --flags-only for every metric
8
17
  "
9
18
  `;
10
19
 
@@ -37,6 +46,7 @@ ok all metadiscourse/1000w 0 (Hyland RA baseline 64.9, warn >90)
37
46
  ok "rather than"/instead-of/10,000w 0 (warn >25; not epanorthosis, no published baseline)
38
47
  ok sentences ending on a cross-ref/citation/hedge (wasted stress position): 0
39
48
  ok sentences whose main claim is a negation: 0%
49
+ ok figure captions over the limit: 0
40
50
  topic positions (whose story is this?): "a rendered page." | "it was hyphenated across a" | "next page text here."
41
51
 
42
52
  (thresholds and their sources are in THRESHOLDS at the top of this file)
@@ -64,6 +74,9 @@ FLAG sentences ending on a cross-ref/citation/hedge (wasted stress position): 12
64
74
  > …], and that results from a handful of runs need honest statistics [1].
65
75
  > …g code [1], and models that memorize answers instead of reasoning [1].
66
76
  ok sentences whose main claim is a negation: 37%
77
+ FLAG figure captions over the limit: 2
78
+ > paper.tex: caption sentence of 52 words (warn >40) — "Two things make it different from how efficiency tools are usually measured: the tool is installed the way a u…"
79
+ > paper.tex: caption sentence of 44 words (warn >40) — "The advertised 65% is an output-token reduction; because output is only math of the dollar cost, even a perfec…"
67
80
  topic positions (whose story is this?): "a fast-growing ecosystem of plugins," | "the most popular single artifact---a" | "we show that such claims," | "efficiency tools are validated on" | "we introduce a reproducible, cost-aware," | "across 140 runs on two" | "output falls far short of" | "the ceiling is also structural:" | "we argue that trustworthy evaluation" | "ai coding agents and their" | "built on the reasoning-and-acting loops" | "assistants of this kind are" | "an unverified performance claim about" | "while the community has invested" | "we focus on the most" | "the flagship example is a" | "naming the commits matters more" | "two separate claims are bundled" | "the first is that the" | "that one has already been" | "the second claim is the" | "nobody had measured it." | "it fails for a reason" | "this paper is about the" | "rq: when a token-efficiency tool" | "the first half is measured," | "our contribution is not that" | "we identify why the headline" | "we build a cost-aware, correctness-gated" | "we measure two widely used" | "output falls far short of" | "the dollar bill does not" | "the broader point is about" | "a modern agent bills tokens" | "a multi-turn agentic session [1]" | "reading the upstream benchmark code" | "the reported number measures something" | "the fix is to measure" | "the compression literature these tools" | "the harness --- the ecosystem" | "we implement the evaluation as" | "figure 1 shows the whole" | "each tool is delivered as" | "a skill that ships as" | "this step is load-bearing: a" | "we use seven self-contained coding" | "the suite deliberately skews short" | "each task is run with" | "the runs date from july" | "the two tools are the" | "for every run we record" | "on a subscription the \`\`bill''" | "we report dollars---the weighting most" | "each task carries a deterministic" | "these are lightweight structural checks" | "a trial where the skill" | "for each task we report" | "we call an effect significant" | "we also note which effects" | "for the bill we report" | "averages taken over a few" | "this protocol is sized to" | "two things are still required" | "the flagship skill (claim 65%)" | "we sign bill changes so" | "for the flagship, the bill" | "either way the answer is" | "the second tool (claim 63%)" | "it is also not significant." | "its one per-task cell that" | "the intervals are wide: at" | "but they exclude anything near" | "whatever room remains is capped" | "correctness." | "both arms passed the correctness" | "a sharper signal hides in" | "this is a short-session effect" | "this is not a quirk" | "both shares below are measured," | "pooled across all tasks and" | "if output is a fraction" | "even a perfect 65% output" | "the advertised 63--75% and the" | "the flagship's other feature trims" | "output is a small fraction" | "we measure cost: latency and" | "we do not claim \`\`these" | "nor do we dispute the" | "our point is that they" | "we label each by its" | "we run five trials per" | "that is itself part of" | "across tasks the flagship's output" | "the honest quantity is therefore" | "our design refutes the advertised" | "what caps those is the" | "a formal equivalence test (tost---a" | "the harness is not toothless," | "at that scale we read" | "we test one model, and" | "anthropic's tiers all share a" | "the ceiling scales linearly with" | "even if output were priced" | "our tasks top out around" | "this cuts in our favor." | "a longer session re-reads a" | "that pushes the ceiling lower," | "one might object that terser" | "but that downstream saving is" | "so it adds a few" | "the compounded ceiling is --," | "the overhead runs the other" | "an always-loaded tool prompt adds" | "that fixed cost amortizes over" | "the durable long-session prediction is" | "an empirical long-session demonstration is" | "every run in the main" | "the harness decides how many" | "a wordier model would raise" | "the ceiling of section 1" | "a pilot on claude haiku" | "nearly double, and still about" | "both runs ship in the" | "a second harness is reachable" | "the flagship now ships wrappers" | "measuring a second harness therefore" | "wrapper list and the rejected" | "we do not study multi-agent" | "we test two tools, and" | "the harness can a/b any" | "the bias runs in our" | "our per-run bill uses the" | "a user can also invalidate" | "(the first two rewrite the" | "that is, they push cost" | "they lower the ceiling and" | "so our clean-caching tasks, if" | "the correctness gate is a" | "so \`\`zero regressions'' bounds gross" | "a stronger gate that runs" | "still, a gate that never" | "without it, a harness would" | "the efficiency-claim failure is a" | "a community norm of cost-aware," | "execution-based, correctness-gated benchmarks such as" | "we keep their correctness-first discipline" | "several lines of work push" | "we take the same stance---price" | "closest to us, [1] argue" | "we turn that lens on" | "a long line of work" | "capability benchmarks show the same" | "we aim the same critique" | "the industry a/b test cited" | "what we add over such" | "the efficiency-tool market runs on" | "these tools trim what the" | "we measured two of the" | "the numbers are not fabricated;" | "the output you are shown" | "trimming the model's replies to" | "we release the measurements with" | "before you install the next" | "the complete harness of section" | "the measurements are released per" | "individual trial rows are not"
68
81
 
69
82
  (thresholds and their sources are in THRESHOLDS at the top of this file)