paperlint 2.0.1 → 3.0.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 (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `<paper>/paperlint.json` — ONE PAPER'S SETTINGS, parsed at the boundary, merged over the root's.
3
+ *
4
+ * One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
5
+ *
6
+ * paperlint.json the project (optional): papersDir, rules, defaults
7
+ * <paper>/paperlint.json this paper: { extends, kind, pdf, rules }
8
+ * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
9
+ *
10
+ * The paper's `extends`, `kind` and `pdf` win over the root's; with no own value, the root's is
11
+ * the default. Every reader — the build, the facts writer, the venue rules, `paperlint lint` —
12
+ * goes through `readPaperSettings`, so there is one answer to "what does this paper declare".
13
+ *
14
+ * 🔴 STRICT. An unknown key is an error naming the known ones: `"venu": "aisec"` would otherwise
15
+ * read as "no preset", and every venue check would be silently off. A project-only key
16
+ * (`papersDir`, …) in a paper's file is refused too: it would be read nowhere.
17
+ */
18
+ import { dirname, join } from "node:path";
19
+ import {
20
+ CONFIG_FILE,
21
+ ROOT_ONLY_KEYS,
22
+ SETTINGS_KEYS,
23
+ findProjectRoot,
24
+ } from "../lib/paper-config.mjs";
25
+ import type { AbsolutePath } from "./domain/paths.ts";
26
+ import { err, ok, type Result } from "./domain/result.ts";
27
+ import type { Files } from "./ports/files.ts";
28
+ import {
29
+ parseRuleEntries,
30
+ type Parsed,
31
+ type RuleEntry,
32
+ } from "./rules-config.ts";
33
+
34
+ /** What one paper declares. Every absent field is null. */
35
+ export interface PaperSettings {
36
+ /** The venue preset the paper is judged against: `paperlint:<name>` or a relative path. */
37
+ readonly extends: string | null;
38
+ /** The kind of paper (`short`, `research`, …), whose page limit applies. */
39
+ readonly kind: string | null;
40
+ /** The built PDF relative to the paper, when it is not `paper.pdf`. */
41
+ readonly pdf: string | null;
42
+ /**
43
+ * Rule overrides for this paper alone — `{ id: severity }` or ESLint blocks — NOT yet checked
44
+ * against the shipped rules.
45
+ */
46
+ readonly rules: Readonly<Record<string, unknown>> | readonly unknown[] | null;
47
+ }
48
+
49
+ /** Why a paper's settings cannot be read. */
50
+ export type SettingsProblem = { readonly kind: "broken"; readonly why: string };
51
+
52
+ const KNOWN = Object.keys(SETTINGS_KEYS);
53
+ const STRING_FIELDS = ["extends", "kind", "pdf"] as const;
54
+
55
+ const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
56
+ typeof v === "object" && v !== null && !Array.isArray(v);
57
+
58
+ /** A string field: absent is null, anything but a non-empty string is the error. */
59
+ function stringField(
60
+ d: Readonly<Record<string, unknown>>,
61
+ k: (typeof STRING_FIELDS)[number],
62
+ ): Result<string | null, string> {
63
+ const v = d[k];
64
+ // `"extends": null` is what `paperlint new` writes: no venue chosen yet.
65
+ if (v === undefined || (k === "extends" && v === null)) return ok(null);
66
+ return typeof v === "string" && v !== ""
67
+ ? ok(v)
68
+ : err(`"${k}" must be a non-empty string, got ${JSON.stringify(v)}`);
69
+ }
70
+
71
+ /** `extends`, `kind` and `pdf`, each null when absent. The root file's defaults parse the same way. */
72
+ export function stringFields(
73
+ d: Readonly<Record<string, unknown>>,
74
+ ): Result<Pick<PaperSettings, (typeof STRING_FIELDS)[number]>, string> {
75
+ const out: Record<string, string | null> = {};
76
+ for (const k of STRING_FIELDS) {
77
+ const f = stringField(d, k);
78
+ if (!f.ok) return f;
79
+ out[k] = f.value;
80
+ }
81
+ return ok({
82
+ extends: out["extends"] ?? null,
83
+ kind: out["kind"] ?? null,
84
+ pdf: out["pdf"] ?? null,
85
+ });
86
+ }
87
+
88
+ /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
89
+ export function parsePaperSettings(
90
+ json: unknown,
91
+ ): Result<PaperSettings, string> {
92
+ if (!isObject(json)) return err("must be a JSON object");
93
+ const unknown = Object.keys(json).filter((k) => !KNOWN.includes(k));
94
+ if (unknown.length > 0)
95
+ return err(`unknown key "${unknown[0]}" — known keys: ${KNOWN.join(", ")}`);
96
+ const project = Object.keys(json).filter((k) => ROOT_ONLY_KEYS.includes(k));
97
+ if (project.length > 0)
98
+ return err(
99
+ `"${project[0]}" is a project setting — set it in the root ${CONFIG_FILE}, not in a paper's`,
100
+ );
101
+ const fields = stringFields(json);
102
+ if (!fields.ok) return fields;
103
+ const rules = json["rules"];
104
+ if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
105
+ return err(
106
+ `"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
107
+ );
108
+ return ok({
109
+ ...fields.value,
110
+ rules: rules ?? null,
111
+ });
112
+ }
113
+
114
+ const at = (p: string): AbsolutePath => p as AbsolutePath;
115
+
116
+ /** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
117
+ function readJson(
118
+ files: Files,
119
+ path: string,
120
+ ): Result<unknown, SettingsProblem> {
121
+ const bytes = files.readBytes(at(path));
122
+ if (bytes === null) return ok(undefined);
123
+ try {
124
+ return ok(JSON.parse(new TextDecoder().decode(bytes)));
125
+ } catch (e) {
126
+ return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The root file's defaults for every paper — `extends`, `kind`, `pdf` — found by walking up from
132
+ * the paper's parent. None when there is no root file, or it is not an object; a root file that
133
+ * is wrong in another way is reported by `paperlint lint`, which parses it strictly.
134
+ */
135
+ function rootDefaults(
136
+ files: Files,
137
+ paperDir: string,
138
+ ): Result<Defaults | null, SettingsProblem> {
139
+ const root = findProjectRoot(dirname(paperDir), (p) => files.isFile(at(p)));
140
+ const json = readJson(files, join(root, CONFIG_FILE));
141
+ if (!json.ok) return json;
142
+ if (!isObject(json.value)) return ok(null);
143
+ const fields = stringFields(json.value);
144
+ return fields.ok
145
+ ? fields
146
+ : err({ kind: "broken", why: `the root ${CONFIG_FILE}: ${fields.error}` });
147
+ }
148
+
149
+ /**
150
+ * A paper's settings, its own `paperlint.json` merged over the root's defaults: null when neither
151
+ * says anything; the problem when a file does not parse. Reads through `files` only; never throws.
152
+ */
153
+ export function readPaperSettings(
154
+ files: Files,
155
+ paperDir: string,
156
+ ): Result<PaperSettings | null, SettingsProblem> {
157
+ const own = readJson(files, join(paperDir, CONFIG_FILE));
158
+ if (!own.ok) return own;
159
+ const parsed =
160
+ own.value === undefined ? ok(null) : parsePaperSettings(own.value);
161
+ if (!parsed.ok) return err({ kind: "broken", why: parsed.error });
162
+ const defaults = rootDefaults(files, paperDir);
163
+ if (!defaults.ok) return defaults;
164
+ return ok(merge(defaults.value, parsed.value));
165
+ }
166
+
167
+ type Defaults = Pick<PaperSettings, "extends" | "kind" | "pdf">;
168
+ const NO_DEFAULTS: Defaults = { extends: null, kind: null, pdf: null };
169
+
170
+ /** The paper's own values win; each absent one falls back to the root's. */
171
+ function merge(
172
+ root: Defaults | null,
173
+ paper: PaperSettings | null,
174
+ ): PaperSettings | null {
175
+ const r = root ?? NO_DEFAULTS;
176
+ const p = paper ?? { ...NO_DEFAULTS, rules: null };
177
+ const merged: PaperSettings = {
178
+ extends: p.extends ?? r.extends,
179
+ kind: p.kind ?? r.kind,
180
+ pdf: p.pdf ?? r.pdf,
181
+ rules: p.rules,
182
+ };
183
+ return paper === null && Object.values(merged).every((v) => v === null)
184
+ ? null
185
+ : merged;
186
+ }
187
+
188
+ // ── the paper's rule overrides ──────────────────────────────────────────────────────────
189
+
190
+ /**
191
+ * `rules` → the parsed entries (rule id → severity or `[severity, options]`), or the error naming
192
+ * the file. Null when the paper sets none. Blocks (the list form) are parsed by the caller, which
193
+ * knows the file globs they are relative to (`paperRuleBlocks` in cli.ts).
194
+ */
195
+ export function paperRules(
196
+ paperDir: string,
197
+ settings: PaperSettings,
198
+ shipped: ReadonlySet<string>,
199
+ ): Parsed<Record<string, RuleEntry> | null> {
200
+ if (settings.rules === null || Array.isArray(settings.rules))
201
+ return { ok: true, value: null };
202
+ const where = `${join(paperDir, CONFIG_FILE)} → "rules"`;
203
+ return parseRuleEntries(
204
+ settings.rules as Readonly<Record<string, unknown>>,
205
+ where,
206
+ shipped,
207
+ );
208
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Checking a paper's references online — shaped by what the build records, not by what DBLP or
3
+ * Crossref answer. The adapter (`adapters/references/`) runs the package's two checkers; a test
4
+ * passes a function.
5
+ */
6
+
7
+ /** What one bibliography entry came to. */
8
+ export interface EntryVerdict {
9
+ readonly key: string;
10
+ /**
11
+ * Does the cited work exist, and does its title match? `false` only on a positive disproof (an
12
+ * identifier that resolves to nothing or to another work); a work that simply was not found is
13
+ * `unresolvable`, never `false`.
14
+ */
15
+ readonly exists: "true" | "false" | "unresolvable";
16
+ /** Are the authors those of the version cited (not the preprint's)? */
17
+ readonly authors: "match" | "mismatch" | "skipped" | "unchecked";
18
+ /** Why, in one line, when either is not a plain pass. */
19
+ readonly why?: string;
20
+ }
21
+
22
+ export type ReferencesCheck =
23
+ | { readonly kind: "checked"; readonly entries: readonly EntryVerdict[] }
24
+ /** Nothing could be asked — no network, every service down. Never a pass. */
25
+ | { readonly kind: "not-checked"; readonly why: string };
26
+
27
+ /** The bibliography's text (BibTeX) → the verdicts. Never throws: a failure is `not-checked`. */
28
+ export type CheckReferences = (bib: string) => Promise<ReferencesCheck>;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Venue presets: what `"extends"` in a paper's `paperlint.json` resolves to. Two spec forms —
3
+ * `paperlint:<name>` (shipped) and a relative path (the project's own) — a chain of at most four
4
+ * presets, merged block by block, and a display label derived from the spec.
5
+ *
6
+ * The shipped presets are read from the package's venues directory, so a shipped file that stops
7
+ * resolving shows here.
8
+ */
9
+ import { readdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { describe, expect, it } from "vitest";
12
+ import { memoryFiles } from "./adapters/memory/index.ts";
13
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
14
+ import {
15
+ MAX_PRESET_DEPTH,
16
+ labelOf,
17
+ presetProblemText,
18
+ resolvePreset,
19
+ } from "./presets.ts";
20
+
21
+ const VENUES = packageVenuesDir();
22
+ const shipped = Object.fromEntries(
23
+ readdirSync(VENUES)
24
+ .filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
25
+ .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f))]),
26
+ );
27
+ const PAPER_FILE = "/work/papers/p/paperlint.json";
28
+ const deps = (extra: Record<string, string> = {}) => ({
29
+ files: memoryFiles({ ...shipped, ...extra }),
30
+ venuesDir: VENUES,
31
+ });
32
+ const resolve = (spec: string, extra: Record<string, string> = {}) =>
33
+ resolvePreset(spec, PAPER_FILE, deps(extra));
34
+
35
+ describe("shipped presets", () => {
36
+ it("paperlint:agenticdev → agenticdev over acm-sigconf: the family's format, the venue's kinds", () => {
37
+ const r = resolve("paperlint:agenticdev");
38
+ expect(r.ok).toBe(true);
39
+ if (!r.ok) return;
40
+ expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
41
+ "acm-sigconf.jsonc",
42
+ "agenticdev.jsonc",
43
+ ]);
44
+ expect(r.value.format.columns).toBe(2);
45
+ expect(r.value.format.fontsText).toBe("LinLibertine");
46
+ expect([...r.value.format.kinds.keys()]).toEqual(["short", "full", "demo"]);
47
+ expect(r.value.template).toBe("acmart");
48
+ expect("acmart" in r.value.tex.packages).toBe(true);
49
+ expect(r.value.label).toBe("agenticdev");
50
+ });
51
+
52
+ it("a paper may extend a family directly: format and fonts, and no kinds", () => {
53
+ const r = resolve("paperlint:acm-sigconf");
54
+ expect(r.ok && r.value.format.kinds.size).toBe(0);
55
+ expect(r.ok && r.value.format.pageWidthIn).toBe(8.5);
56
+ });
57
+
58
+ it("realm (ACL) stands alone: A4, no parent", () => {
59
+ const r = resolve("paperlint:realm");
60
+ expect(r.ok && r.value.chain.length).toBe(1);
61
+ expect(r.ok && r.value.format.pageWidthIn).toBe(8.27);
62
+ });
63
+
64
+ it("agenticdev turns pdf/last-page-balance on — its producer asks for balanced columns", () => {
65
+ const r = resolve("paperlint:agenticdev");
66
+ expect(r.ok && r.value.rules["pdf/last-page-balance"]).toEqual([
67
+ "error",
68
+ { tolerancePt: 120 },
69
+ ]);
70
+ });
71
+ });
72
+
73
+ describe("a project's own preset, by relative path", () => {
74
+ const usenix = JSON.stringify({
75
+ extends: "paperlint:acm-sigconf",
76
+ name: "USENIX Security",
77
+ format: {
78
+ columns: 1,
79
+ kinds: { full: { body_pages_max: 13, ref_pages_max: 0 } },
80
+ },
81
+ tex: { packages: { usenix: ["usenix.sty"] } },
82
+ rules: { "pdf/body-size": "off" },
83
+ });
84
+
85
+ it("./ is relative to the file that says it; merged per block, child wins", () => {
86
+ const r = resolve("../../venues/usenix-sec.jsonc", {
87
+ "/work/venues/usenix-sec.jsonc": usenix,
88
+ });
89
+ expect(r.ok).toBe(true);
90
+ if (!r.ok) return;
91
+ // format: per key, child wins; the rest comes from the family
92
+ expect(r.value.format.columns).toBe(1);
93
+ expect(r.value.format.pageWidthIn).toBe(8.5);
94
+ // tex: union, never subtract
95
+ expect("usenix" in r.value.tex.packages).toBe(true);
96
+ expect("acmart" in r.value.tex.packages).toBe(true);
97
+ // rules: per id
98
+ expect(r.value.rules["pdf/body-size"]).toBe("off");
99
+ // label: the preset's `name` wins over the file name
100
+ expect(r.value.label).toBe("USENIX Security");
101
+ });
102
+
103
+ it("without `name`, the label is the file name without its extension", () => {
104
+ const r = resolve("./venues/usenix-sec.jsonc", {
105
+ "/work/papers/p/venues/usenix-sec.jsonc": JSON.stringify({
106
+ extends: "paperlint:acm-sigconf",
107
+ }),
108
+ });
109
+ expect(r.ok && r.value.label).toBe("usenix-sec");
110
+ });
111
+
112
+ it("a child kind replaces that kind wholesale; other kinds are kept", () => {
113
+ const r = resolve("./mine.jsonc", {
114
+ "/work/papers/p/mine.jsonc": JSON.stringify({
115
+ extends: "paperlint:agenticdev",
116
+ format: { kinds: { short: { body_pages_max: 6 } } },
117
+ }),
118
+ });
119
+ expect(r.ok).toBe(true);
120
+ if (!r.ok) return;
121
+ expect(r.value.format.kinds.get("short")).toEqual({
122
+ bodyPagesMax: 6,
123
+ refPagesMax: null,
124
+ });
125
+ expect(r.value.format.kinds.get("full")?.bodyPagesMax).toBe(10);
126
+ });
127
+
128
+ it("rules: the leaf wins over its parent, per rule id", () => {
129
+ const r = resolve("./mine.jsonc", {
130
+ "/work/papers/p/mine.jsonc": JSON.stringify({
131
+ extends: "paperlint:agenticdev",
132
+ rules: { "pdf/last-page-balance": "off" },
133
+ }),
134
+ });
135
+ expect(r.ok && r.value.rules["pdf/last-page-balance"]).toBe("off");
136
+ });
137
+ });
138
+
139
+ describe("what does not resolve, and says why", () => {
140
+ it.each([
141
+ [
142
+ "an npm package name",
143
+ "@acme/venues",
144
+ "unsupported",
145
+ /npm presets: not yet supported/,
146
+ ],
147
+ ["a bare name", "agenticdev", "unsupported", /paperlint:agenticdev/],
148
+ [
149
+ "a shipped name with a typo",
150
+ "paperlint:agenticdve",
151
+ "not-found",
152
+ /acm-sigconf, agenticdev, aisec, realm/,
153
+ ],
154
+ ["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
155
+ [
156
+ "a relative file that is not there",
157
+ "./nope.jsonc",
158
+ "not-found",
159
+ /nope\.jsonc/,
160
+ ],
161
+ ])("%s", (_, spec, kind, text) => {
162
+ const r = resolve(spec);
163
+ expect(r.ok).toBe(false);
164
+ if (r.ok) return;
165
+ expect(r.error.kind).toBe(kind);
166
+ expect(presetProblemText(r.error)).toMatch(text);
167
+ });
168
+ });
169
+
170
+ describe("chains that do not resolve", () => {
171
+ it("a cycle is refused, naming the chain", () => {
172
+ const r = resolve("./a.jsonc", {
173
+ "/work/papers/p/a.jsonc": JSON.stringify({ extends: "./b.jsonc" }),
174
+ "/work/papers/p/b.jsonc": JSON.stringify({ extends: "./a.jsonc" }),
175
+ });
176
+ expect(r.ok).toBe(false);
177
+ if (r.ok) return;
178
+ expect(r.error.kind).toBe("cycle");
179
+ expect(presetProblemText(r.error)).toMatch(
180
+ /a\.jsonc → .*b\.jsonc → .*a\.jsonc/,
181
+ );
182
+ });
183
+
184
+ it(`a chain longer than ${String(MAX_PRESET_DEPTH)} is refused, printing it`, () => {
185
+ const files: Record<string, string> = {};
186
+ for (let i = 1; i <= 5; i++)
187
+ files[`/work/papers/p/p${String(i)}.jsonc`] = JSON.stringify({
188
+ extends: i < 5 ? `./p${String(i + 1)}.jsonc` : "paperlint:acm-sigconf",
189
+ });
190
+ const r = resolve("./p1.jsonc", files);
191
+ expect(r.ok).toBe(false);
192
+ if (r.ok) return;
193
+ expect(r.error.kind).toBe("too-deep");
194
+ expect(presetProblemText(r.error)).toMatch(/p1\.jsonc → .*p4\.jsonc/);
195
+ });
196
+
197
+ it("a chain of exactly the limit resolves", () => {
198
+ const r = resolve("./p1.jsonc", {
199
+ "/work/papers/p/p1.jsonc": JSON.stringify({ extends: "./p2.jsonc" }),
200
+ "/work/papers/p/p2.jsonc": JSON.stringify({
201
+ extends: "paperlint:agenticdev",
202
+ }),
203
+ });
204
+ expect(r.ok && r.value.chain.length).toBe(MAX_PRESET_DEPTH);
205
+ });
206
+
207
+ it("a preset that fails the schema is named with its file", () => {
208
+ const r = resolve("./bad.jsonc", {
209
+ "/work/papers/p/bad.jsonc": JSON.stringify({
210
+ extends: "paperlint:acm-sigconf",
211
+ colums: 2,
212
+ }),
213
+ });
214
+ expect(r.ok).toBe(false);
215
+ if (r.ok) return;
216
+ expect(r.error.kind).toBe("broken");
217
+ expect(presetProblemText(r.error)).toMatch(/bad\.jsonc/);
218
+ });
219
+
220
+ it("a preset with no `extends` must carry `tex` (it is a root)", () => {
221
+ const r = resolve("./root.jsonc", {
222
+ "/work/papers/p/root.jsonc": JSON.stringify({ format: { columns: 1 } }),
223
+ });
224
+ expect(r.ok).toBe(false);
225
+ });
226
+ });
227
+
228
+ describe("labelOf — display only, never used to resolve", () => {
229
+ it.each([
230
+ ["paperlint:agenticdev", "agenticdev"],
231
+ ["./venues/usenix-sec.jsonc", "usenix-sec"],
232
+ ["../shared/acl.json", "acl"],
233
+ ])("%s → %s", (spec, label) => {
234
+ expect(labelOf(spec)).toBe(label);
235
+ });
236
+ });