paperlint 2.1.0 → 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 (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -1,27 +1,26 @@
1
1
  /**
2
- * `<paper>/paperlint.json` — ONE PAPER'S SETTINGS, parsed at the boundary.
2
+ * `<paper>/paperlint.json` — ONE PAPER'S SETTINGS, parsed at the boundary, merged over the root's.
3
3
  *
4
- * The middle of three levels, each named after the tool (lib/paper-config.mjs):
4
+ * One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
5
5
  *
6
- * package.json → "paperlint" the project
6
+ * paperlint.json the project (optional): papersDir, rules, defaults
7
7
  * <paper>/paperlint.json this paper: { extends, kind, pdf, rules }
8
8
  * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
9
9
  *
10
- * Every reader — the build, the facts writer, the venue rules, `paperlint lint` — goes through
11
- * `readPaperSettings`, so there is one answer to "what does this paper declare".
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".
12
13
  *
13
14
  * 🔴 STRICT. An unknown key is an error naming the known ones: `"venu": "aisec"` would otherwise
14
- * read as "no preset", and every venue check would be silently off.
15
- *
16
- * 🔴 THE OLD NAME IS NOT READ. Before 2.1.0 the file was `venue.json`. A fallback would keep it
17
- * working forever and leave two names for one file; instead a `venue.json` with no `paperlint.json`
18
- * is an error that names `npx paperlint init`, which moves it (`migrationOf` plans that move).
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.
19
17
  */
20
- import { join } from "node:path";
18
+ import { dirname, join } from "node:path";
21
19
  import {
22
- LEGACY_PAPER_SETTINGS_FILE,
23
- PAPER_SETTINGS_FILE,
24
- PAPER_SETTINGS_KEYS,
20
+ CONFIG_FILE,
21
+ ROOT_ONLY_KEYS,
22
+ SETTINGS_KEYS,
23
+ findProjectRoot,
25
24
  } from "../lib/paper-config.mjs";
26
25
  import type { AbsolutePath } from "./domain/paths.ts";
27
26
  import { err, ok, type Result } from "./domain/result.ts";
@@ -40,16 +39,17 @@ export interface PaperSettings {
40
39
  readonly kind: string | null;
41
40
  /** The built PDF relative to the paper, when it is not `paper.pdf`. */
42
41
  readonly pdf: string | null;
43
- /** Rule overrides for this paper alone, NOT yet checked against the shipped rules. */
44
- readonly rules: Readonly<Record<string, unknown>> | 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;
45
47
  }
46
48
 
47
49
  /** Why a paper's settings cannot be read. */
48
- export type SettingsProblem =
49
- | { readonly kind: "broken"; readonly why: string }
50
- | { readonly kind: "legacy" };
50
+ export type SettingsProblem = { readonly kind: "broken"; readonly why: string };
51
51
 
52
- const KNOWN = Object.keys(PAPER_SETTINGS_KEYS);
52
+ const KNOWN = Object.keys(SETTINGS_KEYS);
53
53
  const STRING_FIELDS = ["extends", "kind", "pdf"] as const;
54
54
 
55
55
  const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
@@ -68,8 +68,8 @@ function stringField(
68
68
  : err(`"${k}" must be a non-empty string, got ${JSON.stringify(v)}`);
69
69
  }
70
70
 
71
- /** `extends`, `kind` and `pdf`, each null when absent. */
72
- function stringFields(
71
+ /** `extends`, `kind` and `pdf`, each null when absent. The root file's defaults parse the same way. */
72
+ export function stringFields(
73
73
  d: Readonly<Record<string, unknown>>,
74
74
  ): Result<Pick<PaperSettings, (typeof STRING_FIELDS)[number]>, string> {
75
75
  const out: Record<string, string | null> = {};
@@ -93,11 +93,18 @@ export function parsePaperSettings(
93
93
  const unknown = Object.keys(json).filter((k) => !KNOWN.includes(k));
94
94
  if (unknown.length > 0)
95
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
+ );
96
101
  const fields = stringFields(json);
97
102
  if (!fields.ok) return fields;
98
103
  const rules = json["rules"];
99
- if (rules !== undefined && !isObject(rules))
100
- return err(`"rules" must be an object of rule id → severity`);
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
+ );
101
108
  return ok({
102
109
  ...fields.value,
103
110
  rules: rules ?? null,
@@ -106,101 +113,96 @@ export function parsePaperSettings(
106
113
 
107
114
  const at = (p: string): AbsolutePath => p as AbsolutePath;
108
115
 
109
- /**
110
- * A paper's settings: null when it has no `paperlint.json`; the problem when the file does not parse
111
- * or only the pre-2.1.0 `venue.json` is there. Reads through `files` only; never throws.
112
- */
113
- export function readPaperSettings(
116
+ /** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
117
+ function readJson(
114
118
  files: Files,
115
- paperDir: string,
116
- ): Result<PaperSettings | null, SettingsProblem> {
117
- const bytes = files.readBytes(at(join(paperDir, PAPER_SETTINGS_FILE)));
118
- if (bytes === null)
119
- return files.readBytes(at(join(paperDir, LEGACY_PAPER_SETTINGS_FILE))) ===
120
- null
121
- ? ok(null)
122
- : err({ kind: "legacy" });
123
- let json: unknown;
119
+ path: string,
120
+ ): Result<unknown, SettingsProblem> {
121
+ const bytes = files.readBytes(at(path));
122
+ if (bytes === null) return ok(undefined);
124
123
  try {
125
- json = JSON.parse(new TextDecoder().decode(bytes));
124
+ return ok(JSON.parse(new TextDecoder().decode(bytes)));
126
125
  } catch (e) {
127
126
  return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
128
127
  }
129
- const parsed = parsePaperSettings(json);
130
- return parsed.ok ? parsed : err({ kind: "broken", why: parsed.error });
131
128
  }
132
129
 
133
- // ── the move from venue.json ────────────────────────────────────────────────────────────
134
-
135
- /** What `paperlint init` does with one paper's files. */
136
- export type Migration =
137
- /** No `venue.json`: nothing to do. */
138
- | { readonly kind: "none" }
139
- /** Only `venue.json`: write `text` as `paperlint.json`, delete `venue.json`. */
140
- | { readonly kind: "move"; readonly text: string }
141
- /** Both, and `paperlint.json` already says the same: the old one is a leftover, delete it. */
142
- | { readonly kind: "drop-legacy" }
143
- /** Both, and they differ: refuse — there is no way to know which one the author means. */
144
- | { readonly kind: "conflict" }
145
- /** `venue.json` is not a JSON object: refuse, naming why. */
146
- | { readonly kind: "broken"; readonly why: string };
147
-
148
130
  /**
149
- * A `venue.json` object → the `paperlint.json` object that says the same, in one move:
150
- * `"venue": "aisec"` becomes `"extends": "paperlint:aisec"` (the only presets that existed were the
151
- * shipped ones), and the `"_"` some files used as a comment becomes `"$comment"`. Every other key
152
- * is carried as it is — an unknown one is then refused by name, as in any `paperlint.json`. Pure.
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.
153
134
  */
154
- export function fromLegacy(
155
- legacy: Readonly<Record<string, unknown>>,
156
- ): Record<string, unknown> {
157
- const out: Record<string, unknown> = {};
158
- for (const [k, v] of Object.entries(legacy)) {
159
- if (k === "venue" && typeof v === "string")
160
- out["extends"] = `paperlint:${v}`;
161
- else if (k === "_") out["$comment"] = v;
162
- else out[k] = v;
163
- }
164
- return out;
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}` });
165
147
  }
166
148
 
167
- const jsonOf = (b: Uint8Array): unknown => {
168
- try {
169
- return JSON.parse(new TextDecoder().decode(b));
170
- } catch {
171
- return undefined;
172
- }
173
- };
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
+ }
174
166
 
175
- /** The move, planned from the two files' bytes (null = absent). Pure. */
176
- export function migrationOf(
177
- legacy: Uint8Array | null,
178
- current: Uint8Array | null,
179
- ): Migration {
180
- if (legacy === null) return { kind: "none" };
181
- const old = jsonOf(legacy);
182
- if (!isObject(old)) return { kind: "broken", why: "it is not a JSON object" };
183
- const next = fromLegacy(old);
184
- if (current === null)
185
- return { kind: "move", text: `${JSON.stringify(next, null, 2)}\n` };
186
- return JSON.stringify(jsonOf(current)) === JSON.stringify(next)
187
- ? { kind: "drop-legacy" }
188
- : { kind: "conflict" };
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;
189
186
  }
190
187
 
191
188
  // ── the paper's rule overrides ──────────────────────────────────────────────────────────
192
189
 
193
190
  /**
194
191
  * `rules` → the parsed entries (rule id → severity or `[severity, options]`), or the error naming
195
- * the file. Null when the paper sets none. The block they go into is built where the config is
196
- * (`paperRuleBlocks` in cli.ts), which also knows which files a paper's rules may reach.
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).
197
194
  */
198
195
  export function paperRules(
199
196
  paperDir: string,
200
197
  settings: PaperSettings,
201
198
  shipped: ReadonlySet<string>,
202
199
  ): Parsed<Record<string, RuleEntry> | null> {
203
- if (settings.rules === null) return { ok: true, value: null };
204
- const where = `${join(paperDir, PAPER_SETTINGS_FILE)} → "rules"`;
205
- return parseRuleEntries(settings.rules, where, shipped);
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
+ );
206
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>;
package/src/presets.ts CHANGED
@@ -47,11 +47,7 @@ import {
47
47
  type PaperSettings,
48
48
  type SettingsProblem,
49
49
  } from "./paper-settings.ts";
50
- import {
51
- LEGACY_PAPER_SETTINGS_FILE,
52
- LEGACY_PAPER_SETTINGS_MESSAGE,
53
- PAPER_SETTINGS_FILE,
54
- } from "../lib/paper-config.mjs";
50
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
55
51
 
56
52
  /** The prefix of a shipped preset's spec. */
57
53
  export const SHIPPED_PREFIX = "paperlint:";
@@ -268,7 +264,7 @@ export type PaperPreset =
268
264
  readonly settings: PaperSettings;
269
265
  readonly preset: Preset;
270
266
  }
271
- /** `paperlint.json` does not parse, or only a pre-2.1.0 `venue.json` is there. */
267
+ /** `paperlint.json` does not parse. */
272
268
  | { readonly kind: "settings-problem"; readonly problem: SettingsProblem }
273
269
  /** `extends` does not resolve. */
274
270
  | {
@@ -283,11 +279,7 @@ export function paperPreset(paperDir: string, deps: PresetDeps): PaperPreset {
283
279
  if (!read.ok) return { kind: "settings-problem", problem: read.error };
284
280
  const settings = read.value;
285
281
  if (settings?.extends == null) return { kind: "none", settings };
286
- const r = resolvePreset(
287
- settings.extends,
288
- join(paperDir, PAPER_SETTINGS_FILE),
289
- deps,
290
- );
282
+ const r = resolvePreset(settings.extends, join(paperDir, CONFIG_FILE), deps);
291
283
  return r.ok
292
284
  ? { kind: "resolved", settings, preset: r.value }
293
285
  : { kind: "preset-problem", settings, problem: r.error };
@@ -299,9 +291,7 @@ export function paperPresetProblem(
299
291
  p: PaperPreset,
300
292
  ): string | null {
301
293
  if (p.kind === "preset-problem")
302
- return `${join(paperDir, PAPER_SETTINGS_FILE)}: ${presetProblemText(p.problem)}`;
294
+ return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
303
295
  if (p.kind !== "settings-problem") return null;
304
- return p.problem.kind === "legacy"
305
- ? `${join(paperDir, LEGACY_PAPER_SETTINGS_FILE)}: ${LEGACY_PAPER_SETTINGS_MESSAGE}`
306
- : `${join(paperDir, PAPER_SETTINGS_FILE)}: ${p.problem.why}`;
296
+ return `${join(paperDir, CONFIG_FILE)}: ${p.problem.why}`;
307
297
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The reference rules — `paper/author-list`, `paper/cite-exists`, `paper/refs-checked`,
3
+ * `paper/refs-fresh` — judge `_build/references.json` (`references.ts`), offline. `paperlint build`
4
+ * does the online work; lint never touches the network.
5
+ *
6
+ * entry whose authors are the preprint's paper/author-list (error), on the entry
7
+ * entry whose identifier provably fails paper/cite-exists (error), on the entry
8
+ * no record, or recorded "not checked" paper/refs-checked (warn) — like pdf/measured
9
+ * bibliography edited after the build paper/refs-fresh (error) — like pdf/fresh
10
+ *
11
+ * While the record is stale, the per-entry rules are silent: they would judge a bibliography
12
+ * that no longer exists, and refs-fresh already says so once. A paper with no bibliography gets
13
+ * nothing.
14
+ *
15
+ * `paper/author-list` used to live on PIPELINE-STATUS.md and ask whether the scorecard mentioned
16
+ * a run. It lives here now because its subject is the bibliography, and the record of the run is
17
+ * the run's own output.
18
+ */
19
+ import { basename, dirname } from "node:path";
20
+ import type { Files } from "./ports/files.ts";
21
+ import {
22
+ bibHash,
23
+ bibliographyOf,
24
+ readReferences,
25
+ REFERENCES_FILE,
26
+ type Bibliography,
27
+ } from "./references.ts";
28
+
29
+ interface Loc {
30
+ readonly line: number;
31
+ readonly column: number;
32
+ }
33
+ interface RuleContext {
34
+ readonly filename: string;
35
+ readonly cwd: string;
36
+ readonly sourceCode: {
37
+ getLocFromIndex(i: number): Loc;
38
+ };
39
+ report(d: {
40
+ loc: { start: Loc; end: Loc };
41
+ messageId: string;
42
+ data?: Record<string, string>;
43
+ }): void;
44
+ }
45
+ export interface ReferenceRuleModule {
46
+ readonly meta: {
47
+ readonly type: "problem" | "suggestion";
48
+ readonly docs: { readonly description: string };
49
+ readonly schema: readonly object[];
50
+ readonly messages: Readonly<Record<string, string>>;
51
+ };
52
+ create(context: RuleContext): { "root:exit"?: () => void };
53
+ }
54
+
55
+ type Name = "author-list" | "cite-exists" | "refs-checked" | "refs-fresh";
56
+
57
+ /** What one lint run knows about a paper's references. */
58
+ type Assessment =
59
+ | { readonly kind: "no-bibliography" }
60
+ | { readonly kind: "unrecorded" }
61
+ | { readonly kind: "not-checked"; readonly why: string }
62
+ | { readonly kind: "stale" }
63
+ | {
64
+ readonly kind: "ready";
65
+ readonly bib: Bibliography;
66
+ readonly failing: readonly {
67
+ readonly key: string;
68
+ readonly rule: "author-list" | "cite-exists";
69
+ readonly why: string;
70
+ }[];
71
+ };
72
+
73
+ function assess(files: Files, paperDir: string): Assessment {
74
+ const bib = bibliographyOf(files, paperDir);
75
+ if (bib === null) return { kind: "no-bibliography" };
76
+ const doc = readReferences(files, paperDir);
77
+ if (doc === null) return { kind: "unrecorded" };
78
+ if (doc.bib.sha256 !== bibHash(bib)) return { kind: "stale" };
79
+ if (doc.status === "not-checked")
80
+ return { kind: "not-checked", why: doc.why ?? "no reason recorded" };
81
+ const failing = doc.entries.flatMap((e) => [
82
+ ...(e.authors === "mismatch"
83
+ ? [{ key: e.key, rule: "author-list" as const, why: e.why ?? "" }]
84
+ : []),
85
+ ...(e.exists === "false"
86
+ ? [{ key: e.key, rule: "cite-exists" as const, why: e.why ?? "" }]
87
+ : []),
88
+ ]);
89
+ return { kind: "ready", bib, failing };
90
+ }
91
+
92
+ /** Where entry `key` starts in the source file: its `@type{key,` line, or the file's start. */
93
+ function entryOffset(bib: Bibliography, key: string): number {
94
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
+ const m = new RegExp(`^@\\w+\\s*\\{\\s*${escaped}\\s*,`, "m").exec(bib.text);
96
+ return m ? bib.offset + m.index : 0;
97
+ }
98
+
99
+ const BUILD = "`npx paperlint build`";
100
+
101
+ const META: Readonly<Record<Name, ReferenceRuleModule["meta"]>> = {
102
+ "author-list": {
103
+ type: "problem",
104
+ docs: {
105
+ description:
106
+ "each entry's authors are those of the version it cites, not the preprint's (recorded by paperlint build)",
107
+ },
108
+ schema: [],
109
+ messages: {
110
+ mismatch:
111
+ "`{{key}}`: the authors are not those of the version cited — {{why}}. The citation resolves and the id resolves, and the list is the PREPRINT's under a published venue",
112
+ },
113
+ },
114
+ "cite-exists": {
115
+ type: "problem",
116
+ docs: {
117
+ description:
118
+ "each entry's identifier resolves to the work cited (recorded by paperlint build)",
119
+ },
120
+ schema: [],
121
+ messages: {
122
+ missing: "`{{key}}` does not resolve to the work cited — {{why}}",
123
+ },
124
+ },
125
+ "refs-checked": {
126
+ type: "suggestion",
127
+ docs: {
128
+ description:
129
+ "the build checked the references online (existence, title, authors); a warning when it could not",
130
+ },
131
+ schema: [],
132
+ messages: {
133
+ unrecorded: `the references have not been checked — ${BUILD} checks them online and records the result in _build/${REFERENCES_FILE}`,
134
+ notChecked: `the last build could not check the references ({{why}}) — run ${BUILD} with network`,
135
+ },
136
+ },
137
+ "refs-fresh": {
138
+ type: "problem",
139
+ docs: {
140
+ description:
141
+ "the recorded reference verdicts are about the bibliography on disk, not an earlier one",
142
+ },
143
+ schema: [],
144
+ messages: {
145
+ stale: `the references changed since the last build checked them — run ${BUILD}`,
146
+ },
147
+ },
148
+ };
149
+
150
+ type Report = { at: number; messageId: string; data?: Record<string, string> };
151
+
152
+ const JUDGES: Readonly<Record<Name, (a: Assessment) => Report[]>> = {
153
+ "author-list": (a) =>
154
+ a.kind === "ready"
155
+ ? a.failing
156
+ .filter((f) => f.rule === "author-list")
157
+ .map((f) => ({
158
+ at: entryOffset(a.bib, f.key),
159
+ messageId: "mismatch",
160
+ data: { key: f.key, why: f.why },
161
+ }))
162
+ : [],
163
+ "cite-exists": (a) =>
164
+ a.kind === "ready"
165
+ ? a.failing
166
+ .filter((f) => f.rule === "cite-exists")
167
+ .map((f) => ({
168
+ at: entryOffset(a.bib, f.key),
169
+ messageId: "missing",
170
+ data: { key: f.key, why: f.why },
171
+ }))
172
+ : [],
173
+ "refs-checked": (a) =>
174
+ a.kind === "unrecorded"
175
+ ? [{ at: 0, messageId: "unrecorded" }]
176
+ : a.kind === "not-checked"
177
+ ? [{ at: 0, messageId: "notChecked", data: { why: a.why } }]
178
+ : [],
179
+ "refs-fresh": (a) =>
180
+ a.kind === "stale" ? [{ at: 0, messageId: "stale" }] : [],
181
+ };
182
+
183
+ /** The four rules, reading through `files`. They act on `paper.tex` only. */
184
+ export function referenceRules({
185
+ files,
186
+ }: {
187
+ files: Files;
188
+ }): Record<Name, ReferenceRuleModule> {
189
+ const make = (name: Name): ReferenceRuleModule => ({
190
+ meta: META[name],
191
+ create: (context) =>
192
+ basename(context.filename) !== "paper.tex"
193
+ ? {}
194
+ : {
195
+ "root:exit": () => {
196
+ const paperDir = dirname(context.filename);
197
+ const a = assess(files, paperDir);
198
+ // An external refs.bib: the entry is not in this file, so the finding sits at its
199
+ // start and names the key.
200
+ const external =
201
+ a.kind === "ready" && a.bib.source !== "paper.tex";
202
+ for (const r of JUDGES[name](a)) {
203
+ const loc = context.sourceCode.getLocFromIndex(
204
+ external ? 0 : r.at,
205
+ );
206
+ context.report({
207
+ loc: { start: loc, end: loc },
208
+ messageId: r.messageId,
209
+ ...(r.data ? { data: r.data } : {}),
210
+ });
211
+ }
212
+ },
213
+ },
214
+ });
215
+ return {
216
+ "author-list": make("author-list"),
217
+ "cite-exists": make("cite-exists"),
218
+ "refs-checked": make("refs-checked"),
219
+ "refs-fresh": make("refs-fresh"),
220
+ };
221
+ }
222
+
223
+ /** The level each is on at for every `paper.tex` in paperlint's own config. */
224
+ export const REFERENCE_RULE_LEVELS = {
225
+ "paper/author-list": "error",
226
+ "paper/cite-exists": "error",
227
+ "paper/refs-checked": "warn",
228
+ "paper/refs-fresh": "error",
229
+ } as const;