paperlint 5.0.0 → 6.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 (147) hide show
  1. package/CLAUDE.md +13 -0
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +8 -8
  4. package/bin/paperlint.mjs +5 -1
  5. package/dist/adapters/latex/index.d.ts +1 -0
  6. package/dist/adapters/latex/index.d.ts.map +1 -1
  7. package/dist/adapters/latex/index.js +3 -0
  8. package/dist/adapters/latex/index.js.map +1 -1
  9. package/dist/adapters/latex/layout.d.ts +27 -0
  10. package/dist/adapters/latex/layout.d.ts.map +1 -0
  11. package/dist/adapters/latex/layout.js +62 -0
  12. package/dist/adapters/latex/layout.js.map +1 -0
  13. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  14. package/dist/adapters/latex/nodes.js +11 -1
  15. package/dist/adapters/latex/nodes.js.map +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +5 -1
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +25 -5
  21. package/dist/cli.js.map +1 -1
  22. package/dist/domain/anonymity.d.ts +68 -0
  23. package/dist/domain/anonymity.d.ts.map +1 -0
  24. package/dist/domain/anonymity.js +94 -0
  25. package/dist/domain/anonymity.js.map +1 -0
  26. package/dist/domain/body-pages.d.ts +69 -0
  27. package/dist/domain/body-pages.d.ts.map +1 -0
  28. package/dist/domain/body-pages.js +141 -0
  29. package/dist/domain/body-pages.js.map +1 -0
  30. package/dist/domain/tex-document.d.ts +9 -0
  31. package/dist/domain/tex-document.d.ts.map +1 -1
  32. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  33. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  34. package/dist/facts-file.d.ts +28 -2
  35. package/dist/facts-file.d.ts.map +1 -1
  36. package/dist/facts-file.js +54 -9
  37. package/dist/facts-file.js.map +1 -1
  38. package/dist/lib/paper-config.d.ts.map +1 -1
  39. package/dist/lib/paper-config.js +2 -1
  40. package/dist/lib/paper-config.js.map +1 -1
  41. package/dist/paper-settings.d.ts +11 -0
  42. package/dist/paper-settings.d.ts.map +1 -1
  43. package/dist/paper-settings.js +38 -4
  44. package/dist/paper-settings.js.map +1 -1
  45. package/dist/pdf-content.d.ts +40 -0
  46. package/dist/pdf-content.d.ts.map +1 -0
  47. package/dist/pdf-content.js +97 -0
  48. package/dist/pdf-content.js.map +1 -0
  49. package/dist/pdf-facts.d.ts +30 -0
  50. package/dist/pdf-facts.d.ts.map +1 -1
  51. package/dist/pdf-facts.js +37 -1
  52. package/dist/pdf-facts.js.map +1 -1
  53. package/dist/ports/latex.d.ts +3 -1
  54. package/dist/ports/latex.d.ts.map +1 -1
  55. package/dist/presets.d.ts +2 -0
  56. package/dist/presets.d.ts.map +1 -1
  57. package/dist/presets.js +4 -0
  58. package/dist/presets.js.map +1 -1
  59. package/dist/tex-requirements.d.ts +4 -0
  60. package/dist/tex-requirements.d.ts.map +1 -1
  61. package/dist/tex-requirements.js +3 -0
  62. package/dist/tex-requirements.js.map +1 -1
  63. package/dist/tex-venue-rules.d.ts +7 -1
  64. package/dist/tex-venue-rules.d.ts.map +1 -1
  65. package/dist/tex-venue-rules.js +40 -0
  66. package/dist/tex-venue-rules.js.map +1 -1
  67. package/dist/types.d.ts +2 -0
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/venue-rules.d.ts +38 -8
  70. package/dist/venue-rules.d.ts.map +1 -1
  71. package/dist/venue-rules.js +179 -41
  72. package/dist/venue-rules.js.map +1 -1
  73. package/docs/configuration.md +18 -16
  74. package/docs/e2e.md +32 -11
  75. package/docs/rules/anonymity/identity.md +111 -0
  76. package/docs/rules/format/layout-override.md +61 -0
  77. package/docs/rules/format/page-limit.md +108 -0
  78. package/docs/rules/pdf/body-size.md +87 -0
  79. package/docs/rules.md +44 -39
  80. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  81. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  82. package/fixtures/build-e2e/aidc/expect.json +1 -1
  83. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  84. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  85. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  86. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  87. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  88. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  89. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  90. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  91. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  92. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  93. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  94. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  95. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  96. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  97. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  98. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  99. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  100. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  101. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  102. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  103. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  104. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  105. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  106. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  107. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  108. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  109. package/lib/paper-config.ts +3 -1
  110. package/package.json +1 -1
  111. package/presets/acm-sigconf.jsonc +3 -1
  112. package/presets/aidc.jsonc +16 -17
  113. package/presets/ieee-conference.jsonc +8 -0
  114. package/presets/venue-profile.schema.json +9 -0
  115. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  116. package/src/adapters/latex/index.ts +3 -0
  117. package/src/adapters/latex/layout.ts +106 -0
  118. package/src/adapters/latex/nodes.ts +11 -3
  119. package/src/adapters/latex/parse.ts +5 -1
  120. package/src/build.harness.mjs +2 -2
  121. package/src/build.test.ts +5 -0
  122. package/src/cli.test.ts +13 -0
  123. package/src/cli.ts +36 -4
  124. package/src/domain/anonymity.test.ts +163 -0
  125. package/src/domain/anonymity.ts +129 -0
  126. package/src/domain/body-pages.test.ts +300 -0
  127. package/src/domain/body-pages.ts +223 -0
  128. package/src/domain/tex-document.ts +10 -0
  129. package/src/facts-file.test.ts +100 -4
  130. package/src/facts-file.ts +91 -9
  131. package/src/new-paper.test.ts +7 -1
  132. package/src/paper-settings.test.ts +63 -6
  133. package/src/paper-settings.ts +53 -5
  134. package/src/pdf-content.test.ts +71 -0
  135. package/src/pdf-content.ts +125 -0
  136. package/src/pdf-facts.test.ts +110 -2
  137. package/src/pdf-facts.ts +106 -8
  138. package/src/ports/latex.ts +3 -0
  139. package/src/presets.ts +8 -8
  140. package/src/rule-cases.test.ts +29 -3
  141. package/src/rule-docs.test.ts +0 -2
  142. package/src/tex-requirements.ts +9 -0
  143. package/src/tex-venue-rules.test.ts +110 -5
  144. package/src/tex-venue-rules.ts +57 -2
  145. package/src/types.ts +2 -0
  146. package/src/venue-rules.test.ts +351 -39
  147. package/src/venue-rules.ts +247 -47
