paperlint 12.1.0 → 13.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 (131) hide show
  1. package/dist/adapters/memory/index.d.ts +5 -0
  2. package/dist/adapters/memory/index.d.ts.map +1 -1
  3. package/dist/adapters/memory/index.js +16 -0
  4. package/dist/adapters/memory/index.js.map +1 -1
  5. package/dist/adapters/node/index.d.ts +1 -0
  6. package/dist/adapters/node/index.d.ts.map +1 -1
  7. package/dist/adapters/node/index.js +1 -0
  8. package/dist/adapters/node/index.js.map +1 -1
  9. package/dist/adapters/node/move-file.io.d.ts +4 -0
  10. package/dist/adapters/node/move-file.io.d.ts.map +1 -0
  11. package/dist/adapters/node/move-file.io.js +12 -0
  12. package/dist/adapters/node/move-file.io.js.map +1 -0
  13. package/dist/adapters/yaml/index.d.ts +3 -0
  14. package/dist/adapters/yaml/index.d.ts.map +1 -0
  15. package/dist/adapters/yaml/index.js +17 -0
  16. package/dist/adapters/yaml/index.js.map +1 -0
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +48 -7
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cycle-rules.d.ts +3 -3
  21. package/dist/cycle-rules.d.ts.map +1 -1
  22. package/dist/cycle-rules.js +1 -2
  23. package/dist/cycle-rules.js.map +1 -1
  24. package/dist/domain/cycle.d.ts +16 -2
  25. package/dist/domain/cycle.d.ts.map +1 -1
  26. package/dist/domain/cycle.js +21 -42
  27. package/dist/domain/cycle.js.map +1 -1
  28. package/dist/domain/paper-layout.d.ts.map +1 -1
  29. package/dist/domain/paper-layout.js +5 -2
  30. package/dist/domain/paper-layout.js.map +1 -1
  31. package/dist/domain/paths.d.ts +12 -0
  32. package/dist/domain/paths.d.ts.map +1 -1
  33. package/dist/domain/paths.js +22 -1
  34. package/dist/domain/paths.js.map +1 -1
  35. package/dist/domain/scaffold-layout.js +1 -1
  36. package/dist/domain/versions-migration.d.ts +51 -0
  37. package/dist/domain/versions-migration.d.ts.map +1 -0
  38. package/dist/domain/versions-migration.js +252 -0
  39. package/dist/domain/versions-migration.js.map +1 -0
  40. package/dist/domain/versions.d.ts +167 -0
  41. package/dist/domain/versions.d.ts.map +1 -0
  42. package/dist/domain/versions.js +457 -0
  43. package/dist/domain/versions.js.map +1 -0
  44. package/dist/eslint-rules/paper-stages.d.ts +7 -0
  45. package/dist/eslint-rules/paper-stages.d.ts.map +1 -1
  46. package/dist/eslint-rules/paper-stages.js +62 -7
  47. package/dist/eslint-rules/paper-stages.js.map +1 -1
  48. package/dist/eslint-rules/rule-context.d.ts +2 -0
  49. package/dist/eslint-rules/rule-context.d.ts.map +1 -1
  50. package/dist/ports/move-file.d.ts +7 -0
  51. package/dist/ports/move-file.d.ts.map +1 -0
  52. package/dist/ports/move-file.js +2 -0
  53. package/dist/ports/move-file.js.map +1 -0
  54. package/dist/ports/yaml.d.ts +5 -0
  55. package/dist/ports/yaml.d.ts.map +1 -0
  56. package/dist/ports/yaml.js +2 -0
  57. package/dist/ports/yaml.js.map +1 -0
  58. package/dist/submission.d.ts +2 -0
  59. package/dist/submission.d.ts.map +1 -1
  60. package/dist/submission.js +49 -9
  61. package/dist/submission.js.map +1 -1
  62. package/dist/version-rules.d.ts +16 -0
  63. package/dist/version-rules.d.ts.map +1 -0
  64. package/dist/version-rules.js +201 -0
  65. package/dist/version-rules.js.map +1 -0
  66. package/dist/versions-command.d.ts +37 -0
  67. package/dist/versions-command.d.ts.map +1 -0
  68. package/dist/versions-command.js +209 -0
  69. package/dist/versions-command.js.map +1 -0
  70. package/dist/versions.d.ts +76 -0
  71. package/dist/versions.d.ts.map +1 -0
  72. package/dist/versions.js +164 -0
  73. package/dist/versions.js.map +1 -0
  74. package/docs/configuration.md +76 -2
  75. package/docs/layout.md +5 -5
  76. package/docs/rules/cycle/camera-ready-source.md +62 -0
  77. package/docs/rules/cycle/draft-drift.md +47 -0
  78. package/docs/rules/cycle/port-done.md +2 -2
  79. package/docs/rules/cycle/record.md +2 -1
  80. package/docs/rules/cycle/submitted-frozen.md +65 -0
  81. package/docs/rules/paper/allowed-files-only.md +1 -1
  82. package/docs/rules/versions/layout.md +88 -0
  83. package/docs/rules.md +25 -11
  84. package/docs/submission.md +21 -3
  85. package/eslint-rules/paper-stages.test.mjs +62 -0
  86. package/eslint-rules/paper-stages.ts +67 -8
  87. package/eslint-rules/prereg-coverage.test.ts +1 -0
  88. package/eslint-rules/rule-context.ts +2 -0
  89. package/package.json +2 -1
  90. package/skills/paper-pipeline/references/pipeline-status-template.md +4 -1
  91. package/skills/submit-paper/SKILL.md +18 -10
  92. package/skills/submit-paper/SKILL.md.spec.ts +17 -9
  93. package/skills/submit-paper/references/publishers/acm.md +5 -3
  94. package/src/adapters/memory/index.test.ts +22 -0
  95. package/src/adapters/memory/index.ts +21 -0
  96. package/src/adapters/node/index.ts +1 -0
  97. package/src/adapters/node/move-file.io.ts +21 -0
  98. package/src/adapters/node/move-file.test.ts +51 -0
  99. package/src/adapters/yaml/index.test.ts +16 -0
  100. package/src/adapters/yaml/index.ts +17 -0
  101. package/src/cli.test.ts +43 -0
  102. package/src/cli.ts +58 -6
  103. package/src/cycle-rules.ts +4 -5
  104. package/src/domain/cycle.test.ts +27 -0
  105. package/src/domain/cycle.ts +38 -6
  106. package/src/domain/paper-layout.ts +5 -2
  107. package/src/domain/paths.test.ts +28 -1
  108. package/src/domain/paths.ts +34 -1
  109. package/src/domain/scaffold-layout.ts +1 -1
  110. package/src/domain/versions-migration.test.ts +410 -0
  111. package/src/domain/versions-migration.ts +429 -0
  112. package/src/domain/versions.test.ts +564 -0
  113. package/src/domain/versions.ts +706 -0
  114. package/src/ports/move-file.ts +8 -0
  115. package/src/ports/yaml.ts +5 -0
  116. package/src/rule-cases.test.ts +157 -8
  117. package/src/submission-freeze.test.ts +320 -0
  118. package/src/submission-new.test.ts +11 -1
  119. package/src/submission.ts +82 -11
  120. package/src/version-rules.test.ts +222 -0
  121. package/src/version-rules.ts +282 -0
  122. package/src/versions-command.test.ts +594 -0
  123. package/src/versions-command.ts +360 -0
  124. package/src/versions.ts +286 -0
  125. package/dist/eslint-rules/cycle-stage.d.ts +0 -39
  126. package/dist/eslint-rules/cycle-stage.d.ts.map +0 -1
  127. package/dist/eslint-rules/cycle-stage.js +0 -119
  128. package/dist/eslint-rules/cycle-stage.js.map +0 -1
  129. package/docs/rules/cycle/stage.md +0 -85
  130. package/eslint-rules/cycle-stage.test.mjs +0 -131
  131. package/eslint-rules/cycle-stage.ts +0 -141
