paperlint 7.1.0 → 8.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 (261) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +6 -0
  4. package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
  5. package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
  6. package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
  7. package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/deadlines.io.js +26 -0
  9. package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/deadlines.js +134 -0
  11. package/dist/adapters/hotcrp/deadlines.js.map +1 -0
  12. package/dist/adapters/hotcrp/index.d.ts +2 -0
  13. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/index.js +2 -0
  15. package/dist/adapters/hotcrp/index.js.map +1 -1
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +1 -6
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +3 -4
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +53 -47
  22. package/dist/cli.js.map +1 -1
  23. package/dist/cycle-rules.d.ts +24 -0
  24. package/dist/cycle-rules.d.ts.map +1 -0
  25. package/dist/cycle-rules.js +212 -0
  26. package/dist/cycle-rules.js.map +1 -0
  27. package/dist/domain/cycle.d.ts +148 -0
  28. package/dist/domain/cycle.d.ts.map +1 -0
  29. package/dist/domain/cycle.js +281 -0
  30. package/dist/domain/cycle.js.map +1 -0
  31. package/dist/domain/deadline.d.ts +128 -0
  32. package/dist/domain/deadline.d.ts.map +1 -0
  33. package/dist/domain/deadline.js +281 -0
  34. package/dist/domain/deadline.js.map +1 -0
  35. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  36. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  37. package/dist/eslint-rules/cycle-stage.js +119 -0
  38. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  39. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  40. package/dist/eslint-rules/latex-language.js +10 -13
  41. package/dist/eslint-rules/latex-language.js.map +1 -1
  42. package/dist/eslint-rules/paper-context.js +2 -2
  43. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  44. package/dist/eslint-rules/paper-typography.js +22 -91
  45. package/dist/eslint-rules/paper-typography.js.map +1 -1
  46. package/dist/eslint-rules/papers.d.ts +1 -3
  47. package/dist/eslint-rules/papers.d.ts.map +1 -1
  48. package/dist/eslint-rules/papers.js +2 -4
  49. package/dist/eslint-rules/papers.js.map +1 -1
  50. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  51. package/dist/eslint-rules/tex-build.js +8 -25
  52. package/dist/eslint-rules/tex-build.js.map +1 -1
  53. package/dist/init.d.ts +1 -4
  54. package/dist/init.d.ts.map +1 -1
  55. package/dist/init.js +3 -9
  56. package/dist/init.js.map +1 -1
  57. package/dist/lib/markdown.d.ts +5 -81
  58. package/dist/lib/markdown.d.ts.map +1 -1
  59. package/dist/lib/markdown.js +5 -116
  60. package/dist/lib/markdown.js.map +1 -1
  61. package/dist/lib/paper-config.d.ts.map +1 -1
  62. package/dist/lib/paper-config.js +2 -2
  63. package/dist/lib/paper-config.js.map +1 -1
  64. package/dist/lib/skill-eval-fixture.d.ts +6 -4
  65. package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
  66. package/dist/lib/skill-eval-fixture.js +25 -18
  67. package/dist/lib/skill-eval-fixture.js.map +1 -1
  68. package/dist/new-paper.d.ts +5 -11
  69. package/dist/new-paper.d.ts.map +1 -1
  70. package/dist/new-paper.js +12 -23
  71. package/dist/new-paper.js.map +1 -1
  72. package/dist/paper-files.d.ts +4 -4
  73. package/dist/paper-prose.d.ts +17 -0
  74. package/dist/paper-prose.d.ts.map +1 -0
  75. package/dist/paper-prose.js +46 -0
  76. package/dist/paper-prose.js.map +1 -0
  77. package/dist/paper-settings.d.ts +7 -0
  78. package/dist/paper-settings.d.ts.map +1 -1
  79. package/dist/paper-settings.js +61 -8
  80. package/dist/paper-settings.js.map +1 -1
  81. package/dist/ports/venue-deadlines.d.ts +14 -0
  82. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  83. package/dist/ports/venue-deadlines.js +2 -0
  84. package/dist/ports/venue-deadlines.js.map +1 -0
  85. package/dist/presets.d.ts +12 -0
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -11
  88. package/dist/presets.js.map +1 -1
  89. package/dist/structure.d.ts.map +1 -1
  90. package/dist/structure.js +4 -3
  91. package/dist/structure.js.map +1 -1
  92. package/dist/submission.d.ts +8 -0
  93. package/dist/submission.d.ts.map +1 -1
  94. package/dist/submission.js +41 -10
  95. package/dist/submission.js.map +1 -1
  96. package/dist/tex-requirements.d.ts +3 -0
  97. package/dist/tex-requirements.d.ts.map +1 -1
  98. package/dist/tex-requirements.js +19 -0
  99. package/dist/tex-requirements.js.map +1 -1
  100. package/dist/tex-venue-rules.d.ts +19 -0
  101. package/dist/tex-venue-rules.d.ts.map +1 -1
  102. package/dist/tex-venue-rules.js +69 -0
  103. package/dist/tex-venue-rules.js.map +1 -1
  104. package/dist/types.d.ts +0 -2
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/venue-rules.d.ts +10 -0
  107. package/dist/venue-rules.d.ts.map +1 -1
  108. package/dist/venue-rules.js +26 -10
  109. package/dist/venue-rules.js.map +1 -1
  110. package/docs/configuration.md +146 -20
  111. package/docs/e2e.md +4 -6
  112. package/docs/prior-art/paper-folder-scaffolding.md +24 -29
  113. package/docs/rules/cycle/evidence.md +87 -0
  114. package/docs/rules/cycle/port-done.md +75 -0
  115. package/docs/rules/cycle/record.md +152 -0
  116. package/docs/rules/cycle/stage.md +85 -0
  117. package/docs/rules/tex/claim-provenance.md +0 -1
  118. package/docs/rules.md +48 -47
  119. package/docs/submission.md +5 -0
  120. package/eslint-rules/cycle-stage.test.mjs +131 -0
  121. package/eslint-rules/cycle-stage.ts +141 -0
  122. package/eslint-rules/latex-language.harness.mjs +3 -3
  123. package/eslint-rules/latex-language.ts +10 -13
  124. package/eslint-rules/paper-context.ts +2 -2
  125. package/eslint-rules/paper-typography.test.mjs +17 -85
  126. package/eslint-rules/paper-typography.ts +22 -98
  127. package/eslint-rules/papers.harness.mjs +1 -1
  128. package/eslint-rules/papers.ts +2 -4
  129. package/eslint-rules/tex-build.harness.mjs +2 -2
  130. package/eslint-rules/tex-build.ts +8 -25
  131. package/eslint.config.mjs +41 -2
  132. package/hooks/hooks.harness.ts +15 -15
  133. package/hooks/paper-edit-guard.hook.mjs +5 -8
  134. package/hooks/paper-edit-guard.test.ts +12 -3
  135. package/hooks/paper-skills-nudge.hook.mjs +2 -3
  136. package/hooks/paper-skills-nudge.test.ts +13 -3
  137. package/hooks/paper-status-gates.hook.mjs +2 -2
  138. package/hooks/paper-status-gates.test.ts +14 -2
  139. package/lib/markdown.test.mjs +5 -60
  140. package/lib/markdown.ts +9 -136
  141. package/lib/paper-config.ts +3 -2
  142. package/lib/skill-checks.test.mjs +4 -1
  143. package/lib/skill-eval-fixture.ts +25 -18
  144. package/package.json +1 -3
  145. package/presets/aidc.jsonc +24 -3
  146. package/presets/aidc.md +6 -3
  147. package/presets/msr.jsonc +25 -1
  148. package/presets/msr.md +5 -1
  149. package/presets/realm.jsonc +1 -1
  150. package/presets/venue-profile.schema.json +39 -0
  151. package/scripts/check.ts +4 -0
  152. package/scripts/harness-api.frozen.json +0 -5
  153. package/scripts/refresh-deadlines.test.ts +431 -0
  154. package/scripts/refresh-deadlines.ts +387 -0
  155. package/skills/cold-read-diff/SKILL.md +3 -3
  156. package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
  157. package/skills/grade-paper-writing/SKILL.md +14 -22
  158. package/skills/grade-paper-writing/SKILL.md.spec.ts +13 -21
  159. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
  160. package/skills/grade-paper-writing/prose-lint.mjs +108 -278
  161. package/skills/grade-paper-writing/prose-lint.test.ts +184 -81
  162. package/skills/paper-adversarial-review/SKILL.md +2 -2
  163. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  164. package/skills/paper-pipeline/SKILL.md +9 -12
  165. package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
  166. package/skills/paper-pipeline/description-language.eval.mjs +25 -15
  167. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
  168. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
  169. package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
  170. package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
  171. package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
  172. package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
  173. package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
  174. package/skills/paper-pipeline/scripts/README.md +0 -4
  175. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +8 -72
  176. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +48 -156
  177. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +81 -75
  178. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
  179. package/skills/paper-status/SKILL.md +2 -2
  180. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  181. package/skills/render-paper/SKILL.md +2 -2
  182. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  183. package/skills/render-paper/extract-pdf-facts.mjs +2 -2
  184. package/skills/tighten-paper/SKILL.md +29 -42
  185. package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
  186. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +0 -13
  187. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +23 -11
  188. package/skills/verify-citations/scripts/bib-authors.mjs +8 -93
  189. package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
  190. package/src/adapters/hotcrp/cassette/README.md +22 -0
  191. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  192. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  193. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  194. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  195. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  196. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  197. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  198. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  199. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  200. package/src/adapters/hotcrp/deadlines.ts +161 -0
  201. package/src/adapters/hotcrp/index.ts +2 -0
  202. package/src/build.harness.mjs +2 -2
  203. package/src/build.test.ts +1 -1
  204. package/src/build.ts +1 -6
  205. package/src/cli.harness.mjs +26 -46
  206. package/src/cli.test.ts +41 -40
  207. package/src/cli.ts +57 -58
  208. package/src/cycle-rules.test.ts +278 -0
  209. package/src/cycle-rules.ts +273 -0
  210. package/src/domain/cycle.test.ts +456 -0
  211. package/src/domain/cycle.ts +432 -0
  212. package/src/domain/deadline.test.ts +386 -0
  213. package/src/domain/deadline.ts +472 -0
  214. package/src/folder-venue-rule.test.ts +1 -1
  215. package/src/init.test.ts +6 -7
  216. package/src/init.ts +3 -22
  217. package/src/lint-fix.test.ts +10 -4
  218. package/src/new-paper.harness.mjs +9 -28
  219. package/src/new-paper.test.ts +7 -6
  220. package/src/new-paper.ts +11 -30
  221. package/src/paper-files.test.ts +5 -4
  222. package/src/paper-files.ts +4 -4
  223. package/src/paper-prose.test.ts +49 -0
  224. package/src/paper-prose.ts +67 -0
  225. package/src/paper-settings.test.ts +167 -0
  226. package/src/paper-settings.ts +85 -11
  227. package/src/parse-args.test.ts +0 -1
  228. package/src/ports/venue-deadlines.ts +14 -0
  229. package/src/presets.test.ts +105 -0
  230. package/src/presets.ts +43 -10
  231. package/src/rule-cases.test.ts +144 -0
  232. package/src/structure.harness.ts +5 -11
  233. package/src/structure.ts +4 -3
  234. package/src/submission.test.ts +105 -1
  235. package/src/submission.ts +73 -12
  236. package/src/tex-requirements.ts +31 -0
  237. package/src/tex-venue-rules.test.ts +76 -0
  238. package/src/tex-venue-rules.ts +85 -0
  239. package/src/types.ts +0 -2
  240. package/src/venue-rules.test.ts +82 -0
  241. package/src/venue-rules.ts +44 -12
  242. package/fixtures/real-markdown-paper/baseline.json +0 -22
  243. package/fixtures/real-markdown-paper/baseline.ts +0 -70
  244. package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
  245. package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
  246. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
  247. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
  248. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
  249. package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
  250. package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
  251. package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
  252. package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
  253. package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
  254. package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
  255. package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
  256. package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
  257. package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
  258. package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
  259. package/skills/tighten-paper/structure.mjs +0 -364
  260. package/skills/tighten-paper/structure.test.ts +0 -117
  261. package/templates/paper/paper.md +0 -4