@@ -82,14 +82,24 @@ const doc = (last: PageText, geometry: Geometry = NONE) =>
82
82
  sha: "a".repeat(64),
83
83
  venue: "agenticdev",
84
84
  kind: "short",
85
- read: { pages: 4, fonts: FONTS, last, layout: [] },
85
+ read: {
86
+ pages: 4,
87
+ fonts: FONTS,
88
+ last,
89
+ layout: [],
90
+ pageTexts: [],
91
+ links: [],
92
+ bibAnchorPage: null,
93
+ appendixAnchorPage: null,
94
+ metadata: {},
95
+ },
86
96
  geometry,
87
97
  });
88
98
 
89
99
  // ── the document ────────────────────────────────────────────────────────────────────────
90
- test("schema 2; a measured last page gives both heights, in both fields", () => {
100
+ test("schema 3; a measured last page gives both heights, in both fields", () => {
91
101
  const d = doc(lastPage(60, 30));
92
- assert.equal(d.schema, 2);
102
+ assert.equal(d.schema, 3);
93
103
  assert.deepEqual(d.last_page, { kind: "measured", columns_pt: [600, 300] });
94
104
  assert.deepEqual(d.last_page_cols_pt, [600, 300]);
95
105
  assert.equal(d.npages, 4);
@@ -150,7 +160,17 @@ const PDF_BYTES = "%PDF-pretend";
150
160
  const good: PdfReader = () =>
151
161
  Promise.resolve({
152
162
  ok: true,
153
- facts: { pages: 2, fonts: FONTS, last: lastPage(60, 60), layout: [] },
163
+ facts: {
164
+ pages: 2,
165
+ fonts: FONTS,
166
+ last: lastPage(60, 60),
167
+ layout: [],
168
+ pageTexts: [],
169
+ links: [],
170
+ bibAnchorPage: null,
171
+ appendixAnchorPage: null,
172
+ metadata: {},
173
+ },
154
174
  });
155
175
  /** The shipped presets at their real paths: `extends` resolves through the same Files port. */
156
176
  const SHIPPED_PRESETS = Object.fromEntries(
@@ -290,6 +310,11 @@ test("a PDF with no text has an empty font list, not a failed document", () => {
290
310
  fonts: { kind: "no-text" },
291
311
  last: lastPage(0, 0),
292
312
  layout: [],
313
+ pageTexts: [],
314
+ links: [],
315
+ bibAnchorPage: null,
316
+ appendixAnchorPage: null,
317
+ metadata: {},
293
318
  },
294
319
  geometry: NONE,
295
320
  });
@@ -387,3 +412,74 @@ test("parseFactsText: a missing schema is reported as null, another schema by it
387
412
  const other = parse({ ...written(), schema: 1 });
388
413
  assert.deepEqual(!other.ok && other.error, { kind: "schema", got: "1" });
389
414
  });
415
+
416
+ test("schema 3: what the PDF says is written, and reads back typed", () => {
417
+ const said = {
418
+ ...written(),
419
+ pages_text: ["Ada Example\nIntroduction", "References"],
420
+ metadata: { Author: "Ada Example" },
421
+ links: [{ page: 1, uri: "https://github.com/adaexample" }],
422
+ bib_anchor_page: 2,
423
+ appendix_anchor_page: 3,
424
+ };
425
+ const r = parse(said);
426
+ assert.ok(r.ok);
427
+ assert.deepEqual(r.value.text, {
428
+ pages: ["Ada Example\nIntroduction", "References"],
429
+ metadata: { Author: "Ada Example" },
430
+ links: [{ page: 1, uri: "https://github.com/adaexample" }],
431
+ bibAnchorPage: 2,
432
+ appendixAnchorPage: 3,
433
+ });
434
+ const d = factsDocument({
435
+ pdf: "paper.pdf",
436
+ sha: "a".repeat(64),
437
+ venue: null,
438
+ kind: null,
439
+ read: {
440
+ pages: 1,
441
+ fonts: FONTS,
442
+ last: lastPage(1, 0),
443
+ layout: [],
444
+ pageTexts: ["x"],
445
+ links: [{ page: 1, uri: "u" }],
446
+ bibAnchorPage: 1,
447
+ appendixAnchorPage: null,
448
+ metadata: { Title: "t" },
449
+ },
450
+ geometry: MEASURED,
451
+ });
452
+ assert.deepEqual(
453
+ [
454
+ d.pages_text,
455
+ d.links,
456
+ d.bib_anchor_page,
457
+ d.appendix_anchor_page,
458
+ d.metadata,
459
+ ],
460
+ [["x"], [{ page: 1, uri: "u" }], 1, null, { Title: "t" }],
461
+ );
462
+ });
463
+
464
+ test("schema 3: a missing or malformed text field is refused, naming it", () => {
465
+ const noText = Object.fromEntries(
466
+ Object.entries(written()).filter(([k]) => k !== "pages_text"),
467
+ );
468
+ assert.equal(why(noText), "`pages_text` is not a list of strings");
469
+ assert.equal(
470
+ why({ ...written(), metadata: { Author: 3 } }),
471
+ "`metadata` is not an object of strings",
472
+ );
473
+ assert.equal(
474
+ why({ ...written(), links: [{ page: 0, uri: "u" }] }),
475
+ "`links` is not a list of { page, uri }",
476
+ );
477
+ assert.equal(
478
+ why({ ...written(), bib_anchor_page: 0 }),
479
+ "`bib_anchor_page` is neither a page number nor null",
480
+ );
481
+ assert.equal(
482
+ why({ ...written(), appendix_anchor_page: "3" }),
483
+ "`appendix_anchor_page` is neither a page number nor null",
484
+ );
485
+ });
package/src/facts-file.ts CHANGED
@@ -26,6 +26,14 @@
26
26
  * fonts are all named `Type3`; `embedded` means a usable program is present, so a corrupted one is
27
27
  * false. `pdf` is now relative to the PAPER directory, not to wherever the command ran.
28
28
  *
29
+ * ── SCHEMA 3 (2026-09-29) ───────────────────────────────────────────────────────
30
+ * Adds what the PDF SAYS, for the rules that judge text rather than geometry: `pages_text` (each
31
+ * page's text, in content order), `metadata` (the Info dictionary and the XMP packet, flat),
32
+ * `links` (every link annotation's target and page), `bib_anchor_page` and `appendix_anchor_page`
33
+ * (the pages hyperref anchored the bibliography's first entry and the appendix on, or null). Every schema-2 field keeps its name and meaning. The number moves anyway: `anonymity/identity` and `format/page-limit` read these fields,
34
+ * and schema-2 facts without them would be judged as a PDF that says nothing — a clean pass over no
35
+ * input. A schema-2 file is refused by `pdf/fresh` with "rebuild", which is one `paperlint build`.
36
+ *
29
37
  * 🔴 STALENESS. The PDF is not committed, so neither are these facts: they live in `_build/` and
30
38
  * carry `pdf_sha256`. A rule compares it with the PDF on disk and refuses facts about another build.
31
39
  */
@@ -52,7 +60,7 @@ import { paperPreset, paperPresetProblem } from "./presets.ts";
52
60
  import { presetsDir } from "./package-dirs.ts";
53
61
  import { messageOf } from "./domain/text.ts";
54
62
 
55
- export const FACTS_SCHEMA = 2;
63
+ export const FACTS_SCHEMA = 3;
56
64
  export const FACTS_DIR = "_build";
57
65
  export const FACTS_FILE = "paper.facts.json";
58
66
 
@@ -123,7 +131,17 @@ export type FactsDocument = {
123
131
  readonly last_page: LastPageEntry;
124
132
  /** Schema 1's field, kept: the two heights, or null when the last page is a stub or a review build. */
125
133
  readonly last_page_cols_pt: readonly [number, number] | null;
126
- } & FactsGeometryFields;
134
+ } & FactsTextFields &
135
+ FactsGeometryFields;
136
+
137
+ /** Schema 3's fields: what the PDF says (see the module header). */
138
+ export interface FactsTextFields {
139
+ readonly pages_text: readonly string[];
140
+ readonly metadata: Readonly<Record<string, string>>;
141
+ readonly links: readonly { readonly page: number; readonly uri: string }[];
142
+ readonly bib_anchor_page: number | null;
143
+ readonly appendix_anchor_page: number | null;
144
+ }
127
145
 
128
146
  export function fontEntry(f: FontFact): FontEntry {
129
147
  return {
@@ -170,6 +188,11 @@ export function factsDocument(i: FactsInput): FactsDocument {
170
188
  i.read.fonts.kind === "drawn" ? i.read.fonts.list.map(fontEntry) : [],
171
189
  last_page: last,
172
190
  last_page_cols_pt: last.kind === "measured" ? last.columns_pt : null,
191
+ pages_text: i.read.pageTexts,
192
+ metadata: i.read.metadata,
193
+ links: i.read.links,
194
+ bib_anchor_page: i.read.bibAnchorPage,
195
+ appendix_anchor_page: i.read.appendixAnchorPage,
173
196
  ...flatGeometry(i.geometry),
174
197
  };
175
198
  }
@@ -249,6 +272,19 @@ export interface ReadFacts {
249
272
  readonly fonts: readonly FontEntry[];
250
273
  /** The geometry columns, or null when no measurer ran (`geometry_source` is null). */
251
274
  readonly geometry: FlatGeometry | null;
275
+ /** What the PDF says: schema 3's fields, parsed. */
276
+ readonly text: TextFacts;
277
+ }
278
+
279
+ /** Schema 3's fields as a rule reads them. */
280
+ export interface TextFacts {
281
+ readonly pages: readonly string[];
282
+ readonly metadata: Readonly<Record<string, string>>;
283
+ readonly links: readonly { readonly page: number; readonly uri: string }[];
284
+ /** The page of the first bibliography entry hyperref anchored, or null. */
285
+ readonly bibAnchorPage: number | null;
286
+ /** The page hyperref anchored the appendix on, or null. */
287
+ readonly appendixAnchorPage: number | null;
252
288
  }
253
289
 
254
290
  /** Why a facts file cannot be judged. */
@@ -331,6 +367,56 @@ function geometryOf(
331
367
  });
332
368
  }
333
369
 
370
+ const isPage = (v: unknown): v is number =>
371
+ typeof v === "number" && Number.isInteger(v) && v > 0;
372
+ const isPageOrNull = (v: unknown): v is number | null =>
373
+ v === null || isPage(v);
374
+ const isStrings = (v: unknown): v is readonly string[] =>
375
+ Array.isArray(v) && v.every((x) => typeof x === "string");
376
+ const isStringRecord = (v: unknown): v is Readonly<Record<string, string>> =>
377
+ isRecord(v) && Object.values(v).every((x) => typeof x === "string");
378
+ const isLinks = (
379
+ v: unknown,
380
+ ): v is readonly { readonly page: number; readonly uri: string }[] =>
381
+ Array.isArray(v) &&
382
+ v.every(
383
+ (l) => isRecord(l) && isPage(l["page"]) && typeof l["uri"] === "string",
384
+ );
385
+
386
+ /** Schema 3's fields, or which one is wrong. */
387
+ function textOf(
388
+ d: Readonly<Record<string, unknown>>,
389
+ ): Result<TextFacts, string> {
390
+ const { pages_text, metadata, links } = d;
391
+ const { bib_anchor_page, appendix_anchor_page } = d;
392
+ if (!isStrings(pages_text))
393
+ return err("`pages_text` is not a list of strings");
394
+ if (!isStringRecord(metadata))
395
+ return err("`metadata` is not an object of strings");
396
+ if (!isLinks(links)) return err("`links` is not a list of { page, uri }");
397
+ if (!isPageOrNull(bib_anchor_page))
398
+ return err("`bib_anchor_page` is neither a page number nor null");
399
+ if (!isPageOrNull(appendix_anchor_page))
400
+ return err("`appendix_anchor_page` is neither a page number nor null");
401
+ return ok({
402
+ pages: pages_text,
403
+ metadata,
404
+ links,
405
+ bibAnchorPage: bib_anchor_page,
406
+ appendixAnchorPage: appendix_anchor_page,
407
+ });
408
+ }
409
+
410
+ /** What the PDF measures and what it says: the geometry columns and schema 3's fields. */
411
+ function contentOf(
412
+ d: Readonly<Record<string, unknown>>,
413
+ ): Result<Pick<ReadFacts, "geometry" | "text">, string> {
414
+ const geometry = geometryOf(d);
415
+ if (!geometry.ok) return geometry;
416
+ const text = textOf(d);
417
+ return text.ok ? ok({ geometry: geometry.value, text: text.value }) : text;
418
+ }
419
+
334
420
  /** The PDF the facts describe: its path and its sha256. */
335
421
  function artifactOf(
336
422
  d: Readonly<Record<string, unknown>>,
@@ -368,11 +454,7 @@ export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
368
454
  kind: "broken",
369
455
  why: "`fonts` is not a list of { name, type, embedded, program }",
370
456
  });
371
- const geometry = geometryOf(d);
372
- if (!geometry.ok) return err({ kind: "broken", why: geometry.error });
373
- return ok({
374
- ...artifact.value,
375
- fonts,
376
- geometry: geometry.value,
377
- });
457
+ const content = contentOf(d);
458
+ if (!content.ok) return err({ kind: "broken", why: content.error });
459
+ return ok({ ...artifact.value, fonts, ...content.value });
378
460
  }
@@ -51,7 +51,13 @@ describe("paperlint new — paperlint.json", () => {
51
51
  expect(String(s["$comment"])).toMatch(/paperlint:agenticdev/);
52
52
  expect(parsePaperSettings(s)).toEqual({
53
53
  ok: true,
54
- value: { extends: null, kind: null, pdf: null, rules: null },
54
+ value: {
55
+ extends: null,
56
+ kind: null,
57
+ pdf: null,
58
+ rules: null,
59
+ identity: null,
60
+ },
55
61
  });
56
62
  });
57
63
 
@@ -30,18 +30,31 @@ describe("parsePaperSettings", () => {
30
30
  kind: "research",
31
31
  pdf: null,
32
32
  rules: null,
33
+ identity: null,
33
34
  },
34
35
  });
35
36
  expect(parsePaperSettings({})).toEqual({
36
37
  ok: true,
37
- value: { extends: null, kind: null, pdf: null, rules: null },
38
+ value: {
39
+ extends: null,
40
+ kind: null,
41
+ pdf: null,
42
+ rules: null,
43
+ identity: null,
44
+ },
38
45
  });
39
46
  });
