paperlint 6.3.0 → 7.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 (144) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +2 -2
  3. package/dist/adapters/latex/conditionals.d.ts +21 -0
  4. package/dist/adapters/latex/conditionals.d.ts.map +1 -0
  5. package/dist/adapters/latex/conditionals.js +75 -0
  6. package/dist/adapters/latex/conditionals.js.map +1 -0
  7. package/dist/adapters/latex/headings.d.ts +10 -0
  8. package/dist/adapters/latex/headings.d.ts.map +1 -0
  9. package/dist/adapters/latex/headings.js +129 -0
  10. package/dist/adapters/latex/headings.js.map +1 -0
  11. package/dist/adapters/latex/index.d.ts +1 -0
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +3 -0
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/parse.d.ts +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +3 -2
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/build.d.ts +1 -1
  20. package/dist/build.d.ts.map +1 -1
  21. package/dist/build.js +1 -1
  22. package/dist/build.js.map +1 -1
  23. package/dist/cli.d.ts +1 -1
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +17 -3
  26. package/dist/cli.js.map +1 -1
  27. package/dist/domain/heading-case.d.ts +100 -0
  28. package/dist/domain/heading-case.d.ts.map +1 -0
  29. package/dist/domain/heading-case.js +365 -0
  30. package/dist/domain/heading-case.js.map +1 -0
  31. package/dist/domain/tex-document.d.ts +32 -0
  32. package/dist/domain/tex-document.d.ts.map +1 -1
  33. package/dist/domain/tex-document.js +11 -0
  34. package/dist/domain/tex-document.js.map +1 -1
  35. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  36. package/dist/eslint-rules/latex-language.js +16 -3
  37. package/dist/eslint-rules/latex-language.js.map +1 -1
  38. package/dist/facts-file.d.ts +3 -3
  39. package/dist/facts-file.d.ts.map +1 -1
  40. package/dist/facts-file.js +3 -3
  41. package/dist/facts-file.js.map +1 -1
  42. package/dist/folder-venue-rule.js +6 -6
  43. package/dist/folder-venue-rule.js.map +1 -1
  44. package/dist/heading-case.d.ts +48 -0
  45. package/dist/heading-case.d.ts.map +1 -0
  46. package/dist/heading-case.js +191 -0
  47. package/dist/heading-case.js.map +1 -0
  48. package/dist/ports/latex.d.ts +7 -1
  49. package/dist/ports/latex.d.ts.map +1 -1
  50. package/dist/preset-card-rule.d.ts +100 -0
  51. package/dist/preset-card-rule.d.ts.map +1 -0
  52. package/dist/preset-card-rule.js +221 -0
  53. package/dist/preset-card-rule.js.map +1 -0
  54. package/dist/presets.d.ts +19 -2
  55. package/dist/presets.d.ts.map +1 -1
  56. package/dist/presets.js +97 -62
  57. package/dist/presets.js.map +1 -1
  58. package/dist/tex-requirements.d.ts +33 -3
  59. package/dist/tex-requirements.d.ts.map +1 -1
  60. package/dist/tex-requirements.js +27 -2
  61. package/dist/tex-requirements.js.map +1 -1
  62. package/dist/tex-venue-rules.d.ts +1 -1
  63. package/dist/tex-venue-rules.d.ts.map +1 -1
  64. package/dist/tex-venue-rules.js.map +1 -1
  65. package/docs/configuration.md +13 -1
  66. package/docs/optional-rules.md +4 -3
  67. package/docs/rules/format/page-limit.md +1 -1
  68. package/docs/rules/paper/folder-venue-leftover.md +1 -1
  69. package/docs/rules/talk/profile.md +1 -1
  70. package/docs/rules/tex/heading-case.md +424 -0
  71. package/docs/rules.md +62 -53
  72. package/eslint-rules/latex-language.test.mjs +12 -0
  73. package/eslint-rules/latex-language.ts +17 -4
  74. package/eslint-rules/paper-typography.test.mjs +12 -0
  75. package/eslint.config.mjs +59 -1
  76. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  77. package/fixtures/build-e2e/fallback/expect.json +3 -3
  78. package/lib/skill-checks.mjs +31 -0
  79. package/package.json +7 -1
  80. package/presets/acm-sigconf.jsonc +5 -0
  81. package/presets/acm-sigconf.md +43 -0
  82. package/presets/agenticdev.jsonc +27 -6
  83. package/presets/agenticdev.md +151 -0
  84. package/presets/aidc.jsonc +6 -0
  85. package/presets/aidc.md +75 -0
  86. package/presets/aisec.jsonc +5 -1
  87. package/presets/aisec.md +117 -0
  88. package/presets/ieee-conference.jsonc +5 -0
  89. package/presets/ieee-conference.md +38 -0
  90. package/presets/realm.jsonc +7 -2
  91. package/presets/realm.md +166 -0
  92. package/presets/tex/agenticdev.tex +1 -1
  93. package/presets/tex-base.jsonc +6 -0
  94. package/presets/tex-base.md +30 -0
  95. package/presets/venue-profile.schema.json +40 -1
  96. package/skills/find-venue/SKILL.md +6 -5
  97. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  98. package/skills/paper-pipeline/SKILL.md +5 -6
  99. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  100. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  101. package/skills/study-accepted-papers/SKILL.md +3 -3
  102. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  103. package/skills/submit-paper/SKILL.md +26 -22
  104. package/skills/submit-paper/SKILL.md.spec.ts +25 -21
  105. package/skills/submit-paper/references/publishers/acm.md +1 -1
  106. package/src/adapters/latex/conditionals.ts +130 -0
  107. package/src/adapters/latex/headings.ts +163 -0
  108. package/src/adapters/latex/index.test.ts +65 -0
  109. package/src/adapters/latex/index.ts +3 -0
  110. package/src/adapters/latex/parse.ts +3 -2
  111. package/src/build.ts +2 -2
  112. package/src/cli.test.ts +50 -7
  113. package/src/cli.ts +21 -4
  114. package/src/domain/heading-case.test.ts +660 -0
  115. package/src/domain/heading-case.ts +538 -0
  116. package/src/domain/tex-document.ts +36 -0
  117. package/src/domain/venue-name.test.ts +13 -7
  118. package/src/facts-file.test.ts +4 -4
  119. package/src/facts-file.ts +6 -6
  120. package/src/folder-venue-rule.test.ts +10 -10
  121. package/src/folder-venue-rule.ts +6 -6
  122. package/src/heading-case.test.ts +500 -0
  123. package/src/heading-case.ts +275 -0
  124. package/src/init.test.ts +1 -1
  125. package/src/new-paper.test.ts +10 -6
  126. package/src/paper-settings-commands.test.ts +25 -10
  127. package/src/ports/latex.ts +7 -0
  128. package/src/preset-card-rule.test.ts +266 -0
  129. package/src/preset-card-rule.ts +384 -0
  130. package/src/presets.test.ts +193 -40
  131. package/src/presets.ts +139 -70
  132. package/src/rule-cases.test.ts +136 -50
  133. package/src/submission.test.ts +2 -1
  134. package/src/talk-rules.test.ts +6 -3
  135. package/src/tex-requirements.harness.ts +25 -11
  136. package/src/tex-requirements.test.ts +127 -1
  137. package/src/tex-requirements.ts +70 -8
  138. package/src/tex-venue-rules.test.ts +35 -21
  139. package/src/tex-venue-rules.ts +2 -1
  140. package/src/venue-rules.test.ts +7 -6
  141. package/templates/paper/paper.tex +1 -1
  142. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  143. package/skills/submit-paper/references/venues/aisec.md +0 -105
  144. package/skills/submit-paper/references/venues/realm.md +0 -155
