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,141 @@
1
+ /**
2
+ * THE SUBMISSION ON A CONFERENCE PORTAL — what `paperlint submission` reads and changes.
3
+ *
4
+ * Two declarations, each in the file that owns the fact:
5
+ *
6
+ * venue preset `portal` where the venue takes submissions: `{ "kind": "hotcrp", "url": … }`
7
+ * paper `submission` which submission on that portal is this paper's: `{ "id": 7 }`
8
+ *
9
+ * The values here are the portal's answers in paperlint's words, not in any one portal's: an adapter
10
+ * per portal kind maps its API onto them (`src/adapters/hotcrp/`).
11
+ */
12
+ import { err, ok, type Result } from "./result.ts";
13
+ import { isRecord } from "./record.ts";
14
+ import type { Sha256 } from "./sha256.ts";
15
+
16
+ /** The portal kinds an adapter exists for. Any other kind is refused by name. */
17
+ export type PortalKind = "hotcrp";
18
+ export const PORTAL_KINDS: readonly PortalKind[] = ["hotcrp"];
19
+
20
+ /** A venue preset's `portal`, as the preset declares it: the kind is checked where it is used. */
21
+ export interface VenuePortal {
22
+ readonly kind: string;
23
+ /** The portal site's base URL, without a trailing slash. */
24
+ readonly url: string;
25
+ }
26
+
27
+ /** A portal whose kind has an adapter. */
28
+ export interface SupportedPortal {
29
+ readonly kind: PortalKind;
30
+ readonly url: string;
31
+ }
32
+
33
+ const isPortalKind = (k: string): k is PortalKind =>
34
+ PORTAL_KINDS.some((x) => x === k);
35
+
36
+ /** The declared portal → one paperlint can talk to, or the refusal naming the kind. Pure. */
37
+ export function supportedPortal(
38
+ p: VenuePortal,
39
+ ): Result<SupportedPortal, string> {
40
+ return isPortalKind(p.kind)
41
+ ? ok({ kind: p.kind, url: p.url.replace(/\/+$/, "") })
42
+ : err(`portal kind "${p.kind}" is not supported; upload by hand`);
43
+ }
44
+
45
+ /** A paper's `submission`: which submission on the venue's portal is this paper's. */
46
+ export interface PaperSubmission {
47
+ readonly id: number;
48
+ }
49
+
50
+ /** A paper's `submission` key → the value, null when absent, or one line saying what is wrong. Pure. */
51
+ export function parsePaperSubmission(
52
+ v: unknown,
53
+ ): Result<PaperSubmission | null, string> {
54
+ if (v === undefined) return ok(null);
55
+ const bad = err(
56
+ `"submission" must be { "id": <the submission number on the venue's portal> }`,
57
+ );
58
+ if (!isRecord(v)) return bad;
59
+ const unknown = Object.keys(v).find((k) => k !== "id");
60
+ if (unknown !== undefined)
61
+ return err(`"submission" has an unknown key "${unknown}" — known: id`);
62
+ const id = v["id"];
63
+ return typeof id === "number" && Number.isInteger(id) && id > 0
64
+ ? ok({ id })
65
+ : bad;
66
+ }
67
+
68
+ /** The PDF the portal holds for a submission. */
69
+ export interface PortalDocument {
70
+ /** The sha256 of the stored bytes; null when the portal names another hash or none. */
71
+ readonly sha256: Sha256 | null;
72
+ /** The hash as the portal spells it. */
73
+ readonly hash: string;
74
+ readonly size: number | null;
75
+ readonly mimetype: string | null;
76
+ /** Unix seconds; null when the portal does not say. */
77
+ readonly uploadedAt: number | null;
78
+ }
79
+
80
+ /** One submission, as the portal shows it to its author. */
81
+ export interface SubmissionView {
82
+ readonly id: number;
83
+ readonly status: string;
84
+ readonly title: string | null;
85
+ readonly paperType: string | null;
86
+ readonly topics: readonly string[];
87
+ readonly abstract: string | null;
88
+ /** The PDF the portal holds; null when none is uploaded. */
89
+ readonly pdf: PortalDocument | null;
90
+ /** Unix seconds; null when not submitted or not said. */
91
+ readonly submittedAt: number | null;
92
+ readonly modifiedAt: number | null;
93
+ /** What the portal said beside the answer. */
94
+ readonly messages: readonly PortalMessage[];
95
+ }
96
+
97
+ /** A change to send. Every absent part is left as the portal has it. */
98
+ export interface SubmissionChange {
99
+ readonly pdf: { readonly name: string; readonly bytes: Uint8Array } | null;
100
+ readonly abstract: string | null;
101
+ /** Mark the submission submitted (ready for review). */
102
+ readonly submit: boolean;
103
+ }
104
+
105
+ /** One message from the portal: its text, and the field it is about when it names one. */
106
+ export interface PortalMessage {
107
+ readonly message: string;
108
+ readonly field: string | null;
109
+ /** The portal's severity number, when it gives one. */
110
+ readonly status: number | null;
111
+ }
112
+
113
+ /** What the portal answered to a change. */
114
+ export interface UpdateOutcome {
115
+ readonly httpStatus: number;
116
+ /** The change is valid; for a saved change, it was also committed. */
117
+ readonly valid: boolean;
118
+ /** The fields the change touched. */
119
+ readonly changes: readonly string[];
120
+ readonly messages: readonly PortalMessage[];
121
+ /** The portal reports that nothing was saved. */
122
+ readonly dryRun: boolean;
123
+ }
124
+
125
+ /** Why the portal gave no usable answer. */
126
+ export type PortalFailure =
127
+ | {
128
+ readonly kind: "refused";
129
+ readonly httpStatus: number;
130
+ readonly messages: readonly PortalMessage[];
131
+ }
132
+ | { readonly kind: "unreachable"; readonly detail: string }
133
+ | {
134
+ readonly kind: "malformed";
135
+ readonly httpStatus: number;
136
+ readonly detail: string;
137
+ };
138
+
139
+ /** Words in a text, as a reader counts them: runs of non-space. Pure. */
140
+ export const wordCount = (s: string): number =>
141
+ s.split(/\s+/).filter((w) => w !== "").length;
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cpSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { nodeFiles } from "../adapters/node/index.ts";
6
+ import { presetsDir } from "../package-dirs.ts";
7
+ import { shippedVenueNames } from "../presets.ts";
8
+ import {
9
+ nameTokens,
10
+ venuesNamedBy,
11
+ withoutVenues,
12
+ type NamedVenue,
13
+ } from "./venue-name.ts";
14
+
15
+ describe("nameTokens — a name as the words it is made of", () => {
16
+ it.each<[string, readonly string[]]>([
17
+ ["aisec-2026", ["aisec", "2026"]],
18
+ ["my_paper.v2", ["my", "paper", "v", "2"]],
19
+ ["ACM CCS", ["acm", "ccs"]],
20
+ ["acm-sigconf", ["acm", "sigconf"]],
21
+ // Letters and digits glued together are two words: `aisec2026` names AISec as much as `aisec-2026`.
22
+ ["aisec2026", ["aisec", "2026"]],
23
+ ["--x--", ["x"]],
24
+ ["", []],
25
+ ])("%s", (name, want) => {
26
+ expect(nameTokens(name)).toEqual(want);
27
+ });
28
+ });
29
+
30
+ /**
31
+ * The venues are the SHIPPED presets, read through the one source of venue names
32
+ * (`shippedVenueNames`) — never a list of names written here. A case below depends on what a preset
33
+ * says (`aidc`'s alias ACSAC, `aisec`'s ACM CCS); a preset edit that changes it shows here.
34
+ */
35
+ const VENUES: readonly NamedVenue[] = shippedVenueNames({
36
+ files: nodeFiles,
37
+ venuesDir: presetsDir(),
38
+ });
39
+
40
+ describe("venuesNamedBy — which venues a folder name names", () => {
41
+ it.each<[string, string, readonly { venue: string; name: string }[]]>([
42
+ ["the label", "aisec-2026", [{ venue: "aisec", name: "aisec" }]],
43
+ ["an alias", "acsac-2026", [{ venue: "aidc", name: "ACSAC" }]],
44
+ [
45
+ "a two-word alias, as two tokens in a row",
46
+ "acm-ccs-agents",
47
+ [{ venue: "aisec", name: "ACM CCS" }],
48
+ ],
49
+ ["the two words of an alias apart are not it", "acm-agents-ccs", []],
50
+ ["glued to a year", "realm2026", [{ venue: "realm", name: "realm" }]],
51
+ [
52
+ "two venues, each once, in the order they are listed",
53
+ "realm-aisec-aisec",
54
+ [
55
+ { venue: "aisec", name: "aisec" },
56
+ { venue: "realm", name: "realm" },
57
+ ],
58
+ ],
59
+ // Whole tokens only: a venue's name inside another word is that other word.
60
+ ["inside a word: overrealm", "overrealm-study", []],
61
+ ["inside a word: realms", "realms-of-agents", []],
62
+ ["inside a word: aisecure", "aisecure", []],
63
+ ["no venue at all", "agent-rule-drift", []],
64
+ ])("%s: %s", (_, folder, want) => {
65
+ expect(venuesNamedBy(folder, VENUES)).toEqual(want);
66
+ });
67
+
68
+ it("a name that is also the paper's own is not reported, whichever venue declares it", () => {
69
+ // A paper at a venue whose own chain also says "ACM CCS" (two workshops of one conference).
70
+ expect(venuesNamedBy("ccs-acm-ccs", VENUES, ["ACM CCS"])).toEqual([]);
71
+ expect(venuesNamedBy("aisec-2026", VENUES, ["aisec"])).toEqual([]);
72
+ });
73
+
74
+ it("own names compare by their words, not their spelling", () => {
75
+ expect(venuesNamedBy("acsac-2026", VENUES, ["ACSAC"])).toEqual([]);
76
+ expect(venuesNamedBy("acm-ccs-x", VENUES, ["acm-ccs"])).toEqual([]);
77
+ });
78
+
79
+ it("a preset whose alias has no words names nothing by it", () => {
80
+ // A fixture preset in a copy of the presets directory: alias "—" has no letter or digit, and a
81
+ // name of no words must not stand everywhere.
82
+ const dir = realpathSync(
83
+ mkdtempSync(join(tmpdir(), "paperlint-venue-name-")),
84
+ );
85
+ cpSync(presetsDir(), dir, { recursive: true });
86
+ writeFileSync(
87
+ join(dir, "dash.jsonc"),
88
+ JSON.stringify({ extends: "paperlint:acm-sigconf", aliases: ["—"] }),
89
+ );
90
+ const venues = shippedVenueNames({ files: nodeFiles, venuesDir: dir });
91
+ expect(venues.find((v) => v.label === "dash")?.aliases).toContain("—");
92
+ expect(venuesNamedBy("agent-rule-drift", venues)).toEqual([]);
93
+ expect(venuesNamedBy("dash-2026", venues)).toEqual([
94
+ { venue: "dash", name: "dash" },
95
+ ]);
96
+ });
97
+ });
98
+
99
+ describe("withoutVenues — the name with its venues taken out, when that is a name for the work", () => {
100
+ it.each<[string, string | null]>([
101
+ ["aisec-agent-drift", "agent-drift"],
102
+ ["agent_drift.aisec", "agent-drift"],
103
+ ["acm-ccs-agents", "agents"],
104
+ ["realm-rules-aisec-2026", "rules-2026"],
105
+ ["--aisec--agents", "agents"],
106
+ // Only numbers left: `2026` names no work.
107
+ ["aisec-2026", null],
108
+ ["aisec", null],
109
+ // A venue glued to other words in one part is not cleanly taken out.
110
+ ["aisec2026-agents", null],
111
+ ["agent-rule-drift", "agent-rule-drift"],
112
+ ])("%s → %s", (name, want) => {
113
+ expect(withoutVenues(name, VENUES)).toBe(want);
114
+ });
115
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WHICH VENUE A NAME NAMES — a paper folder's name read as words, against what each venue is
3
+ * called (a preset's label and its `aliases`).
4
+ *
5
+ * A name is compared by its WORDS, never by substring: `overrealm` and `realms` do not name REALM,
6
+ * `aisec-2026` and `aisec2026` name AISec. A word is a run of letters or a run of digits; anything
7
+ * else separates words, and case is ignored. A venue name of several words (`ACM CCS`) names the
8
+ * venue only when those words stand in a row.
9
+ *
10
+ * Read by `paper/folder-venue-leftover` (a folder naming a venue other than the one the paper
11
+ * extends) and by `paperlint new`, which refuses a new folder naming any venue at all.
12
+ */
13
+
14
+ /** A venue as names know it: the word for it in messages, and the other names it goes by. */
15
+ export interface NamedVenue {
16
+ readonly label: string;
17
+ readonly aliases: readonly string[];
18
+ }
19
+
20
+ /** A venue a name names, and which of its names it used, as the venue spells it. */
21
+ export interface NamedIn {
22
+ readonly venue: string;
23
+ readonly name: string;
24
+ }
25
+
26
+ /** A name's words, lowercased: runs of letters and runs of digits; everything else separates. */
27
+ export const nameTokens = (name: string): readonly string[] =>
28
+ name.toLowerCase().match(/\p{L}+|\p{N}+/gu) ?? [];
29
+
30
+ /** Where `words` stand in a row in `tokens`: the index of each first word. None for empty `words`. */
31
+ const placesIn = (
32
+ tokens: readonly string[],
33
+ words: readonly string[],
34
+ ): readonly number[] =>
35
+ words.length === 0
36
+ ? []
37
+ : tokens.flatMap((_, i) =>
38
+ words.every((w, j) => tokens[i + j] === w) ? [i] : [],
39
+ );
40
+
41
+ /** A name's words as one key, to compare two spellings of a name. */
42
+ const keyOf = (name: string): string => nameTokens(name).join(" ");
43
+
44
+ /** Every name of every venue that is not one of `own`, with its words. */
45
+ const candidates = (
46
+ venues: readonly NamedVenue[],
47
+ own: readonly string[],
48
+ ): readonly (NamedIn & { readonly words: readonly string[] })[] => {
49
+ const ownKeys = new Set(own.map(keyOf));
50
+ return venues.flatMap((v) =>
51
+ [v.label, ...v.aliases]
52
+ .filter((n) => !ownKeys.has(keyOf(n)))
53
+ .map((name) => ({ venue: v.label, name, words: nameTokens(name) })),
54
+ );
55
+ };
56
+
57
+ /**
58
+ * Each venue `name` names by one of its names (the label first, then its aliases), once per venue,
59
+ * in the order `venues` lists them. A name that is also one of `own` — the paper's own venue's,
60
+ * compared by words — is never counted: two workshops of one conference share its name.
61
+ */
62
+ export function venuesNamedBy(
63
+ name: string,
64
+ venues: readonly NamedVenue[],
65
+ own: readonly string[] = [],
66
+ ): readonly NamedIn[] {
67
+ const tokens = nameTokens(name);
68
+ const hits = candidates(venues, own).filter(
69
+ (c) => placesIn(tokens, c.words).length > 0,
70
+ );
71
+ return hits
72
+ .filter((c, i) => hits.findIndex((d) => d.venue === c.venue) === i)
73
+ .map(({ venue, name: n }) => ({ venue, name: n }));
74
+ }
75
+
76
+ /** A part of a name between separators, with the indices of its words in the whole name's words. */
77
+ interface Part {
78
+ readonly text: string;
79
+ readonly at: readonly number[];
80
+ }
81
+
82
+ /** A name's hyphen-, underscore- or dot-separated parts, each with where its words stand. */
83
+ const partsOf = (name: string): readonly Part[] =>
84
+ name
85
+ .split(/[^\p{L}\p{N}]+/u)
86
+ .filter((text) => text.length > 0)
87
+ .reduce<{ readonly next: number; readonly out: readonly Part[] }>(
88
+ (acc, text) => {
89
+ const n = nameTokens(text).length;
90
+ const at = Array.from({ length: n }, (_, j) => acc.next + j);
91
+ return { next: acc.next + n, out: [...acc.out, { text, at }] };
92
+ },
93
+ { next: 0, out: [] },
94
+ ).out;
95
+
96
+ /** The indices of the words of `name` that belong to a venue's name. */
97
+ const venueWordsIn = (
98
+ name: string,
99
+ venues: readonly NamedVenue[],
100
+ ): ReadonlySet<number> => {
101
+ const tokens = nameTokens(name);
102
+ return new Set(
103
+ candidates(venues, []).flatMap((c) =>
104
+ placesIn(tokens, c.words).flatMap((at) => c.words.map((_, j) => at + j)),
105
+ ),
106
+ );
107
+ };
108
+
109
+ /**
110
+ * `name` with every venue name taken out, as a name for the work — or null when that is not clean:
111
+ * a venue's words glued to other words in one hyphen-, underscore- or dot-separated part
112
+ * (`aisec2026`), or nothing but numbers left (`aisec-2026` → `2026` names no work). The parts left
113
+ * are joined by `-`.
114
+ */
115
+ export function withoutVenues(
116
+ name: string,
117
+ venues: readonly NamedVenue[],
118
+ ): string | null {
119
+ const venueWord = venueWordsIn(name, venues);
120
+ const inVenue = (p: Part): number =>
121
+ p.at.filter((i) => venueWord.has(i)).length;
122
+ const parts = partsOf(name);
123
+ if (parts.some((p) => inVenue(p) > 0 && inVenue(p) < p.at.length))
124
+ return null;
125
+ const left = parts.filter((p) => inVenue(p) === 0);
126
+ return left.some((p) => /\p{L}/u.test(p.text))
127
+ ? left.map((p) => p.text).join("-")
128
+ : null;
129
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `paper/folder-venue-leftover` (`src/folder-venue-rule.ts`) on papers held in memory beside the
3
+ * SHIPPED presets, so the venue names come from the registry a user gets. Both halves: a folder
4
+ * named after another venue than the paper's `extends` is reported once, on the scorecard's first
5
+ * line; a folder named after the paper's own venue, or after no venue, is not.
6
+ */
7
+ import { readdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ import { memoryFiles } from "./adapters/memory/index.ts";
11
+ import { presetsDir } from "./package-dirs.ts";
12
+ import {
13
+ FOLDER_VENUE_RULE_LEVELS,
14
+ folderVenueRules,
15
+ } from "./folder-venue-rule.ts";
16
+
17
+ const VENUES = presetsDir();
18
+ const PAPERS = "/work/papers";
19
+ const shipped = Object.fromEntries(
20
+ readdirSync(VENUES)
21
+ .filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
22
+ .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f))]),
23
+ );
24
+
25
+ interface Reported {
26
+ readonly messageId: string;
27
+ readonly message: string;
28
+ readonly line: number;
29
+ }
30
+
31
+ /**
32
+ * The rule over one paper folder's file, the way ESLint runs it: `create`, then `root`. `settings`
33
+ * is the paper's paperlint.json (absent: no file); `extra` more files, by absolute path.
34
+ */
35
+ function lint(
36
+ folder: string,
37
+ settings: object | undefined,
38
+ {
39
+ file = "PIPELINE-STATUS.md",
40
+ extra = {},
41
+ }: { file?: string; extra?: Record<string, string> } = {},
42
+ ): Reported[] {
43
+ const dir = `${PAPERS}/${folder}`;
44
+ const files = memoryFiles({
45
+ ...shipped,
46
+ ...(settings === undefined
47
+ ? {}
48
+ : { [`${dir}/paperlint.json`]: JSON.stringify(settings) }),
49
+ ...extra,
50
+ });
51
+ const rule = folderVenueRules({ files, venuesDir: VENUES })[
52
+ "folder-venue-leftover"
53
+ ];
54
+ const out: Reported[] = [];
55
+ const visitor = rule.create({
56
+ filename: `${dir}/${file}`,
57
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
58
+ report: (d) => {
59
+ const template = rule.meta.messages[d.messageId] ?? "";
60
+ const message = Object.entries(d.data).reduce(
61
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
62
+ template,
63
+ );
64
+ out.push({ messageId: d.messageId, message, line: d.loc.start.line });
65
+ },
66
+ });
67
+ visitor.root?.();
68
+ return out;
69
+ }
70
+
71
+ const AIDC = { extends: "paperlint:aidc", kind: "regular" };
72
+
73
+ describe("paper/folder-venue-leftover — the folder names another venue", () => {
74
+ it("a folder named after the venue the paper left is reported, naming both and the cure", () => {
75
+ expect(lint("aisec-2026", AIDC)).toEqual([
76
+ {
77
+ messageId: "leftover",
78
+ line: 1,
79
+ message:
80
+ "the folder name «aisec-2026» names the venue aisec, and this paper extends aidc — the name went stale when the venue changed, and venues change on every resubmission. Name the folder after the work (what the paper shows), not the venue",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("a venue named by an alias says which alias", () => {
86
+ expect(
87
+ lint("acm-ccs-agents", AIDC).map(
88
+ (r) => /names the venue (.*?), and/u.exec(r.message)?.[1],
89
+ ),
90
+ ).toEqual(["aisec («ACM CCS»)"]);
91
+ });
92
+
93
+ it("two other venues are two findings", () => {
94
+ expect(
95
+ lint("realm-then-aisec", AIDC).map(
96
+ (r) => /names the venue (\w+)/u.exec(r.message)?.[1],
97
+ ),
98
+ ).toEqual(["aisec", "realm"]);
99
+ });
100
+
101
+ it("a project's own preset is judged like a shipped one: its label and aliases are its own", () => {
102
+ const extra = {
103
+ [`${PAPERS}/aidc-redux/venue.jsonc`]: JSON.stringify({
104
+ extends: "paperlint:aisec",
105
+ name: "AISec 2027",
106
+ }),
107
+ };
108
+ // Its chain is AISec's, so aisec is its own; aidc is another venue.
109
+ expect(
110
+ lint(
111
+ "aidc-redux",
112
+ { extends: "./venue.jsonc", kind: "short" },
113
+ { extra },
114
+ ),
115
+ ).toHaveLength(1);
116
+ expect(
117
+ lint(
118
+ "aisec-redux",
119
+ { extends: "./venue.jsonc", kind: "short" },
120
+ {
121
+ extra: {
122
+ [`${PAPERS}/aisec-redux/venue.jsonc`]: JSON.stringify({
123
+ extends: "paperlint:aisec",
124
+ }),
125
+ },
126
+ },
127
+ ),
128
+ ).toEqual([]);
129
+ });
130
+ });
131
+
132
+ describe("paper/folder-venue-leftover — silent", () => {
133
+ it.each<[string, string, object | undefined]>([
134
+ [
135
+ "the folder names the paper's own venue",
136
+ "agenticdev-2026",
137
+ { extends: "paperlint:agenticdev", kind: "short" },
138
+ ],
139
+ ["by its alias", "acsac-2026", AIDC],
140
+ ["the folder names no venue", "agent-rule-drift", AIDC],
141
+ // Whole words only: REALM is not inside these.
142
+ ["a venue's name inside another word", "overrealm-realms", AIDC],
143
+ ["no paperlint.json: no venue to compare with", "aisec-2026", undefined],
144
+ ["no extends yet", "aisec-2026", { extends: null }],
145
+ [
146
+ "an extends that does not resolve — pdf/profile says so",
147
+ "aisec-2026",
148
+ { extends: "paperlint:nope" },
149
+ ],
150
+ ])("%s", (_, folder, settings) => {
151
+ expect(lint(folder, settings)).toEqual([]);
152
+ });
153
+
154
+ it("a paperlint.json that does not parse — pdf/profile says so", () => {
155
+ expect(
156
+ lint("aisec-2026", undefined, {
157
+ extra: { [`${PAPERS}/aisec-2026/paperlint.json`]: "{ not json" },
158
+ }),
159
+ ).toEqual([]);
160
+ });
161
+
162
+ it("judged once per paper, on PIPELINE-STATUS.md only", () => {
163
+ expect(lint("aisec-2026", AIDC, { file: "paper.md" })).toEqual([]);
164
+ });
165
+
166
+ it("a paper on a bare template family declares no venue: a venue in its folder may be its own", () => {
167
+ // The accepted ACSAC papers in fixtures/accepted-papers/ are exactly this: `…-acsac24` folders
168
+ // extending ieee-conference, while `aidc` names ACSAC, its parent conference, as an alias.
169
+ expect(
170
+ lint("secure-acsac24", { extends: "paperlint:ieee-conference" }),
171
+ ).toEqual([]);
172
+ expect(lint("aisec-2026", { extends: "paperlint:acm-sigconf" })).toEqual(
173
+ [],
174
+ );
175
+ });
176
+ });
177
+
178
+ it("the level: a warning — a rename is the author's call", () => {
179
+ expect(FOLDER_VENUE_RULE_LEVELS).toEqual({
180
+ "paper/folder-venue-leftover": "warn",
181
+ });
182
+ });