paperlint 6.2.2 → 6.3.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/README.md +8 -1
- package/dist/adapters/hotcrp/index.d.ts +7 -0
- package/dist/adapters/hotcrp/index.d.ts.map +1 -0
- package/dist/adapters/hotcrp/index.js +7 -0
- package/dist/adapters/hotcrp/index.js.map +1 -0
- package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
- package/dist/adapters/hotcrp/portal.io.js +90 -0
- package/dist/adapters/hotcrp/portal.io.js.map +1 -0
- package/dist/adapters/hotcrp/response.d.ts +13 -0
- package/dist/adapters/hotcrp/response.d.ts.map +1 -0
- package/dist/adapters/hotcrp/response.js +138 -0
- package/dist/adapters/hotcrp/response.js.map +1 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +109 -34
- package/dist/cli.js.map +1 -1
- package/dist/domain/result.d.ts +4 -0
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +2 -0
- package/dist/domain/result.js.map +1 -1
- package/dist/domain/submission.d.ts +106 -0
- package/dist/domain/submission.d.ts.map +1 -0
- package/dist/domain/submission.js +39 -0
- package/dist/domain/submission.js.map +1 -0
- package/dist/domain/venue-name.d.ts +38 -0
- package/dist/domain/venue-name.d.ts.map +1 -0
- package/dist/domain/venue-name.js +71 -0
- package/dist/domain/venue-name.js.map +1 -0
- package/dist/folder-venue-rule.d.ts +51 -0
- package/dist/folder-venue-rule.d.ts.map +1 -0
- package/dist/folder-venue-rule.js +96 -0
- package/dist/folder-venue-rule.js.map +1 -0
- package/dist/init.d.ts +6 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +5 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/new-paper.d.ts +19 -5
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +23 -4
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +20 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/submission-portal.d.ts +19 -0
- package/dist/ports/submission-portal.d.ts.map +1 -0
- package/dist/ports/submission-portal.js +2 -0
- package/dist/ports/submission-portal.js.map +1 -0
- package/dist/presets.d.ts +24 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +35 -3
- package/dist/presets.js.map +1 -1
- package/dist/rules-config.js +1 -1
- package/dist/submission.d.ts +61 -0
- package/dist/submission.d.ts.map +1 -0
- package/dist/submission.js +211 -0
- package/dist/submission.js.map +1 -0
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +3 -0
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +6 -7
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +10 -14
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/configuration.md +17 -10
- package/docs/rules/paper/folder-venue-leftover.md +104 -0
- package/docs/rules/talk/captions-cover.md +1 -1
- package/docs/rules/talk/duration-floor.md +1 -1
- package/docs/rules.md +44 -42
- package/docs/skills.md +5 -4
- package/docs/submission.md +64 -0
- package/docs/talk.md +1 -1
- package/lib/paper-config.ts +6 -1
- package/lib/skill-checks.d.mts +7 -0
- package/lib/skill-trigger-cases.mjs +11 -0
- package/package.json +2 -2
- package/presets/aidc.jsonc +6 -0
- package/presets/venue-profile.schema.json +17 -1
- package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
- package/skills/submission-portal/SKILL.md +69 -0
- package/skills/submission-portal/SKILL.md.spec.ts +71 -0
- package/skills/submission-portal/submission-portal.eval.mjs +15 -0
- package/skills/submission-portal/submission-portal.test.ts +81 -0
- package/skills/submit-paper/SKILL.md +2 -1
- package/skills/submit-paper/SKILL.md.spec.ts +1 -0
- package/skills/verify-citations/SKILL.md +8 -4
- package/skills/verify-citations/SKILL.md.spec.ts +7 -3
- package/skills/verify-citations/scripts/verify-cites.mjs +23 -5
- package/skills/verify-citations/scripts/verify-cites.test.mjs +76 -5
- package/src/adapters/hotcrp/cassette/README.md +24 -0
- package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
- package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
- package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
- package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
- package/src/adapters/hotcrp/index.ts +11 -0
- package/src/adapters/hotcrp/portal.io.ts +130 -0
- package/src/adapters/hotcrp/portal.test.ts +503 -0
- package/src/adapters/hotcrp/response.ts +181 -0
- package/src/cli.test.ts +109 -0
- package/src/cli.ts +140 -21
- package/src/domain/result.test.ts +9 -1
- package/src/domain/result.ts +5 -0
- package/src/domain/submission.test.ts +45 -0
- package/src/domain/submission.ts +141 -0
- package/src/domain/venue-name.test.ts +115 -0
- package/src/domain/venue-name.ts +129 -0
- package/src/folder-venue-rule.test.ts +182 -0
- package/src/folder-venue-rule.ts +141 -0
- package/src/init.test.ts +22 -0
- package/src/init.ts +9 -2
- package/src/new-paper.harness.mjs +11 -9
- package/src/new-paper.test.ts +129 -8
- package/src/new-paper.ts +38 -7
- package/src/paper-settings.test.ts +12 -1
- package/src/paper-settings.ts +25 -5
- package/src/parse-args.test.ts +19 -0
- package/src/ports/submission-portal.ts +26 -0
- package/src/presets.test.ts +22 -0
- package/src/presets.ts +46 -3
- package/src/rule-cases.test.ts +19 -0
- package/src/rules-config.ts +1 -1
- package/src/submission.test.ts +496 -0
- package/src/submission.ts +311 -0
- package/src/tex-requirements.ts +8 -0
- package/src/tex-venue-rules.test.ts +1 -1
- package/src/tex-venue-rules.ts +12 -25
- package/src/types.ts +13 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCRP's answers, parsed at the boundary into paperlint's submission values. Pure.
|
|
3
|
+
*
|
|
4
|
+
* Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
|
|
5
|
+
* names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
|
|
6
|
+
* `GET /api/paper` answers `{ ok, paper, message_list? }`, `POST /api/paper` answers
|
|
7
|
+
* `{ ok, valid, change_list, message_list, dry_run?, pid }`. A document's `hash` is
|
|
8
|
+
* `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { err, ok, type Result } from "../../domain/result.ts";
|
|
12
|
+
import { parseSha256, type Sha256 } from "../../domain/sha256.ts";
|
|
13
|
+
import type {
|
|
14
|
+
PortalDocument,
|
|
15
|
+
PortalFailure,
|
|
16
|
+
PortalMessage,
|
|
17
|
+
SubmissionView,
|
|
18
|
+
UpdateOutcome,
|
|
19
|
+
} from "../../domain/submission.ts";
|
|
20
|
+
|
|
21
|
+
const Message = z.object({
|
|
22
|
+
message: z.string().optional(),
|
|
23
|
+
field: z.string().optional(),
|
|
24
|
+
status: z.number().optional(),
|
|
25
|
+
});
|
|
26
|
+
const Messages = z.array(Message).optional();
|
|
27
|
+
|
|
28
|
+
const Document = z.object({
|
|
29
|
+
hash: z.string().optional(),
|
|
30
|
+
size: z.number().optional(),
|
|
31
|
+
mimetype: z.string().optional(),
|
|
32
|
+
timestamp: z.number().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const Paper = z.object({
|
|
36
|
+
pid: z.number(),
|
|
37
|
+
status: z.string(),
|
|
38
|
+
title: z.string().optional(),
|
|
39
|
+
abstract: z.string().optional(),
|
|
40
|
+
paper_type: z.unknown().optional(),
|
|
41
|
+
topics: z.array(z.string()).optional(),
|
|
42
|
+
submission: Document.nullable().optional(),
|
|
43
|
+
submitted_at: z.number().optional(),
|
|
44
|
+
modified_at: z.number().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const ShowBody = z.object({
|
|
48
|
+
ok: z.literal(true),
|
|
49
|
+
paper: Paper,
|
|
50
|
+
message_list: Messages,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const UpdateBody = z.object({
|
|
54
|
+
valid: z.boolean(),
|
|
55
|
+
change_list: z.array(z.string()).optional(),
|
|
56
|
+
message_list: Messages,
|
|
57
|
+
dry_run: z.boolean().optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const Envelope = z.object({ message_list: Messages });
|
|
61
|
+
|
|
62
|
+
const SHA2 = /^sha2-([0-9a-f]{64})$/;
|
|
63
|
+
|
|
64
|
+
/** HotCRP's `sha2-<hex>` → the sha256; null for any other hash it may name. */
|
|
65
|
+
export function sha256OfHash(hash: string): Sha256 | null {
|
|
66
|
+
const hex = SHA2.exec(hash)?.[1];
|
|
67
|
+
return hex === undefined ? null : parseSha256(hex);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest. */
|
|
71
|
+
const textOf = (message: string | undefined): string =>
|
|
72
|
+
(message ?? "").replace(/^<\d+>/, "").trim();
|
|
73
|
+
|
|
74
|
+
/** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
|
|
75
|
+
const messagesOf = (
|
|
76
|
+
list: Readonly<z.infer<typeof Messages>>,
|
|
77
|
+
): readonly PortalMessage[] =>
|
|
78
|
+
(list ?? [])
|
|
79
|
+
.map((m) => ({
|
|
80
|
+
message: textOf(m.message),
|
|
81
|
+
field: m.field ?? null,
|
|
82
|
+
status: m.status ?? null,
|
|
83
|
+
}))
|
|
84
|
+
.filter((m) => m.message !== "");
|
|
85
|
+
|
|
86
|
+
const documentOf = (
|
|
87
|
+
d: Readonly<z.infer<typeof Document>> | null | undefined,
|
|
88
|
+
): PortalDocument | null =>
|
|
89
|
+
d?.hash === undefined
|
|
90
|
+
? null
|
|
91
|
+
: {
|
|
92
|
+
sha256: sha256OfHash(d.hash),
|
|
93
|
+
hash: d.hash,
|
|
94
|
+
size: d.size ?? null,
|
|
95
|
+
mimetype: d.mimetype ?? null,
|
|
96
|
+
uploadedAt: d.timestamp ?? null,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** A paper type as text: HotCRP sends the option's name, or a value of another shape. */
|
|
100
|
+
const typeText = (v: unknown): string | null =>
|
|
101
|
+
v === undefined || v === null
|
|
102
|
+
? null
|
|
103
|
+
: typeof v === "string"
|
|
104
|
+
? v
|
|
105
|
+
: JSON.stringify(v);
|
|
106
|
+
|
|
107
|
+
function viewOf(b: Readonly<z.infer<typeof ShowBody>>): SubmissionView {
|
|
108
|
+
const p = b.paper;
|
|
109
|
+
return {
|
|
110
|
+
id: p.pid,
|
|
111
|
+
status: p.status,
|
|
112
|
+
title: p.title ?? null,
|
|
113
|
+
paperType: typeText(p.paper_type),
|
|
114
|
+
topics: p.topics ?? [],
|
|
115
|
+
abstract: p.abstract ?? null,
|
|
116
|
+
pdf: documentOf(p.submission),
|
|
117
|
+
submittedAt: p.submitted_at ?? null,
|
|
118
|
+
modifiedAt: p.modified_at ?? null,
|
|
119
|
+
messages: messagesOf(b.message_list),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const is2xx = (status: number): boolean => status >= 200 && status < 300;
|
|
124
|
+
|
|
125
|
+
const StatusCode = z.object({ status_code: z.number() });
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The status HotCRP meant. Its error envelope repeats the status in the body (`status_code: 401`
|
|
129
|
+
* beside `ok: false`, recorded in `cassette/get-noauth.json`); when something between us and HotCRP
|
|
130
|
+
* turns the transport status into a 2xx, the body's code still decides.
|
|
131
|
+
*/
|
|
132
|
+
function statusOf(transport: number, body: unknown): number {
|
|
133
|
+
const c = StatusCode.safeParse(body);
|
|
134
|
+
return is2xx(transport) && c.success ? c.data.status_code : transport;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A body that is no answer of HotCRP's: the portal's messages when it sent any, else "malformed". */
|
|
138
|
+
function refusal(
|
|
139
|
+
httpStatus: number,
|
|
140
|
+
body: unknown,
|
|
141
|
+
what: string,
|
|
142
|
+
): PortalFailure {
|
|
143
|
+
const e = Envelope.safeParse(body);
|
|
144
|
+
const messages = e.success ? messagesOf(e.data.message_list) : [];
|
|
145
|
+
return messages.length > 0 || !is2xx(httpStatus)
|
|
146
|
+
? { kind: "refused", httpStatus, messages }
|
|
147
|
+
: { kind: "malformed", httpStatus, detail: what };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `GET /api/paper` → the submission, or why there is none. */
|
|
151
|
+
export function parseShow(
|
|
152
|
+
httpStatus: number,
|
|
153
|
+
body: unknown,
|
|
154
|
+
): Result<SubmissionView, PortalFailure> {
|
|
155
|
+
const status = statusOf(httpStatus, body);
|
|
156
|
+
const b = ShowBody.safeParse(body);
|
|
157
|
+
return b.success && is2xx(status)
|
|
158
|
+
? ok(viewOf(b.data))
|
|
159
|
+
: err(refusal(status, body, "the answer has no submission in `paper`"));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `POST /api/paper` → the outcome, valid or not. A body carrying `valid` is HotCRP's verdict on
|
|
164
|
+
* the change whatever the HTTP status; the caller decides the exit from both.
|
|
165
|
+
*/
|
|
166
|
+
export function parseUpdate(
|
|
167
|
+
httpStatus: number,
|
|
168
|
+
body: unknown,
|
|
169
|
+
): Result<UpdateOutcome, PortalFailure> {
|
|
170
|
+
const status = statusOf(httpStatus, body);
|
|
171
|
+
const b = UpdateBody.safeParse(body);
|
|
172
|
+
if (!b.success)
|
|
173
|
+
return err(refusal(status, body, "the answer has no `valid`"));
|
|
174
|
+
return ok({
|
|
175
|
+
httpStatus: status,
|
|
176
|
+
valid: b.data.valid,
|
|
177
|
+
changes: b.data.change_list ?? [],
|
|
178
|
+
messages: messagesOf(b.data.message_list),
|
|
179
|
+
dryRun: b.data.dry_run ?? false,
|
|
180
|
+
});
|
|
181
|
+
}
|
package/src/cli.test.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
+
import { createServer } from "node:http";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
12
|
import { test } from "vitest";
|
|
12
13
|
import { runNode, useTempDir, writeTree } from "../test/support.ts";
|
|
@@ -82,6 +83,14 @@ test("parseSettings refuses `talk` in the root file: it is one paper's setting",
|
|
|
82
83
|
);
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
test("parseSettings refuses `submission` in the root file: a submission is one paper's", () => {
|
|
87
|
+
const r = parseSettings({ submission: { id: 7 } }, "paperlint.json", "/r");
|
|
88
|
+
assert.equal(
|
|
89
|
+
r.ok ? null : r.error,
|
|
90
|
+
"paperlint.json: \"submission\" is a paper's setting — set it in that paper's paperlint.json",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
85
94
|
test("parseSettings refuses a non-object, names one unknown key or several, and a mistyped default", () => {
|
|
86
95
|
const error = (json: unknown) => {
|
|
87
96
|
const r = parseSettings(json, "paperlint.json", "/r");
|
|
@@ -603,3 +612,103 @@ test("parseSettings refuses a root `identity` that is not a list of words, and t
|
|
|
603
612
|
true,
|
|
604
613
|
);
|
|
605
614
|
});
|
|
615
|
+
|
|
616
|
+
test("parseArgs: submission's flags — two values and two switches", () => {
|
|
617
|
+
assert.deepEqual(
|
|
618
|
+
parseArgs([
|
|
619
|
+
"submission",
|
|
620
|
+
"update",
|
|
621
|
+
"papers/p",
|
|
622
|
+
"--pdf=build/p.pdf",
|
|
623
|
+
"--abstract",
|
|
624
|
+
"abs.txt",
|
|
625
|
+
"--submit",
|
|
626
|
+
"--save",
|
|
627
|
+
]),
|
|
628
|
+
{
|
|
629
|
+
...parseArgs([]),
|
|
630
|
+
cmd: "submission",
|
|
631
|
+
paths: ["update", "papers/p"],
|
|
632
|
+
pdf: "build/p.pdf",
|
|
633
|
+
abstract: "abs.txt",
|
|
634
|
+
submit: true,
|
|
635
|
+
save: true,
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
assert.equal(
|
|
639
|
+
parseArgs(["submission", "update", "--pdf"]).missingValue,
|
|
640
|
+
"--pdf",
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
/** A paper whose venue's HotCRP portal is `site`, submission 7, with a paper.pdf. */
|
|
645
|
+
const submissionPaper = (dir: string, site: string): void => {
|
|
646
|
+
writeTree(dir, {
|
|
647
|
+
"package.json": "{}",
|
|
648
|
+
"venue.jsonc": JSON.stringify({
|
|
649
|
+
extends: "paperlint:aidc",
|
|
650
|
+
portal: { kind: "hotcrp", url: site },
|
|
651
|
+
}),
|
|
652
|
+
"papers/p/paperlint.json": JSON.stringify({
|
|
653
|
+
extends: "../../venue.jsonc",
|
|
654
|
+
submission: { id: 7 },
|
|
655
|
+
}),
|
|
656
|
+
"papers/p/paper.pdf": "%PDF fake",
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
test("🔴 submission: without HOTCRP_TOKEN it names the variable and asks the portal nothing", async () => {
|
|
661
|
+
const dir = join(root, "submission-no-token");
|
|
662
|
+
submissionPaper(dir, "http://127.0.0.1:9");
|
|
663
|
+
const saved = process.env["HOTCRP_TOKEN"];
|
|
664
|
+
delete process.env["HOTCRP_TOKEN"];
|
|
665
|
+
try {
|
|
666
|
+
const r = await cli(["submission", "show", "papers/p"], dir);
|
|
667
|
+
assert.equal(r.code, 2);
|
|
668
|
+
assert.match(r.err, /^HOTCRP_TOKEN is not set — /);
|
|
669
|
+
} finally {
|
|
670
|
+
if (saved !== undefined) process.env["HOTCRP_TOKEN"] = saved;
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("submission show: through the HotCRP adapter to a local portal, the token in the header only", async () => {
|
|
675
|
+
const seen: { url: string; auth: string }[] = [];
|
|
676
|
+
const server = createServer((req, res) => {
|
|
677
|
+
seen.push({ url: req.url ?? "", auth: req.headers.authorization ?? "" });
|
|
678
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
679
|
+
res.end(
|
|
680
|
+
JSON.stringify({
|
|
681
|
+
ok: true,
|
|
682
|
+
paper: { object: "paper", pid: 7, status: "submitted", title: "T" },
|
|
683
|
+
}),
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
687
|
+
const address = server.address();
|
|
688
|
+
assert.ok(address !== null && typeof address === "object");
|
|
689
|
+
const dir = join(root, "submission-show");
|
|
690
|
+
submissionPaper(dir, `http://127.0.0.1:${String(address.port)}`);
|
|
691
|
+
const saved = process.env["HOTCRP_TOKEN"];
|
|
692
|
+
process.env["HOTCRP_TOKEN"] = "hct_cli_test";
|
|
693
|
+
try {
|
|
694
|
+
const r = await cli(["submission", "show"], join(dir, "papers/p"));
|
|
695
|
+
assert.equal(r.code, 0, r.err);
|
|
696
|
+
assert.deepEqual(seen, [
|
|
697
|
+
{ url: "/api/paper?p=7&word_limit=hard", auth: "bearer hct_cli_test" },
|
|
698
|
+
]);
|
|
699
|
+
assert.match(
|
|
700
|
+
r.out,
|
|
701
|
+
/^submission 7 on http:\/\/127\.0\.0\.1:\d+ \(hotcrp\)$/m,
|
|
702
|
+
);
|
|
703
|
+
assert.match(r.out, /^match {9}NO — the portal holds no PDF$/m);
|
|
704
|
+
assert.doesNotMatch(r.out + r.err, /hct_cli_test/);
|
|
705
|
+
} finally {
|
|
706
|
+
if (saved === undefined) delete process.env["HOTCRP_TOKEN"];
|
|
707
|
+
else process.env["HOTCRP_TOKEN"] = saved;
|
|
708
|
+
await new Promise<void>((r) => {
|
|
709
|
+
server.close(() => {
|
|
710
|
+
r();
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -86,6 +86,10 @@ import {
|
|
|
86
86
|
} from "./paper-settings.ts";
|
|
87
87
|
import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
|
|
88
88
|
import { onlineReferences } from "./adapters/references/index.ts";
|
|
89
|
+
import { hotcrpPortal } from "./adapters/hotcrp/index.ts";
|
|
90
|
+
import { runSubmission } from "./submission.ts";
|
|
91
|
+
import type { PortalKind, SupportedPortal } from "./domain/submission.ts";
|
|
92
|
+
import type { SubmissionPortal } from "./ports/submission-portal.ts";
|
|
89
93
|
import type { CheckReferences } from "./ports/check-references.ts";
|
|
90
94
|
import {
|
|
91
95
|
narrowToOwners,
|
|
@@ -99,6 +103,8 @@ import {
|
|
|
99
103
|
presetProblemText,
|
|
100
104
|
resolvePreset,
|
|
101
105
|
shippedPresets,
|
|
106
|
+
shippedSpec,
|
|
107
|
+
shippedVenueNames,
|
|
102
108
|
SHIPPED_PREFIX,
|
|
103
109
|
type PaperPreset,
|
|
104
110
|
type PresetTemplate,
|
|
@@ -152,6 +158,10 @@ export { init };
|
|
|
152
158
|
export { nextSteps } from "./init.ts";
|
|
153
159
|
|
|
154
160
|
import paperStages from "#eslint-rules/paper-stages";
|
|
161
|
+
import {
|
|
162
|
+
FOLDER_VENUE_RULE_LEVELS,
|
|
163
|
+
folderVenueRules,
|
|
164
|
+
} from "./folder-venue-rule.ts";
|
|
155
165
|
import typography from "#eslint-rules/paper-typography";
|
|
156
166
|
import texBuild from "#eslint-rules/tex-build";
|
|
157
167
|
import bibReachable from "#eslint-rules/bib-reachable-entry";
|
|
@@ -171,7 +181,9 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
171
181
|
.claude/settings.json, offer the CI step, report what is missing
|
|
172
182
|
npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
|
|
173
183
|
create <papers>/<name>/ from the template; never overwrites,
|
|
174
|
-
on an existing folder adds only the missing files, then lints it
|
|
184
|
+
on an existing folder adds only the missing files, then lints it.
|
|
185
|
+
A <name> that names a venue (aisec-2026) is refused: name the
|
|
186
|
+
paper after the work
|
|
175
187
|
npx paperlint lint [paths…] run every rule over your papers
|
|
176
188
|
npx paperlint build <paper> | --all
|
|
177
189
|
compile paper.tex to paper.pdf: pdflatex and bibtex, rerun until
|
|
@@ -187,6 +199,14 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
187
199
|
into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
|
|
188
200
|
run does nothing. --check: report what is missing, change nothing
|
|
189
201
|
npx paperlint doctor say what is actually wired — and what only LOOKS wired
|
|
202
|
+
npx paperlint submission show [paper]
|
|
203
|
+
the paper's submission on the venue's portal: status, title,
|
|
204
|
+
type, topics, abstract length, the PDF the portal holds, and
|
|
205
|
+
whether it is the local paper.pdf (sha256)
|
|
206
|
+
npx paperlint submission update [paper] [--pdf <file>] [--abstract <file>] [--submit] [--save]
|
|
207
|
+
send the PDF (default: the paper's own), an abstract, or
|
|
208
|
+
"submitted". A DRY RUN unless --save: the portal checks the
|
|
209
|
+
change and keeps nothing
|
|
190
210
|
npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
|
|
191
211
|
npx paperlint --help
|
|
192
212
|
|
|
@@ -205,6 +225,19 @@ new:
|
|
|
205
225
|
--kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
|
|
206
226
|
preset's kinds; needs --venue
|
|
207
227
|
--format tex|md the paper's source format; default tex
|
|
228
|
+
--allow-venue-name create the folder even though its name names a venue (a whole word such as
|
|
229
|
+
"realm" can be the work's own). Without it such a name is refused: a rejected
|
|
230
|
+
paper moves to another venue and keeps its folder
|
|
231
|
+
|
|
232
|
+
submission:
|
|
233
|
+
The venue preset declares the portal — "portal": { "kind": "hotcrp", "url": "<site>" } — and the
|
|
234
|
+
paper's paperlint.json its submission there — "submission": { "id": <number> }. The token comes
|
|
235
|
+
only from the environment: HOTCRP_TOKEN (HotCRP: Account settings → Developer).
|
|
236
|
+
[paper] the paper's folder; default the current directory
|
|
237
|
+
--pdf <file> the PDF to compare (show) or send (update) instead of the paper's own
|
|
238
|
+
--abstract <file> update: a plain-text file whose text replaces the abstract
|
|
239
|
+
--submit update: mark the submission submitted
|
|
240
|
+
--save update: really change the submission. Without it, nothing is saved
|
|
208
241
|
|
|
209
242
|
lint:
|
|
210
243
|
npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
|
|
@@ -313,11 +346,22 @@ export function buildConfig(
|
|
|
313
346
|
},
|
|
314
347
|
{
|
|
315
348
|
files: ["**/PIPELINE-STATUS.md"],
|
|
316
|
-
|
|
349
|
+
// Every paper folder has one, whatever its source format: the rules about the folder itself
|
|
350
|
+
// (its stages, its frozen sources, its name) are judged here, once per paper.
|
|
351
|
+
plugins: {
|
|
352
|
+
markdown,
|
|
353
|
+
paper: {
|
|
354
|
+
rules: {
|
|
355
|
+
...paperStages.rules,
|
|
356
|
+
...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
317
360
|
...md,
|
|
318
361
|
rules: {
|
|
319
362
|
"paper/stages": "error",
|
|
320
363
|
"paper/source": "error",
|
|
364
|
+
...FOLDER_VENUE_RULE_LEVELS,
|
|
321
365
|
},
|
|
322
366
|
},
|
|
323
367
|
{
|
|
@@ -781,10 +825,15 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
781
825
|
check: false,
|
|
782
826
|
yes: false,
|
|
783
827
|
noHooks: false,
|
|
828
|
+
allowVenueName: false,
|
|
784
829
|
paper: null,
|
|
785
830
|
format: null,
|
|
786
831
|
venue: null,
|
|
787
832
|
kind: null,
|
|
833
|
+
pdf: null,
|
|
834
|
+
abstract: null,
|
|
835
|
+
submit: false,
|
|
836
|
+
save: false,
|
|
788
837
|
hooksMode: null,
|
|
789
838
|
// -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
|
|
790
839
|
// gate that fails on advice gets muted entirely.
|
|
@@ -824,19 +873,14 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
824
873
|
if (inline === "") out.missingValue = a;
|
|
825
874
|
return inline === "" ? undefined : inline;
|
|
826
875
|
};
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
else if (a === "--all") out.all = true;
|
|
830
|
-
else if (a === "--dry-run") out.dryRun = true;
|
|
831
|
-
else if (a === "--check") out.check = true;
|
|
832
|
-
else if (a === "--yes" || a === "-y") out.yes = true;
|
|
833
|
-
else if (a === "--no-hooks") out.noHooks = true;
|
|
876
|
+
const on = SWITCHES.get(a);
|
|
877
|
+
if (on !== undefined) out[on] = true;
|
|
834
878
|
else if (a.startsWith("--hooks="))
|
|
835
879
|
out.hooksMode = a.slice("--hooks=".length);
|
|
836
|
-
else if (
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
880
|
+
else if (STRING_FLAGS.has(a)) {
|
|
881
|
+
const field = STRING_FLAGS.get(a);
|
|
882
|
+
if (field !== undefined) out[field] = take() ?? null;
|
|
883
|
+
}
|
|
840
884
|
// `--options` was the first spelling and is kept working. It named the wrong thing — every
|
|
841
885
|
// other tool in the stack calls this file its config — but a flag in someone's CI is not
|
|
842
886
|
// ours to break.
|
|
@@ -852,12 +896,49 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
852
896
|
return out;
|
|
853
897
|
}
|
|
854
898
|
|
|
899
|
+
/** The flags that take no value: each turns one field of `Args` on. */
|
|
900
|
+
const SWITCHES: ReadonlyMap<
|
|
901
|
+
string,
|
|
902
|
+
| "json"
|
|
903
|
+
| "fix"
|
|
904
|
+
| "all"
|
|
905
|
+
| "dryRun"
|
|
906
|
+
| "check"
|
|
907
|
+
| "yes"
|
|
908
|
+
| "noHooks"
|
|
909
|
+
| "allowVenueName"
|
|
910
|
+
| "submit"
|
|
911
|
+
| "save"
|
|
912
|
+
> = new Map([
|
|
913
|
+
["--json", "json"],
|
|
914
|
+
["--fix", "fix"],
|
|
915
|
+
["--all", "all"],
|
|
916
|
+
["--dry-run", "dryRun"],
|
|
917
|
+
["--check", "check"],
|
|
918
|
+
["--yes", "yes"],
|
|
919
|
+
["-y", "yes"],
|
|
920
|
+
["--no-hooks", "noHooks"],
|
|
921
|
+
["--allow-venue-name", "allowVenueName"],
|
|
922
|
+
["--submit", "submit"],
|
|
923
|
+
["--save", "save"],
|
|
924
|
+
]);
|
|
925
|
+
|
|
926
|
+
/** The flags whose value is one string field of `Args`. */
|
|
927
|
+
const STRING_FLAGS: ReadonlyMap<
|
|
928
|
+
string,
|
|
929
|
+
"paper" | "format" | "venue" | "kind" | "pdf" | "abstract"
|
|
930
|
+
> = new Map([
|
|
931
|
+
["--paper", "paper"],
|
|
932
|
+
["--format", "format"],
|
|
933
|
+
["--venue", "venue"],
|
|
934
|
+
["--kind", "kind"],
|
|
935
|
+
["--pdf", "pdf"],
|
|
936
|
+
["--abstract", "abstract"],
|
|
937
|
+
]);
|
|
938
|
+
|
|
855
939
|
/** The flags that take a value, in either spelling: `--flag value` or `--flag=value`. */
|
|
856
940
|
const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
857
|
-
|
|
858
|
-
"--format",
|
|
859
|
-
"--venue",
|
|
860
|
-
"--kind",
|
|
941
|
+
...STRING_FLAGS.keys(),
|
|
861
942
|
"--config",
|
|
862
943
|
"--options",
|
|
863
944
|
"--max-warnings",
|
|
@@ -1048,15 +1129,21 @@ export async function createPaperAt(
|
|
|
1048
1129
|
err,
|
|
1049
1130
|
cwd,
|
|
1050
1131
|
venue = null,
|
|
1132
|
+
allowVenueName = false,
|
|
1051
1133
|
}: {
|
|
1052
1134
|
log: typeof console.log;
|
|
1053
1135
|
err: typeof console.error;
|
|
1054
1136
|
cwd: string;
|
|
1055
1137
|
/** What `--venue` chose; null writes the template's `paperlint.json` as it is. */
|
|
1056
1138
|
venue?: VenueChoice | null;
|
|
1139
|
+
/** `--allow-venue-name`: a name that names a venue is created, not refused. */
|
|
1140
|
+
allowVenueName?: boolean;
|
|
1057
1141
|
},
|
|
1058
1142
|
): Promise<number> {
|
|
1059
|
-
const result = newPaper(papersRoot, name, format, {
|
|
1143
|
+
const result = newPaper(papersRoot, name, format, {
|
|
1144
|
+
venue,
|
|
1145
|
+
venues: allowVenueName ? [] : shippedVenueNames(PRESET_DEPS),
|
|
1146
|
+
});
|
|
1060
1147
|
const here = (p: string): string => shown(cwd, p);
|
|
1061
1148
|
for (const line of reportNewPaper(result, here)) log(line);
|
|
1062
1149
|
if (!result.ok) return 2;
|
|
@@ -1194,7 +1281,7 @@ function venueSpec(
|
|
|
1194
1281
|
return bad(
|
|
1195
1282
|
`--venue ${venue}: no such venue preset. Shipped: ${shipped.join(", ")} — or a path to your own preset, starting with ./ or ../`,
|
|
1196
1283
|
);
|
|
1197
|
-
return { ok: true, value:
|
|
1284
|
+
return { ok: true, value: shippedSpec(name) };
|
|
1198
1285
|
}
|
|
1199
1286
|
|
|
1200
1287
|
/**
|
|
@@ -1274,6 +1361,7 @@ async function runNew(
|
|
|
1274
1361
|
err,
|
|
1275
1362
|
cwd,
|
|
1276
1363
|
venue: venue.value,
|
|
1364
|
+
allowVenueName: a.allowVenueName,
|
|
1277
1365
|
});
|
|
1278
1366
|
}
|
|
1279
1367
|
|
|
@@ -1420,7 +1508,37 @@ const SIMPLE: Readonly<
|
|
|
1420
1508
|
banal: hostBanalInstaller(),
|
|
1421
1509
|
tex: toolchainTex(cwd),
|
|
1422
1510
|
}),
|
|
1511
|
+
submission: (a, { log, err, cwd }) =>
|
|
1512
|
+
runSubmission(
|
|
1513
|
+
{
|
|
1514
|
+
sub: a.paths[0],
|
|
1515
|
+
paperDir: a.paths[1] ?? ".",
|
|
1516
|
+
extra: a.paths.slice(2),
|
|
1517
|
+
pdf: a.pdf,
|
|
1518
|
+
abstract: a.abstract,
|
|
1519
|
+
submit: a.submit,
|
|
1520
|
+
save: a.save,
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
files: nodeFiles,
|
|
1524
|
+
presets: PRESET_DEPS,
|
|
1525
|
+
cwd,
|
|
1526
|
+
env: (name) => process.env[name],
|
|
1527
|
+
portalFor,
|
|
1528
|
+
log,
|
|
1529
|
+
err,
|
|
1530
|
+
},
|
|
1531
|
+
),
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
/** The adapter of each portal kind — one per kind, chosen here at the root. */
|
|
1535
|
+
const PORTALS: Readonly<
|
|
1536
|
+
Record<PortalKind, (url: string, token: string) => SubmissionPortal>
|
|
1537
|
+
> = {
|
|
1538
|
+
hotcrp: (url, token) => hotcrpPortal({ url, token }),
|
|
1423
1539
|
};
|
|
1540
|
+
const portalFor = (p: SupportedPortal, token: string): SubmissionPortal =>
|
|
1541
|
+
PORTALS[p.kind](p.url, token);
|
|
1424
1542
|
|
|
1425
1543
|
/** banal's installer, wired from this process's environment: the composition root's work. */
|
|
1426
1544
|
function hostBanalInstaller(): ToolInstaller {
|
|
@@ -1503,6 +1621,7 @@ async function runInit(
|
|
|
1503
1621
|
format: isFormat(a.format) ? a.format : null,
|
|
1504
1622
|
createPaper: (papersRoot, name, format) =>
|
|
1505
1623
|
createPaperAt(papersRoot, name, format, { log, err, cwd }),
|
|
1624
|
+
venues: shippedVenueNames(PRESET_DEPS),
|
|
1506
1625
|
tex: initTexLive(a, { log, err, cwd }),
|
|
1507
1626
|
resolveCliPapers: (root: string): string | null =>
|
|
1508
1627
|
cliPapers({ ...a, config: null }, root),
|
|
@@ -1584,8 +1703,8 @@ export async function run(
|
|
|
1584
1703
|
// editing a file.
|
|
1585
1704
|
//
|
|
1586
1705
|
// 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
|
|
1587
|
-
// Otherwise walking up is pointless: from `papers/
|
|
1588
|
-
// `"papersDir": "papers"` would point at `papers/
|
|
1706
|
+
// Otherwise walking up is pointless: from `papers/rule-drift` the root would be found, but
|
|
1707
|
+
// `"papersDir": "papers"` would point at `papers/rule-drift/papers`. A command-line argument stays
|
|
1589
1708
|
// relative to the current directory: it was typed here and now.
|
|
1590
1709
|
//
|
|
1591
1710
|
// Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "vitest";
|
|
3
|
-
import { andThen, err, map, match, ok, type Result } from "./result.ts";
|
|
3
|
+
import { andThen, err, isOk, map, match, ok, type Result } from "./result.ts";
|
|
4
4
|
|
|
5
5
|
const half = (n: number): Result<number, string> =>
|
|
6
6
|
n % 2 === 0 ? ok(n / 2) : err(`${String(n)} is odd`);
|
|
@@ -28,3 +28,11 @@ test("match picks the branch by `ok`", () => {
|
|
|
28
28
|
assert.equal(say(half(4)), "got 2");
|
|
29
29
|
assert.equal(say(half(5)), "no: 5 is odd");
|
|
30
30
|
});
|
|
31
|
+
|
|
32
|
+
test("isOk: filter keeps the successes, and their values are read without a cast", () => {
|
|
33
|
+
const rs = [2, 3, 4].map(half);
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
rs.filter(isOk).map((r) => r.value),
|
|
36
|
+
[1, 2],
|
|
37
|
+
);
|
|
38
|
+
});
|
package/src/domain/result.ts
CHANGED
|
@@ -10,6 +10,11 @@ export type Result<T, E> =
|
|
|
10
10
|
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
|
|
11
11
|
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
|
|
12
12
|
|
|
13
|
+
/** Whether `r` succeeded — a type guard, so `filter(isOk)` keeps the successes as successes. */
|
|
14
|
+
export const isOk = <T, E>(
|
|
15
|
+
r: Result<T, E>,
|
|
16
|
+
): r is Extract<Result<T, E>, { readonly ok: true }> => r.ok;
|
|
17
|
+
|
|
13
18
|
export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
|
|
14
19
|
r.ok ? ok(f(r.value)) : r;
|
|
15
20
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The submission values: a paper's `submission` key, the portal kinds an adapter exists for, words. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { test } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
parsePaperSubmission,
|
|
6
|
+
supportedPortal,
|
|
7
|
+
wordCount,
|
|
8
|
+
} from "./submission.ts";
|
|
9
|
+
|
|
10
|
+
test("parsePaperSubmission: absent is null; { id } with a positive integer is the value", () => {
|
|
11
|
+
assert.deepEqual(parsePaperSubmission(undefined), { ok: true, value: null });
|
|
12
|
+
assert.deepEqual(parsePaperSubmission({ id: 7 }), {
|
|
13
|
+
ok: true,
|
|
14
|
+
value: { id: 7 },
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("🔴 parsePaperSubmission: a string id, zero, a fraction, a list, an unknown key — each refused", () => {
|
|
19
|
+
const shape = `"submission" must be { "id": <the submission number on the venue's portal> }`;
|
|
20
|
+
for (const bad of [{ id: "7" }, { id: 0 }, { id: 1.5 }, [7], 7, null, {}])
|
|
21
|
+
assert.deepEqual(parsePaperSubmission(bad), { ok: false, error: shape });
|
|
22
|
+
assert.deepEqual(parsePaperSubmission({ id: 7, pid: 7 }), {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: `"submission" has an unknown key "pid" — known: id`,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("supportedPortal: hotcrp is, with the trailing slash dropped; any other kind is refused by name", () => {
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
supportedPortal({ kind: "hotcrp", url: "https://example.test//" }),
|
|
31
|
+
{ ok: true, value: { kind: "hotcrp", url: "https://example.test" } },
|
|
32
|
+
);
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
supportedPortal({ kind: "openreview", url: "https://example.test" }),
|
|
35
|
+
{
|
|
36
|
+
ok: false,
|
|
37
|
+
error: `portal kind "openreview" is not supported; upload by hand`,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("wordCount: runs of non-space, any whitespace between", () => {
|
|
43
|
+
assert.equal(wordCount(""), 0);
|
|
44
|
+
assert.equal(wordCount(" one\ttwo\n\nthree "), 3);
|
|
45
|
+
});
|