paperlint 12.0.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 (147) hide show
  1. package/CONTRIBUTING.md +2 -1
  2. package/dist/adapters/memory/index.d.ts +5 -0
  3. package/dist/adapters/memory/index.d.ts.map +1 -1
  4. package/dist/adapters/memory/index.js +16 -0
  5. package/dist/adapters/memory/index.js.map +1 -1
  6. package/dist/adapters/node/index.d.ts +1 -0
  7. package/dist/adapters/node/index.d.ts.map +1 -1
  8. package/dist/adapters/node/index.js +1 -0
  9. package/dist/adapters/node/index.js.map +1 -1
  10. package/dist/adapters/node/move-file.io.d.ts +4 -0
  11. package/dist/adapters/node/move-file.io.d.ts.map +1 -0
  12. package/dist/adapters/node/move-file.io.js +12 -0
  13. package/dist/adapters/node/move-file.io.js.map +1 -0
  14. package/dist/adapters/yaml/index.d.ts +3 -0
  15. package/dist/adapters/yaml/index.d.ts.map +1 -0
  16. package/dist/adapters/yaml/index.js +17 -0
  17. package/dist/adapters/yaml/index.js.map +1 -0
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +48 -7
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cycle-rules.d.ts +3 -3
  22. package/dist/cycle-rules.d.ts.map +1 -1
  23. package/dist/cycle-rules.js +1 -2
  24. package/dist/cycle-rules.js.map +1 -1
  25. package/dist/domain/cycle.d.ts +16 -2
  26. package/dist/domain/cycle.d.ts.map +1 -1
  27. package/dist/domain/cycle.js +21 -42
  28. package/dist/domain/cycle.js.map +1 -1
  29. package/dist/domain/paper-layout.d.ts.map +1 -1
  30. package/dist/domain/paper-layout.js +5 -2
  31. package/dist/domain/paper-layout.js.map +1 -1
  32. package/dist/domain/paths.d.ts +12 -0
  33. package/dist/domain/paths.d.ts.map +1 -1
  34. package/dist/domain/paths.js +22 -1
  35. package/dist/domain/paths.js.map +1 -1
  36. package/dist/domain/scaffold-layout.js +1 -1
  37. package/dist/domain/versions-migration.d.ts +51 -0
  38. package/dist/domain/versions-migration.d.ts.map +1 -0
  39. package/dist/domain/versions-migration.js +252 -0
  40. package/dist/domain/versions-migration.js.map +1 -0
  41. package/dist/domain/versions.d.ts +167 -0
  42. package/dist/domain/versions.d.ts.map +1 -0
  43. package/dist/domain/versions.js +457 -0
  44. package/dist/domain/versions.js.map +1 -0
  45. package/dist/eslint-rules/paper-stages.d.ts +7 -0
  46. package/dist/eslint-rules/paper-stages.d.ts.map +1 -1
  47. package/dist/eslint-rules/paper-stages.js +62 -7
  48. package/dist/eslint-rules/paper-stages.js.map +1 -1
  49. package/dist/eslint-rules/rule-context.d.ts +2 -0
  50. package/dist/eslint-rules/rule-context.d.ts.map +1 -1
  51. package/dist/ports/move-file.d.ts +7 -0
  52. package/dist/ports/move-file.d.ts.map +1 -0
  53. package/dist/ports/move-file.js +2 -0
  54. package/dist/ports/move-file.js.map +1 -0
  55. package/dist/ports/yaml.d.ts +5 -0
  56. package/dist/ports/yaml.d.ts.map +1 -0
  57. package/dist/ports/yaml.js +2 -0
  58. package/dist/ports/yaml.js.map +1 -0
  59. package/dist/submission.d.ts +2 -0
  60. package/dist/submission.d.ts.map +1 -1
  61. package/dist/submission.js +49 -9
  62. package/dist/submission.js.map +1 -1
  63. package/dist/version-rules.d.ts +16 -0
  64. package/dist/version-rules.d.ts.map +1 -0
  65. package/dist/version-rules.js +201 -0
  66. package/dist/version-rules.js.map +1 -0
  67. package/dist/versions-command.d.ts +37 -0
  68. package/dist/versions-command.d.ts.map +1 -0
  69. package/dist/versions-command.js +209 -0
  70. package/dist/versions-command.js.map +1 -0
  71. package/dist/versions.d.ts +76 -0
  72. package/dist/versions.d.ts.map +1 -0
  73. package/dist/versions.js +164 -0
  74. package/dist/versions.js.map +1 -0
  75. package/docs/configuration.md +76 -2
  76. package/docs/layout.md +5 -5
  77. package/docs/rules/cycle/camera-ready-source.md +62 -0
  78. package/docs/rules/cycle/draft-drift.md +47 -0
  79. package/docs/rules/cycle/port-done.md +2 -2
  80. package/docs/rules/cycle/record.md +2 -1
  81. package/docs/rules/cycle/submitted-frozen.md +65 -0
  82. package/docs/rules/paper/allowed-files-only.md +1 -1
  83. package/docs/rules/versions/layout.md +88 -0
  84. package/docs/rules.md +25 -11
  85. package/docs/submission.md +21 -3
  86. package/eslint-rules/paper-stages.test.mjs +62 -0
  87. package/eslint-rules/paper-stages.ts +67 -8
  88. package/eslint-rules/prereg-coverage.test.ts +1 -0
  89. package/eslint-rules/rule-context.ts +2 -0
  90. package/package.json +2 -1
  91. package/presets/agent.jsonc +75 -0
  92. package/presets/agent.md +70 -0
  93. package/presets/agentverify.jsonc +72 -0
  94. package/presets/agentverify.md +85 -0
  95. package/presets/ieee-conference.md +1 -1
  96. package/presets/nlbse.jsonc +76 -0
  97. package/presets/nlbse.md +85 -0
  98. package/scripts/coverage-config.test.ts +3 -0
  99. package/skills/CLAUDE.md +2 -2
  100. package/skills/CLAUDE.md.spec.ts +1 -1
  101. package/skills/paper-pipeline/references/pipeline-status-template.md +4 -1
  102. package/skills/submit-paper/SKILL.md +18 -10
  103. package/skills/submit-paper/SKILL.md.spec.ts +17 -9
  104. package/skills/submit-paper/references/publishers/acm.md +5 -3
  105. package/src/adapters/memory/index.test.ts +22 -0
  106. package/src/adapters/memory/index.ts +21 -0
  107. package/src/adapters/node/index.ts +1 -0
  108. package/src/adapters/node/move-file.io.ts +21 -0
  109. package/src/adapters/node/move-file.test.ts +51 -0
  110. package/src/adapters/yaml/index.test.ts +16 -0
  111. package/src/adapters/yaml/index.ts +17 -0
  112. package/src/cli.test.ts +43 -0
  113. package/src/cli.ts +58 -6
  114. package/src/cycle-rules.ts +4 -5
  115. package/src/domain/cycle.test.ts +27 -0
  116. package/src/domain/cycle.ts +38 -6
  117. package/src/domain/paper-layout.ts +5 -2
  118. package/src/domain/paths.test.ts +28 -1
  119. package/src/domain/paths.ts +34 -1
  120. package/src/domain/scaffold-layout.ts +1 -1
  121. package/src/domain/versions-migration.test.ts +410 -0
  122. package/src/domain/versions-migration.ts +429 -0
  123. package/src/domain/versions.test.ts +564 -0
  124. package/src/domain/versions.ts +706 -0
  125. package/src/paper-settings-commands.test.ts +1 -1
  126. package/src/ports/move-file.ts +8 -0
  127. package/src/ports/yaml.ts +5 -0
  128. package/src/preset-card-rule.test.ts +3 -0
  129. package/src/presets.test.ts +55 -31
  130. package/src/rule-cases.test.ts +157 -8
  131. package/src/submission-freeze.test.ts +320 -0
  132. package/src/submission-new.test.ts +11 -1
  133. package/src/submission.ts +82 -11
  134. package/src/tex-venue-rules.test.ts +3 -0
  135. package/src/venue-rules.test.ts +2 -2
  136. package/src/version-rules.test.ts +222 -0
  137. package/src/version-rules.ts +282 -0
  138. package/src/versions-command.test.ts +594 -0
  139. package/src/versions-command.ts +360 -0
  140. package/src/versions.ts +286 -0
  141. package/dist/eslint-rules/cycle-stage.d.ts +0 -39
  142. package/dist/eslint-rules/cycle-stage.d.ts.map +0 -1
  143. package/dist/eslint-rules/cycle-stage.js +0 -119
  144. package/dist/eslint-rules/cycle-stage.js.map +0 -1
  145. package/docs/rules/cycle/stage.md +0 -85
  146. package/eslint-rules/cycle-stage.test.mjs +0 -131
  147. 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.0.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",
