paperlint 2.0.0 → 2.1.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 (207) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/CLAUDE.md +3 -3
  3. package/CONTRIBUTING.md +21 -0
  4. package/LICENSE +1 -1
  5. package/README.md +81 -24
  6. package/action.harness.mjs +10 -10
  7. package/action.mutations.mjs +3 -3
  8. package/action.yml +11 -11
  9. package/bin/{rpp.mjs → paperlint.mjs} +2 -2
  10. package/dist/adapters/banal/invocation.js +3 -3
  11. package/dist/adapters/banal/invocation.js.map +1 -1
  12. package/dist/adapters/banal/run.d.ts +1 -1
  13. package/dist/adapters/banal/run.js +1 -1
  14. package/dist/adapters/banal/run.js.map +1 -1
  15. package/dist/adapters/banal/settings.d.ts +4 -4
  16. package/dist/adapters/banal/settings.d.ts.map +1 -1
  17. package/dist/adapters/banal/settings.js +4 -4
  18. package/dist/adapters/banal/settings.js.map +1 -1
  19. package/dist/adapters/banal/xml.js +1 -1
  20. package/dist/adapters/banal/xml.js.map +1 -1
  21. package/dist/adapters/curl/download.io.d.ts.map +1 -1
  22. package/dist/adapters/curl/download.io.js +2 -2
  23. package/dist/adapters/curl/download.io.js.map +1 -1
  24. package/dist/build-engine.d.ts +1 -1
  25. package/dist/build-engine.d.ts.map +1 -1
  26. package/dist/build-engine.js +6 -3
  27. package/dist/build-engine.js.map +1 -1
  28. package/dist/build.d.ts +1 -1
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +5 -4
  31. package/dist/build.js.map +1 -1
  32. package/dist/cli.d.ts +29 -30
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +174 -98
  35. package/dist/cli.js.map +1 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +21 -6
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/engine.d.ts +2 -2
  40. package/dist/engine.d.ts.map +1 -1
  41. package/dist/engine.js +1 -1
  42. package/dist/facts-file.d.ts +33 -9
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +117 -17
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/hooks-settings.d.ts +0 -18
  47. package/dist/hooks-settings.d.ts.map +1 -1
  48. package/dist/hooks-settings.js +70 -42
  49. package/dist/hooks-settings.js.map +1 -1
  50. package/dist/init.d.ts +13 -10
  51. package/dist/init.d.ts.map +1 -1
  52. package/dist/init.js +74 -42
  53. package/dist/init.js.map +1 -1
  54. package/dist/link-skills.js +2 -2
  55. package/dist/link-skills.js.map +1 -1
  56. package/dist/new-paper.d.ts +2 -2
  57. package/dist/new-paper.d.ts.map +1 -1
  58. package/dist/new-paper.js +17 -2
  59. package/dist/new-paper.js.map +1 -1
  60. package/dist/paper-settings.d.ts +68 -0
  61. package/dist/paper-settings.d.ts.map +1 -0
  62. package/dist/paper-settings.js +144 -0
  63. package/dist/paper-settings.js.map +1 -0
  64. package/dist/presets.d.ts +84 -0
  65. package/dist/presets.d.ts.map +1 -0
  66. package/dist/presets.js +186 -0
  67. package/dist/presets.js.map +1 -0
  68. package/dist/rules-config.d.ts +8 -1
  69. package/dist/rules-config.d.ts.map +1 -1
  70. package/dist/rules-config.js +13 -8
  71. package/dist/rules-config.js.map +1 -1
  72. package/dist/structure.d.ts.map +1 -1
  73. package/dist/structure.js +2 -1
  74. package/dist/structure.js.map +1 -1
  75. package/dist/tex-requirements.d.ts +63 -7
  76. package/dist/tex-requirements.d.ts.map +1 -1
  77. package/dist/tex-requirements.js +91 -24
  78. package/dist/tex-requirements.js.map +1 -1
  79. package/dist/toolchain.d.ts +2 -2
  80. package/dist/toolchain.d.ts.map +1 -1
  81. package/dist/toolchain.js +8 -8
  82. package/dist/toolchain.js.map +1 -1
  83. package/dist/types.d.ts +4 -4
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/venue-rules.d.ts +109 -0
  86. package/dist/venue-rules.d.ts.map +1 -0
  87. package/dist/venue-rules.js +387 -0
  88. package/dist/venue-rules.js.map +1 -0
  89. package/docs/configuration.md +73 -17
  90. package/docs/e2e.md +3 -4
  91. package/docs/install.md +11 -3
  92. package/docs/optional-rules.md +26 -25
  93. package/docs/rules.md +132 -3
  94. package/docs/toolchain.md +5 -5
  95. package/eslint-rules/paper-typography.mjs +1 -1
  96. package/eslint-rules/paper-typography.mutations.mjs +1 -1
  97. package/eslint-rules/papers.mjs +6 -2
  98. package/eslint-rules/pdf-last-page-balance.harness.mjs +5 -3
  99. package/eslint-rules/pdf-last-page-balance.mjs +23 -2
  100. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  101. package/fixtures/build-e2e/guards/paper.tex +1 -1
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.json +1 -1
  104. package/fixtures/real-markdown-paper/baseline.mjs +2 -2
  105. package/fixtures/toolchain-mirror/install-tl +2 -2
  106. package/fixtures/toolchain-mirror/stub-pdflatex +1 -1
  107. package/fixtures/toolchain-mirror/stub-tlmgr +1 -1
  108. package/hooks/hooks.harness.mjs +1 -1
  109. package/lib/paper-config.d.mts +7 -0
  110. package/lib/paper-config.harness.mjs +3 -3
  111. package/lib/paper-config.mjs +34 -3
  112. package/lib/skill-trigger-cases.harness.mjs +1 -1
  113. package/package.json +4 -4
  114. package/plugin/hooks/hooks.json +3 -3
  115. package/scripts/check.mjs +1 -1
  116. package/scripts/harness-api.frozen.json +1 -1
  117. package/scripts/harness-api.test.ts +2 -2
  118. package/scripts/layer-legacy.frozen.json +1 -1
  119. package/scripts/mutation-batteries-frozen.harness.mjs +1 -1
  120. package/scripts/mutation-batteries.frozen.json +2 -2
  121. package/scripts/release-config.test.ts +1 -1
  122. package/skills/find-venue/SKILL.md +5 -1
  123. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  124. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  125. package/skills/paper-pipeline/SKILL.md +4 -3
  126. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  127. package/skills/paper-pipeline/description-language.eval.mjs +1 -1
  128. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +1 -1
  129. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +2 -2
  130. package/skills/paper-pipeline/pipeline-firing.eval.mjs +1 -1
  131. package/skills/paper-pipeline/pipeline-language.eval.mjs +1 -1
  132. package/skills/paper-pipeline/scripts/consumer.d.mts +1 -0
  133. package/skills/paper-pipeline/scripts/consumer.harness.mjs +10 -4
  134. package/skills/paper-pipeline/scripts/consumer.mjs +8 -1
  135. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +1 -1
  136. package/skills/render-paper/SKILL.md +3 -3
  137. package/skills/render-paper/SKILL.md.spec.ts +2 -2
  138. package/skills/render-paper/extract-pdf-facts.harness.mjs +6 -6
  139. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  140. package/skills/study-accepted-papers/SKILL.md +2 -1
  141. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  142. package/skills/submit-paper/SKILL.md +9 -1
  143. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  144. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  145. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  146. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  147. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  148. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  149. package/skills/submit-paper/references/venues/venue-profile.schema.json +120 -30
  150. package/src/adapters/banal/index.test.ts +1 -1
  151. package/src/adapters/banal/invocation.test.ts +3 -3
  152. package/src/adapters/banal/invocation.ts +3 -3
  153. package/src/adapters/banal/locate.test.ts +4 -4
  154. package/src/adapters/banal/run.ts +2 -2
  155. package/src/adapters/banal/settings.test.ts +12 -6
  156. package/src/adapters/banal/settings.ts +9 -5
  157. package/src/adapters/banal/xml.test.ts +1 -1
  158. package/src/adapters/banal/xml.ts +1 -1
  159. package/src/adapters/curl/download.io.ts +4 -2
  160. package/src/adapters/curl/download.test.ts +3 -1
  161. package/src/adapters/node/files.test.ts +1 -1
  162. package/src/adapters/node/process.test.ts +2 -2
  163. package/src/adapters/node/workspace.test.ts +4 -4
  164. package/src/build-engine.harness.mjs +2 -2
  165. package/src/build-engine.ts +11 -3
  166. package/src/build.harness.mjs +27 -22
  167. package/src/build.mutations.mjs +3 -2
  168. package/src/build.ts +6 -5
  169. package/src/cli.harness.mjs +72 -131
  170. package/src/cli.mutations.mjs +7 -32
  171. package/src/cli.ts +204 -121
  172. package/src/doctor.harness.mjs +9 -25
  173. package/src/doctor.ts +28 -5
  174. package/src/engine.harness.mjs +4 -2
  175. package/src/engine.ts +2 -2
  176. package/src/facts-file.test.ts +54 -7
  177. package/src/facts-file.ts +145 -24
  178. package/src/hooks-settings.harness.mjs +24 -15
  179. package/src/hooks-settings.mutations.mjs +2 -4
  180. package/src/hooks-settings.ts +92 -49
  181. package/src/init.ts +88 -52
  182. package/src/latex-log.harness.mjs +1 -1
  183. package/src/link-skills.harness.mjs +3 -1
  184. package/src/link-skills.mutations.mjs +1 -1
  185. package/src/link-skills.ts +2 -2
  186. package/src/new-paper.harness.mjs +10 -7
  187. package/src/new-paper.test.ts +145 -0
  188. package/src/new-paper.ts +22 -2
  189. package/src/paper-settings-commands.test.ts +311 -0
  190. package/src/paper-settings.test.ts +220 -0
  191. package/src/paper-settings.ts +206 -0
  192. package/src/pdf-facts.harness.mjs +1 -1
  193. package/src/presets.test.ts +236 -0
  194. package/src/presets.ts +307 -0
  195. package/src/rules-config.ts +14 -9
  196. package/src/structure.harness.mjs +4 -4
  197. package/src/structure.ts +2 -1
  198. package/src/tex-requirements.harness.mjs +12 -18
  199. package/src/tex-requirements.ts +179 -29
  200. package/src/toolchain.harness.mjs +34 -25
  201. package/src/toolchain.ts +14 -8
  202. package/src/types.ts +4 -4
  203. package/src/venue-rules.test.ts +545 -0
  204. package/src/venue-rules.ts +573 -0
  205. package/templates/paper/paperlint.json +4 -0
  206. package/fixtures/build-e2e/acmart/venue.json +0 -1
  207. package/plugin/.claude-plugin/plugin.json +0 -8