package/docs/rules.md CHANGED
@@ -5,13 +5,16 @@ reads and when it fails. Errors fail `paperlint lint`; warnings print and do not
5
5
 
6
6
  | Rule | Level | Reads | Fails when |
7
7
  | ------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
8
- | `paper/stages` | error | `PIPELINE-STATUS.md` | a declared stage's PDF is missing, or the `bytes:` it names does not match the file's real size, or a frozen PDF exists that no stage declares |
9
- | `paper/source` | error | `PIPELINE-STATUS.md` | a declared stage has no frozen `.tex` beside its PDF (a commit hash does not count — squash and gc destroy it) |
8
+ | `paper/stages` | error | `PIPELINE-STATUS.md` | a declared stage's PDF is missing, or the `bytes:` it names does not match the file's real size, or a frozen PDF exists that no stage declares. In a paper that keeps `cycles`, the `stages:` list is retired: one finding names `paperlint versions migrate`, and [`versions/layout`](rules/versions/layout.md) judges `versions/` |
9
+ | `paper/source` | error | `PIPELINE-STATUS.md` | a declared stage has no frozen `.tex` beside its PDF (a commit hash does not count — squash and gc destroy it). Silent in a paper that keeps `cycles`: there [`versions/layout`](rules/versions/layout.md) holds each PDF to its source |
10
10
  | [`study/declare-sample`](rules/study/declare-sample.md) | experimental; off unless a paper turns it on (`"error"`) | each study's `repro/<study>/prereg.json` | a study's pre-registration does not declare its `population` (what the claim is about, source, size), the `strata` and what each of its `screens` removed per stratum value, its `coverage` (share 0–1 or null with a reason, and the strata it cannot reach), or the `author_check` of the drawn units (a doubtful verdict needs a recorded `decision`); reported on `PIPELINE-STATUS.md`, once per problem — [page](rules/study/declare-sample.md) |
11
11
  | [`cycle/record`](rules/cycle/record.md) | error | `paperlint.json` (`cycles`) | the paper's attempts at venues (`cycles`, one entry per attempt) hold one source's deadlines out of order (registration ≤ submission ≤ resubmission < notification < camera-ready), or copy a portal reading the venue's preset carries; reported on `PIPELINE-STATUS.md`. A record that does not parse — a bare date, two open cycles, an unknown key — is refused by `paperlint lint` itself before any rule runs, exit 2 — [page](rules/cycle/record.md) |
12
12
  | [`cycle/evidence`](rules/cycle/evidence.md) | error | `paperlint.json` (`cycles`) → the paper folder | a closed cycle (`accepted`, `rejected`, `withdrawn`) or a deadline's `override` names an `evidence` file — the decision mail, the reviews, the withdrawal note, the chairs' mail — that is not in the paper folder; reported on `PIPELINE-STATUS.md` — [page](rules/cycle/evidence.md) |
13
- | [`cycle/stage`](rules/cycle/stage.md) | error | `PIPELINE-STATUS.md` → `paperlint.json` (`cycles`) | with `cycles` declared, a stage names no `cycle: <id>`, names one no cycle has, or a `submitted`/`camera-ready` stage belongs to a cycle whose `phase` is still `porting` — [page](rules/cycle/stage.md) |
14
13
  | [`cycle/port-done`](rules/cycle/port-done.md) | error | `paper.tex` → `paperlint.json` (`cycles`) → the preset's `template` | the current cycle still declares `"phase": "porting"` (which silences the venue's format rules) while the `\documentclass` is already the venue's template: the port is done, drop the phase — [page](rules/cycle/port-done.md) |
