paperlint 6.3.0 → 7.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 (185) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +20 -13
  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 +19 -5
  26. package/dist/cli.js.map +1 -1
  27. package/dist/domain/body-pages.d.ts +29 -0
  28. package/dist/domain/body-pages.d.ts.map +1 -1
  29. package/dist/domain/body-pages.js +25 -0
  30. package/dist/domain/body-pages.js.map +1 -1
  31. package/dist/domain/heading-case.d.ts +100 -0
  32. package/dist/domain/heading-case.d.ts.map +1 -0
  33. package/dist/domain/heading-case.js +365 -0
  34. package/dist/domain/heading-case.js.map +1 -0
  35. package/dist/domain/tex-document.d.ts +32 -0
  36. package/dist/domain/tex-document.d.ts.map +1 -1
  37. package/dist/domain/tex-document.js +11 -0
  38. package/dist/domain/tex-document.js.map +1 -1
  39. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  40. package/dist/eslint-rules/latex-language.js +16 -3
  41. package/dist/eslint-rules/latex-language.js.map +1 -1
  42. package/dist/facts-file.d.ts +3 -3
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +3 -3
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/folder-venue-rule.js +6 -6
  47. package/dist/folder-venue-rule.js.map +1 -1
  48. package/dist/heading-case.d.ts +48 -0
  49. package/dist/heading-case.d.ts.map +1 -0
  50. package/dist/heading-case.js +191 -0
  51. package/dist/heading-case.js.map +1 -0
  52. package/dist/paper-includes.d.ts +3 -0
  53. package/dist/paper-includes.d.ts.map +1 -1
  54. package/dist/paper-includes.js +17 -2
  55. package/dist/paper-includes.js.map +1 -1
  56. package/dist/ports/latex.d.ts +7 -1
  57. package/dist/ports/latex.d.ts.map +1 -1
  58. package/dist/preset-card-rule.d.ts +100 -0
  59. package/dist/preset-card-rule.d.ts.map +1 -0
  60. package/dist/preset-card-rule.js +221 -0
  61. package/dist/preset-card-rule.js.map +1 -0
  62. package/dist/presets.d.ts +29 -5
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +104 -68
  65. package/dist/presets.js.map +1 -1
  66. package/dist/submission.d.ts +8 -0
  67. package/dist/submission.d.ts.map +1 -1
  68. package/dist/submission.js +19 -7
  69. package/dist/submission.js.map +1 -1
  70. package/dist/tex-requirements.d.ts +42 -3
  71. package/dist/tex-requirements.d.ts.map +1 -1
  72. package/dist/tex-requirements.js +34 -5
  73. package/dist/tex-requirements.js.map +1 -1
  74. package/dist/tex-venue-rules.d.ts +6 -3
  75. package/dist/tex-venue-rules.d.ts.map +1 -1
  76. package/dist/tex-venue-rules.js +29 -10
  77. package/dist/tex-venue-rules.js.map +1 -1
  78. package/dist/venue-rules.d.ts +2 -1
  79. package/dist/venue-rules.d.ts.map +1 -1
  80. package/dist/venue-rules.js +55 -7
  81. package/dist/venue-rules.js.map +1 -1
  82. package/docs/configuration.md +13 -1
  83. package/docs/e2e.md +14 -12
  84. package/docs/optional-rules.md +4 -3
  85. package/docs/rules/format/page-limit.md +19 -2
  86. package/docs/rules/paper/folder-venue-leftover.md +1 -1
  87. package/docs/rules/talk/profile.md +1 -1
  88. package/docs/rules/tex/heading-case.md +424 -0
  89. package/docs/rules/tex/template.md +25 -5
  90. package/docs/rules/tex/venue-leftover.md +13 -0
  91. package/docs/rules.md +74 -57
  92. package/docs/submission.md +5 -1
  93. package/eslint-rules/latex-language.test.mjs +12 -0
  94. package/eslint-rules/latex-language.ts +17 -4
  95. package/eslint-rules/paper-typography.test.mjs +12 -0
  96. package/eslint.config.mjs +59 -1
  97. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  98. package/fixtures/build-e2e/fallback/expect.json +3 -3
  99. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  100. package/fixtures/build-e2e/msr/expect.json +18 -0
  101. package/fixtures/build-e2e/msr/paper.tex +49 -0
  102. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  103. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  104. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  105. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  106. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  107. package/lib/skill-checks.mjs +31 -0
  108. package/package.json +7 -1
  109. package/presets/acm-sigconf.jsonc +5 -0
  110. package/presets/acm-sigconf.md +43 -0
  111. package/presets/agenticdev.jsonc +27 -6
  112. package/presets/agenticdev.md +151 -0
  113. package/presets/aidc.jsonc +6 -0
  114. package/presets/aidc.md +75 -0
  115. package/presets/aisec.jsonc +5 -1
  116. package/presets/aisec.md +117 -0
  117. package/presets/ieee-conference.jsonc +5 -0
  118. package/presets/ieee-conference.md +38 -0
  119. package/presets/msr.jsonc +71 -0
  120. package/presets/msr.md +94 -0
  121. package/presets/realm.jsonc +7 -2
  122. package/presets/realm.md +166 -0
  123. package/presets/tex/agenticdev.tex +1 -1
  124. package/presets/tex-base.jsonc +6 -0
  125. package/presets/tex-base.md +30 -0
  126. package/presets/venue-profile.schema.json +65 -1
  127. package/skills/camera-ready/SKILL.md +8 -1
  128. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  129. package/skills/find-venue/SKILL.md +6 -5
  130. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  131. package/skills/paper-pipeline/SKILL.md +5 -6
  132. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  133. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  134. package/skills/study-accepted-papers/SKILL.md +3 -3
  135. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  136. package/skills/submission-portal/SKILL.md +2 -2
  137. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  138. package/skills/submit-paper/SKILL.md +34 -22
  139. package/skills/submit-paper/SKILL.md.spec.ts +33 -21
  140. package/skills/submit-paper/references/publishers/acm.md +1 -1
  141. package/src/adapters/latex/conditionals.ts +130 -0
  142. package/src/adapters/latex/headings.ts +163 -0
  143. package/src/adapters/latex/index.test.ts +65 -0
  144. package/src/adapters/latex/index.ts +3 -0
  145. package/src/adapters/latex/parse.ts +3 -2
  146. package/src/build.ts +2 -2
  147. package/src/cli.test.ts +50 -7
  148. package/src/cli.ts +28 -6
  149. package/src/domain/body-pages.test.ts +144 -0
  150. package/src/domain/body-pages.ts +61 -1
  151. package/src/domain/heading-case.test.ts +660 -0
  152. package/src/domain/heading-case.ts +538 -0
  153. package/src/domain/tex-document.ts +36 -0
  154. package/src/domain/venue-name.test.ts +13 -7
  155. package/src/facts-file.test.ts +4 -4
  156. package/src/facts-file.ts +6 -6
  157. package/src/folder-venue-rule.test.ts +10 -10
  158. package/src/folder-venue-rule.ts +6 -6
  159. package/src/heading-case.test.ts +500 -0
  160. package/src/heading-case.ts +275 -0
  161. package/src/init.test.ts +1 -1
  162. package/src/new-paper.test.ts +37 -6
  163. package/src/paper-includes.test.ts +26 -0
  164. package/src/paper-includes.ts +20 -2
  165. package/src/paper-settings-commands.test.ts +26 -11
  166. package/src/ports/latex.ts +7 -0
  167. package/src/preset-card-rule.test.ts +267 -0
  168. package/src/preset-card-rule.ts +384 -0
  169. package/src/presets.test.ts +259 -41
  170. package/src/presets.ts +161 -75
  171. package/src/rule-cases.test.ts +136 -50
  172. package/src/submission.test.ts +44 -4
  173. package/src/submission.ts +28 -7
  174. package/src/talk-rules.test.ts +6 -3
  175. package/src/tex-requirements.harness.ts +25 -11
  176. package/src/tex-requirements.test.ts +127 -1
  177. package/src/tex-requirements.ts +91 -12
  178. package/src/tex-venue-rules.test.ts +196 -21
  179. package/src/tex-venue-rules.ts +47 -16
  180. package/src/venue-rules.test.ts +180 -8
  181. package/src/venue-rules.ts +71 -10
  182. package/templates/paper/paper.tex +1 -1
  183. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  184. package/skills/submit-paper/references/venues/aisec.md +0 -105
  185. package/skills/submit-paper/references/venues/realm.md +0 -155