@@ -18,7 +18,7 @@ import { z } from "zod";
18
18
  import { run, rulePlugins } from "./cli.ts";
19
19
  import { sha256Hex } from "./domain/sha256.ts";
20
20
  import { bibHash } from "./references.ts";
21
- import { useTempDir, writeTree } from "../test/support.ts";
21
+ import { useTempDir, venuePreset, writeTree } from "../test/support.ts";
22
22
 
23
23
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
24
24
  const fixture = (p: string): string =>
@@ -39,19 +39,87 @@ const paper = (
39
39
  ...extra,
40
40
  });
41
41
 
42
+ // ── the venues: presets of the tree's own, never the shipped ones ──────────────────────────────
43
+ //
44
+ // A case judges a RULE, so every number it is sized against is written here, beside the facts and
45
+ // the bodies that meet or miss it. A shipped venue preset changes when its call for papers does;
46
+ // its own tests resolve and judge it (presets.test.ts, venue-rules.test.ts, tex-venue-rules.test.ts,
47
+ // register-bands.test.ts, heading-case.test.ts). The one exception is the leftover rules, below:
48
+ // they name SHIPPED venues by design, so their reporting cases need one shipped venue's name.
49
+ // The venues below extend a template family only for what the schema asks of a chain's root (its
50
+ // `tex`), and set every key a case reads themselves.
51
+
52
+ /** A short-paper venue on the ACM template: the format the facts of `goodFacts` meet. */
53
+ const EXAMPLECONF = venuePreset("ExampleConf", {
54
+ extends: "paperlint:acm-sigconf",
55
+ format: {
56
+ page_w_in: 8.5,
57
+ page_h_in: 11,
58
+ columns: 2,
59
+ body_pt: 9,
60
+ body_pt_tol: 0.5,
61
+ ref_pt_min: 7,
62
+ ref_pt_max: 9,
63
+ fonts_text: "LinLibertine",
64
+ fonts_title: "LinBiolinum",
65
+ kinds: { short: { body_pages_max: 5, ref_pages_max: 2 } },
66
+ },
67
+ // chicago-headline for the title and every heading (tex/heading-case).
68
+ rules: {
69
+ "tex/heading-case": [
70
+ "error",
71
+ { title: "chicago-headline", headings: "chicago-headline" },
72
+ ],
73
+ },
74
+ });
75
+ const IEEE = "\\documentclass[conference,compsoc]{IEEEtran}";
76
+ const STATEMENT_TITLE = "LLM Usage Statement";
77
+ /**
78
+ * A double-blind venue on the IEEE template: the class options it requires, the section it requires
79
+ * last, and one accepted paper of 10,000 words for the register bands. Its rates (contrast frames 4,
80
+ * claims in bold 4, relation markers 64 per 10,000 words), widened by two Poisson standard
81
+ * deviations, set the bands 0–8, 0–8 and 48–80.
82
+ */
83
+ const BLINDCONF = venuePreset("BlindConf", {
84
+ extends: "paperlint:ieee-conference",
85
+ blind: true,
86
+ template: IEEE,
87
+ required_sections: [{ title: STATEMENT_TITLE, position: "last" }],
88
+ register: {
89
+ anchors: [
90
+ {
91
+ paper: "an-accepted-paper",
92
+ words: 10_000,
93
+ contrast_frames: 4,
94
+ claim_emphasis: 4,
95
+ relation_markers: 64,
96
+ },
97
+ ],
98
+ },
99
+ });
100
+ /** The venue presets every built paper carries beside it, extended or not. */
101
+ const VENUES = {
102
+ [`${P}/exampleconf.jsonc`]: JSON.stringify(EXAMPLECONF),
103
+ [`${P}/blindconf.jsonc`]: JSON.stringify(BLINDCONF),
104
+ };
105
+ /** A paper folder on BlindConf: the preset beside its `paperlint.json`. */
106
+ const onBlindConf = (dir: string): Record<string, string> => ({
107
+ [`${dir}/blindconf.jsonc`]: JSON.stringify(BLINDCONF),
108
+ [`${dir}/paperlint.json`]: JSON.stringify({ extends: "./blindconf.jsonc" }),
109
+ });
110
+
42
111
  // ── the venue rules' inputs: a PDF and the facts `paperlint build` would write about it ─────────