40
47
 
41
48
  it("extends: null is valid and means no venue chosen yet — what `paperlint new` writes", () => {
42
49
  expect(parsePaperSettings({ extends: null })).toEqual({
43
50
  ok: true,
44
- value: { extends: null, kind: null, pdf: null, rules: null },
51
+ value: {
52
+ extends: null,
53
+ kind: null,
54
+ pdf: null,
55
+ rules: null,
56
+ identity: null,
57
+ },
45
58
  });
46
59
  });
47
60
  });
@@ -138,6 +151,9 @@ describe("readPaperSettings — a root paperlint.json that cannot be used", () =
138
151
  });
139
152
  });
140
153
 
154
+ /** The fields a case does not set. */
155
+ const UNSET = { pdf: null, rules: null, identity: null };
156
+
141
157
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
142
158
  const ROOT = "/work";
143
159
  const withFiles = (f: Record<string, string>) =>
@@ -161,8 +177,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
161
177
  ).toEqual({
162
178
  extends: "paperlint:agenticdev",
163
179
  kind: "short",
164
- pdf: null,
165
- rules: null,
180
+ ...UNSET,
166
181
  });
167
182
  });
168
183
 
@@ -172,8 +187,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
172
187
  ).toEqual({
173
188
  extends: "paperlint:aisec",
174
189
  kind: null,
175
- pdf: null,
176
- rules: null,
190
+ ...UNSET,
177
191
  });