14
+ | [`cycle/submitted-frozen`](rules/cycle/submitted-frozen.md) | error | `paperlint.json` (`cycles`) → `versions/` | a cycle has `"submission": { "id" }` — a PDF went to the portal — and no `versions/<cycle>/submitted.pdf`; `submission new | update --save`freeze what they send, so this fires for an upload made another way; reported on`PIPELINE-STATUS.md` — [page](rules/cycle/submitted-frozen.md) |
15
+ | [`cycle/draft-drift`](rules/cycle/draft-drift.md) | warn | `paper.tex` → `paperlint.json` (`cycles`) → `versions/<cycle>/submitted.tex` | the last cycle is open and `paper.tex` (as the last build assembled it) differs from the source frozen when its PDF was sent; says how many lines were added and removed — [page](rules/cycle/draft-drift.md) |
16
+ | [`cycle/camera-ready-source`](rules/cycle/camera-ready-source.md) | error | `paper.tex` → `paperlint.json` (`cycles`) → `versions/<cycle>/` | the last cycle is accepted, its camera-ready is frozen, and the frozen `camera-ready.tex` keeps less than half of the lines of the frozen `submitted.tex`, so it was not built from what was accepted; silent until the camera-ready is frozen — [page](rules/cycle/camera-ready-source.md) |
17
+ | [`versions/layout`](rules/versions/layout.md) | error | `versions/`, `versions/lock.json` → `paperlint.json` (`cycles`) | with `cycles` declared: an entry of `versions/` that is not `lock.json` or a folder named by a cycle id, a file in a cycle's folder that is not `<stage>.pdf`/`.tex` (`submitted`, `camera-ready`, `arxiv`), a PDF without its source (unless the lock records it lost), a PDF sent from a cycle still `porting`, or a frozen file that does not match `versions/lock.json`; reported on `PIPELINE-STATUS.md` — [page](rules/versions/layout.md) |
15
18
  | [`paper/folder-venue-leftover`](rules/paper/folder-venue-leftover.md) | warn | the folder name → `paperlint.json` | the paper's folder is named after another shipped venue than the one it extends (`papers/aisec-2026/` for a paper now set for AIDC); reported on `PIPELINE-STATUS.md` — [page](rules/paper/folder-venue-leftover.md) |
16
19
  | [`paper/allowed-files-only`](rules/paper/allowed-files-only.md) | error | the paper folder's root listing → `paperlint.json` | a file or folder at the paper's root is not part of the layout (`PLAN.md`, `archive/`, a stray PDF); reported on `PIPELINE-STATUS.md`, once per entry, with where it goes; `extra/` takes anything — [page](rules/paper/allowed-files-only.md) |
17
20
  | [`repro/allowed-files-only`](rules/repro/allowed-files-only.md) | error | the listing of the paper's `repro/` | an entry at the top of `repro/` is not `README.md`, `studies.json`, `.gitignore`, `tools/`, `data/` or a study folder named `<id>-<slug>` (or, in a paper without the registry, `<slug>-YYYY-MM-DD`), or a study folder has no `README.md`; reported on `PIPELINE-STATUS.md`, once per entry, with where it goes by kind (a script → `repro/tools/`, data → `repro/data/`, a note → its study or `research/`) — [page](rules/repro/allowed-files-only.md) |
@@ -279,13 +282,24 @@ The paper body is `paper.tex`, and it gets three rules of its own: `paper/typogr
279
282
  run on it but judge the files beside it. The scorecard and the review notes are Markdown files, and
280
283
  the other five rules read those.
281
284
 
282
- ## The scorecard's `bytes:` and `sourceBytes:`
285
+ ## Frozen versions: `versions/<cycle>/` and its lock
286
+
287
+ **A paper that keeps `cycles`** freezes what it sends without a manual step:
288
+ `paperlint submission new --save` and `submission update --save` write the exact PDF they sent and the
289
+ source it was built from into `versions/<cycle>/submitted.{pdf,tex}` (`camera-ready.*` once the cycle
290
+ is accepted), and record their bytes, sha256 and the time in `versions/lock.json`, a file paperlint
291
+ writes and nobody edits. [`versions/layout`](rules/versions/layout.md) then fails if a frozen file no
292
+ longer matches the lock, and `npx paperlint versions` prints each version's state. A file you freeze
293
+ yourself is recorded with `npx paperlint versions lock`. The layout, the states and the move from the
294
+ `stages:` list (`npx paperlint versions migrate`) are in
295
+ [configuration](configuration.md#the-frozen-versions-one-folder-per-attempt).
296
+
297
+ ### Without `cycles`: the scorecard's `bytes:` and `sourceBytes:`
283
298
 
284
299
  **When you submit,** copy the PDF and `paper.tex` you sent into the paper's `versions/` folder and
285
300
  record the stage in the front matter of `PIPELINE-STATUS.md` (the paper's scorecard). From then on
286
301
  `paper/stages` fails if that PDF goes missing or changes size, and `paper/source` if its source was
287
- not kept. Today you record it by hand; a command for it is planned
288
- ([#100](https://github.com/zernie/paperlint/issues/100)). A stage looks like this:
302
+ not kept. A stage looks like this:
289
303
 
290
304
  ```yaml
291
305
  ---
