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.
- package/CONTRIBUTING.md +2 -1
- package/dist/adapters/memory/index.d.ts +5 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +16 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/move-file.io.d.ts +4 -0
- package/dist/adapters/node/move-file.io.d.ts.map +1 -0
- package/dist/adapters/node/move-file.io.js +12 -0
- package/dist/adapters/node/move-file.io.js.map +1 -0
- package/dist/adapters/yaml/index.d.ts +3 -0
- package/dist/adapters/yaml/index.d.ts.map +1 -0
- package/dist/adapters/yaml/index.js +17 -0
- package/dist/adapters/yaml/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +48 -7
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts +3 -3
- package/dist/cycle-rules.d.ts.map +1 -1
- package/dist/cycle-rules.js +1 -2
- package/dist/cycle-rules.js.map +1 -1
- package/dist/domain/cycle.d.ts +16 -2
- package/dist/domain/cycle.d.ts.map +1 -1
- package/dist/domain/cycle.js +21 -42
- package/dist/domain/cycle.js.map +1 -1
- package/dist/domain/paper-layout.d.ts.map +1 -1
- package/dist/domain/paper-layout.js +5 -2
- package/dist/domain/paper-layout.js.map +1 -1
- package/dist/domain/paths.d.ts +12 -0
- package/dist/domain/paths.d.ts.map +1 -1
- package/dist/domain/paths.js +22 -1
- package/dist/domain/paths.js.map +1 -1
- package/dist/domain/scaffold-layout.js +1 -1
- package/dist/domain/versions-migration.d.ts +51 -0
- package/dist/domain/versions-migration.d.ts.map +1 -0
- package/dist/domain/versions-migration.js +252 -0
- package/dist/domain/versions-migration.js.map +1 -0
- package/dist/domain/versions.d.ts +167 -0
- package/dist/domain/versions.d.ts.map +1 -0
- package/dist/domain/versions.js +457 -0
- package/dist/domain/versions.js.map +1 -0
- package/dist/eslint-rules/paper-stages.d.ts +7 -0
- package/dist/eslint-rules/paper-stages.d.ts.map +1 -1
- package/dist/eslint-rules/paper-stages.js +62 -7
- package/dist/eslint-rules/paper-stages.js.map +1 -1
- package/dist/eslint-rules/rule-context.d.ts +2 -0
- package/dist/eslint-rules/rule-context.d.ts.map +1 -1
- package/dist/ports/move-file.d.ts +7 -0
- package/dist/ports/move-file.d.ts.map +1 -0
- package/dist/ports/move-file.js +2 -0
- package/dist/ports/move-file.js.map +1 -0
- package/dist/ports/yaml.d.ts +5 -0
- package/dist/ports/yaml.d.ts.map +1 -0
- package/dist/ports/yaml.js +2 -0
- package/dist/ports/yaml.js.map +1 -0
- package/dist/submission.d.ts +2 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +49 -9
- package/dist/submission.js.map +1 -1
- package/dist/version-rules.d.ts +16 -0
- package/dist/version-rules.d.ts.map +1 -0
- package/dist/version-rules.js +201 -0
- package/dist/version-rules.js.map +1 -0
- package/dist/versions-command.d.ts +37 -0
- package/dist/versions-command.d.ts.map +1 -0
- package/dist/versions-command.js +209 -0
- package/dist/versions-command.js.map +1 -0
- package/dist/versions.d.ts +76 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +164 -0
- package/dist/versions.js.map +1 -0
- package/docs/configuration.md +76 -2
- package/docs/layout.md +5 -5
- package/docs/rules/cycle/camera-ready-source.md +62 -0
- package/docs/rules/cycle/draft-drift.md +47 -0
- package/docs/rules/cycle/port-done.md +2 -2
- package/docs/rules/cycle/record.md +2 -1
- package/docs/rules/cycle/submitted-frozen.md +65 -0
- package/docs/rules/paper/allowed-files-only.md +1 -1
- package/docs/rules/versions/layout.md +88 -0
- package/docs/rules.md +25 -11
- package/docs/submission.md +21 -3
- package/eslint-rules/paper-stages.test.mjs +62 -0
- package/eslint-rules/paper-stages.ts +67 -8
- package/eslint-rules/prereg-coverage.test.ts +1 -0
- package/eslint-rules/rule-context.ts +2 -0
- package/package.json +2 -1
- package/presets/agent.jsonc +75 -0
- package/presets/agent.md +70 -0
- package/presets/agentverify.jsonc +72 -0
- package/presets/agentverify.md +85 -0
- package/presets/ieee-conference.md +1 -1
- package/presets/nlbse.jsonc +76 -0
- package/presets/nlbse.md +85 -0
- package/scripts/coverage-config.test.ts +3 -0
- package/skills/CLAUDE.md +2 -2
- package/skills/CLAUDE.md.spec.ts +1 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +4 -1
- package/skills/submit-paper/SKILL.md +18 -10
- package/skills/submit-paper/SKILL.md.spec.ts +17 -9
- package/skills/submit-paper/references/publishers/acm.md +5 -3
- package/src/adapters/memory/index.test.ts +22 -0
- package/src/adapters/memory/index.ts +21 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/move-file.io.ts +21 -0
- package/src/adapters/node/move-file.test.ts +51 -0
- package/src/adapters/yaml/index.test.ts +16 -0
- package/src/adapters/yaml/index.ts +17 -0
- package/src/cli.test.ts +43 -0
- package/src/cli.ts +58 -6
- package/src/cycle-rules.ts +4 -5
- package/src/domain/cycle.test.ts +27 -0
- package/src/domain/cycle.ts +38 -6
- package/src/domain/paper-layout.ts +5 -2
- package/src/domain/paths.test.ts +28 -1
- package/src/domain/paths.ts +34 -1
- package/src/domain/scaffold-layout.ts +1 -1
- package/src/domain/versions-migration.test.ts +410 -0
- package/src/domain/versions-migration.ts +429 -0
- package/src/domain/versions.test.ts +564 -0
- package/src/domain/versions.ts +706 -0
- package/src/paper-settings-commands.test.ts +1 -1
- package/src/ports/move-file.ts +8 -0
- package/src/ports/yaml.ts +5 -0
- package/src/preset-card-rule.test.ts +3 -0
- package/src/presets.test.ts +55 -31
- package/src/rule-cases.test.ts +157 -8
- package/src/submission-freeze.test.ts +320 -0
- package/src/submission-new.test.ts +11 -1
- package/src/submission.ts +82 -11
- package/src/tex-venue-rules.test.ts +3 -0
- package/src/venue-rules.test.ts +2 -2
- package/src/version-rules.test.ts +222 -0
- package/src/version-rules.ts +282 -0
- package/src/versions-command.test.ts +594 -0
- package/src/versions-command.ts +360 -0
- package/src/versions.ts +286 -0
- package/dist/eslint-rules/cycle-stage.d.ts +0 -39
- package/dist/eslint-rules/cycle-stage.d.ts.map +0 -1
- package/dist/eslint-rules/cycle-stage.js +0 -119
- package/dist/eslint-rules/cycle-stage.js.map +0 -1
- package/docs/rules/cycle/stage.md +0 -85
- package/eslint-rules/cycle-stage.test.mjs +0 -131
- package/eslint-rules/cycle-stage.ts +0 -141
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint versions [show|lock|migrate] [paper]`: the version → state table, the lock written from
|
|
3
|
+
* the files on disk, and the one-command migration from `stages:` — over in-memory files.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { latexReader } from "./adapters/latex/index.ts";
|
|
7
|
+
import {
|
|
8
|
+
memoryFiles,
|
|
9
|
+
memoryCopyFile,
|
|
10
|
+
memoryListDir,
|
|
11
|
+
memoryMoveFile,
|
|
12
|
+
type MemoryFiles,
|
|
13
|
+
} from "./adapters/memory/index.ts";
|
|
14
|
+
import { sourcesCodec } from "./adapters/sources-record/index.ts";
|
|
15
|
+
import { parseYaml } from "./adapters/yaml/index.ts";
|
|
16
|
+
import { absolutePath } from "./domain/paths.ts";
|
|
17
|
+
import { sha256Hex } from "./domain/sha256.ts";
|
|
18
|
+
import { lockText, parseLock, type Lock } from "./domain/versions.ts";
|
|
19
|
+
import {
|
|
20
|
+
carryOut,
|
|
21
|
+
runVersions,
|
|
22
|
+
type VersionsCommandDeps,
|
|
23
|
+
} from "./versions-command.ts";
|
|
24
|
+
import { paperPath } from "./domain/paths.ts";
|
|
25
|
+
import { factsOfFrozen } from "./versions.ts";
|
|
26
|
+
|
|
27
|
+
const PAPER = "/w/papers/p";
|
|
28
|
+
const enc = (t: string) => new TextEncoder().encode(t);
|
|
29
|
+
const locked = (t: string, frozen = "2026-10-23T16:58:01.000Z") => ({
|
|
30
|
+
kind: "file" as const,
|
|
31
|
+
bytes: enc(t).length,
|
|
32
|
+
sha256: sha256Hex(enc(t)),
|
|
33
|
+
frozen,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const decision = (kind: string, date: string) => ({
|
|
37
|
+
kind,
|
|
38
|
+
date,
|
|
39
|
+
evidence: "reviews/decision.md",
|
|
40
|
+
});
|
|
41
|
+
/** A workshop version accepted, a conference attempt rejected, a journal attempt open. */
|
|
42
|
+
const CYCLES = [
|
|
43
|
+
{
|
|
44
|
+
id: "workshop-2026",
|
|
45
|
+
venue: { kind: "preset", extends: "paperlint:agenticdev" },
|
|
46
|
+
opened: "2026-03-01",
|
|
47
|
+
submission: { id: 3 },
|
|
48
|
+
outcome: decision("accepted", "2026-05-01"),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "conf-2026",
|
|
52
|
+
venue: { kind: "preset", extends: "paperlint:aisec" },
|
|
53
|
+
opened: "2026-06-01",
|
|
54
|
+
submission: { id: 11 },
|
|
55
|
+
outcome: decision("rejected", "2026-09-08"),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "journal-2027",
|
|
59
|
+
venue: { kind: "named", name: "Journal 2027", url: "https://j.example" },
|
|
60
|
+
opened: "2026-09-10",
|
|
61
|
+
submission: { id: 7 },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const SENT =
|
|
65
|
+
"\\documentclass{article}\n\\begin{document}\nA.\n\\end{document}\n";
|
|
66
|
+
const DRAFT = SENT.replace("A.", "A, then B.\nC.");
|
|
67
|
+
const PDF = "%PDF sent";
|
|
68
|
+
|
|
69
|
+
/** The paper: settings, draft, and each attempt's frozen files with the lock that records them. */
|
|
70
|
+
function paper(o: { readonly cycles?: readonly object[] | null } = {}) {
|
|
71
|
+
return memoryFiles({
|
|
72
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify(
|
|
73
|
+
o.cycles === null ? { extends: null } : { cycles: o.cycles ?? CYCLES },
|
|
74
|
+
),
|
|
75
|
+
[`${PAPER}/paper.tex`]: DRAFT,
|
|
76
|
+
[`${PAPER}/versions/workshop-2026/submitted.pdf`]: PDF,
|
|
77
|
+
[`${PAPER}/versions/workshop-2026/submitted.tex`]: SENT,
|
|
78
|
+
[`${PAPER}/versions/workshop-2026/camera-ready.pdf`]: PDF,
|
|
79
|
+
[`${PAPER}/versions/workshop-2026/camera-ready.tex`]: SENT,
|
|
80
|
+
[`${PAPER}/versions/conf-2026/submitted.pdf`]: PDF,
|
|
81
|
+
[`${PAPER}/versions/journal-2027/submitted.pdf`]: PDF,
|
|
82
|
+
[`${PAPER}/versions/journal-2027/submitted.tex`]: SENT,
|
|
83
|
+
[`${PAPER}/versions/lock.json`]: lockText({
|
|
84
|
+
"workshop-2026/submitted.pdf": locked(PDF, "2026-03-20"),
|
|
85
|
+
"workshop-2026/submitted.tex": locked(SENT, "2026-03-20"),
|
|
86
|
+
"workshop-2026/camera-ready.pdf": locked(PDF, "2026-05-20T10:00:00.000Z"),
|
|
87
|
+
"workshop-2026/camera-ready.tex": locked(
|
|
88
|
+
SENT,
|
|
89
|
+
"2026-05-20T10:00:00.000Z",
|
|
90
|
+
),
|
|
91
|
+
"conf-2026/submitted.pdf": locked(PDF, "2026-07-22"),
|
|
92
|
+
"conf-2026/submitted.tex": { kind: "lost", frozen: "2026-07-22" },
|
|
93
|
+
"journal-2027/submitted.pdf": locked(PDF),
|
|
94
|
+
"journal-2027/submitted.tex": locked(SENT),
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What the command runs with, over in-memory files. */
|
|
100
|
+
const deps = (
|
|
101
|
+
files: MemoryFiles,
|
|
102
|
+
out: string[],
|
|
103
|
+
errs: string[],
|
|
104
|
+
cwd = "/w",
|
|
105
|
+
): VersionsCommandDeps => ({
|
|
106
|
+
files,
|
|
107
|
+
listDir: memoryListDir(files),
|
|
108
|
+
move: memoryMoveFile(files),
|
|
109
|
+
copy: memoryCopyFile(files),
|
|
110
|
+
yaml: parseYaml,
|
|
111
|
+
latex: latexReader,
|
|
112
|
+
codec: sourcesCodec,
|
|
113
|
+
now: () => "2026-10-27T12:00:00.000Z",
|
|
114
|
+
cwd,
|
|
115
|
+
log: (l) => out.push(l),
|
|
116
|
+
err: (l) => errs.push(l),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function run(words: readonly string[], files: MemoryFiles, cwd = "/w") {
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
const errs: string[] = [];
|
|
122
|
+
const code = runVersions(words, deps(files, out, errs, cwd));
|
|
123
|
+
return { code, out, errs };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const read = (fs: MemoryFiles, p: string): string | null => {
|
|
127
|
+
const b = fs.readBytes(absolutePath(p));
|
|
128
|
+
return b === null ? null : new TextDecoder().decode(b);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
describe("paperlint versions — the table", () => {
|
|
132
|
+
it("each attempt's versions and their state, newest first, then the draft and how far it moved", () => {
|
|
133
|
+
const r = run(["papers/p"], paper());
|
|
134
|
+
expect(r.code).toBe(0);
|
|
135
|
+
expect(r.out).toStrictEqual([
|
|
136
|
+
" journal-2027 submitted CURRENT (under review at Journal 2027) 9 B frozen 2026-10-23 16:58 UTC",
|
|
137
|
+
" conf-2026 submitted historical (rejected) 9 B frozen 2026-07-22, source lost",
|
|
138
|
+
" workshop-2026 submitted camera-ready base 9 B frozen 2026-03-20",
|
|
139
|
+
" workshop-2026 camera-ready final 9 B frozen 2026-05-20 10:00 UTC",
|
|
140
|
+
" paper.tex draft target not chosen differs from journal-2027/submitted.tex: 2 lines added, 1 removed",
|
|
141
|
+
]);
|
|
142
|
+
// `show` is the same; run from the paper's folder, no argument names it.
|
|
143
|
+
expect(run(["show"], paper(), PAPER).out).toStrictEqual(r.out);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("a paper without cycles, or words past the paper: refused with the reason", () => {
|
|
147
|
+
expect(run(["show", "papers/p"], paper({ cycles: null }))).toStrictEqual({
|
|
148
|
+
code: 2,
|
|
149
|
+
out: [],
|
|
150
|
+
errs: [
|
|
151
|
+
'the paper keeps no "cycles" in paperlint.json (or they do not read — `paperlint lint` says why): versions are kept per attempt, versions/<cycle>/<stage>.pdf',
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
expect(
|
|
155
|
+
run(["lock", "papers/p"], paper({ cycles: null })).errs,
|
|
156
|
+
).toStrictEqual([
|
|
157
|
+
'the paper keeps no "cycles" in paperlint.json: there is no versions/<cycle>/ to lock (docs/configuration.md, "The paper\'s cycles")',
|
|
158
|
+
]);
|
|
159
|
+
expect(run(["lock", "papers/p", "more"], paper()).errs).toStrictEqual([
|
|
160
|
+
"`versions lock` takes one paper folder — got also `more`",
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("paperlint versions — the table, when there is less to show", () => {
|
|
166
|
+
it("no attempt open, no lock, no paper.tex: the attempts as they ended, and nothing to measure a draft against", () => {
|
|
167
|
+
const fs = memoryFiles({
|
|
168
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({
|
|
169
|
+
cycles: CYCLES.slice(0, 2),
|
|
170
|
+
}),
|
|
171
|
+
[`${PAPER}/versions/conf-2026/submitted.pdf`]: PDF,
|
|
172
|
+
});
|
|
173
|
+
expect(run(["papers/p"], fs).out).toStrictEqual([
|
|
174
|
+
" conf-2026 submitted historical (rejected) not locked",
|
|
175
|
+
" workshop-2026 submitted NOT FROZEN — submission 3 is on the portal",
|
|
176
|
+
" paper.tex draft target not chosen no attempt to compare with",
|
|
177
|
+
]);
|
|
178
|
+
// With a draft on disk and no attempt open, there is still nothing to measure it against.
|
|
179
|
+
fs.writeAtomic(absolutePath(`${PAPER}/paper.tex`), enc(DRAFT));
|
|
180
|
+
expect(run(["papers/p"], fs).out.at(-1)).toBe(
|
|
181
|
+
" paper.tex draft target not chosen no attempt to compare with",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("an attempt record that does not read: no table, and the reason is lint's", () => {
|
|
186
|
+
const fs = memoryFiles({
|
|
187
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({ cycles: [{ id: "x" }] }),
|
|
188
|
+
});
|
|
189
|
+
const r = run(["papers/p"], fs);
|
|
190
|
+
expect(r.code).toBe(2);
|
|
191
|
+
expect(r.errs[0]).toMatch(
|
|
192
|
+
/^the paper keeps no "cycles" in paperlint\.json \(or they do not read/,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("a frozen file's facts: zero bytes of nothing once it is gone", () => {
|
|
197
|
+
expect(
|
|
198
|
+
factsOfFrozen(PAPER, memoryFiles({}))("x/submitted.pdf"),
|
|
199
|
+
).toStrictEqual({
|
|
200
|
+
bytes: 0,
|
|
201
|
+
sha256: sha256Hex(new Uint8Array()),
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("paperlint versions lock", () => {
|
|
207
|
+
it("records a file put there by hand, drops one that is gone, says when nothing changed", () => {
|
|
208
|
+
const fs = paper();
|
|
209
|
+
fs.writeAtomic(
|
|
210
|
+
absolutePath(`${PAPER}/versions/journal-2027/arxiv.pdf`),
|
|
211
|
+
enc("%PDF arxiv"),
|
|
212
|
+
);
|
|
213
|
+
fs.map.delete(`${PAPER}/versions/workshop-2026/camera-ready.tex`);
|
|
214
|
+
// Not a frozen version: a note and a folder inside an attempt's folder (versions/layout names them).
|
|
215
|
+
fs.writeAtomic(
|
|
216
|
+
absolutePath(`${PAPER}/versions/journal-2027/notes.md`),
|
|
217
|
+
enc("x"),
|
|
218
|
+
);
|
|
219
|
+
fs.writeAtomic(
|
|
220
|
+
absolutePath(`${PAPER}/versions/journal-2027/old/submitted.pdf`),
|
|
221
|
+
enc("x"),
|
|
222
|
+
);
|
|
223
|
+
const r = run(["lock", "papers/p"], fs);
|
|
224
|
+
expect(r.code).toBe(0);
|
|
225
|
+
expect(r.out).toStrictEqual([
|
|
226
|
+
"locked versions/journal-2027/arxiv.pdf (10 bytes)",
|
|
227
|
+
"dropped versions/workshop-2026/camera-ready.tex: the file is gone",
|
|
228
|
+
]);
|
|
229
|
+
const lock = parseLock(
|
|
230
|
+
JSON.parse(read(fs, `${PAPER}/versions/lock.json`) ?? ""),
|
|
231
|
+
);
|
|
232
|
+
expect(lock.ok && lock.value["journal-2027/arxiv.pdf"]).toStrictEqual(
|
|
233
|
+
locked("%PDF arxiv", "2026-10-27T12:00:00.000Z"),
|
|
234
|
+
);
|
|
235
|
+
expect(run(["lock", "papers/p"], fs).out).toStrictEqual([
|
|
236
|
+
"versions/lock.json records every frozen file as it stands",
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("a lock that does not read is written anew from the files; no frozen file, no lock", () => {
|
|
241
|
+
const fs = paper();
|
|
242
|
+
fs.writeAtomic(absolutePath(`${PAPER}/versions/lock.json`), enc("{"));
|
|
243
|
+
const r = run(["lock", "papers/p"], fs);
|
|
244
|
+
expect(r.code).toBe(0);
|
|
245
|
+
expect(r.out[0]).toMatch(
|
|
246
|
+
/^versions\/lock\.json did not read \(not JSON: .*\): written anew from the files$/,
|
|
247
|
+
);
|
|
248
|
+
const empty = memoryFiles({
|
|
249
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({ cycles: CYCLES }),
|
|
250
|
+
});
|
|
251
|
+
expect(run(["lock", "papers/p"], empty).out).toStrictEqual([
|
|
252
|
+
"nothing frozen in versions/ — no lock to write",
|
|
253
|
+
]);
|
|
254
|
+
expect(empty.isFile(absolutePath(`${PAPER}/versions/lock.json`))).toBe(
|
|
255
|
+
false,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/** A paper in the old layout: dated files and the `stages:` list that declares them. */
|
|
261
|
+
function oldLayout(stages: string) {
|
|
262
|
+
return memoryFiles({
|
|
263
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({ cycles: CYCLES.slice(1) }),
|
|
264
|
+
[`${PAPER}/PIPELINE-STATUS.md`]: `---\ntitle: My paper\nstages:\n${stages}state: open\n---\n# Status\n`,
|
|
265
|
+
[`${PAPER}/versions/2026-07-22-submitted.pdf`]: PDF,
|
|
266
|
+
[`${PAPER}/versions/2026-10-23-submitted.pdf`]: PDF,
|
|
267
|
+
[`${PAPER}/versions/2026-10-23-submitted.tex`]: SENT,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const STAGES = ` - stage: submitted
|
|
271
|
+
cycle: conf-2026
|
|
272
|
+
date: 2026-07-22
|
|
273
|
+
pdf: versions/2026-07-22-submitted.pdf
|
|
274
|
+
bytes: 9
|
|
275
|
+
sourceLost: true
|
|
276
|
+
- stage: submitted
|
|
277
|
+
cycle: journal-2027
|
|
278
|
+
date: 2026-10-23
|
|
279
|
+
pdf: versions/2026-10-23-submitted.pdf
|
|
280
|
+
bytes: 9
|
|
281
|
+
source: versions/2026-10-23-submitted.tex
|
|
282
|
+
sourceBytes: ${String(enc(SENT).length)}
|
|
283
|
+
`;
|
|
284
|
+
|
|
285
|
+
describe("paperlint versions migrate", () => {
|
|
286
|
+
it("moves each declared file into its attempt's folder, writes the lock, drops the list — and the paper is then in the convention", () => {
|
|
287
|
+
const fs = oldLayout(STAGES);
|
|
288
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
289
|
+
expect(r.errs).toStrictEqual([]);
|
|
290
|
+
expect(r.code).toBe(0);
|
|
291
|
+
expect(r.out).toStrictEqual([
|
|
292
|
+
"moved versions/2026-07-22-submitted.pdf → versions/conf-2026/submitted.pdf",
|
|
293
|
+
"moved versions/2026-10-23-submitted.pdf → versions/journal-2027/submitted.pdf",
|
|
294
|
+
"moved versions/2026-10-23-submitted.tex → versions/journal-2027/submitted.tex",
|
|
295
|
+
"recorded versions/lock.json, 4 entries",
|
|
296
|
+
"removed the stages: list from PIPELINE-STATUS.md — the folders and the lock are the record now",
|
|
297
|
+
]);
|
|
298
|
+
expect(read(fs, `${PAPER}/PIPELINE-STATUS.md`)).toBe(
|
|
299
|
+
"---\ntitle: My paper\nstate: open\n---\n# Status\n",
|
|
300
|
+
);
|
|
301
|
+
expect(read(fs, `${PAPER}/versions/journal-2027/submitted.tex`)).toBe(SENT);
|
|
302
|
+
const lock = parseLock(
|
|
303
|
+
JSON.parse(read(fs, `${PAPER}/versions/lock.json`) ?? ""),
|
|
304
|
+
);
|
|
305
|
+
const want: Lock = {
|
|
306
|
+
"conf-2026/submitted.pdf": locked(PDF, "2026-07-22"),
|
|
307
|
+
"conf-2026/submitted.tex": { kind: "lost", frozen: "2026-07-22" },
|
|
308
|
+
"journal-2027/submitted.pdf": locked(PDF, "2026-10-23"),
|
|
309
|
+
"journal-2027/submitted.tex": locked(SENT, "2026-10-23"),
|
|
310
|
+
};
|
|
311
|
+
expect(lock).toStrictEqual({ ok: true, value: want });
|
|
312
|
+
// Run again: nothing left to do.
|
|
313
|
+
expect(run(["migrate", "papers/p"], fs).out).toStrictEqual([
|
|
314
|
+
"nothing to migrate: PIPELINE-STATUS.md has no stages: list, and no file of the old layout is left in versions/",
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("refused as a whole, nothing moved: a declared file that is not the one frozen", () => {
|
|
319
|
+
const fs = oldLayout(
|
|
320
|
+
STAGES.replace("bytes: 9\n sourceLost", "bytes: 8\n sourceLost"),
|
|
321
|
+
);
|
|
322
|
+
const before = [...fs.map.keys()];
|
|
323
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
324
|
+
expect(r.code).toBe(2);
|
|
325
|
+
expect(r.errs).toStrictEqual([
|
|
326
|
+
"stages[0]: `versions/2026-07-22-submitted.pdf` has 9 bytes, the stage declares 8 — it is not the file that was frozen: restore it from git first",
|
|
327
|
+
"nothing was moved",
|
|
328
|
+
]);
|
|
329
|
+
expect([...fs.map.keys()]).toStrictEqual(before);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("paperlint versions migrate, refused", () => {
|
|
334
|
+
it("refused before anything moves: no cycles, a frontmatter that does not parse, a lock that does not read", () => {
|
|
335
|
+
const flat = oldLayout(STAGES);
|
|
336
|
+
flat.writeAtomic(
|
|
337
|
+
absolutePath(`${PAPER}/paperlint.json`),
|
|
338
|
+
enc('{"extends":null}'),
|
|
339
|
+
);
|
|
340
|
+
expect(run(["migrate", "papers/p"], flat).errs).toStrictEqual([
|
|
341
|
+
'the paper keeps no "cycles" in paperlint.json: its stages: list stays its record — folders per attempt are named by cycle ids (docs/configuration.md, "The paper\'s cycles")',
|
|
342
|
+
]);
|
|
343
|
+
const bad = oldLayout(" - [\n");
|
|
344
|
+
const r = run(["migrate", "papers/p"], bad);
|
|
345
|
+
expect(r.code).toBe(2);
|
|
346
|
+
expect(r.errs[0]).toMatch(
|
|
347
|
+
/^PIPELINE-STATUS\.md: the frontmatter does not parse \(.*\) — fix it, then migrate$/,
|
|
348
|
+
);
|
|
349
|
+
const lock = oldLayout(STAGES);
|
|
350
|
+
lock.writeAtomic(absolutePath(`${PAPER}/versions/lock.json`), enc("[]"));
|
|
351
|
+
expect(run(["migrate", "papers/p"], lock).errs).toStrictEqual([
|
|
352
|
+
'versions/lock.json: not a lock this paperlint writes ("lockfileVersion": 1) — `npx paperlint versions lock papers/p` writes it anew; then migrate',
|
|
353
|
+
]);
|
|
354
|
+
// Run from the paper itself, the command it names is the paper as `.`.
|
|
355
|
+
expect(run(["migrate"], lock, PAPER).errs).toStrictEqual([
|
|
356
|
+
'versions/lock.json: not a lock this paperlint writes ("lockfileVersion": 1) — `npx paperlint versions lock .` writes it anew; then migrate',
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("a list the text edit would not take out cleanly: refused, nothing moved", () => {
|
|
361
|
+
// An anchor on the list that another key reuses: without the list, that key reads nothing.
|
|
362
|
+
const fs = oldLayout(
|
|
363
|
+
` - stage: submitted\n cycle: conf-2026\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\ncopy: *s\n`,
|
|
364
|
+
);
|
|
365
|
+
fs.writeAtomic(
|
|
366
|
+
absolutePath(`${PAPER}/PIPELINE-STATUS.md`),
|
|
367
|
+
enc(
|
|
368
|
+
(read(fs, `${PAPER}/PIPELINE-STATUS.md`) ?? "").replace(
|
|
369
|
+
"stages:\n",
|
|
370
|
+
"stages: &s\n",
|
|
371
|
+
),
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
375
|
+
expect(r.errs).toStrictEqual([
|
|
376
|
+
"PIPELINE-STATUS.md: taking the stages: list out would change the rest of its frontmatter — take it out by hand, then migrate",
|
|
377
|
+
]);
|
|
378
|
+
expect(r.code).toBe(2);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("a paper with no scorecard and nothing of the old layout: nothing to migrate", () => {
|
|
382
|
+
const fs = memoryFiles({
|
|
383
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({ cycles: CYCLES }),
|
|
384
|
+
});
|
|
385
|
+
expect(run(["migrate", "papers/p"], fs)).toStrictEqual({
|
|
386
|
+
code: 0,
|
|
387
|
+
out: [
|
|
388
|
+
"nothing to migrate: PIPELINE-STATUS.md has no stages: list, and no file of the old layout is left in versions/",
|
|
389
|
+
],
|
|
390
|
+
errs: [],
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("paperlint versions migrate, edge cases", () => {
|
|
396
|
+
it("a scorecard whose frontmatter is a bare value has no list to take out: nothing to migrate", () => {
|
|
397
|
+
const fs = memoryFiles({
|
|
398
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({ cycles: CYCLES }),
|
|
399
|
+
[`${PAPER}/PIPELINE-STATUS.md`]: "---\njust a line\n---\n# Status\n",
|
|
400
|
+
});
|
|
401
|
+
expect(run(["migrate", "papers/p"], fs).out).toStrictEqual([
|
|
402
|
+
"nothing to migrate: PIPELINE-STATUS.md has no stages: list, and no file of the old layout is left in versions/",
|
|
403
|
+
]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("a declared file that is not on disk: refused, nothing moved", () => {
|
|
407
|
+
const fs = oldLayout(
|
|
408
|
+
STAGES.replace("2026-07-22-submitted.pdf", "gone.pdf"),
|
|
409
|
+
);
|
|
410
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
411
|
+
expect(r.errs).toStrictEqual([
|
|
412
|
+
"stages[0]: `versions/gone.pdf` is not on disk",
|
|
413
|
+
// The file it should have named is then named by nobody.
|
|
414
|
+
"versions/2026-07-22-submitted.pdf: no stage in `stages:` names it — declare it with its cycle, or move it to extra/, then migrate",
|
|
415
|
+
"nothing was moved",
|
|
416
|
+
]);
|
|
417
|
+
expect(r.code).toBe(2);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
/** Every file and its bytes: what a refused migration must leave exactly as it was. */
|
|
422
|
+
const disk = (fs: MemoryFiles) =>
|
|
423
|
+
[...fs.map.entries()].map(([p, b]) => [p, sha256Hex(b)]);
|
|
424
|
+
|
|
425
|
+
describe("paperlint versions migrate, where a destination is taken", () => {
|
|
426
|
+
it("a cycle already frozen on send keeps its files: refused before anything moves, the tree byte-identical", () => {
|
|
427
|
+
const fs = oldLayout(STAGES);
|
|
428
|
+
fs.writeAtomic(
|
|
429
|
+
absolutePath(`${PAPER}/versions/journal-2027/submitted.pdf`),
|
|
430
|
+
enc("%PDF newer, sent with --save"),
|
|
431
|
+
);
|
|
432
|
+
fs.writeAtomic(
|
|
433
|
+
absolutePath(`${PAPER}/versions/journal-2027/submitted.tex`),
|
|
434
|
+
enc("newer source"),
|
|
435
|
+
);
|
|
436
|
+
const before = disk(fs);
|
|
437
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
438
|
+
expect(r.code).toBe(2);
|
|
439
|
+
expect(r.errs).toStrictEqual([
|
|
440
|
+
"versions/journal-2027/submitted.pdf is already there, and `versions/2026-10-23-submitted.pdf` would replace it — compare the two, keep one, move the other out of versions/, then migrate",
|
|
441
|
+
"versions/journal-2027/submitted.tex is already there, and `versions/2026-10-23-submitted.tex` would replace it — compare the two, keep one, move the other out of versions/, then migrate",
|
|
442
|
+
"nothing was moved",
|
|
443
|
+
]);
|
|
444
|
+
expect(disk(fs)).toStrictEqual(before);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("a loose file whose place in extra/versions/ is taken: refused too", () => {
|
|
448
|
+
const fs = oldLayout(STAGES);
|
|
449
|
+
fs.writeAtomic(absolutePath(`${PAPER}/versions/README.md`), enc("old"));
|
|
450
|
+
fs.writeAtomic(
|
|
451
|
+
absolutePath(`${PAPER}/extra/versions/README.md`),
|
|
452
|
+
enc("older"),
|
|
453
|
+
);
|
|
454
|
+
const before = disk(fs);
|
|
455
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
456
|
+
expect(r.errs[0]).toBe(
|
|
457
|
+
"extra/versions/README.md is already there, and `versions/README.md` would replace it — compare the two, keep one, move the other out of versions/, then migrate",
|
|
458
|
+
);
|
|
459
|
+
expect(disk(fs)).toStrictEqual(before);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("paperlint versions migrate, one source for two stages", () => {
|
|
464
|
+
it("a source two stages were built from is copied to each, then removed: both frozen, both locked", () => {
|
|
465
|
+
const fs = oldLayout(
|
|
466
|
+
`${STAGES} - stage: arxiv
|
|
467
|
+
cycle: journal-2027
|
|
468
|
+
date: 2026-10-25
|
|
469
|
+
pdf: versions/2026-10-25-arxiv.pdf
|
|
470
|
+
bytes: 9
|
|
471
|
+
source: versions/2026-10-23-submitted.tex
|
|
472
|
+
`,
|
|
473
|
+
);
|
|
474
|
+
fs.writeAtomic(
|
|
475
|
+
absolutePath(`${PAPER}/versions/2026-10-25-arxiv.pdf`),
|
|
476
|
+
enc(PDF),
|
|
477
|
+
);
|
|
478
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
479
|
+
expect(r.errs).toStrictEqual([]);
|
|
480
|
+
expect(r.code).toBe(0);
|
|
481
|
+
expect(r.out).toContain(
|
|
482
|
+
"copied versions/2026-10-23-submitted.tex → versions/journal-2027/submitted.tex",
|
|
483
|
+
);
|
|
484
|
+
expect(r.out).toContain(
|
|
485
|
+
"moved versions/2026-10-23-submitted.tex → versions/journal-2027/arxiv.tex",
|
|
486
|
+
);
|
|
487
|
+
expect(read(fs, `${PAPER}/versions/journal-2027/submitted.tex`)).toBe(SENT);
|
|
488
|
+
expect(read(fs, `${PAPER}/versions/journal-2027/arxiv.tex`)).toBe(SENT);
|
|
489
|
+
expect(
|
|
490
|
+
fs.isFile(absolutePath(`${PAPER}/versions/2026-10-23-submitted.tex`)),
|
|
491
|
+
).toBe(false);
|
|
492
|
+
const lock = parseLock(
|
|
493
|
+
JSON.parse(read(fs, `${PAPER}/versions/lock.json`) ?? ""),
|
|
494
|
+
);
|
|
495
|
+
expect(lock.ok && lock.value["journal-2027/arxiv.tex"]).toStrictEqual(
|
|
496
|
+
lock.ok && {
|
|
497
|
+
...lock.value["journal-2027/submitted.tex"],
|
|
498
|
+
frozen: "2026-10-25",
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("paperlint versions migrate, a path as the scorecard spells it", () => {
|
|
505
|
+
it("`./versions/source.tex`, not dated: moved once into its cycle's folder, nothing left for extra/", () => {
|
|
506
|
+
const fs = oldLayout(
|
|
507
|
+
STAGES.replace(
|
|
508
|
+
"source: versions/2026-10-23-submitted.tex",
|
|
509
|
+
"source: ./versions/source.tex",
|
|
510
|
+
),
|
|
511
|
+
);
|
|
512
|
+
fs.map.delete(`${PAPER}/versions/2026-10-23-submitted.tex`);
|
|
513
|
+
fs.writeAtomic(absolutePath(`${PAPER}/versions/source.tex`), enc(SENT));
|
|
514
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
515
|
+
expect(r.errs).toStrictEqual([]);
|
|
516
|
+
expect(r.code).toBe(0);
|
|
517
|
+
expect(read(fs, `${PAPER}/versions/journal-2027/submitted.tex`)).toBe(SENT);
|
|
518
|
+
expect(fs.isFile(absolutePath(`${PAPER}/extra/versions/source.tex`))).toBe(
|
|
519
|
+
false,
|
|
520
|
+
);
|
|
521
|
+
expect(r.out).toContain(
|
|
522
|
+
"moved versions/source.tex → versions/journal-2027/submitted.tex",
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("a `./`-prefixed dated name migrates like the plain one", () => {
|
|
527
|
+
const fs = oldLayout(
|
|
528
|
+
STAGES.replace(
|
|
529
|
+
"pdf: versions/2026-10-23-submitted.pdf",
|
|
530
|
+
"pdf: ./versions/2026-10-23-submitted.pdf",
|
|
531
|
+
),
|
|
532
|
+
);
|
|
533
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
534
|
+
expect(r.errs).toStrictEqual([]);
|
|
535
|
+
expect(r.code).toBe(0);
|
|
536
|
+
expect(read(fs, `${PAPER}/versions/journal-2027/submitted.pdf`)).toBe(PDF);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("a path out of the paper folder: refused when it is read, the tree byte-identical", () => {
|
|
540
|
+
const fs = oldLayout(
|
|
541
|
+
STAGES.replace(
|
|
542
|
+
"source: versions/2026-10-23-submitted.tex",
|
|
543
|
+
"source: ../x.tex",
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
const before = disk(fs);
|
|
547
|
+
const r = run(["migrate", "papers/p"], fs);
|
|
548
|
+
expect(r.code).toBe(2);
|
|
549
|
+
expect(r.errs).toContain(
|
|
550
|
+
"stages[1]: `../x.tex` is not a path inside the paper folder — a stage names a file of this paper, relative to it",
|
|
551
|
+
);
|
|
552
|
+
expect(disk(fs)).toStrictEqual(before);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe("paperlint versions migrate, a plan that could not run to the end", () => {
|
|
557
|
+
it("is rehearsed first: a plan that moves one file twice is refused, the tree byte-identical", () => {
|
|
558
|
+
const fs = oldLayout(STAGES);
|
|
559
|
+
const before = disk(fs);
|
|
560
|
+
const p = (s: string) => {
|
|
561
|
+
const r = paperPath(s);
|
|
562
|
+
if (!r.ok) throw new Error(r.error);
|
|
563
|
+
return r.value;
|
|
564
|
+
};
|
|
565
|
+
const out: string[] = [];
|
|
566
|
+
const errs: string[] = [];
|
|
567
|
+
const code = carryOut(
|
|
568
|
+
PAPER,
|
|
569
|
+
{
|
|
570
|
+
copies: [],
|
|
571
|
+
moves: [
|
|
572
|
+
{
|
|
573
|
+
from: p("versions/2026-10-23-submitted.pdf"),
|
|
574
|
+
to: p("versions/journal-2027/submitted.pdf"),
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
from: p("versions/2026-10-23-submitted.pdf"),
|
|
578
|
+
to: p("versions/journal-2027/arxiv.pdf"),
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
lock: {},
|
|
582
|
+
},
|
|
583
|
+
{ without: null, stages: [] },
|
|
584
|
+
deps(fs, out, errs),
|
|
585
|
+
);
|
|
586
|
+
expect(code).toBe(2);
|
|
587
|
+
expect(errs).toStrictEqual([
|
|
588
|
+
"move versions/2026-10-23-submitted.pdf → versions/journal-2027/arxiv.pdf: versions/2026-10-23-submitted.pdf is not there at that step",
|
|
589
|
+
"nothing was moved",
|
|
590
|
+
]);
|
|
591
|
+
expect(out).toStrictEqual([]);
|
|
592
|
+
expect(disk(fs)).toStrictEqual(before);
|
|
593
|
+
});
|
|
594
|
+
});
|