paperlint 7.0.0 → 7.2.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/.github/workflows/ci.yml +3 -0
- package/.github/workflows/deadlines.yml +100 -0
- package/README.md +24 -11
- package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
- package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
- package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
- package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
- package/dist/adapters/hotcrp/deadlines.io.js +26 -0
- package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
- package/dist/adapters/hotcrp/deadlines.js +134 -0
- package/dist/adapters/hotcrp/deadlines.js.map +1 -0
- package/dist/adapters/hotcrp/index.d.ts +2 -0
- package/dist/adapters/hotcrp/index.d.ts.map +1 -1
- package/dist/adapters/hotcrp/index.js +2 -0
- package/dist/adapters/hotcrp/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +42 -7
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts +24 -0
- package/dist/cycle-rules.d.ts.map +1 -0
- package/dist/cycle-rules.js +212 -0
- package/dist/cycle-rules.js.map +1 -0
- package/dist/domain/body-pages.d.ts +29 -0
- package/dist/domain/body-pages.d.ts.map +1 -1
- package/dist/domain/body-pages.js +25 -0
- package/dist/domain/body-pages.js.map +1 -1
- package/dist/domain/cycle.d.ts +148 -0
- package/dist/domain/cycle.d.ts.map +1 -0
- package/dist/domain/cycle.js +281 -0
- package/dist/domain/cycle.js.map +1 -0
- package/dist/domain/deadline.d.ts +128 -0
- package/dist/domain/deadline.d.ts.map +1 -0
- package/dist/domain/deadline.js +281 -0
- package/dist/domain/deadline.js.map +1 -0
- package/dist/eslint-rules/cycle-stage.d.ts +39 -0
- package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
- package/dist/eslint-rules/cycle-stage.js +119 -0
- package/dist/eslint-rules/cycle-stage.js.map +1 -0
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -0
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-includes.d.ts +3 -0
- package/dist/paper-includes.d.ts.map +1 -1
- package/dist/paper-includes.js +17 -2
- package/dist/paper-includes.js.map +1 -1
- package/dist/paper-settings.d.ts +7 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +61 -8
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/venue-deadlines.d.ts +14 -0
- package/dist/ports/venue-deadlines.d.ts.map +1 -0
- package/dist/ports/venue-deadlines.js +2 -0
- package/dist/ports/venue-deadlines.js.map +1 -0
- package/dist/presets.d.ts +22 -3
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +26 -17
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +16 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +59 -16
- package/dist/submission.js.map +1 -1
- package/dist/tex-requirements.d.ts +12 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +26 -3
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +24 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +98 -10
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/venue-rules.d.ts +12 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +81 -17
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +140 -12
- package/docs/e2e.md +14 -12
- package/docs/rules/cycle/evidence.md +87 -0
- package/docs/rules/cycle/port-done.md +75 -0
- package/docs/rules/cycle/record.md +152 -0
- package/docs/rules/cycle/stage.md +85 -0
- package/docs/rules/format/page-limit.md +18 -1
- package/docs/rules/tex/template.md +25 -5
- package/docs/rules/tex/venue-leftover.md +13 -0
- package/docs/rules.md +68 -55
- package/docs/submission.md +10 -1
- package/eslint-rules/cycle-stage.test.mjs +131 -0
- package/eslint-rules/cycle-stage.ts +141 -0
- package/eslint.config.mjs +41 -1
- package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr/expect.json +18 -0
- package/fixtures/build-e2e/msr/paper.tex +49 -0
- package/fixtures/build-e2e/msr/paperlint.json +1 -0
- package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
- package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
- package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
- package/lib/paper-config.ts +3 -0
- package/package.json +1 -1
- package/presets/aidc.jsonc +24 -3
- package/presets/aidc.md +6 -3
- package/presets/ieee-conference.md +1 -1
- package/presets/msr.jsonc +95 -0
- package/presets/msr.md +98 -0
- package/presets/venue-profile.schema.json +64 -0
- package/scripts/check.ts +4 -0
- package/scripts/refresh-deadlines.test.ts +431 -0
- package/scripts/refresh-deadlines.ts +387 -0
- package/skills/camera-ready/SKILL.md +8 -1
- package/skills/camera-ready/SKILL.md.spec.ts +7 -0
- package/skills/submission-portal/SKILL.md +2 -2
- package/skills/submission-portal/SKILL.md.spec.ts +1 -1
- package/skills/submit-paper/SKILL.md +9 -1
- package/skills/submit-paper/SKILL.md.spec.ts +8 -0
- package/src/adapters/hotcrp/cassette/README.md +22 -0
- package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
- package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
- package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
- package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
- package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
- package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
- package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
- package/src/adapters/hotcrp/deadlines.io.ts +27 -0
- package/src/adapters/hotcrp/deadlines.test.ts +170 -0
- package/src/adapters/hotcrp/deadlines.ts +161 -0
- package/src/adapters/hotcrp/index.ts +2 -0
- package/src/cli.test.ts +9 -0
- package/src/cli.ts +50 -6
- package/src/cycle-rules.test.ts +278 -0
- package/src/cycle-rules.ts +273 -0
- package/src/domain/body-pages.test.ts +144 -0
- package/src/domain/body-pages.ts +61 -1
- package/src/domain/cycle.test.ts +456 -0
- package/src/domain/cycle.ts +432 -0
- package/src/domain/deadline.test.ts +386 -0
- package/src/domain/deadline.ts +472 -0
- package/src/new-paper.test.ts +28 -0
- package/src/paper-includes.test.ts +26 -0
- package/src/paper-includes.ts +20 -2
- package/src/paper-settings-commands.test.ts +1 -1
- package/src/paper-settings.test.ts +167 -0
- package/src/paper-settings.ts +85 -11
- package/src/ports/venue-deadlines.ts +14 -0
- package/src/preset-card-rule.test.ts +1 -0
- package/src/presets.test.ts +171 -1
- package/src/presets.ts +65 -15
- package/src/rule-cases.test.ts +144 -0
- package/src/submission.test.ts +147 -4
- package/src/submission.ts +100 -18
- package/src/tex-requirements.ts +52 -4
- package/src/tex-venue-rules.test.ts +237 -0
- package/src/tex-venue-rules.ts +130 -15
- package/src/venue-rules.test.ts +255 -2
- package/src/venue-rules.ts +115 -22
package/src/domain/body-pages.ts
CHANGED
|
@@ -186,8 +186,11 @@ function appendixAt(
|
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/** A body end that was found. */
|
|
190
|
+
type Found = Extract<BodyEnd, { readonly kind: "found" }>;
|
|
191
|
+
|
|
189
192
|
/** The body's end at a heading: the heading's page counts when body text stands above it. */
|
|
190
|
-
function endAt(pages: readonly PageReading[], at: At):
|
|
193
|
+
function endAt(pages: readonly PageReading[], at: At): Found {
|
|
191
194
|
const running = runningLines(pages);
|
|
192
195
|
const body = at.above.some((l) => /\p{L}/u.test(l) && !running.has(shape(l)));
|
|
193
196
|
return {
|
|
@@ -221,3 +224,60 @@ export function bodyEnd(
|
|
|
221
224
|
.toSorted((a, b) => a.page - b.page || a.line - b.line)[0];
|
|
222
225
|
return first === undefined ? { kind: "missing" } : endAt(pages, first);
|
|
223
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The pages of a paper split as a venue counts them when its body limit includes the appendices
|
|
230
|
+
* and only the pages holding nothing but references are reference pages (MSR: «10 pages for the
|
|
231
|
+
* main text, inclusive of all figures, tables, appendices, etc. Two more pages containing only
|
|
232
|
+
* references are permitted.»):
|
|
233
|
+
*
|
|
234
|
+
* body the pages up to the references — their page too when body text stands above the
|
|
235
|
+
* heading — and, when the appendix starts after the references, every page from the
|
|
236
|
+
* appendix's to the last, which holds appendix text
|
|
237
|
+
* references the pages between, which hold only references
|
|
238
|
+
*
|
|
239
|
+
* An appendix before the references is body like any other text, and ends nothing. The appendix is
|
|
240
|
+
* found by hyperref's anchor only (`appendixAt`): without hyperref, an appendix after the references
|
|
241
|
+
* is not told from them and is counted with them. Pure.
|
|
242
|
+
*/
|
|
243
|
+
export type PageSplit =
|
|
244
|
+
| {
|
|
245
|
+
readonly kind: "found";
|
|
246
|
+
/** 1-based page holding the references heading. */
|
|
247
|
+
readonly page: number;
|
|
248
|
+
readonly bodyPages: number;
|
|
249
|
+
readonly refPages: number;
|
|
250
|
+
/** 1-based page the appendix starts on, when it follows the references; else null. */
|
|
251
|
+
readonly appendixFrom: number | null;
|
|
252
|
+
}
|
|
253
|
+
| { readonly kind: "missing" }
|
|
254
|
+
| Extract<BodyEnd, { readonly kind: "disagree" }>;
|
|
255
|
+
|
|
256
|
+
/** Whether heading `a` stands after heading `b` in the document. */
|
|
257
|
+
const after = (a: At, b: At): boolean =>
|
|
258
|
+
a.page > b.page || (a.page === b.page && a.line > b.line);
|
|
259
|
+
|
|
260
|
+
export function pageSplit(
|
|
261
|
+
texts: readonly string[],
|
|
262
|
+
anchors: Anchors = { bib: null, appendix: null },
|
|
263
|
+
): PageSplit {
|
|
264
|
+
const pages = texts.map(readPage);
|
|
265
|
+
const refs = referencesAt(pages, anchors.bib);
|
|
266
|
+
const appendix = appendixAt(pages, anchors.appendix);
|
|
267
|
+
if (refs.kind === "disagree") return refs;
|
|
268
|
+
if (appendix.kind === "disagree") return appendix;
|
|
269
|
+
if (refs.kind === "missing") return { kind: "missing" };
|
|
270
|
+
const upTo = endAt(pages, refs).bodyPages;
|
|
271
|
+
const tail =
|
|
272
|
+
appendix.kind === "at" && after(appendix, refs) ? appendix.page : null;
|
|
273
|
+
// The pages from the appendix to the end, less the ones already counted up to the references.
|
|
274
|
+
const appendixPages = tail === null ? 0 : pages.length - Math.max(tail, upTo);
|
|
275
|
+
const bodyPages = upTo + appendixPages;
|
|
276
|
+
return {
|
|
277
|
+
kind: "found",
|
|
278
|
+
page: refs.page + 1,
|
|
279
|
+
bodyPages,
|
|
280
|
+
refPages: pages.length - bodyPages,
|
|
281
|
+
appendixFrom: tail === null ? null : tail + 1,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CYCLE — one attempt of a paper at a venue, parsed from `cycles` in `paperlint.json`, and the
|
|
3
|
+
* current attempt derived from the list. Pure.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { present } from "../../test/support.ts";
|
|
7
|
+
import {
|
|
8
|
+
currentCycle,
|
|
9
|
+
currentDeadlines,
|
|
10
|
+
cycleProblemText,
|
|
11
|
+
effectiveBlind,
|
|
12
|
+
parseCycles,
|
|
13
|
+
type Cycle,
|
|
14
|
+
} from "./cycle.ts";
|
|
15
|
+
|
|
16
|
+
const REJECTED = {
|
|
17
|
+
id: "alpha-2026",
|
|
18
|
+
venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
|
|
19
|
+
kind: "short",
|
|
20
|
+
opened: "2026-07-01",
|
|
21
|
+
deadlines: [
|
|
22
|
+
{
|
|
23
|
+
what: "submission",
|
|
24
|
+
observed: [
|
|
25
|
+
{
|
|
26
|
+
at: "2026-08-06T11:59:00Z",
|
|
27
|
+
source: "call",
|
|
28
|
+
url: "https://example.org/cfp",
|
|
29
|
+
read: "2026-07-01",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
outcome: {
|
|
35
|
+
kind: "rejected",
|
|
36
|
+
date: "2026-09-08",
|
|
37
|
+
desk: false,
|
|
38
|
+
evidence: "reviews/alpha.md",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const OPEN = {
|
|
42
|
+
id: "beta-2027",
|
|
43
|
+
venue: { kind: "preset", extends: "paperlint:ieee-conference" },
|
|
44
|
+
kind: "technical",
|
|
45
|
+
opened: "2026-09-09",
|
|
46
|
+
phase: "porting",
|
|
47
|
+
deadlines: [
|
|
48
|
+
{
|
|
49
|
+
what: "submission",
|
|
50
|
+
observed: [
|
|
51
|
+
{
|
|
52
|
+
at: "2026-10-20T04:00:00Z",
|
|
53
|
+
source: "call",
|
|
54
|
+
url: "https://beta2027.example.org/call",
|
|
55
|
+
read: "2026-09-09",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
submission: { id: 7 },
|
|
61
|
+
outcome: { kind: "open" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const cycles = (r: ReturnType<typeof parseCycles>): readonly Cycle[] => {
|
|
65
|
+
if (!r.ok || r.value === null) throw new Error(JSON.stringify(r));
|
|
66
|
+
return r.value;
|
|
67
|
+
};
|
|
68
|
+
/** The parsed cycles of `input`, each present — a list a test's own setup guarantees. */
|
|
69
|
+
const parsed = (input: readonly unknown[]): Cycle[] =>
|
|
70
|
+
cycles(parseCycles(input)).map((c, i) => present(c, `cycle ${String(i)}`));
|
|
71
|
+
|
|
72
|
+
describe("parseCycles", () => {
|
|
73
|
+
it("absent is null: a paper without cycles keeps its flat settings", () => {
|
|
74
|
+
expect(parseCycles(undefined)).toEqual({ ok: true, value: null });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reads every field, fills the defaults, and keeps the list order", () => {
|
|
78
|
+
const [a, b] = cycles(parseCycles([REJECTED, OPEN]));
|
|
79
|
+
expect(a).toEqual({
|
|
80
|
+
id: "alpha-2026",
|
|
81
|
+
venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
|
|
82
|
+
kind: "short",
|
|
83
|
+
opened: "2026-07-01",
|
|
84
|
+
phase: "prepared",
|
|
85
|
+
deadlines: [
|
|
86
|
+
{
|
|
87
|
+
what: "submission",
|
|
88
|
+
observed: [
|
|
89
|
+
{
|
|
90
|
+
at: "2026-08-06T11:59:00Z",
|
|
91
|
+
source: "call",
|
|
92
|
+
url: "https://example.org/cfp",
|
|
93
|
+
read: "2026-07-01",
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
override: null,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
submission: null,
|
|
100
|
+
outcome: {
|
|
101
|
+
kind: "rejected",
|
|
102
|
+
date: "2026-09-08",
|
|
103
|
+
desk: false,
|
|
104
|
+
evidence: "reviews/alpha.md",
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
expect(b?.phase).toBe("porting");
|
|
108
|
+
expect(b?.submission).toEqual({ id: 7 });
|
|
109
|
+
expect(b?.outcome).toEqual({ kind: "open" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("a named venue (no preset yet) carries its name and call", () => {
|
|
113
|
+
const [c] = cycles(
|
|
114
|
+
parseCycles([
|
|
115
|
+
{
|
|
116
|
+
...OPEN,
|
|
117
|
+
venue: {
|
|
118
|
+
kind: "named",
|
|
119
|
+
name: "ExampleSec '27",
|
|
120
|
+
url: "https://example.org/call",
|
|
121
|
+
},
|
|
122
|
+
kind: null,
|
|
123
|
+
},
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
expect(c?.venue).toEqual({
|
|
127
|
+
kind: "named",
|
|
128
|
+
name: "ExampleSec '27",
|
|
129
|
+
url: "https://example.org/call",
|
|
130
|
+
});
|
|
131
|
+
expect(c?.kind).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("parseCycles — the outcomes", () => {
|
|
136
|
+
it("a withdrawn outcome: date and evidence, nothing else", () => {
|
|
137
|
+
const [c] = parsed([
|
|
138
|
+
{
|
|
139
|
+
...REJECTED,
|
|
140
|
+
outcome: { kind: "withdrawn", date: "2026-07-18", evidence: "n.md" },
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
expect(c?.outcome).toEqual({
|
|
144
|
+
kind: "withdrawn",
|
|
145
|
+
date: "2026-07-18",
|
|
146
|
+
evidence: "n.md",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("parseCycles refuses, naming the entry and the key", () => {
|
|
152
|
+
it.each([
|
|
153
|
+
["not a list", "a cycle", /must be a LIST/],
|
|
154
|
+
["an entry that is not an object", [1], /cycles\[0\] must be an object/],
|
|
155
|
+
[
|
|
156
|
+
"an unknown key",
|
|
157
|
+
[{ ...OPEN, venu: 1 }],
|
|
158
|
+
/cycles\[0\]: unknown key "venu"/,
|
|
159
|
+
],
|
|
160
|
+
["a missing id", [{ ...OPEN, id: undefined }], /cycles\[0\]: "id" must be/],
|
|
161
|
+
[
|
|
162
|
+
"an opened date that is not a date",
|
|
163
|
+
[{ ...OPEN, opened: "September" }],
|
|
164
|
+
/"opened" must be a date YYYY-MM-DD/,
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
"a venue of an unknown kind",
|
|
168
|
+
[{ ...OPEN, venue: { kind: "url" } }],
|
|
169
|
+
/"venue" must be \{ "kind": "preset", "extends": … \} or \{ "kind": "named", "name": …, "url": … \}/,
|
|
170
|
+
],
|
|
171
|
+
])("refuses %s, naming the entry and the key", (_what, input, re) => {
|
|
172
|
+
const r = parseCycles(input);
|
|
173
|
+
expect(r.ok).toBe(false);
|
|
174
|
+
if (!r.ok) expect(r.error).toMatch(re);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("parseCycles refuses a malformed deadline, outcome or submission", () => {
|
|
179
|
+
it.each([
|
|
180
|
+
[
|
|
181
|
+
"a deadline the deadline parser refuses, named by its place in the list",
|
|
182
|
+
[{ ...OPEN, deadlines: [{ what: "submission", notes: "x" }] }],
|
|
183
|
+
/^cycles\[0\]\.deadlines\[0\]: unknown key "notes"/,
|
|
184
|
+
],
|
|
185
|
+
[
|
|
186
|
+
"an unknown key in an open outcome",
|
|
187
|
+
[{ ...OPEN, outcome: { kind: "open", foo: 1 } }],
|
|
188
|
+
/^cycles\[0\]\.outcome: unknown key "foo" — an open outcome's keys: kind$/,
|
|
189
|
+
],
|
|
190
|
+
[
|
|
191
|
+
"an unknown key in a decision",
|
|
192
|
+
[{ ...REJECTED, outcome: { ...REJECTED.outcome, reviews: 3 } }],
|
|
193
|
+
/^cycles\[0\]\.outcome: unknown key "reviews" — a rejected outcome's keys: kind, date, evidence, desk$/,
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
"a desk flag on an acceptance",
|
|
197
|
+
[
|
|
198
|
+
{
|
|
199
|
+
...REJECTED,
|
|
200
|
+
outcome: {
|
|
201
|
+
kind: "accepted",
|
|
202
|
+
date: "2026-08-21",
|
|
203
|
+
evidence: "r.md",
|
|
204
|
+
desk: false,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
/^cycles\[0\]\.outcome: unknown key "desk" — an accepted outcome's keys: kind, date, evidence$/,
|
|
209
|
+
],
|
|
210
|
+
])("refuses %s, naming the entry and the key", (_what, input, re) => {
|
|
211
|
+
const r = parseCycles(input);
|
|
212
|
+
expect(r.ok).toBe(false);
|
|
213
|
+
if (!r.ok) expect(r.error).toMatch(re);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("parseCycles refuses an unknown key or a bad shape in a venue or a submission", () => {
|
|
218
|
+
it.each([
|
|
219
|
+
[
|
|
220
|
+
"an unknown key in a venue",
|
|
221
|
+
[{ ...OPEN, venue: { ...OPEN.venue, portal: "https://x" } }],
|
|
222
|
+
/^cycles\[0\]\.venue: unknown key "portal" — a preset venue's keys: kind, extends$/,
|
|
223
|
+
],
|
|
224
|
+
[
|
|
225
|
+
"an unknown key in a named venue",
|
|
226
|
+
[
|
|
227
|
+
{
|
|
228
|
+
...OPEN,
|
|
229
|
+
venue: { kind: "named", name: "X", url: "https://x", year: 2027 },
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
/^cycles\[0\]\.venue: unknown key "year" — a named venue's keys: kind, name, url$/,
|
|
233
|
+
],
|
|
234
|
+
[
|
|
235
|
+
"a preset venue with an empty extends",
|
|
236
|
+
[{ ...OPEN, venue: { kind: "preset", extends: "" } }],
|
|
237
|
+
/^cycles\[0\]: "venue" must be \{ "kind": "preset"/,
|
|
238
|
+
],
|
|
239
|
+
[
|
|
240
|
+
"a named venue without its url",
|
|
241
|
+
[{ ...OPEN, venue: { kind: "named", name: "X" } }],
|
|
242
|
+
/^cycles\[0\]: "venue" must be \{ "kind": "preset"/,
|
|
243
|
+
],
|
|
244
|
+
[
|
|
245
|
+
"a submission that is a bare number",
|
|
246
|
+
[{ ...OPEN, submission: 7 }],
|
|
247
|
+
/^cycles\[0\]: "submission" must be \{ "id"/,
|
|
248
|
+
],
|
|
249
|
+
[
|
|
250
|
+
"an unknown key in a submission",
|
|
251
|
+
[{ ...OPEN, submission: { id: 7, url: "https://x" } }],
|
|
252
|
+
/^cycles\[0\]\.submission: unknown key "url" — a submission's keys: id$/,
|
|
253
|
+
],
|
|
254
|
+
])("refuses %s, naming the entry and the key", (_what, input, re) => {
|
|
255
|
+
const r = parseCycles(input);
|
|
256
|
+
expect(r.ok).toBe(false);
|
|
257
|
+
if (!r.ok) expect(r.error).toMatch(re);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("parseCycles refuses a malformed decision, phase or submission", () => {
|
|
262
|
+
it.each([
|
|
263
|
+
[
|
|
264
|
+
"a decision without evidence",
|
|
265
|
+
[
|
|
266
|
+
{
|
|
267
|
+
...REJECTED,
|
|
268
|
+
outcome: { kind: "rejected", date: "2026-09-08", desk: false },
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
/"outcome" of kind rejected needs "evidence"/,
|
|
272
|
+
],
|
|
273
|
+
[
|
|
274
|
+
"a desk flag that is not a boolean",
|
|
275
|
+
[{ ...REJECTED, outcome: { ...REJECTED.outcome, desk: "yes" } }],
|
|
276
|
+
/"outcome.desk" must be true or false/,
|
|
277
|
+
],
|
|
278
|
+
[
|
|
279
|
+
"an outcome of an unknown kind",
|
|
280
|
+
[{ ...OPEN, outcome: { kind: "desk-reject" } }],
|
|
281
|
+
/"outcome" kind must be one of open, accepted, rejected, withdrawn/,
|
|
282
|
+
],
|
|
283
|
+
[
|
|
284
|
+
"a phase that is not porting or prepared",
|
|
285
|
+
[{ ...OPEN, phase: "draft" }],
|
|
286
|
+
/"phase" must be "porting" or "prepared"/,
|
|
287
|
+
],
|
|
288
|
+
[
|
|
289
|
+
"a submission id that is not a positive integer",
|
|
290
|
+
[{ ...OPEN, submission: { id: 0 } }],
|
|
291
|
+
/"submission" must be \{ "id": <the submission number/,
|
|
292
|
+
],
|
|
293
|
+
])("refuses %s, naming the entry and the key", (_what, input, re) => {
|
|
294
|
+
const r = parseCycles(input);
|
|
295
|
+
expect(r.ok).toBe(false);
|
|
296
|
+
if (!r.ok) expect(r.error).toMatch(re);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/** The one-line refusal, as every table below asserts it. */
|
|
301
|
+
const refuses = (_what: string, input: unknown, re: RegExp): void => {
|
|
302
|
+
const r = parseCycles(input);
|
|
303
|
+
expect(r.ok).toBe(false);
|
|
304
|
+
if (!r.ok) expect(r.error).toMatch(re);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
describe("parseCycles refuses a value of the wrong shape, naming the entry and the key", () => {
|
|
308
|
+
it.each([
|
|
309
|
+
[
|
|
310
|
+
"a venue that is not an object",
|
|
311
|
+
[{ ...OPEN, venue: "paperlint:ieee-conference" }],
|
|
312
|
+
/"venue" must be \{ "kind": "preset"/,
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
"a decision whose date is not a day",
|
|
316
|
+
[{ ...REJECTED, outcome: { ...REJECTED.outcome, date: "08.09.2026" } }],
|
|
317
|
+
/"outcome" of kind rejected needs "date" \(YYYY-MM-DD\)/,
|
|
318
|
+
],
|
|
319
|
+
[
|
|
320
|
+
"a kind that is not a string",
|
|
321
|
+
[{ ...OPEN, kind: 5 }],
|
|
322
|
+
/"kind" must be a string or null/,
|
|
323
|
+
],
|
|
324
|
+
[
|
|
325
|
+
"two broken cycles: the first is named, the second never read",
|
|
326
|
+
[{}, {}],
|
|
327
|
+
/^cycles\[0\]: "id" must be a non-empty string/,
|
|
328
|
+
],
|
|
329
|
+
])("refuses %s", refuses);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("currentCycle", () => {
|
|
333
|
+
it("no cycles: none; the last cycle open: that cycle; the last cycle closed: parked on it", () => {
|
|
334
|
+
expect(currentCycle([])).toEqual({ ok: true, value: { kind: "none" } });
|
|
335
|
+
const [a, b] = parsed([REJECTED, OPEN]);
|
|
336
|
+
expect(currentCycle(parsed([REJECTED, OPEN]))).toEqual({
|
|
337
|
+
ok: true,
|
|
338
|
+
value: { kind: "cycle", cycle: b },
|
|
339
|
+
});
|
|
340
|
+
expect(currentCycle(parsed([REJECTED]))).toEqual({
|
|
341
|
+
ok: true,
|
|
342
|
+
value: { kind: "parked", last: a },
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("currentCycle — closed lists", () => {
|
|
348
|
+
it("an ACCEPTED last cycle is not parked: the paper lives on at that venue", () => {
|
|
349
|
+
const [a] = parsed([
|
|
350
|
+
{
|
|
351
|
+
...REJECTED,
|
|
352
|
+
outcome: { kind: "accepted", date: "2026-08-21", evidence: "r.md" },
|
|
353
|
+
},
|
|
354
|
+
]);
|
|
355
|
+
expect(
|
|
356
|
+
currentCycle(
|
|
357
|
+
parsed([
|
|
358
|
+
{
|
|
359
|
+
...REJECTED,
|
|
360
|
+
outcome: { kind: "accepted", date: "2026-08-21", evidence: "r.md" },
|
|
361
|
+
},
|
|
362
|
+
]),
|
|
363
|
+
),
|
|
364
|
+
).toEqual({
|
|
365
|
+
ok: true,
|
|
366
|
+
value: { kind: "accepted", cycle: a },
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("two open cycles is dual submission (or a cycle never closed), by name", () => {
|
|
371
|
+
const r = currentCycle(
|
|
372
|
+
parsed([{ ...REJECTED, outcome: { kind: "open" } }, OPEN]),
|
|
373
|
+
);
|
|
374
|
+
expect(r).toEqual({
|
|
375
|
+
ok: false,
|
|
376
|
+
error: { kind: "two-open", ids: ["alpha-2026", "beta-2027"] },
|
|
377
|
+
});
|
|
378
|
+
if (!r.ok)
|
|
379
|
+
expect(cycleProblemText(r.error)).toMatch(
|
|
380
|
+
/cycles «alpha-2026» and «beta-2027» are both open/,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("an open cycle that is not the last is a record out of order", () => {
|
|
385
|
+
const list = parsed([
|
|
386
|
+
{ ...REJECTED, outcome: { kind: "open" } },
|
|
387
|
+
{ ...OPEN, outcome: REJECTED.outcome },
|
|
388
|
+
]);
|
|
389
|
+
expect(currentCycle(list)).toEqual({
|
|
390
|
+
ok: false,
|
|
391
|
+
error: { kind: "open-not-last", id: "alpha-2026" },
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("two cycles with one id cannot be told apart by a stage", () => {
|
|
396
|
+
expect(
|
|
397
|
+
currentCycle(parsed([REJECTED, { ...OPEN, id: "alpha-2026" }])),
|
|
398
|
+
).toEqual({
|
|
399
|
+
ok: false,
|
|
400
|
+
error: { kind: "duplicate-id", id: "alpha-2026" },
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("cycleProblemText — one line per problem, naming what to change", () => {
|
|
406
|
+
it.each([
|
|
407
|
+
[
|
|
408
|
+
{ kind: "open-not-last", id: "alpha-2026" },
|
|
409
|
+
/cycle «alpha-2026» is open but is not the last entry/,
|
|
410
|
+
],
|
|
411
|
+
[
|
|
412
|
+
{ kind: "duplicate-id", id: "alpha-2026" },
|
|
413
|
+
/two cycles carry the id «alpha-2026»/,
|
|
414
|
+
],
|
|
415
|
+
] as const)("%j", (problem, text) => {
|
|
416
|
+
expect(cycleProblemText(problem)).toMatch(text);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("currentDeadlines", () => {
|
|
421
|
+
it("the open or accepted attempt's entries; none when parked or with no cycles", () => {
|
|
422
|
+
const [a, b] = parsed([REJECTED, OPEN]);
|
|
423
|
+
const open = present(b, "open cycle");
|
|
424
|
+
const closed = present(a, "closed cycle");
|
|
425
|
+
expect(currentDeadlines({ kind: "cycle", cycle: open })).toBe(
|
|
426
|
+
open.deadlines,
|
|
427
|
+
);
|
|
428
|
+
expect(currentDeadlines({ kind: "accepted", cycle: closed })).toBe(
|
|
429
|
+
closed.deadlines,
|
|
430
|
+
);
|
|
431
|
+
expect(currentDeadlines({ kind: "parked", last: closed })).toEqual([]);
|
|
432
|
+
expect(currentDeadlines({ kind: "none" })).toEqual([]);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("effectiveBlind", () => {
|
|
437
|
+
it("a blind venue stops being blind for an ACCEPTED cycle: the camera-ready carries the authors", () => {
|
|
438
|
+
const [a, b] = parsed([REJECTED, OPEN]);
|
|
439
|
+
const open = present(b, "open cycle");
|
|
440
|
+
const accepted: Cycle = {
|
|
441
|
+
...present(a, "closed cycle"),
|
|
442
|
+
outcome: {
|
|
443
|
+
kind: "accepted",
|
|
444
|
+
date: "2026-08-21",
|
|
445
|
+
evidence: "reviews/a.md",
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
expect(effectiveBlind(true, { kind: "cycle", cycle: open })).toBe(true);
|
|
449
|
+
expect(effectiveBlind(true, { kind: "accepted", cycle: accepted })).toBe(
|
|
450
|
+
false,
|
|
451
|
+
);
|
|
452
|
+
expect(effectiveBlind(true, { kind: "parked", last: accepted })).toBe(true);
|
|
453
|
+
expect(effectiveBlind(true, { kind: "none" })).toBe(true);
|
|
454
|
+
expect(effectiveBlind(false, { kind: "cycle", cycle: open })).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
});
|