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,564 @@
|
|
|
1
|
+
/** The frozen versions by convention: names, the lock, the layout's findings, each version's state. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { currentCycle, parseCycles, type Cycle } from "./cycle.ts";
|
|
4
|
+
import { err, ok } from "./result.ts";
|
|
5
|
+
import {
|
|
6
|
+
driftBase,
|
|
7
|
+
frozenName,
|
|
8
|
+
judgeVersions,
|
|
9
|
+
keyParts,
|
|
10
|
+
derivesFrom,
|
|
11
|
+
lineage,
|
|
12
|
+
lineDrift,
|
|
13
|
+
lockText,
|
|
14
|
+
parseLock,
|
|
15
|
+
relock,
|
|
16
|
+
stateOf,
|
|
17
|
+
unfrozenSubmissions,
|
|
18
|
+
versionRows,
|
|
19
|
+
versionTable,
|
|
20
|
+
type Entry,
|
|
21
|
+
type LayoutInput,
|
|
22
|
+
type Lock,
|
|
23
|
+
} from "./versions.ts";
|
|
24
|
+
|
|
25
|
+
const SHA = "a".repeat(64);
|
|
26
|
+
const OTHER = "b".repeat(64);
|
|
27
|
+
|
|
28
|
+
/** Cycles parsed as paperlint.json holds them: `rejected` alpha, then `open` beta with a submission. */
|
|
29
|
+
function cycles(...raw: readonly object[]): readonly Cycle[] {
|
|
30
|
+
const r = parseCycles(raw);
|
|
31
|
+
if (!r.ok || r.value === null) throw new Error("bad fixture");
|
|
32
|
+
return r.value;
|
|
33
|
+
}
|
|
34
|
+
const ALPHA = {
|
|
35
|
+
id: "alpha-2026",
|
|
36
|
+
venue: { kind: "preset", extends: "paperlint:aisec" },
|
|
37
|
+
opened: "2026-06-01",
|
|
38
|
+
submission: { id: 3 },
|
|
39
|
+
outcome: {
|
|
40
|
+
kind: "rejected",
|
|
41
|
+
date: "2026-09-01",
|
|
42
|
+
evidence: "reviews/a.md",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const BETA = {
|
|
46
|
+
id: "beta-2027",
|
|
47
|
+
venue: { kind: "named", name: "Beta 2027", url: "https://beta.example" },
|
|
48
|
+
opened: "2026-09-09",
|
|
49
|
+
submission: { id: 7 },
|
|
50
|
+
};
|
|
51
|
+
const TWO = cycles(ALPHA, BETA);
|
|
52
|
+
|
|
53
|
+
const file = (name: string): Entry => ({ name, kind: "file" });
|
|
54
|
+
const dir = (name: string): Entry => ({ name, kind: "dir" });
|
|
55
|
+
|
|
56
|
+
const entry = (bytes = 10, sha256 = SHA, frozen = "2026-10-23T16:58:01.000Z") =>
|
|
57
|
+
({ kind: "file", bytes, sha256, frozen }) as const;
|
|
58
|
+
|
|
59
|
+
/** A clean folder: both cycles frozen as submitted, every file locked as it stands. */
|
|
60
|
+
const CLEAN_LOCK: Lock = {
|
|
61
|
+
"alpha-2026/submitted.pdf": entry(),
|
|
62
|
+
"alpha-2026/submitted.tex": entry(),
|
|
63
|
+
"beta-2027/submitted.pdf": entry(),
|
|
64
|
+
"beta-2027/submitted.tex": entry(),
|
|
65
|
+
};
|
|
66
|
+
const layout = (o: Partial<LayoutInput> = {}): LayoutInput => ({
|
|
67
|
+
cycles: TWO,
|
|
68
|
+
root: [dir("alpha-2026"), dir("beta-2027"), file("lock.json")],
|
|
69
|
+
inFolder: () => [file("submitted.pdf"), file("submitted.tex")],
|
|
70
|
+
lock: ok(CLEAN_LOCK),
|
|
71
|
+
factsOf: () => ({ bytes: 10, sha256: SHA }),
|
|
72
|
+
...o,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("names", () => {
|
|
76
|
+
it("a stage and pdf or tex is a frozen name; anything else is not", () => {
|
|
77
|
+
expect(frozenName("camera-ready.tex")).toStrictEqual({
|
|
78
|
+
stage: "camera-ready",
|
|
79
|
+
ext: "tex",
|
|
80
|
+
});
|
|
81
|
+
expect(frozenName("draft.pdf")).toBeNull();
|
|
82
|
+
expect(frozenName("submitted.docx")).toBeNull();
|
|
83
|
+
expect(frozenName("2026-07-22-submitted.pdf")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a lock key is <cycle>/<stage>.<ext>", () => {
|
|
87
|
+
expect(keyParts("beta-2027/arxiv.pdf")).toStrictEqual({
|
|
88
|
+
cycle: "beta-2027",
|
|
89
|
+
name: { stage: "arxiv", ext: "pdf" },
|
|
90
|
+
});
|
|
91
|
+
expect(keyParts("beta-2027/notes.md")).toBeNull();
|
|
92
|
+
expect(keyParts("submitted.pdf")).toBeNull();
|
|
93
|
+
// The cycle part by the rule parseCycles holds an id to: one folder name, nothing else.
|
|
94
|
+
expect(keyParts("conf/2026/submitted.pdf")).toBeNull();
|
|
95
|
+
expect(keyParts("../submitted.pdf")).toBeNull();
|
|
96
|
+
expect(keyParts("Conf-2026/submitted.pdf")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const json = (files: object) => ({ lockfileVersion: 1, files });
|
|
101
|
+
/** An entry as the file holds it. */
|
|
102
|
+
const written = { bytes: 10, sha256: SHA, frozen: "2026-07-22" };
|
|
103
|
+
|
|
104
|
+
describe("the lock", () => {
|
|
105
|
+
it("parses what paperlint writes, and writes it back the same, keys sorted", () => {
|
|
106
|
+
const lock: Lock = {
|
|
107
|
+
"beta-2027/submitted.pdf": entry(5, SHA, "2026-10-23T16:58:01.000Z"),
|
|
108
|
+
"alpha-2026/submitted.tex": { kind: "lost", frozen: "2026-07-22" },
|
|
109
|
+
"alpha-2026/arxiv.tex": { kind: "lost", frozen: null },
|
|
110
|
+
};
|
|
111
|
+
const text = lockText(lock);
|
|
112
|
+
expect(parseLock(JSON.parse(text))).toStrictEqual(ok(lock));
|
|
113
|
+
expect(JSON.parse(text)).toStrictEqual({
|
|
114
|
+
lockfileVersion: 1,
|
|
115
|
+
note: "generated by paperlint — do not edit: `paperlint submission update --save` and `paperlint versions lock` write it",
|
|
116
|
+
files: {
|
|
117
|
+
"alpha-2026/arxiv.tex": { sourceLost: true },
|
|
118
|
+
"alpha-2026/submitted.tex": { sourceLost: true, frozen: "2026-07-22" },
|
|
119
|
+
"beta-2027/submitted.pdf": {
|
|
120
|
+
bytes: 5,
|
|
121
|
+
sha256: SHA,
|
|
122
|
+
frozen: "2026-10-23T16:58:01.000Z",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
expect(text.endsWith("}\n")).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("the lock, refused", () => {
|
|
131
|
+
it("refuses what it does not write, naming the entry and the key", () => {
|
|
132
|
+
expect(parseLock({ files: {} })).toStrictEqual(
|
|
133
|
+
err('not a lock this paperlint writes ("lockfileVersion": 1)'),
|
|
134
|
+
);
|
|
135
|
+
expect(parseLock([])).toStrictEqual(
|
|
136
|
+
err('not a lock this paperlint writes ("lockfileVersion": 1)'),
|
|
137
|
+
);
|
|
138
|
+
expect(parseLock({ lockfileVersion: 1 })).toStrictEqual(
|
|
139
|
+
err('"files" must be an object'),
|
|
140
|
+
);
|
|
141
|
+
const at = '"files"["b/submitted.pdf"]';
|
|
142
|
+
const cases: readonly (readonly [object, string])[] = [
|
|
143
|
+
[
|
|
144
|
+
{ "b/x.pdf": {} },
|
|
145
|
+
'"files"["b/x.pdf"]: a key is <cycle>/<stage>.pdf or .tex',
|
|
146
|
+
],
|
|
147
|
+
[{ "b/submitted.pdf": 1 }, `${at} must be an object`],
|
|
148
|
+
[
|
|
149
|
+
{ "b/submitted.pdf": { ...written, size: 1 } },
|
|
150
|
+
`${at}: unknown key "size"`,
|
|
151
|
+
],
|
|
152
|
+
[
|
|
153
|
+
{ "b/submitted.pdf": { sourceLost: true } },
|
|
154
|
+
`${at}: only a source (.tex) can be recorded as lost`,
|
|
155
|
+
],
|
|
156
|
+
[
|
|
157
|
+
{ "b/submitted.tex": { sourceLost: true, frozen: "yesterday" } },
|
|
158
|
+
'"files"["b/submitted.tex"]: "frozen" must be a date or an instant',
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
{ "b/submitted.pdf": { ...written, bytes: -1 } },
|
|
162
|
+
`${at}: "bytes" must be a whole number`,
|
|
163
|
+
],
|
|
164
|
+
[
|
|
165
|
+
{ "b/submitted.pdf": { ...written, bytes: "1" } },
|
|
166
|
+
`${at}: "bytes" must be a whole number`,
|
|
167
|
+
],
|
|
168
|
+
[
|
|
169
|
+
{ "b/submitted.pdf": { ...written, sha256: "AB" } },
|
|
170
|
+
`${at}: "sha256" must be 64 lowercase hex digits`,
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
{ "b/submitted.pdf": { bytes: 1, sha256: SHA } },
|
|
174
|
+
`${at}: "frozen" must be a date or an instant`,
|
|
175
|
+
],
|
|
176
|
+
];
|
|
177
|
+
cases.forEach(([files, why]) => {
|
|
178
|
+
expect(parseLock(json(files))).toStrictEqual(err(why));
|
|
179
|
+
});
|
|
180
|
+
// The first problem stops the read.
|
|
181
|
+
expect(
|
|
182
|
+
parseLock(json({ "b/x.pdf": {}, "b/submitted.pdf": 1 })),
|
|
183
|
+
).toStrictEqual(
|
|
184
|
+
err('"files"["b/x.pdf"]: a key is <cycle>/<stage>.pdf or .tex'),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("versions/layout", () => {
|
|
190
|
+
it("a folder per cycle, a file per stage, all locked as they stand: silent", () => {
|
|
191
|
+
expect(judgeVersions(layout())).toStrictEqual([]);
|
|
192
|
+
// Dot files are the system's (`.gitkeep`, `.DS_Store`), not the paper's.
|
|
193
|
+
expect(
|
|
194
|
+
judgeVersions(
|
|
195
|
+
layout({
|
|
196
|
+
root: [...layout().root, file(".gitkeep")],
|
|
197
|
+
inFolder: () => [
|
|
198
|
+
file("submitted.pdf"),
|
|
199
|
+
file("submitted.tex"),
|
|
200
|
+
file(".DS_Store"),
|
|
201
|
+
],
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
).toStrictEqual([]);
|
|
205
|
+
// Nothing frozen and no lock: nothing to say.
|
|
206
|
+
expect(
|
|
207
|
+
judgeVersions(layout({ root: [], lock: null, inFolder: () => [] })),
|
|
208
|
+
).toStrictEqual([]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("versions/layout, entries outside it", () => {
|
|
213
|
+
it("an entry outside the convention: a dated file says how to migrate it, the rest where things go", () => {
|
|
214
|
+
const found = judgeVersions(
|
|
215
|
+
layout({
|
|
216
|
+
root: [
|
|
217
|
+
...layout().root,
|
|
218
|
+
file("2026-07-22-submitted.pdf"),
|
|
219
|
+
file("notes.md"),
|
|
220
|
+
dir("old"),
|
|
221
|
+
],
|
|
222
|
+
inFolder: (name) =>
|
|
223
|
+
name === "beta-2027"
|
|
224
|
+
? [
|
|
225
|
+
file("submitted.pdf"),
|
|
226
|
+
file("submitted.tex"),
|
|
227
|
+
file("final.pdf"),
|
|
228
|
+
dir("figs"),
|
|
229
|
+
]
|
|
230
|
+
: [file("submitted.pdf"), file("submitted.tex")],
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
expect(found.map((f) => [f.messageId, f.data])).toStrictEqual([
|
|
234
|
+
[
|
|
235
|
+
"stray",
|
|
236
|
+
{
|
|
237
|
+
path: "2026-07-22-submitted.pdf",
|
|
238
|
+
hint: "a frozen version in the layout before folders per attempt: `npx paperlint versions migrate <paper>` moves it into versions/<cycle>/",
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
[
|
|
242
|
+
"stray",
|
|
243
|
+
{
|
|
244
|
+
path: "notes.md",
|
|
245
|
+
hint: "versions/ holds lock.json and one folder per cycle, each with <stage>.pdf and <stage>.tex (submitted, camera-ready, arxiv) — anything else goes to extra/",
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
["unknownCycle", { folder: "old", known: "alpha-2026 · beta-2027" }],
|
|
249
|
+
["stray", expect.objectContaining({ path: "beta-2027/final.pdf" })],
|
|
250
|
+
["stray", expect.objectContaining({ path: "beta-2027/figs" })],
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("a folder when the paper declares no cycle names none", () => {
|
|
255
|
+
expect(
|
|
256
|
+
judgeVersions(layout({ cycles: [], lock: ok({}) })).map((f) => f.data),
|
|
257
|
+
).toStrictEqual([
|
|
258
|
+
{ folder: "alpha-2026", known: "(none)" },
|
|
259
|
+
{ folder: "beta-2027", known: "(none)" },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("versions/layout, the files in a folder", () => {
|
|
265
|
+
it("a PDF without its source, unless the lock records the source lost", () => {
|
|
266
|
+
const onlyPdf = () => [file("submitted.pdf")];
|
|
267
|
+
const lock: Lock = {
|
|
268
|
+
"alpha-2026/submitted.pdf": entry(),
|
|
269
|
+
"alpha-2026/submitted.tex": { kind: "lost", frozen: "2026-07-22" },
|
|
270
|
+
"beta-2027/submitted.pdf": entry(),
|
|
271
|
+
};
|
|
272
|
+
expect(
|
|
273
|
+
judgeVersions(layout({ inFolder: onlyPdf, lock: ok(lock) })),
|
|
274
|
+
).toStrictEqual([
|
|
275
|
+
{
|
|
276
|
+
messageId: "noSource",
|
|
277
|
+
data: {
|
|
278
|
+
pdf: "beta-2027/submitted.pdf",
|
|
279
|
+
tex: "beta-2027/submitted.tex",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("a version sent from a cycle still porting; an arXiv copy or a source is not a send", () => {
|
|
286
|
+
const porting = cycles({ ...BETA, phase: "porting" });
|
|
287
|
+
const found = judgeVersions(
|
|
288
|
+
layout({
|
|
289
|
+
cycles: porting,
|
|
290
|
+
root: [dir("beta-2027"), file("lock.json")],
|
|
291
|
+
inFolder: () => [
|
|
292
|
+
file("submitted.pdf"),
|
|
293
|
+
file("submitted.tex"),
|
|
294
|
+
file("arxiv.pdf"),
|
|
295
|
+
file("arxiv.tex"),
|
|
296
|
+
],
|
|
297
|
+
lock: ok({
|
|
298
|
+
"beta-2027/submitted.pdf": entry(),
|
|
299
|
+
"beta-2027/submitted.tex": entry(),
|
|
300
|
+
"beta-2027/arxiv.pdf": entry(),
|
|
301
|
+
"beta-2027/arxiv.tex": entry(),
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
expect(found).toStrictEqual([
|
|
306
|
+
{
|
|
307
|
+
messageId: "porting",
|
|
308
|
+
data: { file: "beta-2027/submitted.pdf", id: "beta-2027" },
|
|
309
|
+
},
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("versions/layout, the lock", () => {
|
|
315
|
+
it("frozen files and no lock, or a lock that does not parse: one finding each", () => {
|
|
316
|
+
expect(judgeVersions(layout({ lock: null }))).toStrictEqual([
|
|
317
|
+
{ messageId: "noLock", data: {} },
|
|
318
|
+
]);
|
|
319
|
+
expect(judgeVersions(layout({ lock: err("not JSON") }))).toStrictEqual([
|
|
320
|
+
{ messageId: "badLock", data: { why: "not JSON" } },
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("each file against its lock entry: missing, recorded lost, other bytes, other digest; an entry with no file", () => {
|
|
325
|
+
const lock: Lock = {
|
|
326
|
+
"alpha-2026/submitted.pdf": entry(10, OTHER, "2026-07-22"),
|
|
327
|
+
"alpha-2026/submitted.tex": { kind: "lost", frozen: null },
|
|
328
|
+
"beta-2027/submitted.pdf": entry(11),
|
|
329
|
+
"beta-2027/camera-ready.pdf": entry(),
|
|
330
|
+
};
|
|
331
|
+
expect(judgeVersions(layout({ lock: ok(lock) }))).toStrictEqual([
|
|
332
|
+
{
|
|
333
|
+
messageId: "changed",
|
|
334
|
+
data: {
|
|
335
|
+
file: "alpha-2026/submitted.pdf",
|
|
336
|
+
want: "10",
|
|
337
|
+
got: "10",
|
|
338
|
+
frozen: "2026-07-22",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{ messageId: "notLocked", data: { file: "alpha-2026/submitted.tex" } },
|
|
342
|
+
{
|
|
343
|
+
messageId: "changed",
|
|
344
|
+
data: {
|
|
345
|
+
file: "beta-2027/submitted.pdf",
|
|
346
|
+
want: "11",
|
|
347
|
+
got: "10",
|
|
348
|
+
frozen: "2026-10-23T16:58:01.000Z",
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{ messageId: "notLocked", data: { file: "beta-2027/submitted.tex" } },
|
|
352
|
+
{ messageId: "lockedGone", data: { file: "beta-2027/camera-ready.pdf" } },
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("cycle/submitted-frozen", () => {
|
|
358
|
+
it("a cycle with a submission and no submitted.pdf; one without a submission owes nothing", () => {
|
|
359
|
+
const has = (k: string) => k === "alpha-2026/submitted.pdf";
|
|
360
|
+
expect(unfrozenSubmissions(TWO, has)).toStrictEqual([
|
|
361
|
+
{
|
|
362
|
+
messageId: "unfrozen",
|
|
363
|
+
data: {
|
|
364
|
+
id: "beta-2027",
|
|
365
|
+
submission: "7",
|
|
366
|
+
pdf: "beta-2027/submitted.pdf",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
expect(unfrozenSubmissions(TWO, () => true)).toStrictEqual([]);
|
|
371
|
+
expect(
|
|
372
|
+
unfrozenSubmissions(cycles({ ...BETA, submission: null }), () => false),
|
|
373
|
+
).toStrictEqual([]);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("the draft against a frozen source", () => {
|
|
378
|
+
it("counts the lines added and removed, as a line diff does", () => {
|
|
379
|
+
expect(lineDrift("a\nb\nc\n", "a\nB\nc\nd\n")).toStrictEqual({
|
|
380
|
+
added: 2,
|
|
381
|
+
removed: 1,
|
|
382
|
+
});
|
|
383
|
+
expect(lineDrift("a\n", "a\n")).toStrictEqual({ added: 0, removed: 0 });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("a later version's lineage: the accepted lines it keeps; half kept still derives, less does not", () => {
|
|
387
|
+
expect(lineage("a\nb\nc\nd\n", "a\nB\nc\nd\ne\n")).toStrictEqual({
|
|
388
|
+
kept: 3,
|
|
389
|
+
total: 4,
|
|
390
|
+
});
|
|
391
|
+
expect(derivesFrom(lineage("a\nb\nc\nd\n", "a\nb\nX\nY\n"))).toBe(true);
|
|
392
|
+
expect(derivesFrom(lineage("a\nb\nc\nd\n", "a\nX\nY\nZ\n"))).toBe(false);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("the base: the open cycle's submitted source; once accepted, the newest of its sources; none between venues", () => {
|
|
396
|
+
const current = (raw: readonly object[]) => {
|
|
397
|
+
const c = currentCycle(cycles(...raw));
|
|
398
|
+
if (!c.ok) throw new Error("bad fixture");
|
|
399
|
+
return c.value;
|
|
400
|
+
};
|
|
401
|
+
expect(driftBase(current([ALPHA, BETA]), () => false)?.key).toBe(
|
|
402
|
+
"beta-2027/submitted.tex",
|
|
403
|
+
);
|
|
404
|
+
const accepted = {
|
|
405
|
+
...BETA,
|
|
406
|
+
outcome: { kind: "accepted", date: "2026-12-01", evidence: "m.eml" },
|
|
407
|
+
};
|
|
408
|
+
expect(driftBase(current([accepted]), () => false)?.key).toBe(
|
|
409
|
+
"beta-2027/submitted.tex",
|
|
410
|
+
);
|
|
411
|
+
expect(driftBase(current([accepted]), () => true)?.key).toBe(
|
|
412
|
+
"beta-2027/camera-ready.tex",
|
|
413
|
+
);
|
|
414
|
+
expect(driftBase(current([ALPHA]), () => true)).toBeNull();
|
|
415
|
+
expect(driftBase(current([]), () => true)).toBeNull();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("each version's state", () => {
|
|
420
|
+
it("follows the cycle's outcome", () => {
|
|
421
|
+
const [alpha, beta] = TWO;
|
|
422
|
+
if (alpha === undefined || beta === undefined) throw new Error("fixture");
|
|
423
|
+
const accepted = cycles({
|
|
424
|
+
...BETA,
|
|
425
|
+
outcome: { kind: "accepted", date: "2026-12-01", evidence: "m.eml" },
|
|
426
|
+
})[0];
|
|
427
|
+
if (accepted === undefined) throw new Error("fixture");
|
|
428
|
+
expect(stateOf(beta, "submitted")).toBe(
|
|
429
|
+
"CURRENT (under review at Beta 2027)",
|
|
430
|
+
);
|
|
431
|
+
expect(stateOf(beta, "camera-ready")).toBe(
|
|
432
|
+
"camera-ready, cycle still open",
|
|
433
|
+
);
|
|
434
|
+
expect(stateOf(alpha, "submitted")).toBe("historical (rejected)");
|
|
435
|
+
expect(stateOf(accepted, "submitted")).toBe("camera-ready base");
|
|
436
|
+
expect(stateOf(accepted, "camera-ready")).toBe("final");
|
|
437
|
+
expect(stateOf(accepted, "arxiv")).toBe("arXiv copy");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("the table: each cycle newest first, then the draft — what is missing said, nothing frozen said", () => {
|
|
441
|
+
const withGamma = cycles(
|
|
442
|
+
ALPHA,
|
|
443
|
+
{
|
|
444
|
+
...BETA,
|
|
445
|
+
outcome: { kind: "withdrawn", date: "2026-09-20", evidence: "w.md" },
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: "gamma-2027",
|
|
449
|
+
venue: { kind: "preset", extends: "paperlint:msr" },
|
|
450
|
+
opened: "2026-10-01",
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
const lock: Lock = {
|
|
454
|
+
"alpha-2026/submitted.pdf": entry(305412, SHA, "2026-07-22"),
|
|
455
|
+
"alpha-2026/submitted.tex": { kind: "lost", frozen: "2026-07-22" },
|
|
456
|
+
};
|
|
457
|
+
const rows = versionRows({
|
|
458
|
+
cycles: withGamma,
|
|
459
|
+
has: (k) => k === "alpha-2026/submitted.pdf",
|
|
460
|
+
lock,
|
|
461
|
+
draft: { kind: "none" },
|
|
462
|
+
});
|
|
463
|
+
expect(versionTable(rows)).toStrictEqual([
|
|
464
|
+
" gamma-2027 — open, nothing sent yet",
|
|
465
|
+
" beta-2027 submitted NOT FROZEN — submission 7 is on the portal",
|
|
466
|
+
" alpha-2026 submitted historical (rejected) 305412 B frozen 2026-07-22, source lost",
|
|
467
|
+
" paper.tex draft target not chosen no attempt to compare with",
|
|
468
|
+
]);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("each version's state, the draft row", () => {
|
|
473
|
+
it("the draft row: the same as its base, drifted, or no base frozen", () => {
|
|
474
|
+
const draft = (d: Parameters<typeof versionRows>[0]["draft"]) =>
|
|
475
|
+
versionRows({ cycles: [], has: () => false, lock: {}, draft: d }).at(-1)
|
|
476
|
+
?.detail;
|
|
477
|
+
expect(draft({ kind: "same", key: "b/submitted.tex" })).toBe(
|
|
478
|
+
"same as b/submitted.tex",
|
|
479
|
+
);
|
|
480
|
+
expect(
|
|
481
|
+
draft({
|
|
482
|
+
kind: "drift",
|
|
483
|
+
key: "b/submitted.tex",
|
|
484
|
+
drift: { added: 1, removed: 18 },
|
|
485
|
+
}),
|
|
486
|
+
).toBe("differs from b/submitted.tex: 1 line added, 18 removed");
|
|
487
|
+
expect(draft({ kind: "no-base", key: "b/submitted.tex" })).toBe(
|
|
488
|
+
"nothing frozen at b/submitted.tex to compare with",
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("a closed cycle with nothing frozen, a frozen file not locked", () => {
|
|
493
|
+
const rows = versionRows({
|
|
494
|
+
cycles: cycles({ ...ALPHA, submission: null }, BETA),
|
|
495
|
+
has: (k) => k === "beta-2027/submitted.pdf",
|
|
496
|
+
lock: {},
|
|
497
|
+
draft: { kind: "none" },
|
|
498
|
+
});
|
|
499
|
+
expect(rows.slice(0, 2)).toStrictEqual([
|
|
500
|
+
{
|
|
501
|
+
version: "beta-2027",
|
|
502
|
+
stage: "submitted",
|
|
503
|
+
state: "CURRENT (under review at Beta 2027)",
|
|
504
|
+
detail: "not locked",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
version: "alpha-2026",
|
|
508
|
+
stage: "—",
|
|
509
|
+
state: "rejected, nothing frozen",
|
|
510
|
+
detail: "",
|
|
511
|
+
},
|
|
512
|
+
]);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe("relock", () => {
|
|
517
|
+
const facts = (bytes: number, sha256 = SHA) => ({ bytes, sha256 });
|
|
518
|
+
it("keeps what matches, records what is new or changed, drops what is gone, keeps a lost source", () => {
|
|
519
|
+
const old: Lock = {
|
|
520
|
+
"a/submitted.pdf": entry(10),
|
|
521
|
+
"a/submitted.tex": entry(5, OTHER, "2026-07-22"),
|
|
522
|
+
"a/arxiv.tex": { kind: "lost", frozen: null },
|
|
523
|
+
"b/submitted.pdf": entry(),
|
|
524
|
+
};
|
|
525
|
+
const r = relock(
|
|
526
|
+
[
|
|
527
|
+
{ key: "a/submitted.pdf", facts: facts(10) },
|
|
528
|
+
{ key: "a/submitted.tex", facts: facts(6) },
|
|
529
|
+
{ key: "c/submitted.pdf", facts: facts(3) },
|
|
530
|
+
],
|
|
531
|
+
old,
|
|
532
|
+
"2026-10-27T12:00:00.000Z",
|
|
533
|
+
);
|
|
534
|
+
expect(r.lock).toStrictEqual({
|
|
535
|
+
"a/arxiv.tex": { kind: "lost", frozen: null },
|
|
536
|
+
"a/submitted.pdf": entry(10),
|
|
537
|
+
"a/submitted.tex": entry(6, SHA, "2026-10-27T12:00:00.000Z"),
|
|
538
|
+
"c/submitted.pdf": entry(3, SHA, "2026-10-27T12:00:00.000Z"),
|
|
539
|
+
});
|
|
540
|
+
expect(r.lines).toStrictEqual([
|
|
541
|
+
"re-locked versions/a/submitted.tex: 5 → 6 bytes — it changed after 2026-07-22",
|
|
542
|
+
"locked versions/c/submitted.pdf (3 bytes)",
|
|
543
|
+
"dropped versions/b/submitted.pdf: the file is gone",
|
|
544
|
+
]);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("a file where the lock recorded a lost source: recorded; same bytes, other digest: recorded anew", () => {
|
|
548
|
+
const r = relock(
|
|
549
|
+
[
|
|
550
|
+
{ key: "a/submitted.tex", facts: facts(5) },
|
|
551
|
+
{ key: "a/submitted.pdf", facts: facts(10, OTHER) },
|
|
552
|
+
],
|
|
553
|
+
{
|
|
554
|
+
"a/submitted.tex": { kind: "lost", frozen: null },
|
|
555
|
+
"a/submitted.pdf": entry(10),
|
|
556
|
+
},
|
|
557
|
+
"2026-10-27T12:00:00.000Z",
|
|
558
|
+
);
|
|
559
|
+
expect(r.lines).toStrictEqual([
|
|
560
|
+
"locked versions/a/submitted.tex (5 bytes)",
|
|
561
|
+
"re-locked versions/a/submitted.pdf: 10 → 10 bytes — it changed after 2026-10-23T16:58:01.000Z",
|
|
562
|
+
]);
|
|
563
|
+
});
|
|
564
|
+
});
|