@@ -7,7 +7,7 @@
7
7
  * resolving shows here.
8
8
  */
9
9
  import { readdirSync, readFileSync } from "node:fs";
10
- import { join } from "node:path";
10
+ import { join, relative } from "node:path";
11
11
  import { describe, expect, it } from "vitest";
12
12
  import { memoryFiles } from "./adapters/memory/index.ts";
13
13
  import { presetsDir } from "./package-dirs.ts";
@@ -35,6 +35,17 @@ const deps = (extra: Record<string, string> = {}) => ({
35
35
  });
36
36
  const resolve = (spec: string, extra: Record<string, string> = {}) =>
37
37
  resolvePreset(spec, PAPER_FILE, deps(extra));
38
+ /** A venue preset of a project's own: the identity every venue declares, then `body`. */
39
+ const venue = (body: Record<string, unknown>, name = "Our Workshop"): string =>
40
+ JSON.stringify({
41
+ type: "venue",
42
+ name,
43
+ url: "https://example.org/cfp",
44
+ ...body,
45
+ });
46
+ /** A template family of a project's own. */
47
+ const family = (body: Record<string, unknown>): string =>
48
+ JSON.stringify({ type: "family", ...body });
38
49
 
39
50
  describe("shipped presets", () => {
40
51
  it("paperlint:agenticdev → agenticdev over acm-sigconf: the family's format, the venue's kinds", () => {
@@ -52,11 +63,53 @@ describe("shipped presets", () => {
52
63
  expect(r.value.template).toEqual({
53
64
  text: "\\documentclass[sigconf]{acmart}",
54
65
  file: join(VENUES, "acm-sigconf.jsonc"),
66
+ forbids: [],
55
67
  });
56
68
  expect("acmart" in r.value.tex.packages).toBe(true);
57
- expect(r.value.label).toBe("agenticdev");
69
+ // The label is the venue's own name, from its file's `name`; the id stays the file name — the
70
+ // stable key the facts file and the build plan carry.
71
+ expect(r.value.label).toBe("AgenticDev");
72
+ expect(r.value.id).toBe("agenticdev");
73
+ expect(r.value.identity).toEqual({
74
+ type: "venue",
75
+ name: "AgenticDev",
76
+ url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
77
+ });
78
+ });
79
+ });
80
+
81
+ describe("shipped presets: paperlint:msr", () => {
82
+ it("paperlint:msr → msr over ieee-conference: one kind, appendices counted as body, compsoc forbidden", () => {
83
+ const r = resolve("paperlint:msr");
84
+ expect(r.ok).toBe(true);
85
+ if (!r.ok) return;
86
+ expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
87
+ "ieee-conference.jsonc",
88
+ "msr.jsonc",
89
+ ]);
90
+ expect([...r.value.format.kinds]).toEqual([
91
+ ["technical", { bodyPagesMax: 10, refPagesMax: 2 }],
92
+ ]);
93
+ expect(r.value.format.bodyEndsAt).toBe("references");
94
+ expect(r.value.format.appendixInBody).toBe(true);
95
+ expect(r.value.format.pageWidthIn).toBe(8.5);
96
+ expect(r.value.template).toEqual({
97
+ text: "\\documentclass[10pt,conference]{IEEEtran}",
98
+ file: join(VENUES, "msr.jsonc"),
99
+ forbids: ["compsoc", "compsocconf"],
100
+ });
101
+ expect(r.value.blind).toBe(true);
102
+ expect(r.value.portal).toEqual({
103
+ kind: "hotcrp",
104
+ url: "https://msr2027.hotcrp.com",
105
+ });
106
+ // No rule turned on: the IEEE capitalization rule has not been read and quoted.
107
+ expect(r.value.rules).toEqual({});
108
+ expect(r.value.registerAnchors).toEqual([]);
58
109
  });
110
+ });
59
111
 
