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
|
@@ -33,6 +33,7 @@ describe("parsePaperSettings", () => {
|
|
|
33
33
|
identity: null,
|
|
34
34
|
talk: null,
|
|
35
35
|
submission: null,
|
|
36
|
+
cycles: null,
|
|
36
37
|
},
|
|
37
38
|
});
|
|
38
39
|
expect(parsePaperSettings({})).toEqual({
|
|
@@ -45,6 +46,7 @@ describe("parsePaperSettings", () => {
|
|
|
45
46
|
identity: null,
|
|
46
47
|
talk: null,
|
|
47
48
|
submission: null,
|
|
49
|
+
cycles: null,
|
|
48
50
|
},
|
|
49
51
|
});
|
|
50
52
|
});
|
|
@@ -60,6 +62,7 @@ describe("parsePaperSettings", () => {
|
|
|
60
62
|
identity: null,
|
|
61
63
|
talk: null,
|
|
62
64
|
submission: null,
|
|
65
|
+
cycles: null,
|
|
63
66
|
},
|
|
64
67
|
});
|
|
65
68
|
});
|
|
@@ -164,6 +167,7 @@ const UNSET = {
|
|
|
164
167
|
identity: null,
|
|
165
168
|
talk: null,
|
|
166
169
|
submission: null,
|
|
170
|
+
cycles: null,
|
|
167
171
|
};
|
|
168
172
|
|
|
169
173
|
describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
|
|
@@ -220,8 +224,14 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
|
|
|
220
224
|
identity: null,
|
|
221
225
|
talk: null,
|
|
222
226
|
submission: null,
|
|
227
|
+
cycles: null,
|
|
223
228
|
});
|
|
224
229
|
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("readPaperSettings — a project key in a paper's file", () => {
|
|
233
|
+
const withFiles = (f: Record<string, string>) =>
|
|
234
|
+
memoryFiles({ "/work/package.json": "{}", ...f });
|
|
225
235
|
|
|
226
236
|
it("papersDir in a paper's file: broken, naming the root file as its place", () => {
|
|
227
237
|
const r = readPaperSettings(
|
|
@@ -264,6 +274,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
|
|
|
264
274
|
identity: null,
|
|
265
275
|
talk: null,
|
|
266
276
|
submission: null,
|
|
277
|
+
cycles: null,
|
|
267
278
|
});
|
|
268
279
|
|
|
269
280
|
it("no rules: none", () => {
|
|
@@ -352,3 +363,159 @@ describe("talk in a paper's paperlint.json", () => {
|
|
|
352
363
|
expect(r.ok ? "" : r.error).toMatch(/"talk.mode"/);
|
|
353
364
|
});
|
|
354
365
|
});
|
|
366
|
+
|
|
367
|
+
describe("parsePaperSettings — `cycles`: the current attempt supplies extends, kind and submission", () => {
|
|
368
|
+
const closed = {
|
|
369
|
+
id: "alpha-2026",
|
|
370
|
+
venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
|
|
371
|
+
kind: "short",
|
|
372
|
+
opened: "2026-07-01",
|
|
373
|
+
outcome: {
|
|
374
|
+
kind: "rejected",
|
|
375
|
+
date: "2026-09-08",
|
|
376
|
+
desk: true,
|
|
377
|
+
evidence: "reviews/alpha.md",
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
const open = {
|
|
381
|
+
id: "beta-2027",
|
|
382
|
+
venue: { kind: "preset", extends: "paperlint:ieee-conference" },
|
|
383
|
+
kind: "technical",
|
|
384
|
+
opened: "2026-09-09",
|
|
385
|
+
submission: { id: 52 },
|
|
386
|
+
outcome: { kind: "open" },
|
|
387
|
+
};
|
|
388
|
+
const settingsOf = (json: unknown) => {
|
|
389
|
+
const r = parsePaperSettings(json);
|
|
390
|
+
if (!r.ok) throw new Error(r.error);
|
|
391
|
+
return r.value;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
it("an open cycle: its venue, kind and submission are the paper's", () => {
|
|
395
|
+
const s = settingsOf({ cycles: [closed, open] });
|
|
396
|
+
expect(s.extends).toBe("paperlint:ieee-conference");
|
|
397
|
+
expect(s.kind).toBe("technical");
|
|
398
|
+
expect(s.submission).toEqual({ id: 52 });
|
|
399
|
+
expect(s.cycles?.current.kind).toBe("cycle");
|
|
400
|
+
expect(s.cycles?.list.map((c) => c.id)).toEqual([
|
|
401
|
+
"alpha-2026",
|
|
402
|
+
"beta-2027",
|
|
403
|
+
]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("parked (the last cycle closed) and none (`cycles: []`): no venue, as `extends: null` reads", () => {
|
|
407
|
+
const parked = settingsOf({ cycles: [closed] });
|
|
408
|
+
expect([parked.extends, parked.kind, parked.submission]).toEqual([
|
|
409
|
+
null,
|
|
410
|
+
null,
|
|
411
|
+
null,
|
|
412
|
+
]);
|
|
413
|
+
expect(parked.cycles?.current.kind).toBe("parked");
|
|
414
|
+
expect(settingsOf({ cycles: [] }).cycles?.current).toEqual({
|
|
415
|
+
kind: "none",
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("parsePaperSettings — `cycles`, closed and named", () => {
|
|
421
|
+
const closed = {
|
|
422
|
+
id: "alpha-2026",
|
|
423
|
+
venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
|
|
424
|
+
kind: "short",
|
|
425
|
+
opened: "2026-07-01",
|
|
426
|
+
outcome: { kind: "withdrawn", date: "2026-09-08", evidence: "n.md" },
|
|
427
|
+
};
|
|
428
|
+
const open = {
|
|
429
|
+
id: "beta-2027",
|
|
430
|
+
venue: { kind: "preset", extends: "paperlint:ieee-conference" },
|
|
431
|
+
kind: "technical",
|
|
432
|
+
opened: "2026-09-09",
|
|
433
|
+
};
|
|
434
|
+
const settingsOf = (json: unknown) => {
|
|
435
|
+
const r = parsePaperSettings(json);
|
|
436
|
+
if (!r.ok) throw new Error(r.error);
|
|
437
|
+
return r.value;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
it("an accepted last cycle keeps its venue and kind: camera-ready and talk are judged against it", () => {
|
|
441
|
+
const accepted = { kind: "accepted", date: "2026-08-21", evidence: "r.md" };
|
|
442
|
+
const s = settingsOf({ cycles: [{ ...closed, outcome: accepted }] });
|
|
443
|
+
expect([s.extends, s.kind, s.cycles?.current.kind]).toEqual([
|
|
444
|
+
"paperlint:acm-sigconf",
|
|
445
|
+
"short",
|
|
446
|
+
"accepted",
|
|
447
|
+
]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("a named venue (no preset yet) resolves no preset: extends is null, the record stays", () => {
|
|
451
|
+
const venue = {
|
|
452
|
+
kind: "named",
|
|
453
|
+
name: "X '27",
|
|
454
|
+
url: "https://example.org/c",
|
|
455
|
+
};
|
|
456
|
+
const s = settingsOf({ cycles: [{ ...open, venue }] });
|
|
457
|
+
expect([s.extends, s.kind]).toEqual([null, "technical"]);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("parsePaperSettings — `cycles` refused", () => {
|
|
462
|
+
const open = {
|
|
463
|
+
id: "beta-2027",
|
|
464
|
+
venue: { kind: "preset", extends: "paperlint:ieee-conference" },
|
|
465
|
+
opened: "2026-09-09",
|
|
466
|
+
};
|
|
467
|
+
const alsoOpen = {
|
|
468
|
+
id: "alpha-2026",
|
|
469
|
+
venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
|
|
470
|
+
opened: "2026-07-01",
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
it("one derived key beside `cycles` is refused in the singular", () => {
|
|
474
|
+
const error: unknown = expect.stringMatching(
|
|
475
|
+
/^"kind" beside "cycles": with cycles, it is the current attempt's/,
|
|
476
|
+
);
|
|
477
|
+
expect(parsePaperSettings({ kind: "short", cycles: [open] })).toEqual({
|
|
478
|
+
ok: false,
|
|
479
|
+
error,
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("a derived key written beside `cycles` is refused, naming it — two sources for one fact", () => {
|
|
484
|
+
const error: unknown = expect.stringMatching(
|
|
485
|
+
/^"extends", "kind" beside "cycles": with cycles, they are the current attempt's/,
|
|
486
|
+
);
|
|
487
|
+
expect(
|
|
488
|
+
parsePaperSettings({
|
|
489
|
+
extends: "paperlint:acm-sigconf",
|
|
490
|
+
kind: "short",
|
|
491
|
+
cycles: [open],
|
|
492
|
+
}),
|
|
493
|
+
).toEqual({ ok: false, error });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("two open cycles is refused as the file's problem: dual submission has no current venue", () => {
|
|
497
|
+
const error: unknown = expect.stringMatching(
|
|
498
|
+
/are both open — a paper is on one attempt/,
|
|
499
|
+
);
|
|
500
|
+
expect(parsePaperSettings({ cycles: [alsoOpen, open] })).toEqual({
|
|
501
|
+
ok: false,
|
|
502
|
+
error,
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("a malformed cycle is refused with the entry and key, through readPaperSettings as `broken`", () => {
|
|
507
|
+
const files = memoryFiles({
|
|
508
|
+
"/work/package.json": "{}",
|
|
509
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({
|
|
510
|
+
cycles: [{ ...open, opened: "soon" }],
|
|
511
|
+
}),
|
|
512
|
+
});
|
|
513
|
+
const why: unknown = expect.stringMatching(
|
|
514
|
+
/^cycles\[0\]: "opened" must be a date/,
|
|
515
|
+
);
|
|
516
|
+
expect(readPaperSettings(files, PAPER)).toEqual({
|
|
517
|
+
ok: false,
|
|
518
|
+
error: { kind: "broken", why },
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
});
|
package/src/paper-settings.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
|
|
5
5
|
*
|
|
6
6
|
* paperlint.json the project (optional): papersDir, rules, defaults
|
|
7
|
-
* <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission }
|
|
7
|
+
* <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission } —
|
|
8
|
+
* or `cycles`, from whose current attempt extends, kind and submission
|
|
9
|
+
* are DERIVED (src/domain/cycle.ts); the two forms do not mix
|
|
8
10
|
* a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
|
|
9
11
|
*
|
|
10
12
|
* The paper's `extends`, `kind` and `pdf` win over the root's; with no own value, the root's is
|
|
@@ -36,6 +38,13 @@ import {
|
|
|
36
38
|
parsePaperSubmission,
|
|
37
39
|
type PaperSubmission,
|
|
38
40
|
} from "./domain/submission.ts";
|
|
41
|
+
import {
|
|
42
|
+
cycleProblemText,
|
|
43
|
+
cyclesOf,
|
|
44
|
+
parseCycles,
|
|
45
|
+
venueCycleOf,
|
|
46
|
+
type Cycles,
|
|
47
|
+
} from "./domain/cycle.ts";
|
|
39
48
|
|
|
40
49
|
/** What one paper declares. Every absent field is null. */
|
|
41
50
|
export interface PaperSettings {
|
|
@@ -60,6 +69,12 @@ export interface PaperSettings {
|
|
|
60
69
|
readonly talk: PaperTalk | null;
|
|
61
70
|
/** Which submission on the venue's portal is this paper's; null when it says none. Never the root's. */
|
|
62
71
|
readonly submission: PaperSubmission | null;
|
|
72
|
+
/**
|
|
73
|
+
* The paper's attempts at venues and the current one, when the file declares `cycles`; null for a
|
|
74
|
+
* file in the flat form. With cycles, `extends`, `kind` and `submission` above are the current
|
|
75
|
+
* attempt's — derived, never written beside it. Never the root's.
|
|
76
|
+
*/
|
|
77
|
+
readonly cycles: Cycles | null;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
/** Why a paper's settings cannot be read. */
|
|
@@ -139,28 +154,85 @@ function rulesAndTalk(
|
|
|
139
154
|
: submission;
|
|
140
155
|
}
|
|
141
156
|
|
|
142
|
-
/** The
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
157
|
+
/** The keys the current cycle supplies: writing one beside `cycles` is a second source for it. */
|
|
158
|
+
const DERIVED_FROM_CYCLE = ["extends", "kind", "submission"] as const;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `cycles`, parsed with its current attempt, or null when the file is in the flat form. A file that
|
|
162
|
+
* has both `cycles` and a derived key is refused: two sources for one fact drift apart.
|
|
163
|
+
*/
|
|
164
|
+
function cyclesField(
|
|
165
|
+
json: Readonly<Record<string, unknown>>,
|
|
166
|
+
): Result<Cycles | null, string> {
|
|
167
|
+
const list = parseCycles(json["cycles"]);
|
|
168
|
+
if (!list.ok) return list;
|
|
169
|
+
if (list.value === null) return ok(null);
|
|
170
|
+
const beside = DERIVED_FROM_CYCLE.filter((k) => k in json);
|
|
171
|
+
if (beside.length > 0)
|
|
172
|
+
return err(
|
|
173
|
+
`"${beside.join('", "')}" beside "cycles": with cycles, ${beside.length === 1 ? "it is" : "they are"} the current attempt's and cannot be set here — move the value into the open cycle`,
|
|
174
|
+
);
|
|
175
|
+
const cycles = cyclesOf(list.value);
|
|
176
|
+
return cycles.ok ? cycles : err(cycleProblemText(cycles.error));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The current attempt's venue, kind and submission, as the flat keys every reader takes. An accepted
|
|
181
|
+
* attempt keeps them: the camera-ready and the talk are judged against the venue that accepted.
|
|
182
|
+
*/
|
|
183
|
+
function derived(
|
|
184
|
+
cycles: Cycles,
|
|
185
|
+
): Pick<PaperSettings, (typeof DERIVED_FROM_CYCLE)[number]> {
|
|
186
|
+
const c = venueCycleOf(cycles.current);
|
|
187
|
+
if (c === null) return { extends: null, kind: null, submission: null };
|
|
188
|
+
return {
|
|
189
|
+
extends: c.venue.kind === "preset" ? c.venue.extends : null,
|
|
190
|
+
kind: c.kind,
|
|
191
|
+
submission: c.submission,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The keys that may not be in a paper's file: unknown ones, and the project's. */
|
|
196
|
+
function refusedKeys(
|
|
197
|
+
json: Readonly<Record<string, unknown>>,
|
|
198
|
+
): Result<Readonly<Record<string, unknown>>, string> {
|
|
147
199
|
const unknown = Object.keys(json).filter((k) => !KNOWN.includes(k));
|
|
148
200
|
if (unknown.length > 0)
|
|
149
201
|
return err(
|
|
150
202
|
`unknown key "${String(unknown[0])}" — known keys: ${KNOWN.join(", ")}`,
|
|
151
203
|
);
|
|
152
204
|
const project = Object.keys(json).filter((k) => ROOT_ONLY_KEYS.includes(k));
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
205
|
+
return project.length > 0
|
|
206
|
+
? err(
|
|
207
|
+
`"${String(project[0])}" is a project setting — set it in the root ${CONFIG_FILE}, not in a paper's`,
|
|
208
|
+
)
|
|
209
|
+
: ok(json);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
|
|
213
|
+
export function parsePaperSettings(
|
|
214
|
+
json: unknown,
|
|
215
|
+
): Result<PaperSettings, string> {
|
|
216
|
+
if (!isObject(json)) return err("must be a JSON object");
|
|
217
|
+
const keys = refusedKeys(json);
|
|
218
|
+
if (!keys.ok) return keys;
|
|
157
219
|
const fields = stringFields(json);
|
|
158
220
|
if (!fields.ok) return fields;
|
|
159
221
|
const identity = identityField(json);
|
|
160
222
|
if (!identity.ok) return identity;
|
|
161
223
|
const rest = rulesAndTalk(json);
|
|
162
224
|
if (!rest.ok) return rest;
|
|
163
|
-
|
|
225
|
+
const cycles = cyclesField(json);
|
|
226
|
+
if (!cycles.ok) return cycles;
|
|
227
|
+
const flat = {
|
|
228
|
+
...fields.value,
|
|
229
|
+
...rest.value,
|
|
230
|
+
identity: identity.value,
|
|
231
|
+
cycles: cycles.value,
|
|
232
|
+
};
|
|
233
|
+
return ok(
|
|
234
|
+
cycles.value === null ? flat : { ...flat, ...derived(cycles.value) },
|
|
235
|
+
);
|
|
164
236
|
}
|
|
165
237
|
|
|
166
238
|
/** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
|
|
@@ -251,6 +323,7 @@ function merge(
|
|
|
251
323
|
rules: null,
|
|
252
324
|
talk: null,
|
|
253
325
|
submission: null,
|
|
326
|
+
cycles: null,
|
|
254
327
|
};
|
|
255
328
|
const merged: PaperSettings = {
|
|
256
329
|
extends: p.extends ?? r.extends,
|
|
@@ -260,6 +333,7 @@ function merge(
|
|
|
260
333
|
identity: joined(r.identity, p.identity),
|
|
261
334
|
talk: p.talk,
|
|
262
335
|
submission: p.submission,
|
|
336
|
+
cycles: p.cycles,
|
|
263
337
|
};
|
|
264
338
|
return paper === null && Object.values(merged).every((v) => v === null)
|
|
265
339
|
? null
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A venue's deadlines as its portal publishes them — the port the scheduled refresh of the shipped
|
|
3
|
+
* presets reads through (`scripts/refresh-deadlines.ts`). Shaped by what the refresh needs (the
|
|
4
|
+
* portal's deadlines, once), not by any portal's page; one adapter per portal kind
|
|
5
|
+
* (`src/adapters/hotcrp/`).
|
|
6
|
+
*/
|
|
7
|
+
import type { Result } from "../domain/result.ts";
|
|
8
|
+
import type { PortalDeadlines } from "../domain/deadline.ts";
|
|
9
|
+
import type { PortalFailure } from "../domain/submission.ts";
|
|
10
|
+
|
|
11
|
+
export interface VenueDeadlines {
|
|
12
|
+
/** The deadlines the portal publishes now. Needs no account: the page is public. */
|
|
13
|
+
read(): Promise<Result<PortalDeadlines, PortalFailure>>;
|
|
14
|
+
}
|
package/src/presets.test.ts
CHANGED
|
@@ -63,6 +63,7 @@ describe("shipped presets", () => {
|
|
|
63
63
|
expect(r.value.template).toEqual({
|
|
64
64
|
text: "\\documentclass[sigconf]{acmart}",
|
|
65
65
|
file: join(VENUES, "acm-sigconf.jsonc"),
|
|
66
|
+
forbids: [],
|
|
66
67
|
});
|
|
67
68
|
expect("acmart" in r.value.tex.packages).toBe(true);
|
|
68
69
|
// The label is the venue's own name, from its file's `name`; the id stays the file name — the
|
|
@@ -75,7 +76,57 @@ describe("shipped presets", () => {
|
|
|
75
76
|
url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
|
|
76
77
|
});
|
|
77
78
|
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("shipped presets: paperlint:msr", () => {
|
|
82
|
+
it("paperlint:msr → msr over ieee-conference: one kind, appendices counted as body, compsoc forbidden", () => {
|
|
83
|
+
const r = resolve("paperlint:msr");
|
|
84
|
+
expect(r.ok).toBe(true);
|
|
85
|
+
if (!r.ok) return;
|
|
86
|
+
expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
|
|
87
|
+
"ieee-conference.jsonc",
|
|
88
|
+
"msr.jsonc",
|
|
89
|
+
]);
|
|
90
|
+
expect([...r.value.format.kinds]).toEqual([
|
|
91
|
+
["technical", { bodyPagesMax: 10, refPagesMax: 2 }],
|
|
92
|
+
]);
|
|
93
|
+
expect(r.value.format.bodyEndsAt).toBe("references");
|
|
94
|
+
expect(r.value.format.appendixInBody).toBe(true);
|
|
95
|
+
expect(r.value.format.pageWidthIn).toBe(8.5);
|
|
96
|
+
expect(r.value.template).toEqual({
|
|
97
|
+
text: "\\documentclass[10pt,conference]{IEEEtran}",
|
|
98
|
+
file: join(VENUES, "msr.jsonc"),
|
|
99
|
+
forbids: ["compsoc", "compsocconf"],
|
|
100
|
+
});
|
|
101
|
+
expect(r.value.blind).toBe(true);
|
|
102
|
+
expect(r.value.portal).toEqual({
|
|
103
|
+
kind: "hotcrp",
|
|
104
|
+
url: "https://msr2027.hotcrp.com",
|
|
105
|
+
});
|
|
106
|
+
// The portal's deadlines, read 2026-10-05 off cassette/deadlines-msr2027.html's page.
|
|
107
|
+
expect(r.value.deadlines).toEqual([
|
|
108
|
+
{
|
|
109
|
+
what: "submission",
|
|
110
|
+
at: "2026-10-20T04:00:00Z",
|
|
111
|
+
source: "portal",
|
|
112
|
+
url: "https://msr2027.hotcrp.com/deadlines",
|
|
113
|
+
read: "2026-10-05",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
what: "resubmission",
|
|
117
|
+
at: "2026-10-23T04:00:00Z",
|
|
118
|
+
source: "portal",
|
|
119
|
+
url: "https://msr2027.hotcrp.com/deadlines",
|
|
120
|
+
read: "2026-10-05",
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
// No rule turned on: the IEEE capitalization rule has not been read and quoted.
|
|
124
|
+
expect(r.value.rules).toEqual({});
|
|
125
|
+
expect(r.value.registerAnchors).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
78
128
|
|
|
129
|
+
describe("shipped presets: a family, and a venue that stands alone", () => {
|
|
79
130
|
it("a paper may extend a family directly: format and fonts, and no kinds", () => {
|
|
80
131
|
const r = resolve("paperlint:acm-sigconf");
|
|
81
132
|
expect(r.ok && r.value.format.kinds.size).toBe(0);
|
|
@@ -139,6 +190,11 @@ describe("what each shipped preset declares itself to be", () => {
|
|
|
139
190
|
url: "https://aisec.cc/",
|
|
140
191
|
},
|
|
141
192
|
"ieee-conference.jsonc": { type: "family" },
|
|
193
|
+
"msr.jsonc": {
|
|
194
|
+
type: "venue",
|
|
195
|
+
name: "MSR",
|
|
196
|
+
url: "https://2027.msrconf.org/track/msr-2027-technical-papers",
|
|
197
|
+
},
|
|
142
198
|
"realm.jsonc": {
|
|
143
199
|
type: "venue",
|
|
144
200
|
name: "REALM",
|
|
@@ -287,6 +343,94 @@ describe("a project's own preset: what its children replace", () => {
|
|
|
287
343
|
});
|
|
288
344
|
});
|
|
289
345
|
|
|
346
|
+
describe("a project's own preset: its deadlines", () => {
|
|
347
|
+
const reading = (what: string, at: string, source = "portal") => ({
|
|
348
|
+
what,
|
|
349
|
+
at,
|
|
350
|
+
source,
|
|
351
|
+
url: "https://example.org/deadlines",
|
|
352
|
+
read: "2026-10-05",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("a child's deadlines replace its parent's whole; none named keeps the parent's", () => {
|
|
356
|
+
const own = resolve("./mine.jsonc", {
|
|
357
|
+
"/work/papers/p/mine.jsonc": venue({
|
|
358
|
+
extends: "paperlint:msr",
|
|
359
|
+
deadlines: [reading("submission", "2026-10-21T04:00:00Z")],
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
expect(own.ok && own.value.deadlines).toEqual([
|
|
363
|
+
reading("submission", "2026-10-21T04:00:00Z"),
|
|
364
|
+
]);
|
|
365
|
+
const inherited = resolve("./mine.jsonc", {
|
|
366
|
+
"/work/papers/p/mine.jsonc": venue({ extends: "paperlint:msr" }),
|
|
367
|
+
});
|
|
368
|
+
expect(inherited.ok && inherited.value.deadlines.length).toBe(2);
|
|
369
|
+
const none = resolve("./mine.jsonc", {
|
|
370
|
+
"/work/papers/p/mine.jsonc": venue({ extends: "paperlint:aisec" }),
|
|
371
|
+
});
|
|
372
|
+
expect(none.ok && none.value.deadlines).toEqual([]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("the portal's and the call's reading of one kind may stand side by side", () => {
|
|
376
|
+
const r = resolve("./mine.jsonc", {
|
|
377
|
+
"/work/papers/p/mine.jsonc": venue({
|
|
378
|
+
extends: "paperlint:aisec",
|
|
379
|
+
deadlines: [
|
|
380
|
+
reading("submission", "2026-10-20T04:00:00Z"),
|
|
381
|
+
reading("submission", "2026-10-21T11:59:59Z", "call"),
|
|
382
|
+
],
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
expect(r.ok && r.value.deadlines.map((d) => d.source)).toEqual([
|
|
386
|
+
"portal",
|
|
387
|
+
"call",
|
|
388
|
+
]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("a project's own preset: deadlines it refuses", () => {
|
|
393
|
+
const reading = (what: string, at: string, source = "portal") => ({
|
|
394
|
+
what,
|
|
395
|
+
at,
|
|
396
|
+
source,
|
|
397
|
+
url: "https://example.org/deadlines",
|
|
398
|
+
read: "2026-10-05",
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it.each([
|
|
402
|
+
[
|
|
403
|
+
"two readings of one kind from one source",
|
|
404
|
+
[
|
|
405
|
+
reading("submission", "2026-10-20T04:00:00Z"),
|
|
406
|
+
reading("submission", "2026-10-21T04:00:00Z"),
|
|
407
|
+
],
|
|
408
|
+
/"deadlines" holds two "portal" readings of "submission" — keep one, the latest/,
|
|
409
|
+
],
|
|
410
|
+
[
|
|
411
|
+
"an instant that is not UTC as a portal publishes it",
|
|
412
|
+
[reading("submission", "2026-10-20 AoE")],
|
|
413
|
+
/deadlines/,
|
|
414
|
+
],
|
|
415
|
+
[
|
|
416
|
+
"a reading without the day it was read",
|
|
417
|
+
[{ ...reading("submission", "2026-10-20T04:00:00Z"), read: undefined }],
|
|
418
|
+
/deadlines/,
|
|
419
|
+
],
|
|
420
|
+
])("refuses %s, naming the file", (_what, deadlines, re) => {
|
|
421
|
+
const r = resolve("./mine.jsonc", {
|
|
422
|
+
"/work/papers/p/mine.jsonc": venue({
|
|
423
|
+
extends: "paperlint:aisec",
|
|
424
|
+
deadlines,
|
|
425
|
+
}),
|
|
426
|
+
});
|
|
427
|
+
expect(r.ok).toBe(false);
|
|
428
|
+
if (r.ok) return;
|
|
429
|
+
expect(presetProblemText(r.error)).toMatch(/mine\.jsonc/);
|
|
430
|
+
expect(presetProblemText(r.error)).toMatch(re);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
290
434
|
describe("what does not resolve, and says why", () => {
|
|
291
435
|
it.each([
|
|
292
436
|
[
|
|
@@ -300,7 +444,7 @@ describe("what does not resolve, and says why", () => {
|
|
|
300
444
|
"a shipped name with a typo",
|
|
301
445
|
"paperlint:agenticdve",
|
|
302
446
|
"not-found",
|
|
303
|
-
/acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
|
|
447
|
+
/acm-sigconf, agenticdev, aidc, aisec, ieee-conference, msr, realm/,
|
|
304
448
|
],
|
|
305
449
|
["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
|
|
306
450
|
[
|
|
@@ -422,8 +566,32 @@ describe("a preset's template: kept as its file spells it, with that file", () =
|
|
|
422
566
|
expect(r.ok && r.value.template).toEqual({
|
|
423
567
|
text: "\\documentclass[conference]{IEEEtran}",
|
|
424
568
|
file: "/work/papers/p/own.jsonc",
|
|
569
|
+
forbids: [],
|
|
425
570
|
});
|
|
426
571
|
});
|
|
572
|
+
|
|
573
|
+
it("the options a file forbids travel with its template", () => {
|
|
574
|
+
const r = resolve("./own.jsonc", {
|
|
575
|
+
"/work/papers/p/own.jsonc": venue({
|
|
576
|
+
extends: "paperlint:ieee-conference",
|
|
577
|
+
template: "\\documentclass[conference]{IEEEtran}",
|
|
578
|
+
template_forbids: ["compsoc"],
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
expect(r.ok && r.value.template?.forbids).toEqual(["compsoc"]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("forbidden options without a template of the same file are refused by the schema", () => {
|
|
585
|
+
const r = resolve("./own.jsonc", {
|
|
586
|
+
"/work/papers/p/own.jsonc": venue({
|
|
587
|
+
extends: "paperlint:aidc",
|
|
588
|
+
template_forbids: ["compsoc"],
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
expect(r.ok).toBe(false);
|
|
592
|
+
if (r.ok) return;
|
|
593
|
+
expect(presetProblemText(r.error)).toMatch(/template_forbids/);
|
|
594
|
+
});
|
|
427
595
|
});
|
|
428
596
|
|
|
429
597
|
describe("labelOf — display only, never used to resolve", () => {
|
|
@@ -448,6 +616,7 @@ describe("shippedVenueNames — what each shipped venue is called", () => {
|
|
|
448
616
|
"AIDC",
|
|
449
617
|
"AISec",
|
|
450
618
|
"ieee-conference",
|
|
619
|
+
"MSR",
|
|
451
620
|
"REALM",
|
|
452
621
|
]);
|
|
453
622
|
expect(names.find((n) => n.label === "AISec")?.aliases).toEqual(
|
|
@@ -469,6 +638,7 @@ describe("shippedVenueNames — what each shipped venue is called", () => {
|
|
|
469
638
|
"AIDC",
|
|
470
639
|
"AISec",
|
|
471
640
|
"ieee-conference",
|
|
641
|
+
"MSR",
|
|
472
642
|
]);
|
|
473
643
|
});
|
|
474
644
|
});
|