@@ -301,8 +315,8 @@ stages:
301
315
  ```
302
316
 
303
317
  A stage name may use `a-z`, `0-9`, `.`, `_` and `-`. You write `bytes:` and `sourceBytes:` yourself
304
- (`wc -c < versions/2026-07-22-submitted.pdf`), or the agent does, once, when the stage is recorded;
305
- no command in this package generates them. They are not a checksum to maintain: a
306
- frozen PDF is never supposed to change, so `bytes:` disagreeing with the file means the file was
307
- replaced after it was declared, and that is exactly the finding. If you really did re-freeze a
308
- stage, update the number in the same commit.
318
+ (`wc -c < versions/2026-07-22-submitted.pdf`), or the agent does, once, when the stage is recorded.
319
+ They are not a checksum to maintain: a frozen PDF is never supposed to change, so `bytes:`
320
+ disagreeing with the file means the file was replaced after it was declared, and that is exactly the
321
+ finding. If you really did re-freeze a stage, update the number in the same commit. Recording the
322
+ paper's attempts as `cycles` moves all of this to the folders and the generated lock.
@@ -88,6 +88,22 @@ against the venue's call for papers.
88
88
  The safe order: `new`, `new --save` once; then `show`, `update`, `update --save`, and `show` again
89
89
  until it says `match yes`. Every `--save` comes after the author has read the dry run and said yes.
90
90
 
91
+ ## What a save freezes
92
+
93
+ In a paper that keeps `cycles`, a `new --save` or `update --save` the portal took freezes what it
94
+ sent: the exact PDF, and the source the build read (a paper split into several files, assembled), as
95
+ `versions/<cycle>/submitted.{pdf,tex}` — `camera-ready.{pdf,tex}` once the cycle is accepted — and
96
+ records both in `versions/lock.json`. It prints one line:
97
+
98
+ ```
99
+ frozen versions/beta-2027/submitted.pdf (186504 bytes) and submitted.tex (57210 bytes), recorded in versions/lock.json
100
+ ```
101
+
102
+ A later save in the same cycle replaces them: the last upload is the one under review. `show` ends
103
+ with the table of the paper's versions and their states, as `paperlint versions` prints it. A paper
104
+ without `cycles` is sent as before, and the save says `not frozen`. The layout and the states:
105
+ [configuration](configuration.md#the-frozen-versions-one-folder-per-attempt).
106
+
91
107
  ## The check before sending
92
108
 
93
109
  A person confirms every send. To help them decide, `new` and `update` first run the venue rules of
@@ -107,9 +123,11 @@ With no findings it prints one line, `venue rules at their default options: no e
107
123
  run at paperlint's default levels and options: a `rules` override in your `paperlint.json` or
108
124
  preset is applied by `paperlint lint`, not here.
109
125
 
110
- The command refuses one thing by itself: `--save` when no build facts describe this PDF (none, or
111
- of other bytes) exits 2 and sends nothing. Rebuild (`paperlint build`) and run the dry run again.
112
- Everything else, including over the limit, is the person's call.
126
+ The command refuses two things by itself, each with exit 2 and nothing sent: `--save` when no build
127
+ facts describe this PDF (none, or of other bytes), and — in a paper that keeps `cycles` — `--save`
128
+ when the sources changed since the build, because the PDF and the source on disk would no longer be a
129
+ pair to freeze. Rebuild (`paperlint build`) and run the dry run again. Everything else, including over
130
+ the limit, is the person's call.
113
131
 
114
132
  **The `submission-confirm` hook** (installed by `paperlint init`) stops any `submission new|update`
115
133
  with `--save` or `--submit` until the person has said yes to that exact PDF in that exact folder. The flow:
@@ -139,3 +139,65 @@ test("a frontmatter node without a value (another markdown language) reads as em
139
139
  stages.rules[rule].create(context).yaml({ type: "yaml" });
140
140
  assert.deepEqual(reports, []);
141
141
  });
142
+
143
+ const CYCLES = { "paperlint.json": JSON.stringify({ cycles: [] }) };
144
+ const RETIRED = (paper) =>
145
+ `\`stages\` is retired for a paper that keeps \`cycles\` in paperlint.json: its frozen versions are versions/<cycle>/<stage>.{pdf,tex}, recorded in versions/lock.json — \`npx paperlint versions migrate ${paper}\` moves the files there and takes the list out`;
146
+
147
+ test("under `cycles`: a `stages` list is one finding naming the migration, and dated files owe it nothing", () => {
148
+ const files = {
149
+ ...CYCLES,
150
+ "versions/2026-08-02-submitted.pdf": "x",
151
+ };
152
+ assert.deepEqual(
153
+ lint(
154
+ "retired",
155
+ "stages:\n - stage: submitted\n date: 2026-08-02\n pdf: versions/2026-08-02-submitted.pdf\n bytes: 9",
156
+ { files },
157
+ ),
158
+ [RETIRED("retired")],
159
+ );
160
+ assert.deepEqual(
161
+ lint("retired-src", "stages:\n - stage: submitted\n date: 2026-08-02", {
162
+ rule: "source",
163
+ files,
164
+ }),
165
+ [],
166
+ );
167
+ });
168
+
169
+ test("under `cycles`: no list, an empty one, or no frontmatter is silent; YAML that does not parse is still named", () => {
170
+ assert.deepEqual(lint("retired-none", "title: x", { files: CYCLES }), []);
171
+ assert.deepEqual(lint("retired-empty", "stages: []", { files: CYCLES }), []);
172
+ const [msg] = lint("retired-yaml", "stages: [", { files: CYCLES });
173
+ assert.match(msg, /^the frontmatter does not parse as YAML: /);
174
+ // A paperlint.json that does not parse keeps the flat form's checks: `paperlint lint` refuses it before any rule runs.
175
+ assert.deepEqual(
176
+ lint("retired-broken", "stages:", {
177
+ files: { "paperlint.json": "{" },
178
+ }),
179
+ [
180
+ "`stages` must be a LIST of entries, not empty — a paper can reach the same stage twice",
181
+ ],
182
+ );
183
+ });
184
+
185
+ test("under `cycles`: a frontmatter node without a value reads as empty YAML", () => {
186
+ const dir = writeTree(join(root, "retired-valueless"), CYCLES);
187
+ const reports = [];
188
+ const context = {
189
+ filename: join(dir, "PIPELINE-STATUS.md"),
190
+ cwd: dir,
191
+ report: (r) => reports.push(r),
192
+ };
193
+ stages.rules.stages.create(context).yaml({ type: "yaml" });
194
+ assert.deepEqual(reports, []);
195
+ // Linted from the paper itself, the command names it as `.`.
196
+ stages.rules.stages
197
+ .create(context)
198
+ .yaml({ type: "yaml", value: "stages:\n - stage: submitted" });
199
+ assert.deepEqual(
200
+ reports.map((r) => r.data),
201
+ [{ paper: "." }],
202
+ );
203
+ });
@@ -46,8 +46,8 @@
46
46
  * keyed by stage name holds one. The `versions/<date>-<stage>.pdf` convention already holds