112
+ describe("shipped presets: a family, and a venue that stands alone", () => {
60
113
  it("a paper may extend a family directly: format and fonts, and no kinds", () => {
61
114
  const r = resolve("paperlint:acm-sigconf");
62
115
  expect(r.ok && r.value.format.kinds.size).toBe(0);
@@ -78,19 +131,77 @@ describe("shipped presets", () => {
78
131
  });
79
132
  });
80
133
 
81
- describe("the naming convention of shipped presets (docs/rules.md)", () => {
82
- // Each shipped preset, parsed: its file name and what it extends. The families are DERIVED — a
83
- // shipped preset another shipped preset extends — so a new family is checked the day it ships.
84
- const presets = shippedPresets(VENUES).map((name) => {
85
- const file = join(VENUES, `${name}.jsonc`);
86
- return {
87
- name,
88
- extends: parsePreset(readFileSync(file, "utf8"), file, VENUES).extends,
89
- };
134
+ // Each shipped preset, parsed: its file name and what it extends. The families are DERIVED — a
135
+ // shipped preset another shipped preset extends — so a new family is checked the day it ships.
136
+ const PARSED = shippedPresets(VENUES).map((name) => {
137
+ const file = join(VENUES, `${name}.jsonc`);
138
+ return {
139
+ name,
140
+ preset: parsePreset(readFileSync(file, "utf8"), file, VENUES),
141
+ };
142
+ });
143
+ const FAMILIES = PARSED.filter((p) =>
144
+ PARSED.some((q) => q.preset.extends === `${SHIPPED_PREFIX}${p.name}`),
145
+ );
146
+
147
+ describe("what each shipped preset declares itself to be", () => {
148
+ it("every shipped preset loads and declares what it is; a venue links its call", () => {
149
+ const declared = Object.fromEntries(
150
+ readdirSync(VENUES)
151
+ .filter((f) => f.endsWith(".jsonc"))
152
+ .map((f) => [
153
+ f,
154
+ parsePreset(readFileSync(join(VENUES, f), "utf8"), f, VENUES)
155
+ .identity,
156
+ ]),
157
+ );
158
+ expect(declared).toEqual({
159
+ "acm-sigconf.jsonc": { type: "family" },
160
+ "agenticdev.jsonc": {
161
+ type: "venue",
162
+ name: "AgenticDev",
163
+ url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
164
+ },
165
+ "aidc.jsonc": {
166
+ type: "venue",
167
+ name: "AIDC",
168
+ url: "https://aidcworkshop.github.io/",
169
+ },
170
+ "aisec.jsonc": {
171
+ type: "venue",
172
+ name: "AISec",
173
+ url: "https://aisec.cc/",
174
+ },
175
+ "ieee-conference.jsonc": { type: "family" },
176
+ "msr.jsonc": {
177
+ type: "venue",
178
+ name: "MSR",
179
+ url: "https://2027.msrconf.org/track/msr-2027-technical-papers",
180
+ },
181
+ "realm.jsonc": {
182
+ type: "venue",
183
+ name: "REALM",
184
+ url: "https://realm-workshop.github.io/call_for_papers",
185
+ },
186
+ "tex-base.jsonc": { type: "base" },
187
+ });
90
188
  });
91
- const families = presets.filter((p) =>
92
- presets.some((q) => q.extends === `${SHIPPED_PREFIX}${p.name}`),
93
- );
189
+
190
+ it("the families another preset extends are exactly the ones that declare `family`", () => {
191
+ expect(
192
+ PARSED.filter((p) => p.preset.identity.type === "family").map(
193
+ (p) => p.name,
194
+ ),
195
+ ).toEqual(FAMILIES.map((f) => f.name));
196
+ });
197
+ });
198
+
199
+ describe("the naming convention of shipped presets (docs/rules.md)", () => {
200
+ const families = FAMILIES;
201
+ const presets = PARSED.map((p) => ({
202
+ name: p.name,
203
+ extends: p.preset.extends,
204
+ }));
94
205
  const publishers = families.map((f) => f.name.slice(0, f.name.indexOf("-")));
95
206
 
96
207
  it("there are families to check (else the checks below see nothing)", () => {
@@ -118,16 +229,18 @@ describe("the naming convention of shipped presets (docs/rules.md)", () => {
118
229
  });
119
230
 
120
231
  describe("a project's own preset, by relative path", () => {
121
- const usenix = JSON.stringify({
122
- extends: "paperlint:acm-sigconf",
123
- name: "USENIX Security",
124
- format: {
125
- columns: 1,
126
- kinds: { full: { body_pages_max: 13, ref_pages_max: 0 } },
232
+ const usenix = venue(
233
+ {
234
+ extends: "paperlint:acm-sigconf",
235
+ format: {
236
+ columns: 1,
237
+ kinds: { full: { body_pages_max: 13, ref_pages_max: 0 } },
238
+ },
239
+ tex: { packages: { usenix: ["usenix.sty"] } },
240
+ rules: { "pdf/body-size": "off" },
127
241
  },
128
- tex: { packages: { usenix: ["usenix.sty"] } },
129
- rules: { "pdf/body-size": "off" },
130
- });
242
+ "USENIX Security",
243
+ );
131
244
 
132
245
  it("./ is relative to the file that says it; merged per block, child wins", () => {
133
246
  const r = resolve("../../venues/usenix-sec.jsonc", {
@@ -147,18 +260,21 @@ describe("a project's own preset, by relative path", () => {
147
260
  expect(r.value.label).toBe("USENIX Security");
148
261
  });
149
262
 
150
- it("without `name`, the label is the file name without its extension", () => {
263
+ it("a family names no venue: its label is the file name without its extension", () => {
151
264
  const r = resolve("./venues/usenix-sec.jsonc", {
152
- "/work/papers/p/venues/usenix-sec.jsonc": JSON.stringify({
265
+ "/work/papers/p/venues/usenix-sec.jsonc": family({
153
266
  extends: "paperlint:acm-sigconf",
154
267
  }),
155
268
  });
156
269
  expect(r.ok && r.value.label).toBe("usenix-sec");
270
+ expect(r.ok && r.value.identity).toEqual({ type: "family" });
157
271
  });
272
+ });
158
273
 
274
+ describe("a project's own preset: what its children replace", () => {
159
275
  it("a child kind replaces that kind wholesale; other kinds are kept", () => {
160
276
  const r = resolve("./mine.jsonc", {
161
- "/work/papers/p/mine.jsonc": JSON.stringify({
277
+ "/work/papers/p/mine.jsonc": venue({
162
278
  extends: "paperlint:agenticdev",
163
279
  format: { kinds: { short: { body_pages_max: 6 } } },
164
280
  }),
@@ -174,13 +290,40 @@ describe("a project's own preset, by relative path", () => {
174
290
 
175
291
  it("rules: the leaf wins over its parent, per rule id", () => {
176
292
  const r = resolve("./mine.jsonc", {
177
- "/work/papers/p/mine.jsonc": JSON.stringify({
293
+ "/work/papers/p/mine.jsonc": venue({
178
294
  extends: "paperlint:agenticdev",
179
295
  rules: { "pdf/last-page-balance": "off" },
180
296
  }),
181
297
  });
182
298
  expect(r.ok && r.value.rules["pdf/last-page-balance"]).toBe("off");
183
299
  });
300
+
301
+ it("ruleOrigins: each rule's entry names the file of the chain that set it", () => {
302
+ const r = resolve("./mine.jsonc", {
303
+ "/work/papers/p/mine.jsonc": venue({
304
+ extends: "paperlint:agenticdev",
305
+ rules: { "pdf/body-size": "off" },
306
+ }),
307
+ });
308
+ // Guards: an own rule is the leaf's, an inherited one names the parent that set it.
309
+ expect(r.ok && r.value.ruleOrigins).toEqual({
310
+ "pdf/last-page-balance": join(VENUES, "agenticdev.jsonc"),
311
+ "tex/heading-case": join(VENUES, "agenticdev.jsonc"),
312
+ "pdf/body-size": "/work/papers/p/mine.jsonc",
313
+ });
314
+ });
315
+
316
+ it("ruleOrigins: a rule the leaf sets again is the leaf's, not its parent's", () => {
317
+ const r = resolve("./mine.jsonc", {
318
+ "/work/papers/p/mine.jsonc": venue({
319
+ extends: "paperlint:agenticdev",
320
+ rules: { "tex/heading-case": "off" },
321
+ }),
322
+ });
323
+ expect(r.ok && r.value.ruleOrigins["tex/heading-case"]).toBe(
324
+ "/work/papers/p/mine.jsonc",
325
+ );
326
+ });
184
327
  });
185
328
 
186
329
  describe("what does not resolve, and says why", () => {
@@ -196,7 +339,7 @@ describe("what does not resolve, and says why", () => {
196
339
  "a shipped name with a typo",
197
340
  "paperlint:agenticdve",
198
341
  "not-found",
199
- /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
342
+ /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, msr, realm/,
200
343
  ],
201
344
  ["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
202
345
  [
@@ -217,8 +360,8 @@ describe("what does not resolve, and says why", () => {
217
360
  describe("chains that do not resolve", () => {
218
361
  it("a cycle is refused, naming the chain", () => {
219
362
  const r = resolve("./a.jsonc", {
220
- "/work/papers/p/a.jsonc": JSON.stringify({ extends: "./b.jsonc" }),
221
- "/work/papers/p/b.jsonc": JSON.stringify({ extends: "./a.jsonc" }),
363
+ "/work/papers/p/a.jsonc": family({ extends: "./b.jsonc" }),
364
+ "/work/papers/p/b.jsonc": family({ extends: "./a.jsonc" }),
222
365
  });
223
366
  expect(r.ok).toBe(false);
224
367
  if (r.ok) return;
@@ -231,7 +374,7 @@ describe("chains that do not resolve", () => {
231
374
  it(`a chain longer than ${String(MAX_PRESET_DEPTH)} is refused, printing it`, () => {
232
375
  const files: Record<string, string> = {};
233
376
  for (let i = 1; i <= 5; i++)
234
- files[`/work/papers/p/p${String(i)}.jsonc`] = JSON.stringify({
377
+ files[`/work/papers/p/p${String(i)}.jsonc`] = venue({
235
378
  extends: i < 5 ? `./p${String(i + 1)}.jsonc` : "paperlint:acm-sigconf",
236
379
  });
237
380
  const r = resolve("./p1.jsonc", files);
@@ -243,17 +386,17 @@ describe("chains that do not resolve", () => {
243
386
 
244
387
  it("a chain of exactly the limit resolves", () => {
245
388
  const r = resolve("./p1.jsonc", {
246
- "/work/papers/p/p1.jsonc": JSON.stringify({ extends: "./p2.jsonc" }),
247
- "/work/papers/p/p2.jsonc": JSON.stringify({
248
- extends: "paperlint:agenticdev",
249
- }),
389
+ "/work/papers/p/p1.jsonc": venue({ extends: "./p2.jsonc" }),
390
+ "/work/papers/p/p2.jsonc": venue({ extends: "paperlint:agenticdev" }),
250
391
  });
251
392
  expect(r.ok && r.value.chain.length).toBe(MAX_PRESET_DEPTH);
252
393
  });
394
+ });
253
395
 
396
+ describe("chains that do not resolve: the files themselves", () => {
254
397
  it("a preset that fails the schema is named with its file", () => {
255
398
  const r = resolve("./bad.jsonc", {
256
- "/work/papers/p/bad.jsonc": JSON.stringify({
399
+ "/work/papers/p/bad.jsonc": family({
257
400
  extends: "paperlint:acm-sigconf",
258
401
  colums: 2,
259
402
  }),
@@ -266,16 +409,51 @@ describe("chains that do not resolve", () => {
266
409
 
267
410
  it("a preset with no `extends` must carry `tex` (it is a root)", () => {
268
411
  const r = resolve("./root.jsonc", {
269
- "/work/papers/p/root.jsonc": JSON.stringify({ format: { columns: 1 } }),
412
+ "/work/papers/p/root.jsonc": family({ format: { columns: 1 } }),
413
+ });
414
+ expect(r.ok).toBe(false);
415
+ });
416
+
417
+ it("a family that extends a venue is refused, naming both files", () => {
418
+ const r = resolve("./fam.jsonc", {
419
+ "/work/papers/p/fam.jsonc": family({ extends: "paperlint:aisec" }),
420
+ });
421
+ expect(r.ok).toBe(false);
422
+ if (r.ok) return;
423
+ expect(presetProblemText(r.error)).toBe(
424
+ `the preset /work/papers/p/fam.jsonc is a template family and extends ${join(VENUES, "aisec.jsonc")}, a venue — a family builds only on a family; a venue extends a family or another venue`,
425
+ );
426
+ });
427
+
428
+ it("nothing extends the TeX base set, even by a relative path", () => {
429
+ const r = resolve("./mine.jsonc", {
430
+ "/work/papers/p/mine.jsonc": venue({
431
+ extends: relative("/work/papers/p", join(VENUES, "tex-base.jsonc")),
432
+ }),
270
433
  });
271
434
  expect(r.ok).toBe(false);
435
+ if (r.ok) return;
436
+ expect(presetProblemText(r.error)).toBe(
437
+ `the preset /work/papers/p/mine.jsonc extends ${join(VENUES, "tex-base.jsonc")}, the TeX base set — every paper gets that set already, and no preset extends it`,
438
+ );
439
+ });
440
+
441
+ it("a venue may extend a venue, and the resolved identity is the leaf's", () => {
442
+ const r = resolve("./mine.jsonc", {
443
+ "/work/papers/p/mine.jsonc": venue({ extends: "paperlint:aisec" }),
444
+ });
445
+ expect(r.ok && r.value.identity).toEqual({
446
+ type: "venue",
447
+ name: "Our Workshop",
448
+ url: "https://example.org/cfp",
449
+ });
272
450
  });
273
451
  });
274
452
 
275
453
  describe("a preset's template: kept as its file spells it, with that file", () => {
276
454
  it("a child's template replaces its parent's, and names its own file", () => {
277
455
  const r = resolve("./own.jsonc", {
278
- "/work/papers/p/own.jsonc": JSON.stringify({
456
+ "/work/papers/p/own.jsonc": venue({
279
457
  extends: "paperlint:aidc",
280
458
  template: "\\documentclass[conference]{IEEEtran}",
281
459
  }),
@@ -283,7 +461,31 @@ describe("a preset's template: kept as its file spells it, with that file", () =
283
461
  expect(r.ok && r.value.template).toEqual({
284
462
  text: "\\documentclass[conference]{IEEEtran}",
285
463
  file: "/work/papers/p/own.jsonc",
464
+ forbids: [],
465
+ });
466
+ });
467
+
468
+ it("the options a file forbids travel with its template", () => {
469
+ const r = resolve("./own.jsonc", {
470
+ "/work/papers/p/own.jsonc": venue({
471
+ extends: "paperlint:ieee-conference",
472
+ template: "\\documentclass[conference]{IEEEtran}",
473
+ template_forbids: ["compsoc"],
474
+ }),
475
+ });
476
+ expect(r.ok && r.value.template?.forbids).toEqual(["compsoc"]);
477
+ });
478
+
479
+ it("forbidden options without a template of the same file are refused by the schema", () => {
480
+ const r = resolve("./own.jsonc", {
481
+ "/work/papers/p/own.jsonc": venue({
482
+ extends: "paperlint:aidc",
483
+ template_forbids: ["compsoc"],
484
+ }),
286
485
  });
486
+ expect(r.ok).toBe(false);
487
+ if (r.ok) return;
488
+ expect(presetProblemText(r.error)).toMatch(/template_forbids/);
287
489
  });
288
490
  });
289
491
 
@@ -302,8 +504,17 @@ describe("labelOf — display only, never used to resolve", () => {
302
504
  describe("shippedVenueNames — what each shipped venue is called", () => {
303
505
  it("every shipped preset, by its label and the names along its chain", () => {
304
506
  const names = shippedVenueNames(deps());
305
- expect(names.map((n) => n.label)).toEqual(shippedPresets(VENUES));
306
- expect(names.find((n) => n.label === "aisec")?.aliases).toEqual(
507
+ // A venue is labelled by its `name`, a family by its file name.
508
+ expect(names.map((n) => n.label)).toEqual([
509
+ "acm-sigconf",
510
+ "AgenticDev",
511
+ "AIDC",
512
+ "AISec",
513
+ "ieee-conference",
514
+ "MSR",
515
+ "REALM",
516
+ ]);
517
+ expect(names.find((n) => n.label === "AISec")?.aliases).toEqual(
307
518
  expect.arrayContaining(["AISec", "ACM CCS"]),
308
519
  );
309
520
  });
@@ -316,6 +527,13 @@ describe("shippedVenueNames — what each shipped venue is called", () => {
316
527
  );
317
528
  expect(
318
529
  shippedVenueNames({ files, venuesDir: VENUES }).map((n) => n.label),
319
- ).toEqual(shippedPresets(VENUES).filter((n) => n !== "realm"));
530
+ ).toEqual([
531
+ "acm-sigconf",
532
+ "AgenticDev",
533
+ "AIDC",
534
+ "AISec",
535
+ "ieee-conference",
536
+ "MSR",
537
+ ]);
320
538
  });
321
539
  });