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.
Files changed (134) hide show
  1. package/README.md +8 -1
  2. package/dist/adapters/hotcrp/index.d.ts +7 -0
  3. package/dist/adapters/hotcrp/index.d.ts.map +1 -0
  4. package/dist/adapters/hotcrp/index.js +7 -0
  5. package/dist/adapters/hotcrp/index.js.map +1 -0
  6. package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
  7. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/portal.io.js +90 -0
  9. package/dist/adapters/hotcrp/portal.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/response.d.ts +13 -0
  11. package/dist/adapters/hotcrp/response.d.ts.map +1 -0
  12. package/dist/adapters/hotcrp/response.js +138 -0
  13. package/dist/adapters/hotcrp/response.js.map +1 -0
  14. package/dist/cli.d.ts +3 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +109 -34
  17. package/dist/cli.js.map +1 -1
  18. package/dist/domain/result.d.ts +4 -0
  19. package/dist/domain/result.d.ts.map +1 -1
  20. package/dist/domain/result.js +2 -0
  21. package/dist/domain/result.js.map +1 -1
  22. package/dist/domain/submission.d.ts +106 -0
  23. package/dist/domain/submission.d.ts.map +1 -0
  24. package/dist/domain/submission.js +39 -0
  25. package/dist/domain/submission.js.map +1 -0
  26. package/dist/domain/venue-name.d.ts +38 -0
  27. package/dist/domain/venue-name.d.ts.map +1 -0
  28. package/dist/domain/venue-name.js +71 -0
  29. package/dist/domain/venue-name.js.map +1 -0
  30. package/dist/folder-venue-rule.d.ts +51 -0
  31. package/dist/folder-venue-rule.d.ts.map +1 -0
  32. package/dist/folder-venue-rule.js +96 -0
  33. package/dist/folder-venue-rule.js.map +1 -0
  34. package/dist/init.d.ts +6 -0
  35. package/dist/init.d.ts.map +1 -1
  36. package/dist/init.js +3 -3
  37. package/dist/init.js.map +1 -1
  38. package/dist/lib/paper-config.d.ts.map +1 -1
  39. package/dist/lib/paper-config.js +5 -1
  40. package/dist/lib/paper-config.js.map +1 -1
  41. package/dist/new-paper.d.ts +19 -5
  42. package/dist/new-paper.d.ts.map +1 -1
  43. package/dist/new-paper.js +23 -4
  44. package/dist/new-paper.js.map +1 -1
  45. package/dist/paper-settings.d.ts +3 -0
  46. package/dist/paper-settings.d.ts.map +1 -1
  47. package/dist/paper-settings.js +20 -4
  48. package/dist/paper-settings.js.map +1 -1
  49. package/dist/ports/submission-portal.d.ts +19 -0
  50. package/dist/ports/submission-portal.d.ts.map +1 -0
  51. package/dist/ports/submission-portal.js +2 -0
  52. package/dist/ports/submission-portal.js.map +1 -0
  53. package/dist/presets.d.ts +24 -0
  54. package/dist/presets.d.ts.map +1 -1
  55. package/dist/presets.js +35 -3
  56. package/dist/presets.js.map +1 -1
  57. package/dist/rules-config.js +1 -1
  58. package/dist/submission.d.ts +61 -0
  59. package/dist/submission.d.ts.map +1 -0
  60. package/dist/submission.js +211 -0
  61. package/dist/submission.js.map +1 -0
  62. package/dist/tex-requirements.d.ts +3 -0
  63. package/dist/tex-requirements.d.ts.map +1 -1
  64. package/dist/tex-requirements.js +3 -0
  65. package/dist/tex-requirements.js.map +1 -1
  66. package/dist/tex-venue-rules.d.ts +6 -7
  67. package/dist/tex-venue-rules.d.ts.map +1 -1
  68. package/dist/tex-venue-rules.js +10 -14
  69. package/dist/tex-venue-rules.js.map +1 -1
  70. package/dist/types.d.ts +13 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/docs/configuration.md +17 -10
  73. package/docs/rules/paper/folder-venue-leftover.md +104 -0
  74. package/docs/rules/talk/captions-cover.md +1 -1
  75. package/docs/rules/talk/duration-floor.md +1 -1
  76. package/docs/rules.md +44 -42
  77. package/docs/skills.md +5 -4
  78. package/docs/submission.md +64 -0
  79. package/docs/talk.md +1 -1
  80. package/lib/paper-config.ts +6 -1
  81. package/lib/skill-checks.d.mts +7 -0
  82. package/lib/skill-trigger-cases.mjs +11 -0
  83. package/package.json +2 -2
  84. package/presets/aidc.jsonc +6 -0
  85. package/presets/venue-profile.schema.json +17 -1
  86. package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
  87. package/skills/submission-portal/SKILL.md +69 -0
  88. package/skills/submission-portal/SKILL.md.spec.ts +71 -0
  89. package/skills/submission-portal/submission-portal.eval.mjs +15 -0
  90. package/skills/submission-portal/submission-portal.test.ts +81 -0
  91. package/skills/submit-paper/SKILL.md +2 -1
  92. package/skills/submit-paper/SKILL.md.spec.ts +1 -0
  93. package/skills/verify-citations/SKILL.md +8 -4
  94. package/skills/verify-citations/SKILL.md.spec.ts +7 -3
  95. package/skills/verify-citations/scripts/verify-cites.mjs +23 -5
  96. package/skills/verify-citations/scripts/verify-cites.test.mjs +76 -5
  97. package/src/adapters/hotcrp/cassette/README.md +24 -0
  98. package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
  99. package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
  100. package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
  101. package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
  102. package/src/adapters/hotcrp/index.ts +11 -0
  103. package/src/adapters/hotcrp/portal.io.ts +130 -0
  104. package/src/adapters/hotcrp/portal.test.ts +503 -0
  105. package/src/adapters/hotcrp/response.ts +181 -0
  106. package/src/cli.test.ts +109 -0
  107. package/src/cli.ts +140 -21
  108. package/src/domain/result.test.ts +9 -1
  109. package/src/domain/result.ts +5 -0
  110. package/src/domain/submission.test.ts +45 -0
  111. package/src/domain/submission.ts +141 -0
  112. package/src/domain/venue-name.test.ts +115 -0
  113. package/src/domain/venue-name.ts +129 -0
  114. package/src/folder-venue-rule.test.ts +182 -0
  115. package/src/folder-venue-rule.ts +141 -0
  116. package/src/init.test.ts +22 -0
  117. package/src/init.ts +9 -2
  118. package/src/new-paper.harness.mjs +11 -9
  119. package/src/new-paper.test.ts +129 -8
  120. package/src/new-paper.ts +38 -7
  121. package/src/paper-settings.test.ts +12 -1
  122. package/src/paper-settings.ts +25 -5
  123. package/src/parse-args.test.ts +19 -0
  124. package/src/ports/submission-portal.ts +26 -0
  125. package/src/presets.test.ts +22 -0
  126. package/src/presets.ts +46 -3
  127. package/src/rule-cases.test.ts +19 -0
  128. package/src/rules-config.ts +1 -1
  129. package/src/submission.test.ts +496 -0
  130. package/src/submission.ts +311 -0
  131. package/src/tex-requirements.ts +8 -0
  132. package/src/tex-venue-rules.test.ts +1 -1
  133. package/src/tex-venue-rules.ts +12 -25
  134. package/src/types.ts +13 -0
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: submission-portal
3
+ description: "Read or change a paper's submission on the venue's HotCRP portal via `paperlint submission show|update`: replace the PDF or abstract, mark it submitted, check it is really \"Submitted\", and verify by sha256 that the portal holds the latest build. Use when the PDF or abstract on HotCRP needs replacing, when asked whether the paper is truly submitted or which PDF reviewers see, or before a deadline. Composes with submit-paper (what to submit); this gets it onto the portal and proves it landed."
4
+ allowed-tools: [Read, Grep, Glob, Bash]
5
+ ---
6
+
7
+ <!-- vigiles:sha256:deb3d74f5c3f93a8 compiled from skills/submission-portal/SKILL.md.spec.ts -->
8
+
9
+ # submission-portal — read and update the submission on the portal, then prove it landed
10
+
11
+ `paperlint submission` talks to the venue's submission portal through its API. Only HotCRP is
12
+ supported; for any other portal the command says so and the upload is done by hand.
13
+
14
+ ## What must be in place
15
+
16
+ - **The venue preset declares the portal**: `"portal": { "kind": "hotcrp", "url": "<the site>" }`
17
+ (`paperlint:aidc` has it). For your own venue preset, add it there.
18
+ - **The paper's `paperlint.json` names its submission**: `"submission": { "id": <number> }` — the
19
+ number HotCRP shows for the paper.
20
+ - **A token in `HOTCRP_TOKEN`**, created by the author on the HotCRP site under Account settings →
21
+ Developer (scopes `submeta:read` and `document:read` to read; `submeta:write` and
22
+ `document:write` to update). Never ask for it in chat, never print it, never write it to a file:
23
+ the command reads it from the environment and sends it only as the `Authorization` header.
24
+
25
+ A missing key or token stops the command with exit 2 and a line naming the file and key, or the
26
+ variable — fix exactly that and run again.
27
+
28
+ ## The procedure — always in this order
29
+
30
+ 1. **Read it.** `npx paperlint submission show papers/<paper>` — status, title, type, topics,
31
+ abstract length, the PDF the portal holds, and `match`: whether that PDF's sha256 equals the
32
+ local `paper.pdf`. Rebuild first (`npx paperlint build papers/<paper>`) if the local PDF is stale.
33
+ 2. **Dry run.** `npx paperlint submission update papers/<paper>` — sends the PDF (add
34
+ `--abstract <file>` for a plain-text abstract, `--submit` to mark it submitted) with HotCRP's
35
+ `dry_run=1`: the portal checks the change and **keeps nothing**. Read `valid`, `changes` and every
36
+ `portal says:` line. Exit 1 means the portal would refuse it — report why, do not go on.
37
+ 3. **Ask the author.** Show the dry run's output and ask for an explicit yes to change the real
38
+ submission.
39
+ 4. **Save — only after that yes.** `npx paperlint submission update papers/<paper> --save` (same
40
+ flags as the dry run). 🔴 `--save` CHANGES A REAL SUBMISSION that reviewers will see. Never run it
41
+ without the author's explicit yes in this conversation, never "to test", and never because a
42
+ dry run was valid.
43
+ 5. **Prove it landed.** `npx paperlint submission show papers/<paper>` again: `match` must read
44
+ `yes — the portal holds this build`, and the status must be what was intended (`submitted` if
45
+ `--submit` was sent). Anything else is not done — say so.
46
+
47
+ The same five steps as commands, in order (`<paper>` is the paper's folder):
48
+
49
+ ```bash
50
+ npx paperlint submission show papers/<paper>
51
+ npx paperlint submission update papers/<paper>
52
+ # … the author reads the dry run and says yes …
53
+ npx paperlint submission update papers/<paper> --save
54
+ npx paperlint submission show papers/<paper>
55
+ ```
56
+
57
+ ## Reading the answers
58
+
59
+ - `match yes` is the only proof the reviewers get the local build; a status of `submitted` alone
60
+ says nothing about WHICH PDF.
61
+ - `the portal refused (HTTP 401): …` — the token is missing, wrong or expired; `403` — the token
62
+ lacks the scope, or the deadline has passed for this change. The text after the colon is
63
+ HotCRP's own; quote it.
64
+ - `SAVE WITHHELD by the portal` — HotCRP did not save; nothing changed.
65
+
66
+ ## What this skill does not do
67
+
68
+ It does not decide whether the paper is ready (that is `harden-paper`), anonymize it, or fill the
69
+ submission form's other fields (authors, conflicts) — see `submit-paper`.
@@ -0,0 +1,71 @@
1
+ // Compiled to SKILL.md by `vigiles compile`. Edit THIS file, never the markdown.
2
+ import { experimental_skill } from "vigiles/spec";
3
+
4
+ export default experimental_skill({
5
+ name: "submission-portal",
6
+ description:
7
+ "Read or change a paper's submission on the venue's HotCRP portal via `paperlint submission show|update`: replace the PDF or abstract, mark it submitted, check it is really \"Submitted\", and verify by sha256 that the portal holds the latest build. Use when the PDF or abstract on HotCRP needs replacing, when asked whether the paper is truly submitted or which PDF reviewers see, or before a deadline. Composes with submit-paper (what to submit); this gets it onto the portal and proves it landed.",
8
+ tools: ["Read", "Grep", "Glob", "Bash"],
9
+ body: `
10
+ # submission-portal — read and update the submission on the portal, then prove it landed
11
+
12
+ \`paperlint submission\` talks to the venue's submission portal through its API. Only HotCRP is
13
+ supported; for any other portal the command says so and the upload is done by hand.
14
+
15
+ ## What must be in place
16
+
17
+ - **The venue preset declares the portal**: \`"portal": { "kind": "hotcrp", "url": "<the site>" }\`
18
+ (\`paperlint:aidc\` has it). For your own venue preset, add it there.
19
+ - **The paper's \`paperlint.json\` names its submission**: \`"submission": { "id": <number> }\` — the
20
+ number HotCRP shows for the paper.
21
+ - **A token in \`HOTCRP_TOKEN\`**, created by the author on the HotCRP site under Account settings →
22
+ Developer (scopes \`submeta:read\` and \`document:read\` to read; \`submeta:write\` and
23
+ \`document:write\` to update). Never ask for it in chat, never print it, never write it to a file:
24
+ the command reads it from the environment and sends it only as the \`Authorization\` header.
25
+
26
+ A missing key or token stops the command with exit 2 and a line naming the file and key, or the
27
+ variable — fix exactly that and run again.
28
+
29
+ ## The procedure — always in this order
30
+
31
+ 1. **Read it.** \`npx paperlint submission show papers/<paper>\` — status, title, type, topics,
32
+ abstract length, the PDF the portal holds, and \`match\`: whether that PDF's sha256 equals the
33
+ local \`paper.pdf\`. Rebuild first (\`npx paperlint build papers/<paper>\`) if the local PDF is stale.
34
+ 2. **Dry run.** \`npx paperlint submission update papers/<paper>\` — sends the PDF (add
35
+ \`--abstract <file>\` for a plain-text abstract, \`--submit\` to mark it submitted) with HotCRP's
36
+ \`dry_run=1\`: the portal checks the change and **keeps nothing**. Read \`valid\`, \`changes\` and every
37
+ \`portal says:\` line. Exit 1 means the portal would refuse it — report why, do not go on.
38
+ 3. **Ask the author.** Show the dry run's output and ask for an explicit yes to change the real
39
+ submission.
40
+ 4. **Save — only after that yes.** \`npx paperlint submission update papers/<paper> --save\` (same
41
+ flags as the dry run). 🔴 \`--save\` CHANGES A REAL SUBMISSION that reviewers will see. Never run it
42
+ without the author's explicit yes in this conversation, never "to test", and never because a
43
+ dry run was valid.
44
+ 5. **Prove it landed.** \`npx paperlint submission show papers/<paper>\` again: \`match\` must read
45
+ \`yes — the portal holds this build\`, and the status must be what was intended (\`submitted\` if
46
+ \`--submit\` was sent). Anything else is not done — say so.
47
+
48
+ The same five steps as commands, in order (\`<paper>\` is the paper's folder):
49
+
50
+ \`\`\`bash
51
+ npx paperlint submission show papers/<paper>
52
+ npx paperlint submission update papers/<paper>
53
+ # … the author reads the dry run and says yes …
54
+ npx paperlint submission update papers/<paper> --save
55
+ npx paperlint submission show papers/<paper>
56
+ \`\`\`
57
+
58
+ ## Reading the answers
59
+
60
+ - \`match yes\` is the only proof the reviewers get the local build; a status of \`submitted\` alone
61
+ says nothing about WHICH PDF.
62
+ - \`the portal refused (HTTP 401): …\` — the token is missing, wrong or expired; \`403\` — the token
63
+ lacks the scope, or the deadline has passed for this change. The text after the colon is
64
+ HotCRP's own; quote it.
65
+ - \`SAVE WITHHELD by the portal\` — HotCRP did not save; nothing changed.
66
+
67
+ ## What this skill does not do
68
+
69
+ It does not decide whether the paper is ready (that is \`harden-paper\`), anonymize it, or fill the
70
+ submission form's other fields (authors, conflicts) — see \`submit-paper\`.`,
71
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * submission-portal — the PAID tier: does this skill's description actually fire?
3
+ *
4
+ * The prompts live in `lib/skill-trigger-cases.mjs` beside every other skill's, so collisions with
5
+ * `submit-paper` and `osf-artifact-upload` are visible in one table. Measures recall on its own
6
+ * territory and precision against those siblings, against the real harness.
7
+ *
8
+ * ⚠️ NEVER RUN. Written 2026-10-03 with the skill; its rate is UNKNOWN, not assumed good.
9
+ *
10
+ * Costs money; not CI.
11
+ * node skills/submission-portal/submission-portal.eval.mjs [trials]
12
+ */
13
+ import { runSkillTriggerEval } from "../../lib/skill-eval-kit.mjs";
14
+
15
+ await runSkillTriggerEval("submission-portal");
@@ -0,0 +1,81 @@
1
+ /**
2
+ * submission-portal — the free tier: the skill's commands are commands the CLI accepts, in the
3
+ * order the skill promises, and the skill passes the shared per-skill checks. No model, no network,
4
+ * no portal.
5
+ *
6
+ * 🔴 WHY THE COMMANDS ARE PARSED, NOT GREPPED: a skill that tells the agent to run a flag the CLI
7
+ * refuses fails at the worst moment — before a deadline, on a real submission. So every
8
+ * `paperlint submission …` the skill writes goes through the CLI's own argument parser and its
9
+ * usage check, and must come out runnable.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { test } from "vitest";
16
+ import { commandsIn } from "vigiles";
17
+ import { checkSkill } from "../../lib/skill-checks.mjs";
18
+ import { parseArgs } from "../../src/cli.ts";
19
+ import { usageProblem } from "../../src/submission.ts";
20
+
21
+ const SKILL = readFileSync(
22
+ join(dirname(fileURLToPath(import.meta.url)), "SKILL.md"),
23
+ "utf8",
24
+ );
25
+ const commands = commandsIn(
26
+ SKILL,
27
+ /(^|[|;&]\s*)npx paperlint submission\b/,
28
+ ).map((c) => c.text);
29
+
30
+ /** A command as the CLI receives it: the words after `npx paperlint`. */
31
+ const argvOf = (command: string): string[] =>
32
+ command
33
+ .replace(/^.*?npx paperlint /, "")
34
+ .split(/\s+/)
35
+ .filter(Boolean);
36
+
37
+ test("the skill names show, a dry run and a save — and nothing else", () => {
38
+ const kinds = commands.map((c) => {
39
+ const a = parseArgs(argvOf(c));
40
+ return `${String(a.paths[0])}${a.save ? " --save" : ""}`;
41
+ });
42
+ assert.deepEqual([...new Set(kinds)], ["show", "update", "update --save"]);
43
+ });
44
+
45
+ test("every `paperlint submission` command in the skill is one the CLI runs", () => {
46
+ assert.ok(
47
+ commands.length > 0,
48
+ "the skill names no `paperlint submission` command",
49
+ );
50
+ for (const c of commands) {
51
+ const a = parseArgs(argvOf(c));
52
+ assert.equal(a.cmd, "submission", c);
53
+ assert.equal(a.unknownFlag, undefined, c);
54
+ assert.equal(a.missingValue, undefined, c);
55
+ const problem = usageProblem({
56
+ sub: a.paths[0],
57
+ paperDir: a.paths[1] ?? ".",
58
+ extra: a.paths.slice(2),
59
+ pdf: a.pdf,
60
+ abstract: a.abstract,
61
+ submit: a.submit,
62
+ save: a.save,
63
+ });
64
+ assert.equal(problem, null, c);
65
+ }
66
+ });
67
+
68
+ test("🔴 the procedure reads, dry-runs, then saves, then reads again — never saves first", () => {
69
+ const order = commands.map((c) => {
70
+ const a = parseArgs(argvOf(c));
71
+ return a.paths[0] === "show" ? "show" : a.save ? "save" : "dry";
72
+ });
73
+ const firstSave = order.indexOf("save");
74
+ assert.ok(firstSave > order.indexOf("dry"), order.join(" → "));
75
+ assert.ok(order.indexOf("dry") > order.indexOf("show"), order.join(" → "));
76
+ assert.ok(order.lastIndexOf("show") > firstSave, order.join(" → "));
77
+ });
78
+
79
+ test("the shared per-skill checks pass", async () => {
80
+ await checkSkill("submission-portal");
81
+ });
@@ -4,7 +4,7 @@ description: End-to-end playbook for submitting a peer-reviewed paper to a doubl
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:9e57bcb2ba1fb055 compiled from skills/submit-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:767a4757d1df67d3 compiled from skills/submit-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # submit-paper — get a reviewed paper from "done" to "ready for review"
10
10
 
@@ -110,6 +110,7 @@ Many workshop HotCRPs have **no supplementary-upload field** — check first; if
110
110
  is the step people miss.
111
111
  - You can edit until the deadline: use **Replace** on the Submission field to swap the PDF for a late
112
112
  fix (e.g. adding the artifact link after you host it). Submit early, upgrade before the deadline.
113
+ - When the venue preset declares its HotCRP portal, replacing the PDF or abstract and proving the portal holds the latest build is `submission-portal` (`paperlint submission show|update`).
113
114
 
114
115
  ## 6. Independent-researcher / dossier notes
115
116
  - **Name consistency** is the quiet risk: publications, LinkedIn, passport, letters must resolve to one
@@ -126,6 +126,7 @@ Many workshop HotCRPs have **no supplementary-upload field** — check first; if
126
126
  is the step people miss.
127
127
  - You can edit until the deadline: use **Replace** on the Submission field to swap the PDF for a late
128
128
  fix (e.g. adding the artifact link after you host it). Submit early, upgrade before the deadline.
129
+ - When the venue preset declares its HotCRP portal, replacing the PDF or abstract and proving the portal holds the latest build is \`submission-portal\` (\`paperlint submission show|update\`).
129
130
 
130
131
  ## 6. Independent-researcher / dossier notes
131
132
  - **Name consistency** is the quiet risk: publications, LinkedIn, passport, letters must resolve to one
@@ -4,7 +4,7 @@ description: Verify every citation is a real work with correct metadata, and tha
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:fe3634eb63db5c0d compiled from skills/verify-citations/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:faf8d55c8ddb82ed compiled from skills/verify-citations/SKILL.md.spec.ts -->
8
8
 
9
9
  # verify-citations — every cite real, the delta explicit
10
10
 
@@ -102,7 +102,7 @@ not-found, not mistaken for an unrelated paper.
102
102
  | verdict | when | meaning |
103
103
  |---|---|---|
104
104
  | `true` | any resolver **matched** (match always wins) | found, metadata matches |
105
- | `false` | no match **and** an *authoritative* disproof: doi.org says the DOI is `responseCode 100`, the reachable arXiv API definitively rejects the id, NVD has no such CVE, **or** a DOI/arXiv id resolves to a *confidently-different* paper (`DOI_MISMATCH`) | fabrication — the only thing we call fabrication |
105
+ | `false` | no match **and** an *authoritative* disproof: doi.org says the DOI is `responseCode 100`, the reachable arXiv API definitively rejects the id, NVD has no such CVE, **or** the id's own registry (arXiv for an arXiv id or arXiv DOI, Crossref for any other DOI) puts it on a *confidently-different* paper (`DOI_MISMATCH`) | fabrication — the only thing we call fabrication |
106
106
  | `unresolvable` | title-misses, registry-404s with no authority disproof, uncomparable titles, and/or unreachable resolvers | couldn't verify, but **no positive disproof** |
107
107
 
108
108
  The whole point: **a title you simply can't find is NOT fabrication** → `unresolvable`, never `false`. A
@@ -217,13 +217,17 @@ by design, the gate **declines** in three situations rather than risk a false ac
217
217
  emit `DOI_MISMATCH` — the id counts as resolved (`true`) or, absent other confirmation, `unresolvable`.
218
218
  Never `false`. The `doi_mismatch → false` verdict fires **only** for two confidently-comparable titles that
219
219
  are genuinely different.
220
+ - **An aggregator (OpenAlex, Semantic Scholar) that alone puts the id on another paper** →
221
+ `unresolvable` with `DOI_MISMATCH unconfirmed`. Aggregators copy metadata and do attach DOIs to the
222
+ wrong work; on 2026-10-01 OpenAlex mapped the arXiv DOIs of SWE-bench and ReAct to unrelated papers while
223
+ arXiv was rate-limiting. Only the id's own registry can disprove it.
220
224
  - **A DOI that resolves at doi.org but has no content-registry metadata** (DataCite/Zenodo, fresh 2026 DOIs)
221
225
  → `unresolvable`, because the work exists but its metadata (title/year) cannot be cross-checked here. This
222
226
  is the correct verdict for a paper's own artifact DOI; treat `unresolvable` as advisory, not a failure.
223
227
 
224
228
  **Known narrow residue (rare, treat a lone `DOI_MISMATCH → false` as advisory, eyeball it).** Two edge cases
225
- can still mis-fire: (a) a correct DOI whose *every* registry records junk metadata — e.g. the container /
226
- proceedings title instead of the paper's — reads as "unrelated" (mitigated when any one registry has the real
229
+ can still mis-fire: (a) a correct DOI whose *own* registry records junk metadata — e.g. the container /
230
+ proceedings title instead of the paper's — reads as "unrelated" (mitigated when any other registry has the real
227
231
  title, since a match wins); (b) a translated-journal title in the *same script* (transliterated Latin vs
228
232
  English) that shares no tokens. Both need semantics the gate doesn't have; both are near-zero frequency in an
229
233
  English CS bib. Identifier-prefix / trailing-punctuation mangling (`doi:`, `arXiv:`, a bib-swallowed `.`) is
@@ -118,7 +118,7 @@ not-found, not mistaken for an unrelated paper.
118
118
  | verdict | when | meaning |
119
119
  |---|---|---|
120
120
  | \`true\` | any resolver **matched** (match always wins) | found, metadata matches |
121
- | \`false\` | no match **and** an *authoritative* disproof: doi.org says the DOI is \`responseCode 100\`, the reachable arXiv API definitively rejects the id, NVD has no such CVE, **or** a DOI/arXiv id resolves to a *confidently-different* paper (\`DOI_MISMATCH\`) | fabrication — the only thing we call fabrication |
121
+ | \`false\` | no match **and** an *authoritative* disproof: doi.org says the DOI is \`responseCode 100\`, the reachable arXiv API definitively rejects the id, NVD has no such CVE, **or** the id's own registry (arXiv for an arXiv id or arXiv DOI, Crossref for any other DOI) puts it on a *confidently-different* paper (\`DOI_MISMATCH\`) | fabrication — the only thing we call fabrication |
122
122
  | \`unresolvable\` | title-misses, registry-404s with no authority disproof, uncomparable titles, and/or unreachable resolvers | couldn't verify, but **no positive disproof** |
123
123
 
124
124
  The whole point: **a title you simply can't find is NOT fabrication** → \`unresolvable\`, never \`false\`. A
@@ -233,13 +233,17 @@ by design, the gate **declines** in three situations rather than risk a false ac
233
233
  emit \`DOI_MISMATCH\` — the id counts as resolved (\`true\`) or, absent other confirmation, \`unresolvable\`.
234
234
  Never \`false\`. The \`doi_mismatch → false\` verdict fires **only** for two confidently-comparable titles that
235
235
  are genuinely different.
236
+ - **An aggregator (OpenAlex, Semantic Scholar) that alone puts the id on another paper** →
237
+ \`unresolvable\` with \`DOI_MISMATCH unconfirmed\`. Aggregators copy metadata and do attach DOIs to the
238
+ wrong work; on 2026-10-01 OpenAlex mapped the arXiv DOIs of SWE-bench and ReAct to unrelated papers while
239
+ arXiv was rate-limiting. Only the id's own registry can disprove it.
236
240
  - **A DOI that resolves at doi.org but has no content-registry metadata** (DataCite/Zenodo, fresh 2026 DOIs)
237
241
  → \`unresolvable\`, because the work exists but its metadata (title/year) cannot be cross-checked here. This
238
242
  is the correct verdict for a paper's own artifact DOI; treat \`unresolvable\` as advisory, not a failure.
239
243
 
240
244
  **Known narrow residue (rare, treat a lone \`DOI_MISMATCH → false\` as advisory, eyeball it).** Two edge cases
241
- can still mis-fire: (a) a correct DOI whose *every* registry records junk metadata — e.g. the container /
242
- proceedings title instead of the paper's — reads as "unrelated" (mitigated when any one registry has the real
245
+ can still mis-fire: (a) a correct DOI whose *own* registry records junk metadata — e.g. the container /
246
+ proceedings title instead of the paper's — reads as "unrelated" (mitigated when any other registry has the real
243
247
  title, since a match wins); (b) a translated-journal title in the *same script* (transliterated Latin vs
244
248
  English) that shares no tokens. Both need semantics the gate doesn't have; both are near-zero frequency in an
245
249
  English CS bib. Identifier-prefix / trailing-punctuation mangling (\`doi:\`, \`arXiv:\`, a bib-swallowed \`.\`) is
@@ -159,6 +159,15 @@ export function normalizeArxiv(arxiv) {
159
159
  /** arXiv registers a DataCite DOI for every paper: `10.48550/arXiv.<id>`. */
160
160
  const ARXIV_DOI = /^10\.48550\/arxiv\.(.+)$/i;
161
161
 
162
+ /** The one source whose record of an identifier is the identifier's own metadata: arXiv for an
163
+ * arXiv id or an arXiv DOI, Crossref for any other DOI. OpenAlex and Semantic Scholar are
164
+ * aggregators: they copy that metadata and can attach a DOI to the wrong work (OpenAlex did, for
165
+ * SWE-bench and ReAct, on 2026-10-01), so a title they disagree on is not proof of anything. */
166
+ function identifierAuthority(citation, query) {
167
+ if (query === "arxiv") return "arxiv";
168
+ return citation.doi && ARXIV_DOI.test(citation.doi) ? "arxiv" : "crossref";
169
+ }
170
+
162
171
  /** Shallow copy of a citation with doi/arxiv identifiers normalized. Applied at the
163
172
  * verify boundary so BOTH JSON input and .bib-parsed input are clean before any
164
173
  * network / authority lookup.
@@ -367,6 +376,7 @@ export function classifyResolver(citation, response) {
367
376
  db,
368
377
  status: "doi_mismatch",
369
378
  foundTitle: response.record.title,
379
+ authoritative: identifierAuthority(citation, response.query) === db,
370
380
  };
371
381
  }
372
382
  }
@@ -473,8 +483,9 @@ export function checkCommit(citation) {
473
483
  * commit flags, produce the final verdict.
474
484
  *
475
485
  * true iff ANY evidence is `matched` (a match always WINS)
476
- * false iff NOT matched AND ≥1 evidence is `id_unmatched` or `doi_mismatch`
477
- * (a resolvable id that fails, or a DOI on the wrong paper)
486
+ * false iff NOT matched AND ≥1 evidence is an authoritative `id_unmatched` or
487
+ * `doi_mismatch` (a resolvable id that fails, or an id that its own registry
488
+ * puts on the wrong paper)
478
489
  * unresolvable otherwise (only `title_miss` and/or `unreachable` — no disproof)
479
490
  */
480
491
  export function reduceVerdict(citation, evidence, commitFlags = []) {
@@ -500,8 +511,13 @@ export function reduceVerdict(citation, evidence, commitFlags = []) {
500
511
  );
501
512
  }
502
513
 
503
- // A DOI/arXiv id that resolves to a CONFIDENTLY-DIFFERENT paper → misdirection.
504
- const mismatch = ev.find((e) => e.status === "doi_mismatch");
514
+ // A DOI/arXiv id that resolves to a CONFIDENTLY-DIFFERENT paper AT ITS AUTHORITY → misdirection.
515
+ const mismatch = ev.find(
516
+ (e) => e.status === "doi_mismatch" && e.authoritative,
517
+ );
518
+ const aggregatorMismatch = ev.find(
519
+ (e) => e.status === "doi_mismatch" && !e.authoritative,
520
+ );
505
521
  if (mismatch) {
506
522
  return out(
507
523
  "false",
@@ -553,7 +569,9 @@ export function reduceVerdict(citation, evidence, commitFlags = []) {
553
569
  // ── No positive disproof → unresolvable, with the most informative reason ──
554
570
  const reachable = ev.some((e) => e.status !== "unreachable");
555
571
  let reason;
556
- if (doiIdMiss && authorityPresent) {
572
+ if (aggregatorMismatch) {
573
+ reason = `DOI_MISMATCH unconfirmed: ${aggregatorMismatch.db} maps the identifier to "${aggregatorMismatch.foundTitle}" (claimed "${citation.title}"), but ${aggregatorMismatch.db} only aggregates metadata and the identifier's own registry did not confirm it — eyeball the entry → unresolvable, not false`;
574
+ } else if (doiIdMiss && authorityPresent) {
557
575
  reason = `DOI ${citation.doi} resolves at doi.org but no content registry (Crossref/OpenAlex/Semantic Scholar) has metadata for it — common for DataCite/Zenodo and freshly-minted DOIs; the work EXISTS, its metadata is just unindexed → unresolvable, NOT fabrication`;
558
576
  } else if (doiIdMiss) {
559
577
  reason = `DOI ${citation.doi} not found in any content registry, and the DOI authority (doi.org) was not reachable to confirm non-existence — cannot assert fabrication → unresolvable, never false`;
@@ -104,17 +104,88 @@ console.log(
104
104
  title: "A Formal Verification Framework for Autonomous Agents",
105
105
  year: 2021,
106
106
  };
107
- const openalex = {
108
- db: "openalex",
107
+ const wrongRecord = {
108
+ title: "Photosynthesis in Deep-Sea Bacteria",
109
+ year: 2009,
110
+ };
111
+ const crossref = {
112
+ db: "crossref",
109
113
  transport: "ok",
110
114
  query: "doi",
111
- record: { title: "Photosynthesis in Deep-Sea Bacteria", year: 2009 },
115
+ record: wrongRecord,
112
116
  };
113
- const ev = classifyResolver(cite, openalex);
117
+ const ev = classifyResolver(cite, crossref);
114
118
  eq(ev.status, "doi_mismatch", "classify → doi_mismatch");
115
119
  const v = reduceVerdict(cite, [ev], checkCommit(cite));
116
- eq(v.verdict, "false", "verdict false");
120
+ eq(v.verdict, "false", "the DOI's own registry (Crossref) disagrees → false");
117
121
  ok(/DOI_MISMATCH/.test(v.reason), "reason names DOI_MISMATCH");
122
+
123
+ // An aggregator alone is not proof: it can attach a DOI to the wrong work.
124
+ const openalex = {
125
+ db: "openalex",
126
+ transport: "ok",
127
+ query: "doi",
128
+ record: wrongRecord,
129
+ };
130
+ const agg = reduceVerdict(cite, [classifyResolver(cite, openalex)]);
131
+ eq(
132
+ agg.verdict,
133
+ "unresolvable",
134
+ "only OpenAlex disagrees → unresolvable, not false",
135
+ );
136
+ ok(
137
+ /DOI_MISMATCH unconfirmed/.test(agg.reason),
138
+ "reason says the mismatch is unconfirmed",
139
+ );
140
+ }
141
+
142
+ // ── (2b) 2026-10-01: OpenAlex mapped the SWE-bench arXiv DOI to another paper while
143
+ // arXiv answered 429. The entry is correct; it must not be called fabricated.
144
+ console.log(
145
+ "\n(2b) aggregator mismatch on an arXiv DOI, arXiv rate-limited → unresolvable:",
146
+ );
147
+ {
148
+ const cite = normalizeIdentifiers({
149
+ id: "jimenez2024swebench",
150
+ doi: "10.48550/arXiv.2310.06770",
151
+ title: "SWE-bench: Can Language Models Resolve Real-World GitHub Issues?",
152
+ year: 2024,
153
+ });
154
+ const openalex = {
155
+ db: "openalex",
156
+ transport: "ok",
157
+ query: "doi",
158
+ record: {
159
+ title: "GardenBench: A lightweight, daily evaluation of LLM capabilities",
160
+ year: 2025,
161
+ },
162
+ };
163
+ const arxiv = {
164
+ db: "arxiv",
165
+ transport: "error",
166
+ query: "arxiv",
167
+ record: null,
168
+ };
169
+ const s2 = {
170
+ db: "semantic_scholar",
171
+ transport: "error",
172
+ query: "doi",
173
+ record: null,
174
+ };
175
+ const v = reduceVerdict(
176
+ cite,
177
+ [openalex, arxiv, s2].map((r) => classifyResolver(cite, r)),
178
+ );
179
+ eq(
180
+ v.verdict,
181
+ "unresolvable",
182
+ "correct entry with a corrupted aggregator record → unresolvable",
183
+ );
184
+
185
+ // …and arXiv itself putting the id on another paper is still misdirection.
186
+ const arxivWrong = { ...arxiv, transport: "ok", record: openalex.record };
187
+ const w = reduceVerdict(cite, [classifyResolver(cite, arxivWrong)]);
188
+ eq(w.verdict, "false", "arXiv (the id's registry) disagrees → false");
118
189
  }
119
190
 
120
191
  // ── (3) made-up arXiv id that resolves to nothing → false ────────────────────
@@ -0,0 +1,24 @@
1
+ # HotCRP cassette
2
+
3
+ Real answers to the requests `paperlint submission` sends, recorded from a live HotCRP instance
4
+ through HotCRP's documented API (`/api/paper`). `portal.test.ts` replays them from its
5
+ local mock server and parses each one through the adapter's real zod schemas: a schema that rejects
6
+ a real answer is the defect class these files exist to catch. A hand-written mock body is what let
7
+ a wrong upload field name through once.
8
+
9
+ Only read-only requests were recorded: one `GET` and two `POST`s with `dry_run=1` — nothing was
10
+ saved. Identifying values (the submission id, title, abstract, authors, topics, the PDF's hash, the
11
+ timestamps) are replaced with fakes; HotCRP's keys, value types, statuses and message texts are
12
+ verbatim.
13
+
14
+ | file | request | HTTP |
15
+ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---: |
16
+ | `get-paper.json` | `GET /api/paper?p=<id>&word_limit=hard` with a token | 200 |
17
+ | `get-noauth.json` | the same without a token | 401 |
18
+ | `post-dot.json` | `POST /api/paper?p=<id>&dry_run=1`, PDF part named `paper.pdf`, `content_file` the same — PHP renames the field to `paper_pdf`, so HotCRP finds no upload | 200 |
19
+ | `post-ok.json` | the same with the part and `content_file` named `paperlint_pdf` | 200 |
20
+
21
+ To re-record: run the same four requests with `curl` against a HotCRP site where you have a
22
+ submission and a token (`Authorization: bearer $HOTCRP_TOKEN`), always with `dry_run=1` on a `POST`,
23
+ save each body, then replace every identifying value with a fake of the same type before committing.
24
+ These files ship in the npm package with the rest of `src/`, like the tests beside them.
@@ -0,0 +1,10 @@
1
+ {
2
+ "ok": false,
3
+ "message_list": [
4
+ {
5
+ "status": 2,
6
+ "message": "<0>Missing credentials"
7
+ }
8
+ ],
9
+ "status_code": 401
10
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "ok": true,
3
+ "paper": {
4
+ "object": "paper",
5
+ "pid": 7,
6
+ "title": "An Example Paper Title",
7
+ "submission": {
8
+ "mimetype": "application/pdf",
9
+ "hash": "sha2-b3e602c135f2455f64aecfdc2e1e7f81dad20c5d69f0981e965b1a0131d47163",
10
+ "timestamp": 1790000000,
11
+ "size": 17,
12
+ "pages": 2
13
+ },
14
+ "abstract": "An example abstract of exactly ten words for the test.",
15
+ "contacts": [
16
+ {
17
+ "email": "author@example.org",
18
+ "given_name": "A.",
19
+ "family_name": "Author",
20
+ "affiliation": "Example University"
21
+ }
22
+ ],
23
+ "paper_type": "Example paper type",
24
+ "authors": [
25
+ {
26
+ "email": "author@example.org",
27
+ "given_name": "A.",
28
+ "family_name": "Author",
29
+ "affiliation": "Example University"
30
+ }
31
+ ],
32
+ "topics": ["Example topic one", "Example topic two"],
33
+ "pc_conflicts": {},
34
+ "status": "submitted",
35
+ "submitted": true,
36
+ "submitted_at": 1790000100,
37
+ "modified_at": 1790000100
38
+ }
39
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "ok": false,
3
+ "message_list": [
4
+ {
5
+ "status": 2,
6
+ "field": "submission",
7
+ "message": "<0>Ignored attempt to upload document without any content"
8
+ },
9
+ {
10
+ "status": 3,
11
+ "field": "submission"
12
+ },
13
+ {
14
+ "status": -3
15
+ }
16
+ ],
17
+ "dry_run": true,
18
+ "valid": false,
19
+ "change_list": [],
20
+ "pid": 7
21
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "ok": true,
3
+ "message_list": [
4
+ {
5
+ "status": -3
6
+ }
7
+ ],
8
+ "dry_run": true,
9
+ "valid": true,
10
+ "change_list": ["submission"],
11
+ "pid": 7
12
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * HotCRP, as the `SubmissionPortal` port: its REST API read and written over `fetch`, its answers
3
+ * parsed here. Everything that changes if HotCRP's API does lives in this folder.
4
+ */
5
+ export {
6
+ CONTENT_FIELD,
7
+ changeObject,
8
+ hotcrpPortal,
9
+ type HotcrpOptions,
10
+ } from "./portal.io.ts";
11
+ export { parseShow, parseUpdate, sha256OfHash } from "./response.ts";