178
192
  });
179
193
 
@@ -191,6 +205,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
191
205
  pdf: null,
192
206
  // The root's rules are the project's blocks (cli.ts), not this paper's.
193
207
  rules: { "pdf/fonts": "off" },
208
+ identity: null,
194
209
  });
195
210
  });
196
211
 
@@ -232,6 +247,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
232
247
  kind: null,
233
248
  pdf: null,
234
249
  rules,
250
+ identity: null,
235
251
  });
236
252
 
237
253
  it("no rules: none", () => {
@@ -267,3 +283,44 @@ describe("paperRules — `rules` in paperlint.json", () => {
267
283
  }
268
284
  });
269
285
  });
286
+
287
+ describe("readPaperSettings — identity, at both levels", () => {
288
+ const ROOT = "/work";
289
+ const withFiles = (f: Record<string, string>) =>
290
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
291
+ const read = (f: Record<string, string>) => {
292
+ const r = readPaperSettings(withFiles(f), PAPER);
293
+ if (!r.ok) throw new Error(r.error.why);
294
+ return r.value;
295
+ };
296
+
297
+ it("identity: the root's list and the paper's, joined without repeats", () => {
298
+ expect(
299
+ read({
300
+ [`${ROOT}/paperlint.json`]: '{"identity":["Ada Example","adaexample"]}',
301
+ [`${PAPER}/paperlint.json`]:
302
+ '{"identity":["adaexample","Bob Coauthor"]}',
303
+ })?.identity,
304
+ ).toEqual(["Ada Example", "adaexample", "Bob Coauthor"]);
305
+ expect(
306
+ read({ [`${ROOT}/paperlint.json`]: '{"identity":["Ada Example"]}' })
307
+ ?.identity,
308
+ ).toEqual(["Ada Example"]);
309
+ });
310
+
311
+ it("identity of the wrong shape, at either level, is refused naming it", () => {
312
+ const why = (f: Record<string, string>) => {
313
+ const r = readPaperSettings(withFiles(f), PAPER);
314
+ return r.ok ? "" : r.error.why;
315
+ };
316
+ expect(why({ [`${PAPER}/paperlint.json`]: '{"identity":"Ada"}' })).toMatch(
317
+ /"identity" must be a list of strings/,
318
+ );
319
+ expect(why({ [`${PAPER}/paperlint.json`]: '{"identity":[" "]}' })).toMatch(
320
+ /"identity" must be a list of strings, each with a letter or digit/,
321
+ );
322
+ expect(why({ [`${ROOT}/paperlint.json`]: '{"identity":[3]}' })).toMatch(
323
+ /^the root paperlint\.json: "identity" must be/,
324
+ );
325
+ });
326
+ });
@@ -45,6 +45,12 @@ export interface PaperSettings {
45
45
  * against the shipped rules.
46
46
  */
47
47
  readonly rules: Readonly<Record<string, unknown>> | readonly unknown[] | null;
48
+ /**
49
+ * What identifies the authors — `anonymity/identity` requires a blind venue's PDF to say none of it.
50
+ * The root's list and the paper's own, joined: a paper adds its co-authors to the project's author
51
+ * and cannot drop them by accident. Null when neither declares one.
52
+ */
53
+ readonly identity: readonly string[] | null;
48
54
  }