47
47
  * many, and the field must not be weaker than the filenames it describes.
48
48
  */
49
- import { existsSync, statSync, readdirSync } from "node:fs";
50
- import { join, dirname } from "node:path";
49
+ import { existsSync, readFileSync, statSync, readdirSync } from "node:fs";
50
+ import { join, dirname, relative } from "node:path";
51
51
  import assert from "node:assert/strict";
52
52
  import { load } from "js-yaml";
53
53
  import { z } from "zod";
@@ -68,6 +68,58 @@ const StageRecord = z
68
68
  sourceLost: z.unknown().optional(),
69
69
  })
70
70
  .catch({});
71
+ /** What these rules read of the paper's settings: whether it keeps `cycles` at all. */
72
+ const Settings = z
73
+ .looseObject({ cycles: z.array(z.unknown()).optional() })
74
+ .catch({});
75
+
76
+ /**
77
+ * Whether the paper keeps `cycles` in its paperlint.json. Its frozen versions are then
78
+ * `versions/<cycle>/<stage>.{pdf,tex}` recorded in `versions/lock.json`, judged by `versions/layout`,
79
+ * and the `stages:` list is retired: `paperlint versions migrate` turns it into folders.
80
+ */
81
+ function keepsCycles(dir: string): boolean {
82
+ try {
83
+ const raw: unknown = JSON.parse(
84
+ readFileSync(join(dir, "paperlint.json"), "utf8"),
85
+ );
86
+ return Settings.parse(raw).cycles !== undefined;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** Under `cycles`: a non-empty `stages:` list is one finding, naming the command that retires it. */
93
+ function retired(context: RuleContext, dir: string) {
94
+ return {
95
+ yaml(node: { readonly value?: string }) {
96
+ const parsed = (() => {
97
+ try {
98
+ return { ok: true as const, data: load(node.value ?? "") };
99
+ } catch (e) {
100
+ assert.ok(e instanceof Error);
101
+ return { ok: false as const, reason: e.message };
102
+ }
103
+ })();
104
+ if (!parsed.ok) {
105
+ context.report({
106
+ node,
107
+ messageId: "badYaml",
108
+ data: { reason: parsed.reason },
109
+ });
110
+ return;
111
+ }
112
+ const raw = Frontmatter.parse(parsed.data).stages;
113
+ if (raw === undefined || (Array.isArray(raw) && raw.length === 0)) return;
114
+ context.report({
115
+ node,
116
+ messageId: "retired",
117
+ data: { paper: relative(context.cwd, dir) || "." },
118
+ });
119
+ },
120
+ };
121
+ }
122
+
71
123
  const REQUIRED = ["date", "pdf", "bytes"] satisfies readonly (
72
124
  "date" | "pdf" | "bytes"
73
125
  )[];
@@ -126,6 +178,12 @@ function frozenPdfs(
126
178
  return out;
127
179
  }
128
180
 
181
+ /** The frozen PDFs in `versions/` that no declared stage of the same day names. */
182
+ const undeclared = (dir: string, records: readonly Declared[]) =>
183
+ frozenPdfs(join(dir, "versions")).filter(
184
+ (f) => !records.some((r) => r.stage === f.stage && r.date === f.date),
185
+ );
186
+
129
187
  export default {
130
188
  rules: {
131
189
  stages: {
@@ -150,12 +208,14 @@ export default {
150
208
  "«{{stage}}» ({{date}}): {{want}} bytes declared, {{got}} on disk — this is NOT that file",
151
209
  fileNotDeclared:
152
210
  "`versions/{{file}}` is frozen, but no «{{stage}}» stage on {{date}} is declared in `stages` — the artefact ran ahead of the declaration",
211
+ retired:
212
+ "`stages` is retired for a paper that keeps `cycles` in paperlint.json: its frozen versions are versions/<cycle>/<stage>.{pdf,tex}, recorded in versions/lock.json — `npx paperlint versions migrate {{paper}}` moves the files there and takes the list out",
153
213
  },
154
214
  },
155
215
  create(context: RuleContext) {
156
216
  const dir = dirname(context.filename);
217
+ if (keepsCycles(dir)) return retired(context, dir);
157
218
  let declared: Declared[] | null = null; // null = there was no frontmatter at all
158
-
159
219
  return {
160
220
  yaml(node: { readonly value?: string }) {
161
221
  let data: unknown;
@@ -252,16 +312,13 @@ export default {
252
312
  // 🔴 On `root:exit` rather than inside `yaml`, because the case worth catching is
253
313
  // precisely the one with NO frontmatter at all — where the `yaml` visitor never runs.
254
314
  "root:exit"(node: object) {
255
- const records = declared ?? [];
256
- for (const f of frozenPdfs(join(dir, "versions"))) {
257
- if (records.some((r) => r.stage === f.stage && r.date === f.date))
258
- continue;
315
+ undeclared(dir, declared ?? []).forEach((f) => {
259
316
  context.report({
260
317
  node,
261
318
  messageId: "fileNotDeclared",
262
319
  data: { file: f.name, stage: f.stage, date: f.date },
263
320
  });
264
- }
321
+ });
265
322
  },
266
323
  };
267
324
  },
@@ -319,6 +376,8 @@ export default {
319
376
  },
320
377
  create(context: RuleContext) {
321
378
  const dir = dirname(context.filename);
379
+ // Under `cycles` the lock records each frozen source, and `versions/layout` checks it.
380
+ if (keepsCycles(dir)) return {};
322
381
  return {
323
382
  yaml(node: { readonly value?: string }) {
324
383
  let data: unknown;
@@ -399,6 +399,7 @@ describe("the rule, on the status file of a paper", () => {
399
399
  rule
400
400
  .create({
401
401
  filename: join(dir, file),
402
+ cwd: dir,
402
403
  options: [],
403
404
  settings: {},
404
405
  sourceCode: {
@@ -55,6 +55,8 @@ export interface RuleReport {
55
55
 
56
56
  export interface RuleContext {
57
57
  readonly filename: string;
58
+ /** The directory ESLint runs from: what a command a message names is relative to. */
59
+ readonly cwd: string;
58
60
  readonly options: readonly unknown[];
59
61
  readonly settings: Readonly<Record<string, unknown>>;
60
62
  readonly sourceCode: RuleSourceCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "12.1.0",
3
+ "version": "13.0.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "@unified-latex/unified-latex-util-replace": "^1.8.4",
31
31
  "@unified-latex/unified-latex-util-visit": "^1.8.4",
32
32
  "ajv": "^6.12.6",
33
+ "diff": "^8.0.4",
33
34
  "eslint": "^10.9.1",
34
35
  "js-yaml": "^4.1.0",
35
36
  "markdown-it": "^14.3.0",
@@ -11,7 +11,10 @@ the verdict line's wording and the `☐`/`☑` marks as they are, or the hook go
11
11
 
12
12
  ## The front matter
13
13
 
14
- Above the sections, the file carries YAML. The rules read its `stages` field:
14
+ Above the sections, the file carries YAML. In a paper WITHOUT `cycles` in its `paperlint.json`, the
15
+ rules read its `stages` field (a paper with `cycles` keeps no such list: its frozen versions are the
16
+ folders `versions/<cycle>/` and the generated `versions/lock.json`, and `paperlint versions migrate`
17
+ moves an old list there):
15
18
 
16
19
  ```yaml
17
20
  ---
@@ -4,7 +4,7 @@ description: End-to-end playbook for submitting a peer-reviewed paper to a doubl
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:81d5777962c2864a compiled from skills/submit-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:567bfff9583abdf1 compiled from skills/submit-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # submit-paper — get a reviewed paper from "done" to "ready for review"
10
10
 
@@ -137,20 +137,28 @@ Many workshop HotCRPs have **no supplementary-upload field** — check first; if
137
137
 
138
138
  ## 7. Freeze the version you actually uploaded
139
139
 
140
- 🔴 **The moment the portal accepts the file, download it back and commit it.** The rendered PDF is the
141
- only artifact git cannot reconstruct — it was built outside the repo and lived on the portal.
140
+ 🔴 **What the reviewers read is the one version git cannot rebuild for you later** — it was built
141
+ outside the repo and lives on the portal. Freeze it at the send.
142
+
143
+ In a paper that keeps `cycles` in `paperlint.json`, the send freezes it:
144
+ `paperlint submission update <paper> --save` (and `new --save`) writes the exact PDF it sent and the
145
+ source the build read into
142
146
 
143
147
  ```
144
- <paper-dir>/versions/<ISO-date>-submitted.pdf
148
+ <paper-dir>/versions/<cycle id>/submitted.pdf
149
+ <paper-dir>/versions/<cycle id>/submitted.tex
145
150
  ```
146
151
 
147
- and in `PIPELINE-STATUS.md`, on the Submit row, the source that produced it: the literal word
148
- **`commit <hash>`**. Stages are `submitted` / `camera-ready` / `arxiv`; each one you *declare* in
149
- PIPELINE-STATUS needs its own frozen file.
152
+ and records their bytes and sha256 in `versions/lock.json` (generated; never edit it). After an
153
+ acceptance the same command freezes `camera-ready.{pdf,tex}`. An upload made on the portal by hand:
154
+ put that PDF and its source there under those names and run `npx paperlint versions lock <paper>`.
155
+ `npx paperlint versions` prints which version is under review and how far `paper.tex` has moved.
150
156
 
151
- **This is enforced, not advised** — `checkFrozenVersions()` in `.claude/hooks/paper-lint.mjs` reads the
152
- declared stages and reports a paper that declared one without freezing it. The convention itself is
153
- documented per-paper in `versions/README.md`.
157
+ **This is enforced, not advised** — `cycle/submitted-frozen` fails a cycle that has a submission and
158
+ no frozen `submitted.pdf`, and `versions/layout` fails a frozen file that no longer matches the lock.
159
+ A paper without `cycles` records each stage in `PIPELINE-STATUS.md`'s `stages:` list instead,
160
+ checked by `paper/stages` and `paper/source`; `npx paperlint versions migrate` moves it to the
161
+ folders once it keeps `cycles`.
154
162
 
155
163
  **Why it is a step and not hygiene:** `agenticdev-2026` kept a folder for exactly this for a month and
156
164
  the file inside was the wrong build (340 952 B vs the real 352 357 B). It surfaced only when a reviewer
@@ -153,20 +153,28 @@ Many workshop HotCRPs have **no supplementary-upload field** — check first; if
153
153
 
154
154
  ## 7. Freeze the version you actually uploaded
155
155
 
156
- 🔴 **The moment the portal accepts the file, download it back and commit it.** The rendered PDF is the
157
- only artifact git cannot reconstruct — it was built outside the repo and lived on the portal.
156
+ 🔴 **What the reviewers read is the one version git cannot rebuild for you later** — it was built
157
+ outside the repo and lives on the portal. Freeze it at the send.
158
+
159
+ In a paper that keeps \`cycles\` in \`paperlint.json\`, the send freezes it:
160
+ \`paperlint submission update <paper> --save\` (and \`new --save\`) writes the exact PDF it sent and the
161
+ source the build read into
158
162
 
159
163
  \`\`\`
160
- <paper-dir>/versions/<ISO-date>-submitted.pdf
164
+ <paper-dir>/versions/<cycle id>/submitted.pdf
165
+ <paper-dir>/versions/<cycle id>/submitted.tex
161
166
  \`\`\`
162
167
 
163
- and in \`PIPELINE-STATUS.md\`, on the Submit row, the source that produced it: the literal word
164
- **\`commit <hash>\`**. Stages are \`submitted\` / \`camera-ready\` / \`arxiv\`; each one you *declare* in
165
- PIPELINE-STATUS needs its own frozen file.
168
+ and records their bytes and sha256 in \`versions/lock.json\` (generated; never edit it). After an
169
+ acceptance the same command freezes \`camera-ready.{pdf,tex}\`. An upload made on the portal by hand:
170
+ put that PDF and its source there under those names and run \`npx paperlint versions lock <paper>\`.
171
+ \`npx paperlint versions\` prints which version is under review and how far \`paper.tex\` has moved.
166
172
 
167
- **This is enforced, not advised** — \`checkFrozenVersions()\` in \`.claude/hooks/paper-lint.mjs\` reads the
168
- declared stages and reports a paper that declared one without freezing it. The convention itself is
169
- documented per-paper in \`versions/README.md\`.
173
+ **This is enforced, not advised** — \`cycle/submitted-frozen\` fails a cycle that has a submission and
174
+ no frozen \`submitted.pdf\`, and \`versions/layout\` fails a frozen file that no longer matches the lock.
175
+ A paper without \`cycles\` records each stage in \`PIPELINE-STATUS.md\`'s \`stages:\` list instead,
176
+ checked by \`paper/stages\` and \`paper/source\`; \`npx paperlint versions migrate\` moves it to the
177
+ folders once it keeps \`cycles\`.
170
178
 
171
179
  **Why it is a step and not hygiene:** \`agenticdev-2026\` kept a folder for exactly this for a month and
172
180
  the file inside was the wrong build (340 952 B vs the real 352 357 B). It surfaced only when a reviewer
@@ -81,9 +81,11 @@ need to wait for them or make them up.
81
81
 
82
82
  ## The frozen version
83
83
 
84
- Camera-ready is a separate **stage** in the sense of `versions/` (see `checkFrozenVersions()` in
85
- `paper-lint.mjs`): the uploaded PDF is stored as `versions/<date>-camera-ready.pdf`, and the commit
86
- of its source is recorded in `PIPELINE-STATUS.md` as `commit <hash>`.
84
+ Camera-ready is a separate **stage** in `versions/`: once the cycle's `outcome` is `accepted`,
85
+ `paperlint submission update <paper> --save` freezes the PDF it sends and its source as
86
+ `versions/<cycle>/camera-ready.{pdf,tex}` and records them in `versions/lock.json`. A camera-ready
87
+ uploaded through the publisher's own system (not the review portal) goes there by hand, followed by
88
+ `npx paperlint versions lock <paper>`.
87
89
 
88
90
  **Why this isn't a formality:** the arXiv posting and the extension paper get diffed against
89
91
  camera-ready later, and reviewer references to line numbers only resolve against the exact render
@@ -6,9 +6,31 @@ import {
6
6
  fixedDownload,
7
7
  memoryFiles,
8
8
  memoryListEntries,
9
+ memoryCopyFile,
10
+ memoryMoveFile,
9
11
  memoryWorkspace,
10
12
  } from "./index.ts";
11
13
 
14
+ test("memoryMoveFile: the bytes move to the new path; a path holding nothing throws", () => {
15
+ const files = memoryFiles({ "/p/a.pdf": "x" });
16
+ const move = memoryMoveFile(files);
17
+ move(absolutePath("/p/a.pdf"), absolutePath("/p/v/b.pdf"));
18
+ assert.deepEqual([...files.map.keys()], ["/p/v/b.pdf"]);
19
+ assert.throws(() => {
20
+ move(absolutePath("/p/a.pdf"), absolutePath("/p/c.pdf"));
21
+ }, /ENOENT/);
22
+ });
23
+
24
+ test("memoryCopyFile: the bytes under the new path too; a path holding nothing throws", () => {
25
+ const files = memoryFiles({ "/p/a.tex": "x" });
26
+ const copy = memoryCopyFile(files);
27
+ copy(absolutePath("/p/a.tex"), absolutePath("/p/v/b.tex"));
28
+ assert.deepEqual([...files.map.keys()], ["/p/a.tex", "/p/v/b.tex"]);
29
+ assert.throws(() => {
30
+ copy(absolutePath("/p/none"), absolutePath("/p/c.tex"));
31
+ }, /ENOENT/);
32
+ });
33
+
12
34
  test("memoryWorkspace: a `within` that throws is recorded as `threw`, and the error goes on", () => {
13
35
  const ws = memoryWorkspace();
14
36
  assert.throws(
@@ -7,6 +7,7 @@ import type { Download } from "../../ports/download.ts";
7
7
  import type { Files } from "../../ports/files.ts";
8
8
  import type { ListDir } from "../../ports/talk-media.ts";
9
9
  import type { ListEntries } from "../../ports/list-entries.ts";
10
+ import type { CopyFile, MoveFile } from "../../ports/move-file.ts";
10
11
  import type { Command, ProcessExit, RunProcess } from "../../ports/process.ts";
11
12
  import type { NotAPromise, Scratch, Workspace } from "../../ports/workspace.ts";
12
13
  import { err, ok } from "../../domain/result.ts";
@@ -73,6 +74,26 @@ export const memoryListDir =
73
74
  ),
74
75
  ];
75
76
 
77
+ /** `MoveFile` over `memoryFiles`: the bytes under the new path, none under the old; nothing there throws. */
78
+ /** `CopyFile` over `memoryFiles`: the bytes under the new path too; nothing there throws. */
79
+ export const memoryCopyFile =
80
+ (files: MemoryFiles): CopyFile =>
81
+ (from, to) => {
82
+ const b = files.map.get(from);
83
+ if (b === undefined) throw new Error(`ENOENT: no file to copy at ${from}`);
84
+ files.writeAtomic(to, b);
85
+ };
86
+
87
+ export const memoryMoveFile =
88
+ (files: MemoryFiles): MoveFile =>
89
+ (from, to) => {
90
+ const b = files.map.get(from);
91
+ if (b === undefined) throw new Error(`ENOENT: no file to move at ${from}`);
92
+ files.writeAtomic(to, b);
93
+ // eslint-disable-next-line functional/immutable-data -- a test double's disk is the mutable map memoryFiles keeps
94
+ files.map.delete(from);
95
+ };
96
+
76
97
  /**
77
98
  * `ListEntries` over `memoryFiles`: each name directly under a directory, a file when it is one, else a
78
99
  * directory. Memory holds no links: the walk's refusal to follow one is the node adapter's to show.
@@ -36,3 +36,4 @@ export { spawnProcess, type SpawnSync } from "./process.io.ts";
36
36
  export { hostDirs } from "./host.io.ts";
37
37
  export { nodeListDir } from "./list-dir.io.ts";
38
38
  export { nodeListEntries } from "./list-entries.io.ts";
39
+ export { nodeCopyFile, nodeMoveFile } from "./move-file.io.ts";
@@ -0,0 +1,21 @@
1
+ /** `MoveFile` and `CopyFile` over `node:fs`: a rename or a copy, the directory made first. */
2
+ import { copyFileSync, mkdirSync, renameSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import type { AbsolutePath } from "../../domain/paths.ts";
5
+ import type { CopyFile, MoveFile } from "../../ports/move-file.ts";
6
+
7
+ export const nodeMoveFile: MoveFile = (
8
+ from: AbsolutePath,
9
+ to: AbsolutePath,
10
+ ): void => {
11
+ mkdirSync(dirname(to), { recursive: true });
12
+ renameSync(from, to);
13
+ };
14
+
15
+ export const nodeCopyFile: CopyFile = (
16
+ from: AbsolutePath,
17
+ to: AbsolutePath,
18
+ ): void => {
19
+ mkdirSync(dirname(to), { recursive: true });
20
+ copyFileSync(from, to);
21
+ };
@@ -0,0 +1,51 @@
1
+ /** `nodeMoveFile`: the file arrives with its bytes, into a folder made for it, and is gone from where it was. */
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { absolutePath } from "../../domain/paths.ts";
6
+ import { useTempDir } from "../../../test/support.ts";
7
+ import { nodeCopyFile, nodeMoveFile } from "./move-file.io.ts";
8
+
9
+ const root = useTempDir("paperlint-move-file-");
10
+
11
+ describe("nodeMoveFile", () => {
12
+ it("moves a file into a folder that does not exist yet", () => {
13
+ writeFileSync(join(root, "a.pdf"), "bytes");
14
+ nodeMoveFile(
15
+ absolutePath(join(root, "a.pdf")),
16
+ absolutePath(join(root, "x", "y", "b.pdf")),
17
+ );
18
+ expect(existsSync(join(root, "a.pdf"))).toBe(false);
19
+ expect(readFileSync(join(root, "x", "y", "b.pdf"), "utf8")).toBe("bytes");
20
+ });
21
+
22
+ it("throws when there is nothing to move", () => {
23
+ expect(() => {
24
+ nodeMoveFile(
25
+ absolutePath(join(root, "none")),
26
+ absolutePath(join(root, "z")),
27
+ );
28
+ }).toThrow();
29
+ });
30
+ });
31
+
32
+ describe("nodeCopyFile", () => {
33
+ it("copies a file into a folder that does not exist yet, and leaves it where it was", () => {
34
+ writeFileSync(join(root, "s.tex"), "source");
35
+ nodeCopyFile(
36
+ absolutePath(join(root, "s.tex")),
37
+ absolutePath(join(root, "c", "d", "s.tex")),
38
+ );
39
+ expect(readFileSync(join(root, "s.tex"), "utf8")).toBe("source");
40
+ expect(readFileSync(join(root, "c", "d", "s.tex"), "utf8")).toBe("source");
41
+ });
42
+
43
+ it("throws when there is nothing to copy", () => {
44
+ expect(() => {
45
+ nodeCopyFile(
46
+ absolutePath(join(root, "none")),
47
+ absolutePath(join(root, "w")),
48
+ );
49
+ }).toThrow();
50
+ });
51
+ });