package/src/cli.ts CHANGED
@@ -74,6 +74,8 @@ import {
74
74
  import {
75
75
  FORMAT_RULE_LEVELS,
76
76
  formatRules,
77
+ CYCLE_PORT_RULE_LEVELS,
78
+ cyclePortRules,
77
79
  TEX_VENUE_RULE_LEVELS,
78
80
  texVenueRules,
79
81
  } from "./tex-venue-rules.ts";
@@ -130,15 +132,7 @@ import {
130
132
  init,
131
133
  processInteractivity,
132
134
  } from "./init.ts";
133
- import {
134
- DEFAULT_FORMAT,
135
- FORMATS,
136
- isFormat,
137
- newPaper,
138
- reportNewPaper,
139
- type PaperFormat,
140
- type VenueSetting,
141
- } from "./new-paper.ts";
135
+ import { newPaper, reportNewPaper, type VenueSetting } from "./new-paper.ts";
142
136
  // The one source for the consumer's config key lives in lib/ (the ESLint rules and the skill scripts
143
137
  // import it too). It is imported compiled, from dist/, the one path that resolves the same from src/
144
138
  // and from dist/ — see CONTRIBUTING.md.
@@ -164,6 +158,8 @@ export { init };
164
158
  export { nextSteps } from "./init.ts";
165
159
 
166
160
  import paperStages from "#eslint-rules/paper-stages";
161
+ import cycleStage from "#eslint-rules/cycle-stage";
162
+ import { CYCLE_RULE_LEVELS, cycleRules } from "./cycle-rules.ts";
167
163
  import {
168
164
  FOLDER_VENUE_RULE_LEVELS,
169
165
  folderVenueRules,
@@ -185,7 +181,7 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
185
181
  npx paperlint init [dir] set the project up: detect the papers directory, declare it
186
182
  in package.json, link the skills, wire the hooks into
187
183
  .claude/settings.json, offer the CI step, report what is missing
188
- npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
184
+ npx paperlint new <name> [--venue <preset>] [--kind <kind>]
189
185
  create <papers>/<name>/ from the template; never overwrites,
190
186
  on an existing folder adds only the missing files, then lints it.
191
187
  A <name> that names a venue (aisec-2026) is refused: name the
@@ -221,7 +217,6 @@ init:
221
217
  or with CI set, nothing is asked either
222
218
  --no-hooks do not wire the hooks (the default without a human is to wire them)
223
219
  --paper <name> create this paper too (without a human, the only way init creates one)
224
- --format tex|md the new paper's source format; default tex
225
220
 
226
221
  new:
227
222
  --venue <preset> the venue preset, written as "extends" into the paper's paperlint.json:
@@ -230,7 +225,6 @@ new:
230
225
  On a terminal without --venue, new asks; "none" leaves it unset
231
226
  --kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
232
227
  preset's kinds; needs --venue
233
- --format tex|md the paper's source format; default tex
234
228
  --allow-venue-name create the folder even though its name names a venue (a whole word such as
235
229
  "realm" can be the work's own). Without it such a name is refused: a rejected
236
230
  paper moves to another venue and keeps its folder
@@ -280,6 +274,24 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
280
274
  "extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
281
275
  or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
282
276
 
277
+ cycles and deadlines — a paper's attempts at venues, oldest first, instead of "extends",
278
+ "kind" and "submission" (those are then the current attempt's, and refused beside "cycles"):
279
+
280
+ { "cycles": [ { "id": "msr-2027", "venue": { "kind": "preset", "extends": "paperlint:msr" },
281
+ "kind": "technical", "opened": "2026-10-05",
282
+ "deadlines": [ { "what": "submission",
283
+ "observed": [ { "at": "2026-10-23 AoE", "source": "call",
284
+ "url": "<the call>", "read": "2026-10-05" } ] } ] } ] }
285
+
286
+ A venue preset carries the portal's deadlines, read and dated; the package updates them and
287
+ your dependency update brings them. A paper adds what the call says ("source": "call") and,
288
+ for a deadline moved by the chairs, "override": { "at", "reason", "evidence" } — evidence a
289
+ file in the paper folder. The override wins; otherwise the earlier reading binds. "what":
290
+ registration (the call's abstract), submission (the full paper), resubmission, notification,
291
+ camera-ready. A venue without a preset: "venue": { "kind": "named", "name", "url" }, every
292
+ deadline typed from its call. Lint never reads the clock: it checks the record, not the time
293
+ left. docs/configuration.md#the-papers-cycles
294
+
283
295
  "rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
284
296
  blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
285
297
  own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
@@ -303,6 +315,22 @@ const REGISTER_AND_CASE_RULES = {
303
315
  "heading-case": headingCaseRule(TEX_RULE_DEPS),
304
316
  };
305
317
 
318
+ /**
319
+ * The paper's attempts (`cycles` in paperlint.json), judged on `PIPELINE-STATUS.md`: the record itself
320
+ * and its evidence (src/cycle-rules.ts), and the stages that name a cycle (eslint-rules/cycle-stage.ts).
321
+ * The `cycle` plugin's other rule, `port-done`, reads `paper.tex` (src/tex-venue-rules.ts).
322
+ */
323
+ /** The markdown language with YAML frontmatter: what every markdown block of the config lints as. */
324
+ const MARKDOWN_WITH_FRONTMATTER = {
325
+ language: "markdown/gfm",
326
+ languageOptions: { frontmatter: "yaml" },
327
+ };
328
+
329
+ const CYCLE_STATUS_RULES = {
330
+ ...cycleRules({ files: nodeFiles, venuesDir: presetsDir() }),
331
+ ...cycleStage.rules,
332
+ };
333
+
306
334
  /** The rules of the files `paper.tex` includes, each read on its own as a fragment (#144). */
307
335
  const FRAGMENT_RULES = {
308
336
  "future-promise": texBuild["future-promise"],
@@ -333,10 +361,7 @@ export function buildConfig(
333
361
  "paper/section-word": "warn",
334
362
  "paper/leading-zero": "warn",
335
363
  };
336
- const md = {
337
- language: "markdown/gfm",
338
- languageOptions: { frontmatter: "yaml" },
339
- };
364
+ const md = MARKDOWN_WITH_FRONTMATTER;
340
365
 
341
366
  const cfg: ConfigBlock[] = [
342
367
  // 🔴 THE PROJECT'S PAPER TEMPLATE IS NOT A PAPER. `paperlint new` reads `<papers>/.template/`, and
@@ -364,8 +389,8 @@ export function buildConfig(
364
389
  },
365
390
  {
366
391
  files: ["**/PIPELINE-STATUS.md"],
367
- // Every paper folder has one, whatever its source format: the rules about the folder itself
368
- // (its stages, its frozen sources, its name) are judged here, once per paper.
392
+ // Every paper folder has one: the rules about the folder itself (its stages, its frozen
393
+ // sources, its name) are judged here, once per paper.
369
394
  plugins: {
370
395
  markdown,
371
396
  paper: {
@@ -374,20 +399,16 @@ export function buildConfig(
374
399
  ...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
375
400
  },
376
401
  },
402
+ cycle: { rules: CYCLE_STATUS_RULES },
377
403
  },
378
404
  ...md,
379
405
  rules: {
380
406
  "paper/stages": "error",
381
407
  "paper/source": "error",
382
408
  ...FOLDER_VENUE_RULE_LEVELS,
409
+ ...CYCLE_RULE_LEVELS,
383
410
  },
384
411
  },
385
- {
386
- files: ["**/paper.md", "**/draft.md"],
387
- plugins: { markdown, paper: { rules: paperRules } },
388
- ...md,
389
- rules: prose,
390
- },
391
412
  {
392
413
  files: ["**/reviews/*.md"],
393
414
  plugins: { markdown, review: reviewFrontmatter },
@@ -432,6 +453,7 @@ export function buildConfig(
432
453
  ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
433
454
  },
434
455
  },
456
+ cycle: { rules: cyclePortRules(TEX_RULE_DEPS) },
435
457
  },
436
458
  language: "tex/latex",
437
459
  rules: {
@@ -454,6 +476,7 @@ export function buildConfig(
454
476
  ...TEX_VENUE_RULE_LEVELS,
455
477
  ...FORMAT_RULE_LEVELS,
456
478
  ...PAGE_LIMIT_RULE_LEVELS,
479
+ ...CYCLE_PORT_RULE_LEVELS,
457
480
  // Silent for a paper that declares no `talk` (src/talk-rules.ts), but talk/undeclared.
458
481
  ...TALK_RULE_LEVELS,
459
482
  },
@@ -546,8 +569,7 @@ export interface RulePlugin {
546
569
  * file knows every rule id. `@eslint/markdown` is a dependency's plugin and is left out. The LaTeX
547
570
  * language rides on `tex` when `texLanguage` is given, and that is how a consumer registers it: a
548
571
  * second `tex` plugin of its own is refused by ESLint ("Cannot redefine plugin "tex"", measured
549
- * with and without the language here). Without it, `tex` carries rules only — for a config that
550
- * lints markdown papers alone.
572
+ * with and without the language here). Without it, `tex` carries rules only.
551
573
  */
552
574
  export function rulePlugins(texLanguage?: unknown): Record<string, RulePlugin> {
553
575
  const out: Record<string, { rules: Record<string, unknown> }> = {};
@@ -849,7 +871,6 @@ export function parseArgs(argv: readonly string[]): Args {
849
871
  noHooks: false,
850
872
  allowVenueName: false,
851
873
  paper: null,
852
- format: null,
853
874
  venue: null,
854
875
  kind: null,
855
876
  pdf: null,
@@ -948,10 +969,9 @@ const SWITCHES: ReadonlyMap<
948
969
  /** The flags whose value is one string field of `Args`. */
949
970
  const STRING_FLAGS: ReadonlyMap<
950
971
  string,
951
- "paper" | "format" | "venue" | "kind" | "pdf" | "abstract"
972
+ "paper" | "venue" | "kind" | "pdf" | "abstract"
952
973
  > = new Map([
953
974
  ["--paper", "paper"],
954
- ["--format", "format"],
955
975
  ["--venue", "venue"],
956
976
  ["--kind", "kind"],
957
977
  ["--pdf", "pdf"],
@@ -1145,7 +1165,6 @@ export function runHook(
1145
1165
  export async function createPaperAt(
1146
1166
  papersRoot: string,
1147
1167
  name: string,
1148
- format: PaperFormat,
1149
1168
  {
1150
1169
  log,
1151
1170
  err,
@@ -1162,7 +1181,7 @@ export async function createPaperAt(
1162
1181
  allowVenueName?: boolean;
1163
1182
  },
1164
1183
  ): Promise<number> {
1165
- const result = newPaper(papersRoot, name, format, {
1184
+ const result = newPaper(papersRoot, name, {
1166
1185
  venue,
1167
1186
  venues: allowVenueName ? [] : shippedVenueNames(PRESET_DEPS),
1168
1187
  });
@@ -1327,25 +1346,10 @@ async function runNew(
1327
1346
  const [name, ...extra] = a.paths;
1328
1347
  if (!name || extra.length > 0) {
1329
1348
  err(
1330
- `\`new\` takes exactly one paper name: \`paperlint new my-paper [--venue <preset>] [--kind <kind>] [--format tex|md]\``,
1349
+ `\`new\` takes exactly one paper name: \`paperlint new my-paper [--venue <preset>] [--kind <kind>]\``,
1331
1350
  );
1332
1351
  return 2;
1333
1352
  }
1334
- let format: PaperFormat = DEFAULT_FORMAT;
1335
- if (a.format !== null) {
1336
- if (!isFormat(a.format)) {
1337
- err(
1338
- `--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``,
1339
- );
1340
- return 2;
1341
- }
1342
- format = a.format;
1343
- } else if (processInteractivity(a.yes).interactive) {
1344
- const f = (
1345
- await askOrDefault(ask, `format: tex / md [${DEFAULT_FORMAT}] `)
1346
- )?.trim();
1347
- if (isFormat(f)) format = f;
1348
- }
1349
1353
  const cfg = readConfig({ ...a, json: false }, { log: () => {}, err, cwd });
1350
1354
  if (cfg.code !== undefined) return cfg.code;
1351
1355
  const roots = papersRoots(cfg);
@@ -1378,7 +1382,7 @@ async function runNew(
1378
1382
  ask,
1379
1383
  });
1380
1384
  if (!venue.ok) return (err(venue.error), 2);
1381
- return createPaperAt(papersRoot, name, format, {
1385
+ return createPaperAt(papersRoot, name, {
1382
1386
  log,
1383
1387
  err,
1384
1388
  cwd,
@@ -1629,10 +1633,6 @@ async function runInit(
1629
1633
  );
1630
1634
  return 2;
1631
1635
  }
1632
- if (a.format !== null && !isFormat(a.format)) {
1633
- err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
1634
- return 2;
1635
- }
1636
1636
  return await init(a.paths[0] ?? ".", {
1637
1637
  log,
1638
1638
  err,
@@ -1640,9 +1640,8 @@ async function runInit(
1640
1640
  yes: a.yes,
1641
1641
  hooks: !a.noHooks,
1642
1642
  paper: a.paper,
1643
- format: isFormat(a.format) ? a.format : null,
1644
- createPaper: (papersRoot, name, format) =>
1645
- createPaperAt(papersRoot, name, format, { log, err, cwd }),
1643
+ createPaper: (papersRoot, name) =>
1644
+ createPaperAt(papersRoot, name, { log, err, cwd }),
1646
1645
  venues: shippedVenueNames(PRESET_DEPS),
1647
1646
  tex: initTexLive(a, { log, err, cwd }),
1648
1647
  resolveCliPapers: (root: string): string | null =>
@@ -1764,8 +1763,8 @@ export async function run(
1764
1763
  };
1765
1764
 
1766
1765
  // Loaded lazily because only `lint` needs it. If the module cannot be loaded (a broken install:
1767
- // the file missing from the package), lint still runs over the markdown papers rather than
1768
- // failing outright — the LaTeX rules simply have no language to run in.
1766
+ // the file missing from the package), lint still runs over the scorecards and the review notes
1767
+ // rather than failing outright — the LaTeX rules simply have no language to run in.
1769
1768
  let texLanguage: unknown = null;
1770
1769
  try {
1771
1770
  ({ texLanguage } = await import("#eslint-rules/latex-language"));
@@ -1831,7 +1830,7 @@ export async function run(
1831
1830
  // is not invoked — and, not being invoked, it physically cannot report that.
1832
1831
  if (results.length === 0) {
1833
1832
  err(
1834
- `nothing was linted under ${paths.map((x) => shown(cwd, x)).join(", ")} — no PIPELINE-STATUS.md, paper.md/tex or reviews/ found there. A clean report over zero files is not a clean report.`,
1833
+ `nothing was linted under ${paths.map((x) => shown(cwd, x)).join(", ")} — no PIPELINE-STATUS.md, paper.tex or reviews/ found there. A clean report over zero files is not a clean report.`,
1835
1834
  );
1836
1835
  return 1;
1837
1836
  }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `cycle/record` and `cycle/evidence` on a paper folder's settings file: both halves of each — the
3
+ * finding on a planted defect, silence on a consistent record and on the flat form.
4
+ */
5
+ import { readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { describe, expect, it } from "vitest";
8
+ import { memoryFiles } from "./adapters/memory/index.ts";
9
+ import { presetsDir } from "./package-dirs.ts";
10
+ import { cycleRules } from "./cycle-rules.ts";
11
+
12
+ const VENUES = presetsDir();
13
+ const DIR = "/work/papers/p";
14
+ /** The shipped presets, so a cycle naming `paperlint:msr` resolves through the memory files. */
15
+ const SHIPPED = Object.fromEntries(
16
+ readdirSync(VENUES)
17
+ .filter((f) => f.endsWith(".jsonc"))
18
+ .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f), "utf8")]),
19
+ );
20
+
21
+ interface Reported {
22
+ readonly rule: string;
23
+ readonly messageId: string;
24
+ readonly message: string;
25
+ }
26
+
27
+ const closed = {
28
+ id: "alpha-2026",
29
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
30
+ kind: "short",
31
+ opened: "2026-07-01",
32
+ outcome: {
33
+ kind: "rejected",
34
+ date: "2026-09-08",
35
+ desk: true,
36
+ evidence: "reviews/alpha-decision.md",
37
+ },
38
+ };
39
+ const open = {
40
+ id: "beta-2027",
41
+ venue: { kind: "preset", extends: "paperlint:ieee-conference" },
42
+ kind: "technical",
43
+ opened: "2026-09-09",
44
+ deadlines: [
45
+ {
46
+ what: "submission",
47
+ observed: [
48
+ {
49
+ at: "2026-10-20T04:00:00Z",
50
+ source: "portal",
51
+ url: "https://beta2027.example.org/deadlines",
52
+ read: "2026-09-09",
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ };
58
+ const CALL = {
59
+ source: "call",
60
+ url: "https://2027.msrconf.org/track/msr-2027-technical-papers",
61
+ read: "2026-10-05",
62
+ };
63
+ /** A cycle at the shipped `paperlint:msr`, whose preset carries the portal's readings. */
64
+ const msr = (deadlines: readonly object[]) => ({
65
+ id: "msr-2027",
66
+ venue: { kind: "preset", extends: "paperlint:msr" },
67
+ kind: "technical",
68
+ opened: "2026-10-05",
69
+ deadlines,
70
+ });
71
+ const OVERRIDE = {
72
+ at: "2026-10-27T04:00:00Z",
73
+ reason: "the chairs extended the deadline by mail",
74
+ evidence: "mail/extension.eml",
75
+ };
76
+
77
+ /** Every cycle rule over the paper folder holding `settings` (a string: the raw file) and `extra` files. */
78
+ function lint(
79
+ settings: object | string | undefined,
80
+ extra: Readonly<Record<string, string>> = {},
81
+ file = "PIPELINE-STATUS.md",
82
+ ): Reported[] {
83
+ const files = memoryFiles({
84
+ ...(settings === undefined
85
+ ? {}
86
+ : {
87
+ [`${DIR}/paperlint.json`]:
88
+ typeof settings === "string" ? settings : JSON.stringify(settings),
89
+ }),
90
+ ...SHIPPED,
91
+ ...extra,
92
+ });
93
+ const out: Reported[] = [];
94
+ Object.entries(cycleRules({ files, venuesDir: VENUES })).forEach(
95
+ ([name, rule]) => {
96
+ rule
97
+ .create({
98
+ filename: `${DIR}/${file}`,
99
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
100
+ report: (d) => {
101
+ const template = rule.meta.messages[d.messageId] ?? "";
102
+ out.push({
103
+ rule: `cycle/${name}`,
104
+ messageId: d.messageId,
105
+ message: Object.entries(d.data).reduce(
106
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
107
+ template,
108
+ ),
109
+ });
110
+ },
111
+ })
112
+ .root?.();
113
+ },
114
+ );
115
+ return out;
116
+ }
117
+
118
+ const EVIDENCE = { [`${DIR}/reviews/alpha-decision.md`]: "# decision\n" };
119
+
120
+ describe("cycle/record", () => {
121
+ it("reports on PIPELINE-STATUS.md only — any other file in the folder gets nothing", () => {
122
+ expect(lint({ cycles: [open, open] }, {}, "paper.tex")).toEqual([]);
123
+ });
124
+
125
+ it("a consistent record with its evidence on disk: every cycle rule is silent", () => {
126
+ expect(lint({ cycles: [closed, open] }, EVIDENCE)).toEqual([]);
127
+ });
128
+
129
+ it("the flat form, no settings file, or a file that is not JSON: silent — not this rule's to say", () => {
130
+ expect(lint({ extends: "paperlint:aidc", kind: "regular" })).toEqual([]);
131
+ expect(lint(undefined)).toEqual([]);
132
+ expect(lint("{ not json")).toEqual([]);
133
+ });
134
+
135
+ it("two open cycles: `broken`, naming both and the cure", () => {
136
+ const [f] = lint({
137
+ cycles: [{ ...closed, outcome: { kind: "open" } }, open],
138
+ });
139
+ expect(f?.messageId).toBe("broken");
140
+ expect(f?.message).toMatch(
141
+ /^paperlint\.json: cycles «alpha-2026» and «beta-2027» are both open/,
142
+ );
143
+ });
144
+
145
+ it("a deadline without its zone: `broken`, naming the entry and the key", () => {
146
+ const bad = msr([
147
+ { what: "submission", observed: [{ ...CALL, at: "2026-10-23" }] },
148
+ ]);
149
+ const m: unknown = expect.stringMatching(
150
+ /^paperlint\.json: cycles\[0\]\.deadlines\[0\]\.observed\[0\]: "at" must be an instant with its zone/,
151
+ );
152
+ expect(lint({ cycles: [bad] }).map((f) => f.message)).toEqual([m]);
153
+ });
154
+ });
155
+
156
+ describe("cycle/record — the deadlines, over the venue preset's readings", () => {
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.
160
+ const fs = lint({
161
+ cycles: [
162
+ msr([
163
+ {
164
+ what: "registration",
165
+ observed: [{ ...CALL, at: "2026-10-20 AoE" }],
166
+ },
167
+ { what: "submission", observed: [{ ...CALL, at: "2026-10-23 AoE" }] },
168
+ ]),
169
+ ],
170
+ });
171
+ expect(fs).toEqual([]);
172
+ });
173
+
174
+ it("the call's own dates out of order: `order`, naming the cycle and the source", () => {
175
+ const fs = lint({
176
+ cycles: [
177
+ msr([
178
+ {
179
+ what: "registration",
180
+ observed: [{ ...CALL, at: "2026-10-25 AoE" }],
181
+ },
182
+ { what: "submission", observed: [{ ...CALL, at: "2026-10-23 AoE" }] },
183
+ ]),
184
+ ],
185
+ });
186
+ expect(fs.map((f) => f.messageId)).toEqual(["order"]);
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)',
189
+ );
190
+ });
191
+ });
192
+
193
+ describe("cycle/record — a portal reading beside the preset's", () => {
194
+ it("a portal reading of a deadline the venue's preset already carries: `presetCopy`, naming the preset's reading", () => {
195
+ const fs = lint({
196
+ cycles: [
197
+ msr([
198
+ {
199
+ what: "submission",
200
+ observed: [
201
+ {
202
+ at: "2026-10-20T04:00:00Z",
203
+ source: "portal",
204
+ url: "https://msr2027.hotcrp.com/deadlines",
205
+ read: "2026-10-01",
206
+ },
207
+ ],
208
+ },
209
+ ]),
210
+ ],
211
+ });
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)",
214
+ ]);
215
+ });
216
+
217
+ it("a portal reading of a venue whose preset carries none, or a named venue: the paper's to record — silent", () => {
218
+ expect(lint({ cycles: [open] })).toEqual([]);
219
+ expect(
220
+ lint({
221
+ cycles: [
222
+ {
223
+ ...open,
224
+ venue: {
225
+ kind: "named",
226
+ name: "Beta '27",
227
+ url: "https://beta.example.org",
228
+ },
229
+ },
230
+ ],
231
+ }),
232
+ ).toEqual([]);
233
+ });
234
+
235
+ it("a cycle whose preset does not resolve: its readings are judged alone (pdf/profile names the preset)", () => {
236
+ expect(
237
+ lint({
238
+ cycles: [
239
+ { ...open, venue: { kind: "preset", extends: "paperlint:nosuch" } },
240
+ ],
241
+ }),
242
+ ).toEqual([]);
243
+ });
244
+ });
245
+
246
+ describe("cycle/evidence", () => {
247
+ it("a closed cycle whose evidence file is not in the folder: `missing`, with the path", () => {
248
+ const fs = lint({ cycles: [closed, open] });
249
+ expect(fs.map((f) => f.rule)).toEqual(["cycle/evidence"]);
250
+ expect(fs[0]?.message).toMatch(
251
+ /^cycle «alpha-2026» is rejected \(2026-09-08\) with evidence `reviews\/alpha-decision\.md`, which is not in the paper folder/,
252
+ );
253
+ });
254
+
255
+ it("an override whose evidence is not in the folder: `overrideMissing`; on disk: silent", () => {
256
+ const settings = {
257
+ cycles: [msr([{ what: "submission", override: OVERRIDE }])],
258
+ };
259
+ expect(lint(settings).map((f) => [f.rule, f.message])).toEqual([
260
+ [
261
+ "cycle/evidence",
262
+ 'cycle «msr-2027»: the override of the "submission" deadline (2026-10-27T04:00:00Z) rests on `mail/extension.eml`, which is not in the paper folder — save the mail or the page that grants it there, or correct the path',
263
+ ],
264
+ ]);
265
+ expect(
266
+ lint(settings, { [`${DIR}/mail/extension.eml`]: "From: chairs\n" }),
267
+ ).toEqual([]);
268
+ });
269
+
270
+ it("an open cycle owes no evidence; a broken record is `cycle/record`'s alone", () => {
271
+ expect(lint({ cycles: [open] })).toEqual([]);
272
+ expect(
273
+ lint({ cycles: [{ ...closed, outcome: { kind: "open" } }, open] }).map(
274
+ (f) => f.rule,
275
+ ),
276
+ ).toEqual(["cycle/record"]);
277
+ });
278
+ });