49
55
 
50
56
  /** Why a paper's settings cannot be read. */
@@ -86,6 +92,23 @@ export function stringFields(
86
92
  });
87
93
  }
88
94
 
95
+ /**
96
+ * `identity`: absent is null; otherwise a list of strings, each with a letter or digit in it — a
97
+ * blank entry would match nothing and read as a declared, clean identity.
98
+ */
99
+ export function identityField(
100
+ d: Readonly<Record<string, unknown>>,
101
+ ): Result<readonly string[] | null, string> {
102
+ const v = d["identity"];
103
+ if (v === undefined) return ok(null);
104
+ return Array.isArray(v) &&
105
+ v.every((x) => typeof x === "string" && /[\p{L}\p{N}]/u.test(x))
106
+ ? ok(v.filter((x): x is string => typeof x === "string"))
107
+ : err(
108
+ `"identity" must be a list of strings, each with a letter or digit, got ${JSON.stringify(v)}`,
109
+ );
110
+ }
111
+
89
112
  /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
90
113
  export function parsePaperSettings(
91
114
  json: unknown,
@@ -103,6 +126,8 @@ export function parsePaperSettings(
103
126
  );
104
127
  const fields = stringFields(json);
105
128
  if (!fields.ok) return fields;
129
+ const identity = identityField(json);
130
+ if (!identity.ok) return identity;
106
131
  const rules = json["rules"];
107
132
  if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
108
133
  return err(
@@ -111,6 +136,7 @@ export function parsePaperSettings(
111
136
  return ok({
112
137
  ...fields.value,
113
138
  rules: rules ?? null,
139
+ identity: identity.value,
114
140
  });
115
141
  }
116
142
 
@@ -144,9 +170,18 @@ function rootDefaults(
144
170
  if (!json.ok) return json;
145
171
  if (!isObject(json.value)) return ok(null);
146
172
  const fields = stringFields(json.value);
147
- return fields.ok
148
- ? fields
149
- : err({ kind: "broken", why: `the root ${CONFIG_FILE}: ${fields.error}` });
173
+ const identity = identityField(json.value);
174
+ if (!fields.ok)
175
+ return err({
176
+ kind: "broken",
177
+ why: `the root ${CONFIG_FILE}: ${fields.error}`,
178
+ });
179
+ return identity.ok
180
+ ? ok({ ...fields.value, identity: identity.value })
181
+ : err({
182
+ kind: "broken",
183
+ why: `the root ${CONFIG_FILE}: ${identity.error}`,
184
+ });
150
185
  }
151
186
 
152
187
  /**
@@ -167,8 +202,20 @@ export function readPaperSettings(
167
202
  return ok(merge(defaults.value, parsed.value));
168
203
  }
169
204
 
170
- type Defaults = Pick<PaperSettings, "extends" | "kind" | "pdf">;
171
- const NO_DEFAULTS: Defaults = { extends: null, kind: null, pdf: null };
205
+ type Defaults = Pick<PaperSettings, "extends" | "kind" | "pdf" | "identity">;
206
+ const NO_DEFAULTS: Defaults = {
207
+ extends: null,
208
+ kind: null,
209
+ pdf: null,
210
+ identity: null,
211
+ };
212
+
213
+ /** Both lists, joined without repeats; null when neither side has one. */
214
+ const joined = (
215
+ a: readonly string[] | null,
216
+ b: readonly string[] | null,
217
+ ): readonly string[] | null =>
218
+ a === null && b === null ? null : [...new Set([...(a ?? []), ...(b ?? [])])];
172
219
 
173
220
  /** The paper's own values win; each absent one falls back to the root's. */
174
221
  function merge(
@@ -182,6 +229,7 @@ function merge(
182
229
  kind: p.kind ?? r.kind,
183
230
  pdf: p.pdf ?? r.pdf,
184
231
  rules: p.rules,
232
+ identity: joined(r.identity, p.identity),
185
233
  };
186
234
  return paper === null && Object.values(merged).every((v) => v === null)
187
235
  ? null
@@ -0,0 +1,71 @@
1
+ /** What a page says, read from pdf.js's shapes: text, links, metadata. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { linkTargetsOf, metadataOf, pageTextOf, xmpOf } from "./pdf-content.ts";
4
+
5
+ describe("pageTextOf", () => {
6
+ it("joins items without a space, and breaks the line where pdf.js marks one", () => {
7
+ expect(
8
+ pageTextOf([
9
+ { str: "Ada Ex", hasEOL: false },
10
+ { str: "ample", hasEOL: true },
11
+ { type: "beginMarkedContent" },
12
+ { str: "", hasEOL: true },
13
+ { str: "Intro" },
14
+ ]),
15
+ ).toBe("Ada Example\n\nIntro");
16
+ });
17
+ });
18
+
19
+ describe("linkTargetsOf", () => {
20
+ it("each link's target once: url, else unsafeUrl; not other annotations, not empty ones", () => {
21
+ expect(
22
+ linkTargetsOf([
23
+ { subtype: "Link", url: "https://a.example" },
24
+ { subtype: "Link", url: "https://a.example" },
25
+ { subtype: "Link", unsafeUrl: "mailto:x@example.org" },
26
+ { subtype: "Link", dest: "sec:1" },
27
+ { subtype: "Link", url: "" },
28
+ { subtype: "Widget", url: "https://b.example" },
29
+ ]),
30
+ ).toEqual(["https://a.example", "mailto:x@example.org"]);
31
+ });
32
+ });
33
+
34
+ describe("metadataOf and xmpOf", () => {
35
+ it("every string of the Info dictionary, its custom entries and the XMP packet, flat", () => {
36
+ const xmp = xmpOf(
37
+ new Map<unknown, unknown>([
38
+ ["dc:creator", ["Ada Example", "Bob Other"]],
39
+ ["dc:title", "A Title"],
40
+ ["xmp:rating", 3],
41
+ [7, "not a name"],
42
+ ]),
43
+ );
44
+ expect(xmp).toEqual({
45
+ "dc:creator": ["Ada Example", "Bob Other"],
46
+ "dc:title": "A Title",
47
+ "xmp:rating": 3,
48
+ });
49
+ expect(
50
+ metadataOf(
51
+ {
52
+ Author: "Ada Example",
53
+ Subject: "",
54
+ Trapped: { name: "False" },
55
+ Custom: { "PTEX.Fullbanner": "This is pdfTeX" },
56
+ },
57
+ xmp,
58
+ ),
59
+ ).toEqual({
60
+ Author: "Ada Example",
61
+ "PTEX.Fullbanner": "This is pdfTeX",
62
+ "dc:creator": "Ada Example; Bob Other",
63
+ "dc:title": "A Title",
64
+ });
65
+ });
66
+
67
+ it("no Info and no packet: nothing", () => {
68
+ expect(xmpOf(null)).toBeNull();
69
+ expect(metadataOf(null, null)).toEqual({});
70
+ });
71
+ });