@@ -0,0 +1,75 @@
1
+ // AGENT'27 (the Second International Workshop on Agentic Engineering, an ICSE 2027 workshop) — a
2
+ // VENUE preset over the IEEE conference family. Everything the IEEEtran template decides (page
3
+ // size, columns, fonts, body size, TeX packages) comes from ieee-conference.jsonc; this file adds
4
+ // what AGENT's call for papers sets. Its card, in prose: `agent.md` beside this file.
5
+ //
6
+ // Verbatim from https://conf.researchr.org/home/icse-2027/agent-2027 (fetched 2026-10-26):
7
+ // «Full papers (research or experience): up to 8 pages, including references.»
8
+ // «Short papers (position, emerging research, tool, benchmark, or experience): up to 5 pages,
9
+ // including references.»
10
+ // «All submissions must be original, written in English, and formatted according to the IEEE
11
+ // conference proceedings template. Submissions will be reviewed single-anonymously. Accepted
12
+ // workshop papers will be published by IEEE. It is not possible to pay for extra pages.»
13
+ // «Workshop paper submission deadline: November 13, 2026 / Notification of acceptance: December
14
+ // 11, 2026 / Camera-ready deadline: January 29, 2027 / All dates are Anywhere on Earth (AoE).»
15
+ {
16
+ "type": "venue",
17
+ "name": "AGENT 2027",
18
+ // The call quoted above.
19
+ "url": "https://conf.researchr.org/home/icse-2027/agent-2027",
20
+ "extends": "paperlint:ieee-conference",
21
+
22
+ // «AGENT» alone is an ordinary word: as a name it would flag every paper folder called
23
+ // `agent-…` (paper/folder-venue-leftover compares folder names by words, and `mentions` reaches
24
+ // only the text rules). So the venue is «AGENT 2027» and «AGENT'27» (the call's own spelling,
25
+ // with either apostrophe). ICSE is left out: papers cite ICSE papers by name.
26
+ "aliases": ["AGENT'27", "AGENT\u201927"],
27
+
28
+ // No `template` and no `blind`, on purpose. The call names «the IEEE conference proceedings
29
+ // template» and no class line, so the family's `[conference]` stands (the ICSE workshops page
30
+ // gives `[10pt,conference]{IEEEtran}` for workshop PROPOSALS only). The review is
31
+ // single-anonymous (above), so `blind` — which turns on anonymity/identity — stays unset; the
32
+ // portal agrees: its status data carries no `"blind":true` (read 2026-10-26).
33
+
34
+ // «Submission link» on the call's page: HotCRP.
35
+ "portal": { "kind": "hotcrp", "url": "https://icse2027-agent.hotcrp.com" },
36
+
37
+ // The portal's reading: `"sub":1794657599` on https://icse2027-agent.hotcrp.com/deadlines, shown
38
+ // as «Saturday Nov 14, 2026, 6:59:59 AM EST» = 2026-11-14T11:59:59Z = the end of 13 Nov AoE. The
39
+ // scheduled refresh rewrites it. The call's two other dates are not on the portal; they are the
40
+ // call's, in UTC at the end of the AoE day.
41
+ "deadlines": [
42
+ {
43
+ "what": "submission",
44
+ "at": "2026-11-14T11:59:59Z",
45
+ "source": "portal",
46
+ "url": "https://icse2027-agent.hotcrp.com/deadlines",
47
+ "read": "2026-10-26",
48
+ },
49
+ {
50
+ "what": "notification",
51
+ "at": "2026-12-12T11:59:59Z",
52
+ "source": "call",
53
+ "url": "https://conf.researchr.org/home/icse-2027/agent-2027",
54
+ "read": "2026-10-26",
55
+ },
56
+ {
57
+ "what": "camera-ready",
58
+ "at": "2027-01-30T11:59:59Z",
59
+ "source": "call",
60
+ "url": "https://conf.researchr.org/home/icse-2027/agent-2027",
61
+ "read": "2026-10-26",
62
+ },
63
+ ],
64
+
65
+ "format": {
66
+ // 🔴 THE LIMITS INCLUDE THE REFERENCES («up to 8 pages, including references»), and no key
67
+ // gates a total. `body_pages_max` is banal's body count, which is never more than the total, so
68
+ // a paper over it is over the limit; a paper whose references sit alone on a page after a full
69
+ // body passes this gate and is still over. The card says so.
70
+ "kinds": {
71
+ "full": { "body_pages_max": 8 },
72
+ "short": { "body_pages_max": 5 },
73
+ },
74
+ },
75
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: "AGENT @ ICSE — venue card"
3
+ ---
4
+
5
+ # AGENT @ ICSE
6
+
7
+ The card of the venue preset `paperlint:agent` ([`agent.jsonc`](agent.jsonc)): what the venue asks
8
+ for, in prose. The numbers a machine checks live in the preset; the section at the end is generated
9
+ from it. The `submit-paper` skill reads this card for the venue's specifics.
10
+
11
+ **Re-verify the call for papers every year**
12
+ ([conf.researchr.org/home/icse-2027/agent-2027](https://conf.researchr.org/home/icse-2027/agent-2027)).
13
+ Sources read 2026-10-26: that page, the portal's
14
+ [deadlines page](https://icse2027-agent.hotcrp.com/deadlines), and the
15
+ [ICSE 2027 workshops page](https://conf.researchr.org/track/icse-2027/icse-2027-workshops).
16
+
17
+ ## The venue (2027 edition)
18
+
19
+ - **Venue**: The Second International Workshop on Agentic Engineering, an ICSE 2027 workshop,
20
+ Mon 26 April 2027, Dublin.
21
+ - **Portal**: HotCRP, `https://icse2027-agent.hotcrp.com/` (the page's «Submission link»).
22
+ - **Format**: «formatted according to the IEEE conference proceedings template». No class line is
23
+ given for papers, so the preset keeps the family's `\documentclass[conference]{IEEEtran}`.
24
+ - **Review**: «Submissions will be reviewed single-anonymously.» The authors' names stay on the
25
+ paper; the portal's status data carries no `"blind":true`, which agrees.
26
+ - **Publisher**: «Accepted workshop papers will be published by IEEE.» The page does not say
27
+ «archival» or offer a non-archival choice.
28
+ - **Deadlines (AoE)**: submission 13 Nov 2026, notification 11 Dec 2026, camera-ready 29 Jan 2027.
29
+ The portal reads `"sub":1794657599`, 2026-11-14T11:59:59Z, the end of 13 Nov AoE: the two agree.
30
+
31
+ ## Paper kinds and limits
32
+
33
+ | kind | pages | what it is (the call's words) |
34
+ | ------- | ----: | ------------------------------------------------------------------------------------------------------------------ |
35
+ | `full` | 8 | «Full papers (research or experience): up to 8 pages, including references.» |
36
+ | `short` | 5 | «Short papers (position, emerging research, tool, benchmark, or experience): up to 5 pages, including references.» |
37
+
38
+ 🔴 **The limit is a total, references included**, and «It is not possible to pay for extra pages.»
39
+ `format/page-limit` can only compare banal's body count with the number, so a paper whose body
40
+ fills the pages and whose references stand alone on one more page passes the rule and is over the
41
+ limit. Count the PDF's pages yourself before submitting.
42
+
43
+ ## What else the preset encodes
44
+
45
+ - **The IEEE template**, through `extends: paperlint:ieee-conference` ([`ieee-conference.md`](ieee-conference.md)).
46
+ - **The venue's name** is `AGENT 2027`, with the aliases `AGENT'27` and `AGENT’27` (what
47
+ `tex/venue-leftover` looks for in another venue's paper). «AGENT» alone is an ordinary word, and
48
+ as a name it would flag every paper folder called `agent-…` (`paper/folder-venue-leftover`).
49
+ - **Portal and deadlines** (`portal`, `deadlines`): the submission reading is the portal's and is
50
+ refreshed by paperlint's scheduled workflow; notification and camera-ready are the call's.
51
+
52
+ ## Not verified
53
+
54
+ - **Remote presentation**: neither the workshop page, the ICSE workshops page nor the ICSE 2027
55
+ site's «Attending» menu states a policy. Ask the chairs before relying on one.
56
+ - **AI / LLM usage policy and required sections**: none stated on the workshop page.
57
+ - **The proceedings' class line**: see Format above; the page links the IEEE templates page only.
58
+
59
+ ## What the preset resolves to
60
+
61
+ <!-- paperlint:preset-rules -->
62
+ <!-- prettier-ignore-start -->
63
+ <!-- Generated from agent.jsonc by `npx eslint --fix` (rule preset/card-rules). Edit the preset, not this section. -->
64
+
65
+ **`paperlint:agent`** — venue preset for AGENT 2027 · call for papers: <https://conf.researchr.org/home/icse-2027/agent-2027> · extends `paperlint:ieee-conference`
66
+
67
+ No preset of this chain sets a rule: under it, every rule keeps paperlint's default.
68
+
69
+ <!-- prettier-ignore-end -->
70
+ <!-- /paperlint:preset-rules -->
@@ -0,0 +1,72 @@
1
+ // AGENTVERIFY 2027 (the International Workshop on Infrastructure for Trustworthy Software Agents, an
2
+ // ICSE 2027 workshop) — a VENUE preset over the IEEE conference family. Everything the IEEEtran
3
+ // template decides comes from ieee-conference.jsonc; this file adds what AGENTVERIFY's call sets.
4
+ // Its card, in prose: `agentverify.md` beside this file.
5
+ //
6
+ // Verbatim from https://agentverify2027.github.io/cfp.html (fetched 2026-10-26):
7
+ // «Full Papers: Maximum of 8 pages, including references.»
8
+ // «Short & Demonstration Papers: Maximum of 4 pages, including references.»
9
+ // «Extended Abstracts: Maximum of 2 pages, including references.»
10
+ // «Papers will be submitted via HotCRP and reviewed double-blind. Submissions should follow the
11
+ // IEEE format in line with ICSE 2027 and present an original contribution.»
12
+ // «Papers are due November 13th, 2026 (AoE).»
13
+ // and from https://agentverify2027.github.io/ (same day): «Paper submission deadline November 13,
14
+ // 2026 / Final notification December 11, 2026 / Camera-ready deadline January 29, 2027 / All
15
+ // deadlines are Anywhere on Earth (AoE).»
16
+ {
17
+ "type": "venue",
18
+ "name": "AGENTVERIFY",
19
+ // The call quoted above.
20
+ "url": "https://agentverify2027.github.io/cfp.html",
21
+ "extends": "paperlint:ieee-conference",
22
+
23
+ // «reviewed double-blind» (the call above), and `"blind":true` in the portal's status data
24
+ // (https://icse2027-agentverify.hotcrp.com/deadlines, read 2026-10-26). anonymity/identity then
25
+ // requires the paper's `identity`.
26
+ "blind": true,
27
+
28
+ // «Submit Your Paper →» on the workshop's site: HotCRP.
29
+ "portal": {
30
+ "kind": "hotcrp",
31
+ "url": "https://icse2027-agentverify.hotcrp.com",
32
+ },
33
+
34
+ // The portal's reading: `"sub":1794657599` = 2026-11-14T11:59:59Z = the end of 13 Nov AoE. It
35
+ // agrees with the call and with the ICSE workshops page; the organizers' email of 2026-10-23
36
+ // (agentverify.md) says the same and that there is no separate abstract deadline, so there is no
37
+ // `registration`. Notification and camera-ready are the site's, at the end of the AoE day.
38
+ "deadlines": [
39
+ {
40
+ "what": "submission",
41
+ "at": "2026-11-14T11:59:59Z",
42
+ "source": "portal",
43
+ "url": "https://icse2027-agentverify.hotcrp.com/deadlines",
44
+ "read": "2026-10-26",
45
+ },
46
+ {
47
+ "what": "notification",
48
+ "at": "2026-12-12T11:59:59Z",
49
+ "source": "call",
50
+ "url": "https://agentverify2027.github.io/",
51
+ "read": "2026-10-26",
52
+ },
53
+ {
54
+ "what": "camera-ready",
55
+ "at": "2027-01-30T11:59:59Z",
56
+ "source": "call",
57
+ "url": "https://agentverify2027.github.io/",
58
+ "read": "2026-10-26",
59
+ },
60
+ ],
61
+
62
+ "format": {
63
+ // 🔴 THE LIMITS INCLUDE THE REFERENCES, and no key gates a total: `body_pages_max` is banal's
64
+ // body count, never more than the total, so a paper over it is over the limit; references alone
65
+ // on a page after a full body pass this gate and are still over (agentverify.md).
66
+ "kinds": {
67
+ "full": { "body_pages_max": 8 },
68
+ "short": { "body_pages_max": 4 },
69
+ "abstract": { "body_pages_max": 2 },
70
+ },
71
+ },
72
+ }