package/src/doctor.ts CHANGED
@@ -37,9 +37,11 @@ import {
37
37
  import {
38
38
  LEGACY_CONFIG_KEY,
39
39
  LEGACY_KEY_MESSAGE,
40
+ LEGACY_PAPER_SETTINGS_FILE,
41
+ PAPER_SETTINGS_FILE,
40
42
  declaredSettings,
41
43
  } from "../lib/paper-config.mjs";
42
- import { PAPER_MARKERS } from "./build.ts";
44
+ import { PAPER_MARKERS, papersIn } from "./build.ts";
43
45
  import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
44
46
  import { doctorHooks } from "./hooks-settings.ts";
45
47
 
@@ -233,6 +235,28 @@ function papersVerdict(
233
235
  return { lines: out, bad };
234
236
  }
235
237
 
238
+ /**
239
+ * A pre-2.1.0 `venue.json` left in a paper: it is no longer read, so that paper's venue checks
240
+ * silently would not run if nothing named it. `init` moves it.
241
+ */
242
+ function leftoverSettingsVerdict(
243
+ root: string,
244
+ papersDir: string | null,
245
+ ): { lines: string[]; bad: number } {
246
+ if (papersDir === null) return { lines: [], bad: 0 };
247
+ const lines = papersIn(resolve(root, papersDir), [
248
+ ...PAPER_MARKERS,
249
+ LEGACY_PAPER_SETTINGS_FILE,
250
+ ])
251
+ .map((dir) => join(dir, LEGACY_PAPER_SETTINGS_FILE))
252
+ .filter((f) => existsSync(f))
253
+ .map(
254
+ (f) =>
255
+ ` ✗ ${relative(root, f)} is no longer read — paperlint 2.1.0 renamed it ${PAPER_SETTINGS_FILE}, so this paper's venue checks do not run. \`npx paperlint init\` moves it`,
256
+ );
257
+ return { lines, bad: lines.length };
258
+ }
259
+
236
260
  export interface DoctorOptions {
237
261
  log?: typeof console.log;
238
262
  cwd?: string;
@@ -282,8 +306,6 @@ export function doctor({
282
306
  out.push(...declaration.lines);
283
307
  bad += declaration.bad;
284
308
  }
285
- if (existsSync(join(root, "rpp.json")))
286
- out.push(` ⚠ rpp.json is present — deprecated; the hooks never read it`);
287
309
 
288
310
  out.push("", "papers directory");
289
311
  const hookRoot = rawPkg ? papersRoot(rawPkg) : null;
@@ -295,8 +317,9 @@ export function doctor({
295
317
  ` the hooks will guard ${hookSays ?? "(nothing — the guard refuses and says why on first use)"}`,
296
318
  );
297
319
  const verdict = papersVerdict(root, cliPapers, hookSays);
298
- out.push(...verdict.lines);
299
- bad += verdict.bad;
320
+ const leftover = leftoverSettingsVerdict(root, cliPapers ?? hookSays);
321
+ out.push(...verdict.lines, ...leftover.lines);
322
+ bad += verdict.bad + leftover.bad;
300
323
 
301
324
  // A skill that is not linked is ADVISORY, like a missing program: `paperlint lint`, the hooks and CI
302
325
  // work without it, and an entry of the same name that `init` refused to replace is the
@@ -53,7 +53,7 @@ const facts = (o) => ({
53
53
  ...o,
54
54
  });
55
55
  const ROWS = [
56
- // Guards: the order — rpp's own verified cache first, so a build does not depend on whatever TeX
56
+ // Guards: the order — paperlint's own verified cache first, so a build does not depend on whatever TeX
57
57
  // Live the machine happens to carry.
58
58
  [
59
59
  "a complete cache wins, even over a complete system TeX",
@@ -198,7 +198,9 @@ check(
198
198
  // to start, while `paperlint toolchain --check` reported the tree verified. Checked on disk, not
199
199
  // through an injected predicate, because the predicate is exactly what was wrong.
200
200
  {
201
- const bin = realpathSync(mkdtempSync(join(tmpdir(), "rpp-engine-bin-")));
201
+ const bin = realpathSync(
202
+ mkdtempSync(join(tmpdir(), "paperlint-engine-bin-")),
203
+ );
202
204
  try {
203
205
  mkdirSync(join(bin, "texcount"));
204
206
  writeFileSync(join(bin, "checkcites"), "#!/bin/sh\n");
package/src/engine.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * TeX Live qualifies only when `kpsewhich` finds every file the venue declares (issue #26).
9
9
  *
10
10
  * The order (the Playwright pattern, decided 2026-09-24):
11
- * 1. rpp's own cache (`paperlint toolchain` installs it) — when it has everything;
11
+ * 1. paperlint's own cache (`paperlint toolchain` installs it) — when it has everything;
12
12
  * 2. a TeX Live already on PATH — when IT has everything;
13
13
  * 3. neither: on a terminal, ask once and install; without one (CI, an agent), refuse in one line
14
14
  * that names `npx paperlint toolchain` and the missing packages. Never a silent install without a
@@ -41,7 +41,7 @@ export interface TreeProbe {
41
41
  export interface EngineFacts {
42
42
  /** install-tl-unx runs here (Linux, macOS). */
43
43
  readonly supported: boolean;
44
- /** rpp's cached TeX Live, probed — or null when there is none. */
44
+ /** paperlint's cached TeX Live, probed — or null when there is none. */
45
45
  readonly cache: TreeProbe | null;
46
46
  /** The `pdflatex` on PATH, probed — or null when there is none. */
47
47
  readonly system: TreeProbe | null;
@@ -8,6 +8,9 @@
8
8
  * real banal is `test/e2e/banal.mjs`.
9
9
  */
10
10
  import assert from "node:assert/strict";
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
11
14
  import { test } from "vitest";
12
15
  import { memoryFiles } from "./adapters/memory/index.ts";
13
16
  import {
@@ -147,12 +150,23 @@ const good: PdfReader = async () => ({
147
150
  ok: true,
148
151
  facts: { pages: 2, fonts: FONTS, last: lastPage(60, 60), layout: [] },
149
152
  });
150
- /** Options over in-memory files: the PDF and venue.json on "disk", a measurer answering `geometry`. */
153
+ /** The shipped presets at their real paths: `extends` resolves through the same Files port. */
154
+ const SHIPPED_PRESETS = Object.fromEntries(
155
+ readdirSync(packageVenuesDir())
156
+ .filter((f) => /\.jsonc?$/.test(f))
157
+ .map((f) => [
158
+ join(packageVenuesDir(), f),
159
+ readFileSync(join(packageVenuesDir(), f)),
160
+ ]),
161
+ );
162
+
163
+ /** Options over in-memory files: the PDF and paperlint.json on "disk", a measurer answering `geometry`. */
151
164
  function setup(geometry: Geometry = NONE) {
152
165
  const files = memoryFiles({
153
166
  [PDF]: PDF_BYTES,
154
- [`${PAPER}/venue.json`]: JSON.stringify({
155
- venue: "agenticdev",
167
+ ...SHIPPED_PRESETS,
168
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
169
+ extends: "paperlint:agenticdev",
156
170
  kind: "short",
157
171
  }),
158
172
  });
@@ -170,7 +184,7 @@ function setup(geometry: Geometry = NONE) {
170
184
  return { files, measured, o };
171
185
  }
172
186
 
173
- test("measure: venue and kind from venue.json, pdf relative to the paper, the file's sha256", async () => {
187
+ test("measure: venue and kind from paperlint.json, pdf relative to the paper, the file's sha256", async () => {
174
188
  const { o } = setup();
175
189
  const m = await measurePaper(PAPER, PDF, o);
176
190
  assert.ok(m.ok);
@@ -233,8 +247,41 @@ test("writeFactsFile: <paper>/_build/paper.facts.json holds exactly the document
233
247
  assert.deepEqual(JSON.parse(text), m.value.facts);
234
248
  });
235
249
 
236
- test("declaredVenue: none without venue.json, none when it names no venue", () => {
250
+ test("declaredVenue: none without paperlint.json; no label when it extends no preset", () => {
237
251
  assert.equal(declaredVenue(memoryFiles(), PAPER), null);
238
- const files = memoryFiles({ [`${PAPER}/venue.json`]: '{"kind":"x"}' });
239
- assert.equal(declaredVenue(files, PAPER), null);
252
+ const files = memoryFiles({
253
+ [`${PAPER}/paperlint.json`]: '{"kind":"x","pdf":"b/p.pdf"}',
254
+ });
255
+ assert.deepEqual(declaredVenue(files, PAPER), {
256
+ label: null,
257
+ kind: "x",
258
+ pdf: "b/p.pdf",
259
+ });
260
+ });
261
+
262
+ test("declaredVenue: the label is the preset's, and an extends that resolves nowhere is refused", () => {
263
+ const ok = memoryFiles({
264
+ ...SHIPPED_PRESETS,
265
+ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
266
+ });
267
+ assert.equal(declaredVenue(ok, PAPER)?.label, "aisec");
268
+ const typo = memoryFiles({
269
+ ...SHIPPED_PRESETS,
270
+ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisek"}',
271
+ });
272
+ assert.throws(() => declaredVenue(typo, PAPER), /shipped presets/);
273
+ });
274
+
275
+ test("declaredVenue: a venue.json alone is refused with the command that moves it, not read", () => {
276
+ const files = memoryFiles({
277
+ [`${PAPER}/venue.json`]: '{"venue":"agenticdev"}',
278
+ });
279
+ assert.throws(() => declaredVenue(files, PAPER), /npx paperlint init/);
280
+ });
281
+
282
+ test("declaredVenue: a paperlint.json with an unknown key is refused, naming the key", () => {
283
+ const files = memoryFiles({
284
+ [`${PAPER}/paperlint.json`]: '{"venu":"agenticdev"}',
285
+ });
286
+ assert.throws(() => declaredVenue(files, PAPER), /unknown key "venu"/);
240
287
  });
package/src/facts-file.ts CHANGED
@@ -40,13 +40,16 @@ import { describeFailure, type PdfFacts, type PdfReader } from "./pdf-facts.ts";
40
40
  import {
41
41
  flatGeometry,
42
42
  type FactsGeometryFields,
43
+ type FlatGeometry,
43
44
  type Geometry,
44
45
  } from "./domain/geometry.ts";
45
- import { sha256Hex } from "./domain/sha256.ts";
46
+ import { parseSha256, sha256Hex, type Sha256 } from "./domain/sha256.ts";
46
47
  import type { MeasureGeometry } from "./ports/measure-geometry.ts";
47
48
  import type { AbsolutePath } from "./domain/paths.ts";
48
49
  import type { Files } from "./ports/files.ts";
49
50
  import { err, ok, type Result } from "./domain/result.ts";
51
+ import { paperPreset, paperPresetProblem } from "./presets.ts";
52
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
50
53
 
51
54
  export const FACTS_SCHEMA = 2;
52
55
  export const FACTS_DIR = "_build";
@@ -56,41 +59,42 @@ export const FACTS_FILE = "paper.facts.json";
56
59
  export const factsPath = (paperDir: string): string =>
57
60
  join(paperDir, FACTS_DIR, FACTS_FILE);
58
61
 
59
- /** The `venue.json` a paper declares: where it is submitted, as which kind, and where its PDF is. */
60
- export interface VenueDecl {
61
- readonly venue: string;
62
- readonly kind: string | null;
63
- readonly pdf: string | null;
64
- }
65
-
66
62
  /**
67
63
  * A path as the caller gave it. The `Files` adapter resolves a relative one against the process's
68
64
  * cwd, which is what these callers have always meant; the brand is not a claim this code checked it.
69
65
  */
70
66
  const at = (p: string): AbsolutePath => p as AbsolutePath;
71
67
 
72
- const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
73
-
74
- /** A parsed `venue.json`, or null when it names no venue. */
75
- export function parseVenueDecl(json: unknown): VenueDecl | null {
76
- const d = typeof json === "object" && json !== null ? json : {};
77
- const field = (k: string) => str((d as Record<string, unknown>)[k]);
78
- const venue = field("venue");
79
- return venue ? { venue, kind: field("kind"), pdf: field("pdf") } : null;
68
+ /** What the facts need from a paper's `paperlint.json`: its venue's label, its kind, where its PDF is. */
69
+ export interface VenueDecl {
70
+ /** The venue preset's display label (`agenticdev`), or null when the paper extends none. */
71
+ readonly label: string | null;
72
+ readonly kind: string | null;
73
+ readonly pdf: string | null;
80
74
  }
81
75
 
82
76
  /**
83
- * The paper's `venue.json`, or null when there is none or it names no venue. The one reader of
84
- * that file: the build, the facts writer and the shim all read it here.
77
+ * What a paper's `paperlint.json` declares, or null when it has none. THROWS with a one-line reason
78
+ * when the file does not parse, its `extends` does not resolve, or only the pre-2.1.0 `venue.json`
79
+ * is there: the build reports that as its `facts` failure rather than building against no venue.
85
80
  */
86
81
  export function declaredVenue(
87
82
  files: Files,
88
83
  paperDir: string,
84
+ venuesDir: string = packageVenuesDir(),
89
85
  ): VenueDecl | null {
90
- const bytes = files.readBytes(at(join(paperDir, "venue.json")));
91
- return bytes === null
92
- ? null
93
- : parseVenueDecl(JSON.parse(new TextDecoder().decode(bytes)));
86
+ const p = paperPreset(paperDir, { files, venuesDir });
87
+ const problem = paperPresetProblem(paperDir, p);
88
+ if (problem !== null) throw new Error(problem);
89
+ if (p.kind === "resolved")
90
+ return {
91
+ label: p.preset.label,
92
+ kind: p.settings.kind,
93
+ pdf: p.settings.pdf,
94
+ };
95
+ return p.kind === "none" && p.settings
96
+ ? { label: null, kind: p.settings.kind, pdf: p.settings.pdf }
97
+ : null;
94
98
  }
95
99
 
96
100
  // ── the document ────────────────────────────────────────────────────────────────────────
@@ -184,7 +188,7 @@ export interface MeasureOptions {
184
188
  readonly kind?: string | null;
185
189
  /** The page-geometry measurer (banal, as the composition root wired it). */
186
190
  readonly measure: MeasureGeometry;
187
- /** Where the PDF and `venue.json` are read from. */
191
+ /** Where the PDF and `paperlint.json` are read from. */
188
192
  readonly files: Files;
189
193
  }
190
194
 
@@ -215,7 +219,7 @@ export async function measurePaper(
215
219
  const facts = factsDocument({
216
220
  pdf: posix(relative(paperDir, pdf)),
217
221
  sha: sha256Hex(bytes),
218
- venue: o.venue ?? decl?.venue ?? null,
222
+ venue: o.venue ?? decl?.label ?? null,
219
223
  kind: o.kind ?? decl?.kind ?? null,
220
224
  read: r.facts,
221
225
  geometry,
@@ -239,3 +243,120 @@ export function writeFactsFile(
239
243
  );
240
244
  return out;
241
245
  }
246
+
247
+ // ── reading ─────────────────────────────────────────────────────────────────────────────
248
+
249
+ /** What a rule judges, read back from the facts file and typed once. */
250
+ export interface ReadFacts {
251
+ /** As the file spells it: relative to the paper directory, or absolute. */
252
+ readonly pdf: string;
253
+ readonly sha: Sha256;
254
+ readonly fonts: readonly FontEntry[];
255
+ /** The geometry columns, or null when no measurer ran (`geometry_source` is null). */
256
+ readonly geometry: FlatGeometry | null;
257
+ }
258
+
259
+ /** Why a facts file cannot be judged. */
260
+ export type FactsProblem =
261
+ | { readonly kind: "schema"; readonly got: string }
262
+ | { readonly kind: "broken"; readonly why: string };
263
+
264
+ const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
265
+ typeof v === "object" && v !== null && !Array.isArray(v);
266
+ const isNum = (v: unknown): v is number =>
267
+ typeof v === "number" && Number.isFinite(v);
268
+ const numOrNull = (v: unknown): v is number | null => v === null || isNum(v);
269
+
270
+ function fontOf(v: unknown): FontEntry | null {
271
+ if (!isRecord(v)) return null;
272
+ const { name, type, embedded, program } = v;
273
+ return typeof name === "string" &&
274
+ typeof type === "string" &&
275
+ typeof embedded === "boolean" &&
276
+ typeof program === "string"
277
+ ? { name, type, embedded, program }
278
+ : null;
279
+ }
280
+
281
+ const MAYBE_NUMBERS = [
282
+ "page_w_in",
283
+ "page_h_in",
284
+ "columns",
285
+ "body_pt",
286
+ "ref_pt",
287
+ ] as const;
288
+ const NUMBERS = ["body_pages", "ref_pages", "appendix_pages"] as const;
289
+
290
+ /** The geometry columns when a measurer ran; the shape error otherwise. */
291
+ function geometryOf(
292
+ d: Readonly<Record<string, unknown>>,
293
+ ): Result<FlatGeometry | null, string> {
294
+ if (d["geometry_source"] === null) return ok(null);
295
+ if (typeof d["geometry_source"] !== "string")
296
+ return err("`geometry_source` is neither a measurer's name nor null");
297
+ const bad =
298
+ MAYBE_NUMBERS.find((k) => !numOrNull(d[k])) ??
299
+ NUMBERS.find((k) => !isNum(d[k]));
300
+ if (bad) return err(`\`${bad}\` is not a number`);
301
+ const pagesByType = d["pages_by_type"];
302
+ if (!isRecord(pagesByType) || !Object.values(pagesByType).every(isNum))
303
+ return err("`pages_by_type` is not an object of page counts");
304
+ const n = (k: (typeof MAYBE_NUMBERS)[number]) => d[k] as number | null;
305
+ return ok({
306
+ page_w_in: n("page_w_in"),
307
+ page_h_in: n("page_h_in"),
308
+ columns: n("columns"),
309
+ body_pt: n("body_pt"),
310
+ ref_pt: n("ref_pt"),
311
+ body_pages: d["body_pages"] as number,
312
+ ref_pages: d["ref_pages"] as number,
313
+ appendix_pages: d["appendix_pages"] as number,
314
+ pages_by_type: pagesByType as Readonly<Record<string, number>>,
315
+ });
316
+ }
317
+
318
+ /** The PDF the facts describe: its path and its sha256. */
319
+ function artifactOf(
320
+ d: Readonly<Record<string, unknown>>,
321
+ ): Result<{ pdf: string; sha: Sha256 }, string> {
322
+ const pdf = d["pdf"];
323
+ if (typeof pdf !== "string" || pdf === "") return err("no `pdf` path");
324
+ try {
325
+ return ok({ pdf, sha: parseSha256(String(d["pdf_sha256"])) });
326
+ } catch {
327
+ return err("no `pdf_sha256` of 64 hex digits");
328
+ }
329
+ }
330
+
331
+ /**
332
+ * The facts file's text → what a rule judges, or why it cannot be judged. The one reader of the
333
+ * file in `src/`, beside its one writer: a field renamed here and not there is a compile error in
334
+ * this module, not a rule that silently reads `undefined`. Pure.
335
+ */
336
+ export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
337
+ let d: unknown;
338
+ try {
339
+ d = JSON.parse(text);
340
+ } catch (e) {
341
+ return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
342
+ }
343
+ if (!isRecord(d)) return err({ kind: "broken", why: "not a JSON object" });
344
+ if (d["schema"] !== FACTS_SCHEMA)
345
+ return err({ kind: "schema", got: JSON.stringify(d["schema"] ?? null) });
346
+ const artifact = artifactOf(d);
347
+ if (!artifact.ok) return err({ kind: "broken", why: artifact.error });
348
+ const rawFonts = d["fonts"];
349
+ const fonts = Array.isArray(rawFonts) ? rawFonts.map(fontOf) : null;
350
+ if (fonts === null || fonts.some((f) => f === null))
351
+ return err({
352
+ kind: "broken",
353
+ why: "`fonts` is not a list of { name, type, embedded, program }",
354
+ });
355
+ const geometry = geometryOf(d);
356
+ if (!geometry.ok) return err({ kind: "broken", why: geometry.error });
357
+ return ok({
358
+ ...artifact.value,
359
+ fonts: fonts as FontEntry[],
360
+ geometry: geometry.value,
361
+ });
362
+ }
@@ -40,7 +40,6 @@ const {
40
40
  doctorHooks,
41
41
  readSettings,
42
42
  MANAGED_BY,
43
- LEGACY_MANAGED_BY,
44
43
  SETTINGS_PATH,
45
44
  } = await import(join(HERE, "hooks-settings.ts"));
46
45
  const { claudeCodeHookProtocol } = await import("vigiles/claude-code");
@@ -66,7 +65,7 @@ check(
66
65
  wiring.names.join() ===
67
66
  "paper-edit-guard,paper-skills-nudge,paper-status-gates",
68
67
  );
69
- const work = realpathSync(mkdtempSync(join(tmpdir(), "rpp-hooks-")));
68
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-hooks-")));
70
69
  try {
71
70
  const empty = join(work, "empty-hooks.json");
72
71
  writeFileSync(empty, '{"hooks":{}}');
@@ -89,11 +88,16 @@ try {
89
88
  `node "$CLAUDE_PROJECT_DIR/${MANAGED_BY}" hook paper-status-gates`,
90
89
  { name: "paper-status-gates", ours: true, legacy: false },
91
90
  ],
92
- // Guards: what an install under the old package name wrote is recognised as ours-but-stale.
91
+ // Guards: what an older install wrote is recognised as ours-but-stale. Spelled literally, not
92
+ // built from a constant: these are what 1.x and 2.0.0 actually put in users' settings files.
93
93
  [
94
- `node "$CLAUDE_PROJECT_DIR/${LEGACY_MANAGED_BY}" hook paper-edit-guard`,
94
+ `node "$CLAUDE_PROJECT_DIR/node_modules/research-paper-pipeline/bin/rpp.mjs" hook paper-edit-guard`,
95
95
  { name: "paper-edit-guard", ours: false, legacy: true },
96
96
  ],
97
+ [
98
+ `node "\${CLAUDE_PROJECT_DIR}/node_modules/paperlint/bin/rpp.mjs" hook paper-status-gates`,
99
+ { name: "paper-status-gates", ours: false, legacy: true },
100
+ ],
97
101
  [
98
102
  `node "$CLAUDE_PROJECT_DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program "$CLAUDE_PROJECT_DIR/node_modules/paperlint/hooks/paper-edit-guard.hook.mjs"`,
99
103
  { name: "paper-edit-guard", ours: false, legacy: false },
@@ -107,11 +111,11 @@ try {
107
111
  { name: "paper-skills-nudge", ours: false, legacy: false },
108
112
  ],
109
113
  [
110
- `node /abs/proj/node_modules/paperlint/bin/rpp.mjs hook paper-edit-guard`,
114
+ `node /abs/proj/node_modules/paperlint/bin/paperlint.mjs hook paper-edit-guard`,
111
115
  { name: "paper-edit-guard", ours: false, legacy: false },
112
116
  ],
113
117
  [`node my-own-lint.mjs`, null],
114
- [`node node_modules/paperlint/bin/rpp.mjs lint`, null],
118
+ [`node node_modules/paperlint/bin/paperlint.mjs lint`, null],
115
119
  ];
116
120
  for (const [cmd, want] of cases)
117
121
  check(
@@ -154,37 +158,42 @@ try {
154
158
  );
155
159
  }
156
160
 
157
- // ── an install under the old package name is migrated, the user's own command kept ─────
158
- {
161
+ // ── what an older install wrote is migrated, the user's own command kept ─────────────────
162
+ // Two real histories, spelled literally: 1.x wrote the old package directory, 2.0.0 wrote the
163
+ // new directory with the old entry file name. Neither file exists after an upgrade.
164
+ for (const [release, stale] of [
165
+ ["1.x", "node_modules/research-paper-pipeline/bin/rpp.mjs"],
166
+ ["2.0.0", "node_modules/paperlint/bin/rpp.mjs"],
167
+ ]) {
159
168
  const legacyWired = JSON.parse(
160
169
  JSON.stringify(merge({}, wiring.compiled, MANAGED_BY)).replaceAll(
161
170
  MANAGED_BY,
162
- LEGACY_MANAGED_BY,
171
+ stale,
163
172
  ),
164
173
  );
165
174
  legacyWired.hooks.PostToolUse[0].hooks.push({
166
175
  type: "command",
167
176
  command: "node my-own-lint.mjs",
168
177
  });
169
- const dir = project("legacy", legacyWired);
178
+ const dir = project(`legacy-${release}`, legacyWired);
170
179
  const before = doctorHooks(dir, wiring).join("\n");
171
180
  check(
172
- "🔴 doctor names hook commands left under the old package name, and the fix",
173
- /still point into node_modules\/research-paper-pipeline\//.test(before) &&
181
+ `🔴 doctor names hook commands ${release} left behind — the missing file, and the fix`,
182
+ before.includes(`run ${stale}, which this version does not install`) &&
174
183
  /npx paperlint init` replaces them/.test(before),
175
184
  );
176
185
  const r = wireHooks(dir, merge, wiring);
177
186
  const s = JSON.parse(text(dir));
178
187
  const counts = [...wiredCounts(s, wiring.names).values()];
179
188
  check(
180
- "🔴 init replaces them: every hook wired once under the new name, none left under the old",
189
+ `🔴 init replaces what ${release} wrote: every hook wired once, none left at the old path`,
181
190
  r.status === "written" &&
182
191
  r.replaced === wiring.names.length &&
183
192
  counts.every((c) => c.ours === 1 && c.legacy === 0 && c.other === 0) &&
184
- !text(dir).includes("research-paper-pipeline"),
193
+ !text(dir).includes(stale),
185
194
  );
186
195
  check(
187
- "and the user's own command in the same matcher survives the migration",
196
+ `and the user's own command in the same matcher survives the ${release} migration`,
188
197
  text(dir).includes("node my-own-lint.mjs"),
189
198
  );
190
199
  }
@@ -27,11 +27,9 @@ process.exit(
27
27
  expect: '→ {"name":"paper-edit-guard","ours":false,"legacy":true}',
28
28
  disables:
29
29
  "the duplicate guard for every bin spelling but ours. A hook wired by hand as " +
30
- "`npx paperlint hook …` or through an absolute rpp.mjs path reads as ours, init merges its own " +
30
+ "`npx paperlint hook …` or through an absolute path to the bin reads as ours, init merges its own " +
31
31
  "copy beside it, and the hook runs twice per event",
32
- edits: [
33
- [SRC, " ours: t === MANAGED_BY,", " ours: true,"],
34
- ],
32
+ edits: [[SRC, " ours: t === MANAGED_BY,", " ours: true,"]],
35
33
  },
36
34
  {
37
35
  name: "another spelling no longer stops the write",