43
112
 
44
113
  const PDF = "%PDF-1.5 a stand-in for the built PDF";
45
- const AGENTICDEV = { extends: "paperlint:agenticdev", kind: "short" };
114
+ const EXAMPLE = { extends: "./exampleconf.jsonc", kind: "short" };
46
115
  /** A double-blind venue's paper that declares who wrote it (`anonymity/*`). */
47
116
  const BLIND = {
48
- extends: "paperlint:aidc",
49
- kind: "regular",
117
+ extends: "./blindconf.jsonc",
50
118
  identity: ["Ada Example", "adaexample"],
51
119
  };
52
120
  const ACM_TEX = tex("Text.", "\\documentclass[sigconf]{acmart}");
53
121
 
54
- /** The facts of a short agenticdev paper that meets every number in its preset. */
122
+ /** The facts of a short ExampleConf paper that meets every number in its preset. */
55
123
  const goodFacts = (): Record<string, unknown> => ({
56
124
  schema: 3,
57
125
  pages_text: [],
@@ -61,7 +129,7 @@ const goodFacts = (): Record<string, unknown> => ({
61
129
  appendix_anchor_page: null,
62
130
  pdf: "paper.pdf",
63
131
  pdf_sha256: sha256Hex(new TextEncoder().encode(PDF)),
64
- venue: "agenticdev",
132
+ venue: "exampleconf",
65
133
  kind: "short",
66
134
  npages: 7,
67
135
  fonts_source: "pdfjs-drawn",
@@ -85,12 +153,13 @@ const goodFacts = (): Record<string, unknown> => ({
85
153
  pages_by_type: { body: 5, bib: 2 },
86
154
  });
87
155
 
88
- /** An agenticdev paper, built: its settings, the PDF, and facts with `patch` applied. */
156
+ /** An ExampleConf paper, built: its settings, the PDF, and facts with `patch` applied. */
89
157
  const built = (
90
158
  patch: Record<string, unknown> = {},
91
- settings: Record<string, unknown> = AGENTICDEV,
159
+ settings: Record<string, unknown> = EXAMPLE,
92
160
  ): Record<string, string> =>
93
161
  paper(ACM_TEX, {
162
+ ...VENUES,
94
163
  [`${P}/paperlint.json`]: JSON.stringify(settings),
95
164
  [`${P}/paper.pdf`]: PDF,
96
165
  [`${P}/_build/paper.facts.json`]: JSON.stringify({
@@ -129,23 +198,26 @@ const checked = (exists: string, authors: string) => ({
129
198
  entries: [{ key: "a", exists, authors }],
130
199
  });
131
200
 
132
- // ── an AIDC paper, for the LaTeX venue rules ──────────────────────────────────────────────────
201
+ // ── a BlindConf paper, for the LaTeX venue rules ──────────────────────────────────────────────
133
202
 
134
- const AIDC = {
135
- [`${P}/paperlint.json`]: JSON.stringify({
136
- extends: "paperlint:aidc",
137
- kind: "short",
138
- }),
203
+ const BLIND_PAPER = onBlindConf(P);
204
+ const ACM_CLASS = "\\documentclass[sigconf]{acmart}";
205
+ /** A paper on ExampleConf's preset: it turns `tex/heading-case` on, chicago-headline for the title and every heading. */
206
+ const ACM_PRESET = {
207
+ ...VENUES,
208
+ [`${P}/paperlint.json`]: JSON.stringify(EXAMPLE),
139
209
  };
140
- const IEEE = "\\documentclass[conference,compsoc]{IEEEtran}";
141
- const STATEMENT = "\\section*{LLM Usage Statement}\nNone.";
210
+ const STATEMENT = `\\section*{${STATEMENT_TITLE}}\nNone.`;
142
211
  /** Two `\\documentclass` lines behind a switch: `article` for one venue, `second` for this one. */
143
212
  const switched = (second: string): string =>
144
213
  `\\def\\venue{2}\n\\if\\venue1\n\\documentclass{article}\n\\fi\n\\if\\venue2\n${second}\n\\fi`;
145
- const aidc = (body: string, cls = IEEE) =>
146
- paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
214
+ const blind = (body: string, cls = IEEE) =>
215
+ paper(
216
+ tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls),
217
+ BLIND_PAPER,
218
+ );
147
219
 
148
- // ── a paper that declares a talk: a preset of its own over agenticdev, and synthetic media ───────
220
+ // ── a paper that declares a talk: a preset of its own over ExampleConf, and synthetic media ──────
149
221
 
150
222
  const media = (name: string): Uint8Array =>
151
223
  readFileSync(join(ROOT, "fixtures", "talk", name));
@@ -173,10 +245,13 @@ const talkPaper = (
173
245
  };
174
246
  return {
175
247
  ...paper(ACM_TEX, {
176
- [`${P}/talk-venue.jsonc`]: JSON.stringify({
177
- extends: "paperlint:agenticdev",
178
- talk: { ...TALK_VENUE, ...venue },
179
- }),
248
+ ...VENUES,
249
+ [`${P}/talk-venue.jsonc`]: JSON.stringify(
250
+ venuePreset("talk-venue", {
251
+ extends: "./exampleconf.jsonc",
252
+ talk: { ...TALK_VENUE, ...venue },
253
+ }),
254
+ ),
180
255
  [`${P}/paperlint.json`]: JSON.stringify({
181
256
  extends: "./talk-venue.jsonc",
182
257
  kind: "short",
@@ -226,7 +301,7 @@ const FORMAL = Array.from(
226
301
  /** FORMAL with `n` more sentences `s`: a body whose rate of one register measure is set. */
227
302
  const formalWith = (n: number, s: string): string =>
228
303
  `${FORMAL} ${Array.from({ length: n }, () => s).join(" ")}`;
229
- /** FORMAL with enough relation markers for AIDC's band (above 25.4 per 10,000 words). */
304
+ /** FORMAL with 20 relation markers, 68.7 per 10,000 words: inside BlindConf's band (48–80). */
230
305
  const LINKED = formalWith(
231
306
  10,
232
307
  "Therefore, the rule counts the sentence because the body holds it.",
@@ -373,49 +448,60 @@ const CASES: Readonly<Record<string, RuleCases>> = {
373
448
  // The class picked behind a TeX switch: two candidates, one of them the venue's or none.
374
449
  "tex/template": {
375
450
  reports: {
376
- tree: aidc("Text.", switched("\\documentclass[conference]{IEEEtran}")),
451
+ tree: blind("Text.", switched("\\documentclass[conference]{IEEEtran}")),
377
452
  file: TEX_FILE,
378
453
  severity: 2,
379
454
  line: 3,
380
455
  },
381
- silent: aidc("Text.", switched(IEEE)),
456
+ silent: blind("Text.", switched(IEEE)),
382
457
  },
383
458
  "tex/required-section": {
384
459
  reports: {
385
- tree: paper(tex("\\section{Introduction}\nText.", IEEE), AIDC),
460
+ tree: paper(tex("\\section{Introduction}\nText.", IEEE), BLIND_PAPER),
386
461
  file: TEX_FILE,
387
462
  severity: 2,
388
463
  line: 5,
389
464
  },
390
- silent: aidc("Text."),
465
+ silent: blind("Text."),
466
+ },
467
+ "tex/heading-case": {
468
+ reports: {
469
+ tree: paper(tex("\\section{Related work}", ACM_CLASS), ACM_PRESET),
470
+ file: TEX_FILE,
471
+ severity: 2,
472
+ line: 3,
473
+ },
474
+ silent: paper(tex("\\section{Related Work}", ACM_CLASS), ACM_PRESET),
391
475
  },
392
476
  "paper/folder-venue-leftover": {
393
477
  reports: {
394
- // An AIDC paper still in the folder it had when it targeted AISec.
478
+ // A BlindConf paper in a folder named for EMNLP. The rule knows only the SHIPPED venues'
479
+ // names (REALM's preset calls it by its conference, EMNLP), so the other venue is one of them.
395
480
  tree: {
396
- "papers/aisec-2026/PIPELINE-STATUS.md": STATUS,
397
- "papers/aisec-2026/paper.tex": tex("x"),
398
- "papers/aisec-2026/paperlint.json": AIDC[`${P}/paperlint.json`],
481
+ "papers/emnlp-2026/PIPELINE-STATUS.md": STATUS,
482
+ "papers/emnlp-2026/paper.tex": tex("x"),
483
+ ...onBlindConf("papers/emnlp-2026"),
399
484
  },
400
- file: "papers/aisec-2026/PIPELINE-STATUS.md",
485
+ file: "papers/emnlp-2026/PIPELINE-STATUS.md",
401
486
  severity: 1,
402
487
  line: 1,
403
488
  },
404
489
  // The folder names the paper's own venue.
405
490
  silent: {
406
- "papers/aidc-2026/PIPELINE-STATUS.md": STATUS,
407
- "papers/aidc-2026/paper.tex": tex("x"),
408
- "papers/aidc-2026/paperlint.json": AIDC[`${P}/paperlint.json`],
491
+ "papers/blindconf-2026/PIPELINE-STATUS.md": STATUS,
492
+ "papers/blindconf-2026/paper.tex": tex("x"),
493
+ ...onBlindConf("papers/blindconf-2026"),
409
494
  },
410
495
  },
496
+ // EMNLP, a shipped venue's name (REALM's conference), in a BlindConf paper's text.
411
497
  "tex/venue-leftover": {
412
498
  reports: {
413
- tree: aidc("First written for AISec."),
499
+ tree: blind("First written for EMNLP."),
414
500
  file: TEX_FILE,
415
501
  severity: 1,
416
502
  line: 4,
417
503
  },
418
- silent: aidc("Text."),
504
+ silent: blind("Text."),
419
505
  },
420
506
  "tex/claim-provenance": {
421
507
  reports: {
@@ -439,31 +525,31 @@ const CASES: Readonly<Record<string, RuleCases>> = {
439
525
  },
440
526
  silent: paper(tex(FORMAL)),
441
527
  },
442
- // The three register bands judge a body against the anchors of AIDC's preset; the body opens on
443
- // line 3, right after the \\section, where the finding about the whole body stands.
528
+ // The three register bands judge a body against the anchor of BlindConf's preset; the body opens
529
+ // on line 3, right after the \\section, where the finding about the whole body stands.
444
530
  "tex/contrast-frames": {
445
531
  reports: {
446
- tree: aidc(formalWith(5, "It reads the word, not the operation.")),
532
+ tree: blind(formalWith(5, "It reads the word, not the operation.")),
447
533
  file: TEX_FILE,
448
534
  severity: 1,
449
535
  line: 3,
450
536
  },
451
- silent: aidc(LINKED),
537
+ silent: blind(LINKED),
452
538
  },
453
539
  "tex/claim-emphasis": {
454
540
  reports: {
455
- tree: aidc(
541
+ tree: blind(
456
542
  formalWith(6, "We find that \\textbf{the rule counts 46 sentences}."),
457
543
  ),
458
544
  file: TEX_FILE,
459
545
  severity: 1,
460
546
  line: 3,
461
547
  },
462
- silent: aidc(LINKED),
548
+ silent: blind(LINKED),
463
549
  },
464
550
  "tex/relation-markers": {
465
- reports: { tree: aidc(FORMAL), file: TEX_FILE, severity: 1, line: 3 },
466
- silent: aidc(LINKED),
551
+ reports: { tree: blind(FORMAL), file: TEX_FILE, severity: 1, line: 3 },
552
+ silent: blind(LINKED),
467
553
  },
468
554
  "bib/reachable-entry": {
469
555
  reports: {
@@ -518,23 +604,23 @@ const CASES: Readonly<Record<string, RuleCases>> = {
518
604
  },
519
605
  "format/layout-override": {
520
606
  reports: {
521
- tree: aidc("\\linespread{0.9}Text."),
607
+ tree: blind("\\linespread{0.9}Text."),
522
608
  file: TEX_FILE,
523
609
  severity: 2,
524
610
  line: 4,
525
611
  },
526
- silent: aidc("\\vspace{-2mm}Text."),
612
+ silent: blind("\\vspace{-2mm}Text."),
527
613
  },
528
614
  "pdf/last-page-balance": {
529
615
  reports: onPdf(
530
616
  built(
531
617
  { last_page: { kind: "measured", columns_pt: [621.5, 264.8] } },
532
- { ...AGENTICDEV, rules: { "pdf/last-page-balance": "error" } },
618
+ { ...EXAMPLE, rules: { "pdf/last-page-balance": "error" } },
533
619
  ),
534
620
  ),
535
621
  silent: built(
536
622
  {},
537
- { ...AGENTICDEV, rules: { "pdf/last-page-balance": "error" } },
623
+ { ...EXAMPLE, rules: { "pdf/last-page-balance": "error" } },
538
624
  ),
539
625
  },
540
626
  "talk/profile": {
@@ -27,6 +27,7 @@ import {
27
27
  usageProblem,
28
28
  type SubmissionArgs,
29
29
  } from "./submission.ts";
30
+ import { venuePreset } from "../test/support.ts";
30
31
 
31
32
  const PAPER = "/w/papers/p";
32
33
  const SITE = "http://127.0.0.1:9";
@@ -41,7 +42,7 @@ const withPresets = (fs: Files): Files => ({
41
42
  });
42
43
 
43
44
  const venue = (portal: unknown): string =>
44
- JSON.stringify({ extends: "paperlint:aidc", portal });
45
+ JSON.stringify(venuePreset("venue", { extends: "paperlint:aidc", portal }));
45
46
  const files = (o: {
46
47
  readonly portal?: unknown;
47
48
  readonly paper?: Readonly<Record<string, unknown>>;
@@ -23,7 +23,7 @@ import {
23
23
  talkRules,
24
24
  type TalkRuleName,
25
25
  } from "./talk-rules.ts";
26
- import { useTempDir, writeTree } from "../test/support.ts";
26
+ import { useTempDir, venuePreset, writeTree } from "../test/support.ts";
27
27
 
28
28
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
29
  const media = (name: string): Uint8Array =>
@@ -62,8 +62,11 @@ function run(
62
62
  const dir = useTempDir("paperlint-talk-rules-");
63
63
  const venue =
64
64
  o.venue === null
65
- ? { extends: "paperlint:acm-sigconf" }
66
- : { extends: "paperlint:agenticdev", talk: { ...VENUE, ...o.venue } };
65
+ ? { type: "family", extends: "paperlint:acm-sigconf" }
66
+ : venuePreset("venue", {
67
+ extends: "paperlint:agenticdev",
68
+ talk: { ...VENUE, ...o.venue },
69
+ });
67
70
  writeTree(dir, {
68
71
  "package.json": "{}",
69
72
  "p/paper.tex": "x",
@@ -130,32 +130,40 @@ copyFileSync(join(VENUES, R.SCHEMA_FILE), join(tmp, R.SCHEMA_FILE));
130
130
  const bad = (text: string) =>
131
131
  throws(() => R.parseProfile(text, "bad.jsonc", tmp));
132
132
  const BAD: [string, string, string][] = [
133
- ["no `tex` block at all", `{ "template": "acmart" }`, "tex"],
133
+ [
134
+ "no `tex` block at all",
135
+ `{ "type": "family", "template": "acmart" }`,
136
+ "tex",
137
+ ],
134
138
  // Guards: typo detection — `templat` would be accepted and the field silently unread.
135
139
  [
136
140
  "an unknown top-level key (a typo)",
137
- `{ "tex": { "packages": { "a": ["a.sty"] } }, "templat": "x" }`,
141
+ `{ "type": "family", "tex": { "packages": { "a": ["a.sty"] } }, "templat": "x" }`,
138
142
  "additional properties",
139
143
  ],
140
144
  [
141
145
  "a package with NO proof file",
142
- `{ "tex": { "packages": { "acmart": [] } } }`,
146
+ `{ "type": "family", "tex": { "packages": { "acmart": [] } } }`,
143
147
  ".tex.packages['acmart']",
144
148
  ],
145
149
  [
146
150
  "a package name tlmgr would not know (upper case)",
147
- `{ "tex": { "packages": { "Acmart": ["acmart.cls"] } } }`,
151
+ `{ "type": "family", "tex": { "packages": { "Acmart": ["acmart.cls"] } } }`,
148
152
  "property name",
149
153
  ],
150
154
  [
151
155
  "a proof that is a path, not a file name",
152
- `{ "tex": { "packages": { "a": ["tex/a.sty"] } } }`,
156
+ `{ "type": "family", "tex": { "packages": { "a": ["tex/a.sty"] } } }`,
153
157
  "pattern",
154
158
  ],
155
- ["an empty packages map", `{ "tex": { "packages": {} } }`, "fewer than 1"],
159
+ [
160
+ "an empty packages map",
161
+ `{ "type": "family", "tex": { "packages": {} } }`,
162
+ "fewer than 1",
163
+ ],
156
164
  [
157
165
  "an unknown key inside tex",
158
- `{ "tex": { "packages": { "a": ["a.sty"] }, "pkgs": {} } }`,
166
+ `{ "type": "family", "tex": { "packages": { "a": ["a.sty"] }, "pkgs": {} } }`,
159
167
  "additional properties",
160
168
  ],
161
169
  ];
@@ -174,7 +182,9 @@ check(
174
182
  );
175
183
  check(
176
184
  "comments and trailing commas ARE JSONC",
177
- bad(`// quote\n{ "tex": { "packages": { "a": ["a.sty"], }, }, }`) === "",
185
+ bad(
186
+ `// quote\n{ "type": "family", "tex": { "packages": { "a": ["a.sty"], }, }, }`,
187
+ ) === "",
178
188
  );
179
189
  rmSync(tmp, { recursive: true, force: true });
180
190
 
@@ -239,7 +249,7 @@ rmSync(tmp, { recursive: true, force: true });
239
249
  // ── 5. the parser's tolerances: an optional block left out, and Ajv's optional fields ───
240
250
  {
241
251
  const bare = R.parsePreset(
242
- '{ "extends": "paperlint:aisec" }',
252
+ '{ "type": "venue", "name": "X", "url": "https://x.org/", "extends": "paperlint:aisec" }',
243
253
  "bare.jsonc",
244
254
  VENUES,
245
255
  );
@@ -254,9 +264,13 @@ rmSync(tmp, { recursive: true, force: true });
254
264
  check(
255
265
  "a tex block without `packages` is refused by the schema, before anything reads it",
256
266
  /tex\.jsonc: \.tex should have required property 'packages'/.test(
257
- throws(() => R.parsePreset('{ "tex": {} }', "tex.jsonc", VENUES)),
267
+ throws(() =>
268
+ R.parsePreset('{ "type": "family", "tex": {} }', "tex.jsonc", VENUES),
269
+ ),
270
+ ),
271
+ throws(() =>
272
+ R.parsePreset('{ "type": "family", "tex": {} }', "tex.jsonc", VENUES),
258
273
  ),
259
- throws(() => R.parsePreset('{ "tex": {} }', "tex.jsonc", VENUES)),
260
274
  );
261
275
  check(
262
276
  "violations: Ajv's null `errors` is no violation; a message Ajv left out is an empty one",
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "vitest";
3
- import { loadTypescript } from "./tex-requirements.ts";
3
+ import {
4
+ identityHint,
5
+ identityHints,
6
+ loadTypescript,
7
+ parsePreset,
8
+ } from "./tex-requirements.ts";
4
9
 
5
10
  test("loadTypescript refuses a module without the two functions a profile is read with", () => {
6
11
  assert.throws(
@@ -19,3 +24,124 @@ test("loadTypescript hands back what `require` loaded when it has them", () => {
19
24
  ts,
20
25
  );
21
26
  });
27
+
28
+ // ── what a preset is: the identity its `type` declares ─────────────────────────────────────
29
+
30
+ /** What `parsePreset` throws for `preset`, as text; "" when it parses. */
31
+ function refusalOf(name: string, preset: object): string {
32
+ try {
33
+ parsePreset(JSON.stringify(preset), name);
34
+ return "";
35
+ } catch (e) {
36
+ return e instanceof Error ? e.message : String(e);
37
+ }
38
+ }
39
+
40
+ test("a venue without the link to its call for papers is refused at load, saying what to add", () => {
41
+ // Guards: the wrong state the identity exists to make unrepresentable — a venue nobody can check.
42
+ assert.equal(
43
+ refusalOf("v.jsonc", {
44
+ type: "venue",
45
+ name: "X",
46
+ extends: "paperlint:acm-sigconf",
47
+ }),
48
+ [
49
+ "v.jsonc does not match venue-profile.schema.json:",
50
+ ' a venue preset names its venue and links its call for papers: "name": "<the venue, as its call spells it>", "url": "https://<the call for papers>"',
51
+ " v.jsonc: (top level) should have required property 'url'",
52
+ ' v.jsonc: (top level) should match "then" schema',
53
+ ].join("\n"),
54
+ );
55
+ });
56
+
57
+ test("a template family with a venue's name and url is refused, naming each key", () => {
58
+ assert.equal(
59
+ refusalOf("f.jsonc", {
60
+ type: "family",
61
+ name: "ACM",
62
+ url: "https://acm.org/",
63
+ extends: "paperlint:acm-sigconf",
64
+ }),
65
+ [
66
+ "f.jsonc does not match venue-profile.schema.json:",
67
+ ' a template family names no venue: drop "name" — "name" and "url" belong to the venue presets that extend it',
68
+ ' a template family names no venue: drop "url" — "name" and "url" belong to the venue presets that extend it',
69
+ " f.jsonc: .name boolean schema is false",
70
+ " f.jsonc: .url boolean schema is false",
71
+ ' f.jsonc: (top level) should match "then" schema',
72
+ ].join("\n"),
73
+ );
74
+ });
75
+
76
+ test("a preset that does not say what it is is refused, with the three answers", () => {
77
+ assert.equal(
78
+ refusalOf("n.jsonc", { extends: "paperlint:acm-sigconf" }),
79
+ [
80
+ "n.jsonc does not match venue-profile.schema.json:",
81
+ ' say what this preset is: "type": "venue" (one venue\'s call for papers), "family" (a publisher\'s template that venues extend) or "base" (the TeX base set)',
82
+ " n.jsonc: (top level) should have required property 'type'",
83
+ ].join("\n"),
84
+ );
85
+ });
86
+
87
+ test("the TeX base set holds its packages and nothing else", () => {
88
+ assert.equal(
89
+ refusalOf("b.jsonc", {
90
+ type: "base",
91
+ tex: { packages: { a: ["a.sty"] } },
92
+ format: { columns: 2 },
93
+ }),
94
+ [
95
+ "b.jsonc does not match venue-profile.schema.json:",
96
+ ' the TeX base set holds "type" and "tex" only — a venue\'s or a family\'s settings go in its own preset',
97
+ " b.jsonc: (top level) should NOT have more than 2 properties",
98
+ ' b.jsonc: (top level) should match "then" schema',
99
+ ].join("\n"),
100
+ );
101
+ });
102
+
103
+ test("a venue is parsed into its identity; a family and the base set carry none of a venue's fields", () => {
104
+ const read = (p: object) =>
105
+ parsePreset(JSON.stringify(p), "p.jsonc").identity;
106
+ assert.deepEqual(
107
+ [
108
+ read({
109
+ type: "venue",
110
+ name: "X",
111
+ url: "https://x.org/cfp",
112
+ extends: "paperlint:acm-sigconf",
113
+ }),
114
+ read({ type: "family", extends: "paperlint:acm-sigconf" }),
115
+ read({ type: "base", tex: { packages: { a: ["a.sty"] } } }),
116
+ ],
117
+ [
118
+ { type: "venue", name: "X", url: "https://x.org/cfp" },
119
+ { type: "family" },
120
+ { type: "base" },
121
+ ],
122
+ );
123
+ });
124
+
125
+ test("identityHint has nothing to add to a violation that is not about the identity", () => {
126
+ assert.equal(
127
+ identityHint({
128
+ keyword: "additionalProperties",
129
+ dataPath: "",
130
+ params: { additionalProperty: "colums" },
131
+ }),
132
+ null,
133
+ );
134
+ });
135
+
136
+ test("identityHints: Ajv's null errors give none; a hint two violations share is given once", () => {
137
+ assert.deepEqual(identityHints({ errors: null }), []);
138
+ const missing = {
139
+ keyword: "required",
140
+ dataPath: "",
141
+ schemaPath: "#/required",
142
+ params: { missingProperty: "type" },
143
+ };
144
+ const hints = identityHints({ errors: [missing, missing] });
145
+ assert.equal(hints.length, 1);
146
+ assert.deepEqual(hints, [identityHint(missing)]);
147
+ });