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
@@ -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", () => {
@@ -54,7 +65,15 @@ describe("shipped presets", () => {
54
65
  file: join(VENUES, "acm-sigconf.jsonc"),
55
66
  });
56
67
  expect("acmart" in r.value.tex.packages).toBe(true);
57
- expect(r.value.label).toBe("agenticdev");
68
+ // The label is the venue's own name, from its file's `name`; the id stays the file name — the
69
+ // stable key the facts file and the build plan carry.
70
+ expect(r.value.label).toBe("AgenticDev");
71
+ expect(r.value.id).toBe("agenticdev");
72
+ expect(r.value.identity).toEqual({
73
+ type: "venue",
74
+ name: "AgenticDev",
75
+ url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
76
+ });
58
77
  });
59
78
 
60
79
  it("a paper may extend a family directly: format and fonts, and no kinds", () => {
@@ -78,19 +97,72 @@ describe("shipped presets", () => {
78
97
  });
79
98
  });
80
99
 
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
- };
100
+ // Each shipped preset, parsed: its file name and what it extends. The families are DERIVED — a
101
+ // shipped preset another shipped preset extends — so a new family is checked the day it ships.
102
+ const PARSED = shippedPresets(VENUES).map((name) => {
103
+ const file = join(VENUES, `${name}.jsonc`);
104
+ return {
105
+ name,
106
+ preset: parsePreset(readFileSync(file, "utf8"), file, VENUES),
107
+ };
108
+ });
109
+ const FAMILIES = PARSED.filter((p) =>
110
+ PARSED.some((q) => q.preset.extends === `${SHIPPED_PREFIX}${p.name}`),
111
+ );
112
+
113
+ describe("what each shipped preset declares itself to be", () => {
114
+ it("every shipped preset loads and declares what it is; a venue links its call", () => {
115
+ const declared = Object.fromEntries(
116
+ readdirSync(VENUES)
117
+ .filter((f) => f.endsWith(".jsonc"))
118
+ .map((f) => [
119
+ f,
120
+ parsePreset(readFileSync(join(VENUES, f), "utf8"), f, VENUES)
121
+ .identity,
122
+ ]),
123
+ );
124
+ expect(declared).toEqual({
125
+ "acm-sigconf.jsonc": { type: "family" },
126
+ "agenticdev.jsonc": {
127
+ type: "venue",
128
+ name: "AgenticDev",
129
+ url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
130
+ },
131
+ "aidc.jsonc": {
132
+ type: "venue",
133
+ name: "AIDC",
134
+ url: "https://aidcworkshop.github.io/",
135
+ },
136
+ "aisec.jsonc": {
137
+ type: "venue",
138
+ name: "AISec",
139
+ url: "https://aisec.cc/",
140
+ },
141
+ "ieee-conference.jsonc": { type: "family" },
142
+ "realm.jsonc": {
143
+ type: "venue",
144
+ name: "REALM",
145
+ url: "https://realm-workshop.github.io/call_for_papers",
146
+ },
147
+ "tex-base.jsonc": { type: "base" },
148
+ });
90
149
  });
91
- const families = presets.filter((p) =>
92
- presets.some((q) => q.extends === `${SHIPPED_PREFIX}${p.name}`),
93
- );
150
+
151
+ it("the families another preset extends are exactly the ones that declare `family`", () => {
152
+ expect(
153
+ PARSED.filter((p) => p.preset.identity.type === "family").map(
154
+ (p) => p.name,
155
+ ),
156
+ ).toEqual(FAMILIES.map((f) => f.name));
157
+ });
158
+ });
159
+
160
+ describe("the naming convention of shipped presets (docs/rules.md)", () => {
161
+ const families = FAMILIES;
162
+ const presets = PARSED.map((p) => ({
163
+ name: p.name,
164
+ extends: p.preset.extends,
165
+ }));
94
166
  const publishers = families.map((f) => f.name.slice(0, f.name.indexOf("-")));
95
167
 
96
168
  it("there are families to check (else the checks below see nothing)", () => {
@@ -118,16 +190,18 @@ describe("the naming convention of shipped presets (docs/rules.md)", () => {
118
190
  });
119
191
 
120
192
  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 } },
193
+ const usenix = venue(
194
+ {
195
+ extends: "paperlint:acm-sigconf",
196
+ format: {
197
+ columns: 1,
198
+ kinds: { full: { body_pages_max: 13, ref_pages_max: 0 } },
199
+ },
200
+ tex: { packages: { usenix: ["usenix.sty"] } },
201
+ rules: { "pdf/body-size": "off" },
127
202
  },
128
- tex: { packages: { usenix: ["usenix.sty"] } },
129
- rules: { "pdf/body-size": "off" },
130
- });
203
+ "USENIX Security",
204
+ );
131
205
 
132
206
  it("./ is relative to the file that says it; merged per block, child wins", () => {
133
207
  const r = resolve("../../venues/usenix-sec.jsonc", {
@@ -147,18 +221,21 @@ describe("a project's own preset, by relative path", () => {
147
221
  expect(r.value.label).toBe("USENIX Security");
148
222
  });
149
223
 
150
- it("without `name`, the label is the file name without its extension", () => {
224
+ it("a family names no venue: its label is the file name without its extension", () => {
151
225
  const r = resolve("./venues/usenix-sec.jsonc", {
152
- "/work/papers/p/venues/usenix-sec.jsonc": JSON.stringify({
226
+ "/work/papers/p/venues/usenix-sec.jsonc": family({
153
227
  extends: "paperlint:acm-sigconf",
154
228
  }),
155
229
  });
156
230
  expect(r.ok && r.value.label).toBe("usenix-sec");
231
+ expect(r.ok && r.value.identity).toEqual({ type: "family" });
157
232
  });
233
+ });
158
234
 
235
+ describe("a project's own preset: what its children replace", () => {
159
236
  it("a child kind replaces that kind wholesale; other kinds are kept", () => {
160
237
  const r = resolve("./mine.jsonc", {
161
- "/work/papers/p/mine.jsonc": JSON.stringify({
238
+ "/work/papers/p/mine.jsonc": venue({
162
239
  extends: "paperlint:agenticdev",
163
240
  format: { kinds: { short: { body_pages_max: 6 } } },
164
241
  }),
@@ -174,13 +251,40 @@ describe("a project's own preset, by relative path", () => {
174
251
 
175
252
  it("rules: the leaf wins over its parent, per rule id", () => {
176
253
  const r = resolve("./mine.jsonc", {
177
- "/work/papers/p/mine.jsonc": JSON.stringify({
254
+ "/work/papers/p/mine.jsonc": venue({
178
255
  extends: "paperlint:agenticdev",
179
256
  rules: { "pdf/last-page-balance": "off" },
180
257
  }),
181
258
  });
182
259
  expect(r.ok && r.value.rules["pdf/last-page-balance"]).toBe("off");
183
260
  });
261
+
262
+ it("ruleOrigins: each rule's entry names the file of the chain that set it", () => {
263
+ const r = resolve("./mine.jsonc", {
264
+ "/work/papers/p/mine.jsonc": venue({
265
+ extends: "paperlint:agenticdev",
266
+ rules: { "pdf/body-size": "off" },
267
+ }),
268
+ });
269
+ // Guards: an own rule is the leaf's, an inherited one names the parent that set it.
270
+ expect(r.ok && r.value.ruleOrigins).toEqual({
271
+ "pdf/last-page-balance": join(VENUES, "agenticdev.jsonc"),
272
+ "tex/heading-case": join(VENUES, "agenticdev.jsonc"),
273
+ "pdf/body-size": "/work/papers/p/mine.jsonc",
274
+ });
275
+ });
276
+
277
+ it("ruleOrigins: a rule the leaf sets again is the leaf's, not its parent's", () => {
278
+ const r = resolve("./mine.jsonc", {
279
+ "/work/papers/p/mine.jsonc": venue({
280
+ extends: "paperlint:agenticdev",
281
+ rules: { "tex/heading-case": "off" },
282
+ }),
283
+ });
284
+ expect(r.ok && r.value.ruleOrigins["tex/heading-case"]).toBe(
285
+ "/work/papers/p/mine.jsonc",
286
+ );
287
+ });
184
288
  });
185
289
 
186
290
  describe("what does not resolve, and says why", () => {
@@ -217,8 +321,8 @@ describe("what does not resolve, and says why", () => {
217
321
  describe("chains that do not resolve", () => {
218
322
  it("a cycle is refused, naming the chain", () => {
219
323
  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" }),
324
+ "/work/papers/p/a.jsonc": family({ extends: "./b.jsonc" }),
325
+ "/work/papers/p/b.jsonc": family({ extends: "./a.jsonc" }),
222
326
  });
223
327
  expect(r.ok).toBe(false);
224
328
  if (r.ok) return;
@@ -231,7 +335,7 @@ describe("chains that do not resolve", () => {
231
335
  it(`a chain longer than ${String(MAX_PRESET_DEPTH)} is refused, printing it`, () => {
232
336
  const files: Record<string, string> = {};
233
337
  for (let i = 1; i <= 5; i++)
234
- files[`/work/papers/p/p${String(i)}.jsonc`] = JSON.stringify({
338
+ files[`/work/papers/p/p${String(i)}.jsonc`] = venue({
235
339
  extends: i < 5 ? `./p${String(i + 1)}.jsonc` : "paperlint:acm-sigconf",
236
340
  });
237
341
  const r = resolve("./p1.jsonc", files);
@@ -243,17 +347,17 @@ describe("chains that do not resolve", () => {
243
347
 
244
348
  it("a chain of exactly the limit resolves", () => {
245
349
  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
- }),
350
+ "/work/papers/p/p1.jsonc": venue({ extends: "./p2.jsonc" }),
351
+ "/work/papers/p/p2.jsonc": venue({ extends: "paperlint:agenticdev" }),
250
352
  });
251
353
  expect(r.ok && r.value.chain.length).toBe(MAX_PRESET_DEPTH);
252
354
  });
355
+ });
253
356
 
357
+ describe("chains that do not resolve: the files themselves", () => {
254
358
  it("a preset that fails the schema is named with its file", () => {
255
359
  const r = resolve("./bad.jsonc", {
256
- "/work/papers/p/bad.jsonc": JSON.stringify({
360
+ "/work/papers/p/bad.jsonc": family({
257
361
  extends: "paperlint:acm-sigconf",
258
362
  colums: 2,
259
363
  }),
@@ -266,16 +370,51 @@ describe("chains that do not resolve", () => {
266
370
 
267
371
  it("a preset with no `extends` must carry `tex` (it is a root)", () => {
268
372
  const r = resolve("./root.jsonc", {
269
- "/work/papers/p/root.jsonc": JSON.stringify({ format: { columns: 1 } }),
373
+ "/work/papers/p/root.jsonc": family({ format: { columns: 1 } }),
374
+ });
375
+ expect(r.ok).toBe(false);
376
+ });
377
+
378
+ it("a family that extends a venue is refused, naming both files", () => {
379
+ const r = resolve("./fam.jsonc", {
380
+ "/work/papers/p/fam.jsonc": family({ extends: "paperlint:aisec" }),
381
+ });
382
+ expect(r.ok).toBe(false);
383
+ if (r.ok) return;
384
+ expect(presetProblemText(r.error)).toBe(
385
+ `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`,
386
+ );
387
+ });
388
+
389
+ it("nothing extends the TeX base set, even by a relative path", () => {
390
+ const r = resolve("./mine.jsonc", {
391
+ "/work/papers/p/mine.jsonc": venue({
392
+ extends: relative("/work/papers/p", join(VENUES, "tex-base.jsonc")),
393
+ }),
270
394
  });
271
395
  expect(r.ok).toBe(false);
396
+ if (r.ok) return;
397
+ expect(presetProblemText(r.error)).toBe(
398
+ `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`,
399
+ );
400
+ });
401
+
402
+ it("a venue may extend a venue, and the resolved identity is the leaf's", () => {
403
+ const r = resolve("./mine.jsonc", {
404
+ "/work/papers/p/mine.jsonc": venue({ extends: "paperlint:aisec" }),
405
+ });
406
+ expect(r.ok && r.value.identity).toEqual({
407
+ type: "venue",
408
+ name: "Our Workshop",
409
+ url: "https://example.org/cfp",
410
+ });
272
411
  });
273
412
  });
274
413
 
275
414
  describe("a preset's template: kept as its file spells it, with that file", () => {
276
415
  it("a child's template replaces its parent's, and names its own file", () => {
277
416
  const r = resolve("./own.jsonc", {
278
- "/work/papers/p/own.jsonc": JSON.stringify({
417
+ "/work/papers/p/own.jsonc": venue({
279
418
  extends: "paperlint:aidc",
280
419
  template: "\\documentclass[conference]{IEEEtran}",
281
420
  }),
@@ -302,8 +441,16 @@ describe("labelOf — display only, never used to resolve", () => {
302
441
  describe("shippedVenueNames — what each shipped venue is called", () => {
303
442
  it("every shipped preset, by its label and the names along its chain", () => {
304
443
  const names = shippedVenueNames(deps());
305
- expect(names.map((n) => n.label)).toEqual(shippedPresets(VENUES));
306
- expect(names.find((n) => n.label === "aisec")?.aliases).toEqual(
444
+ // A venue is labelled by its `name`, a family by its file name.
445
+ expect(names.map((n) => n.label)).toEqual([
446
+ "acm-sigconf",
447
+ "AgenticDev",
448
+ "AIDC",
449
+ "AISec",
450
+ "ieee-conference",
451
+ "REALM",
452
+ ]);
453
+ expect(names.find((n) => n.label === "AISec")?.aliases).toEqual(
307
454
  expect.arrayContaining(["AISec", "ACM CCS"]),
308
455
  );
309
456
  });
@@ -316,6 +463,12 @@ describe("shippedVenueNames — what each shipped venue is called", () => {
316
463
  );
317
464
  expect(
318
465
  shippedVenueNames({ files, venuesDir: VENUES }).map((n) => n.label),
319
- ).toEqual(shippedPresets(VENUES).filter((n) => n !== "realm"));
466
+ ).toEqual([
467
+ "acm-sigconf",
468
+ "AgenticDev",
469
+ "AIDC",
470
+ "AISec",
471
+ "ieee-conference",
472
+ ]);
320
473
  });
321
474
  });
package/src/presets.ts CHANGED
@@ -12,20 +12,27 @@
12
12
  * Anything else — an npm package name, a bare name — is refused by name. npm presets are not
13
13
  * supported yet; a bare name is almost always a shipped name missing its prefix, and the message says so.
14
14
  *
15
+ * ── WHAT EACH FILE IS ─────────────────────────────────────────────────────────────
16
+ * Every preset declares its `type` (`venue-profile.schema.json`): a `venue` names itself and links
17
+ * its call for papers, a template `family` names no venue, and the TeX `base` set is extended by
18
+ * nothing. A chain must read top-down as template → venue: a family extends only a family, and
19
+ * nothing extends the base set. A chain that does not is refused naming both files.
20
+ *
15
21
  * ── THE CHAIN ─────────────────────────────────────────────────────────────────────
16
22
  * At most `MAX_PRESET_DEPTH` files, root to leaf. A cycle (relative paths can form one) is refused
17
23
  * naming the chain. The chain is merged block by block:
18
24
  *
19
25
  * tex union — a child never removes a package its parent needs
20
26
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
21
- * rules per rule id, the child wins
27
+ * rules per rule id, the child wins; `ruleOrigins` keeps which file set each
22
28
  * template the child wins; so does `name`, and `required_sections`, `register`, `talk` and
23
29
  * `portal` (each whole)
24
30
  * aliases union, with every `name` — what the venue is called along the chain
25
31
  *
26
- * ── THE LABEL ─────────────────────────────────────────────────────────────────────
27
- * Messages, the facts file and the build plan need a word for the venue. It is the most derived
28
- * preset's `name` when one sets it, else the spec's file name without its extension
32
+ * ── THE ID AND THE LABEL ──────────────────────────────────────────────────────────
33
+ * The facts file and the build plan carry the preset's `id`: the spec's file name without its
34
+ * extension, a stable key consumers match against preset ids. Messages say the `label`: the most
35
+ * derived venue's `name` when the chain has a venue, else the id
29
36
  * (`paperlint:agenticdev` → `agenticdev`, `./venues/usenix-sec.jsonc` → `usenix-sec`). Display
30
37
  * only: nothing is ever resolved by it.
31
38
  */
@@ -38,6 +45,7 @@ import {
38
45
  profileFileOf,
39
46
  venueNames,
40
47
  type PresetFile,
48
+ type PresetIdentity,
41
49
  type RequiredSection,
42
50
  type TexRequirements,
43
51
  type VenueFormat,
@@ -75,10 +83,17 @@ export interface PresetTemplate {
75
83
 
76
84
  /** A resolved chain, merged. */
77
85
  export interface Preset {
78
- /** The word for this venue in messages. */
86
+ /**
87
+ * The preset's id: the spec's file name without its extension (`agenticdev`, `usenix-sec`). The
88
+ * stable key a machine reads — the facts file and the build plan carry it — whatever `name` says.
89
+ */
90
+ readonly id: string;
91
+ /** The word for this venue in messages: the venue's `name`, else the id. */
79
92
  readonly label: string;
80
93
  /** Every file of the chain, root first. */
81
94
  readonly chain: readonly string[];
95
+ /** What the spec names — the leaf file of the chain: a venue (with its call), or a family. */
96
+ readonly identity: PresetIdentity;
82
97
  /**
83
98
  * The `\\documentclass` the venue's template uses, as the preset spells it, and the file that
84
99
  * spells it; null when no file of the chain names one. Read by the rules and `paperlint new`,
@@ -93,6 +108,8 @@ export interface Preset {
93
108
  readonly tex: TexRequirements;
94
109
  readonly format: VenueFormat;
95
110
  readonly rules: Readonly<Record<string, unknown>>;
111
+ /** Rule id → the file of the chain that set its entry in `rules` (the last one to name it). */
112
+ readonly ruleOrigins: Readonly<Record<string, string>>;
96
113
  /** The accepted papers the register rules measure a body against; empty when no file names any. */
97
114
  readonly registerAnchors: readonly RegisterAnchor[];
98
115
  /** What the venue asks a presenter to send (`talk/*`); null when no file of the chain says. */
@@ -112,6 +129,14 @@ export type PresetProblem =
112
129
  }
113
130
  | { readonly kind: "cycle"; readonly chain: readonly string[] }
114
131
  | { readonly kind: "too-deep"; readonly chain: readonly string[] }
132
+ /** `file` extends `parent`, which a preset of its type may not extend. */
133
+ | {
134
+ readonly kind: "wrong-parent";
135
+ readonly file: string;
136
+ readonly parent: string;
137
+ /** A family extending a venue, or anything extending the base set. */
138
+ readonly parentType: "venue" | "base";
139
+ }
115
140
  | { readonly kind: "broken"; readonly file: string; readonly why: string };
116
141
 
117
142
  /** The display label of a spec: the file name without its extension. Pure. */
@@ -166,27 +191,47 @@ function readPreset(
166
191
  }
167
192
  }
168
193
 
169
- /** The chain's files, leaf first, each parsed. */
170
- function chainOf(
171
- spec: string,
172
- from: string,
194
+ /** One file of a chain: where it is, and what it says. */
195
+ interface Link {
196
+ readonly file: string;
197
+ readonly preset: PresetFile;
198
+ }
199
+
200
+ /**
201
+ * The chain from `child` up, leaf first: `below` is every file read so far, `child` the last of them.
202
+ * Each parent is refused when it closes a cycle, makes the chain too long, or is of a type `child`
203
+ * may not extend.
204
+ */
205
+ function chainUp(
206
+ below: readonly Link[],
207
+ child: Link,
173
208
  deps: PresetDeps,
174
- ): Result<{ file: string; preset: PresetFile }[], PresetProblem> {
175
- const out: { file: string; preset: PresetFile }[] = [];
176
- let next: { spec: string; from: string } | null = { spec, from };
177
- while (next !== null) {
178
- const r = readPreset(next.spec, next.from, deps);
179
- if (!r.ok) return r;
180
- const files = out.map((x) => x.file);
181
- if (files.includes(r.value.file))
182
- return err({ kind: "cycle", chain: [...files, r.value.file] });
183
- if (out.length === MAX_PRESET_DEPTH)
184
- return err({ kind: "too-deep", chain: [...files, r.value.file] });
185
- out.push(r.value);
186
- const up: string | null = r.value.preset.extends;
187
- next = up === null ? null : { spec: up, from: r.value.file };
188
- }
189
- return ok(out);
209
+ ): Result<readonly Link[], PresetProblem> {
210
+ const up = child.preset.extends;
211
+ if (up === null) return ok(below);
212
+ const r = readPreset(up, child.file, deps);
213
+ if (!r.ok) return r;
214
+ const files = below.map((x) => x.file);
215
+ if (files.includes(r.value.file))
216
+ return err({ kind: "cycle", chain: [...files, r.value.file] });
217
+ if (below.length === MAX_PRESET_DEPTH)
218
+ return err({ kind: "too-deep", chain: [...files, r.value.file] });
219
+ const parentType = r.value.preset.identity.type;
220
+ if (parentType === "venue" && child.preset.identity.type === "family")
221
+ return err({
222
+ kind: "wrong-parent",
223
+ file: child.file,
224
+ parent: r.value.file,
225
+ parentType,
226
+ });
227
+ if (parentType === "base")
228
+ return err({
229
+ kind: "wrong-parent",
230
+ file: child.file,
231
+ parent: r.value.file,
232
+ parentType,
233
+ });
234
+ return chainUp([...below, r.value], r.value, deps);
190
235
  }
191
236
 
192
237
  /** A child's format over its parent's: per key the child wins, kinds by name. Pure. */
@@ -213,52 +258,68 @@ export function mergeFormat(
213
258
  };
214
259
  }
215
260
 
261
+ /** A venue's name; a family and the base set have none. Pure. */
262
+ const nameOf = (i: PresetIdentity): string | null =>
263
+ i.type === "venue" ? i.name : null;
264
+
265
+ /** A venue's name as a list, empty for a family and the base set. Pure. */
266
+ const namesOf = (i: PresetIdentity): readonly string[] =>
267
+ i.type === "venue" ? [i.name] : [];
268
+
269
+ /** What the merge carries from file to file: the preset without what only the whole chain knows. */
270
+ type Merging = Omit<Preset, "id" | "label" | "chain" | "identity"> & {
271
+ readonly name: string | null;
272
+ };
273
+
274
+ const NOTHING_MERGED: Merging = {
275
+ name: null,
276
+ template: null,
277
+ aliases: [],
278
+ blind: false,
279
+ requiredSections: [],
280
+ tex: NO_REQUIREMENTS,
281
+ format: NO_FORMAT,
282
+ rules: {},
283
+ ruleOrigins: {},
284
+ registerAnchors: [],
285
+ talk: null,
286
+ portal: null,
287
+ };
288
+
289
+ /** One file of the chain, `file`, merged over what its parents gave. Pure. */
290
+ const mergeOne = (
291
+ acc: Merging,
292
+ { preset: p, file }: { readonly preset: PresetFile; readonly file: string },
293
+ ): Merging => ({
294
+ name: nameOf(p.identity) ?? acc.name,
295
+ template: p.template === null ? acc.template : { text: p.template, file },
296
+ aliases: [...new Set([...acc.aliases, ...namesOf(p.identity), ...p.aliases])],
297
+ blind: p.blind ?? acc.blind,
298
+ requiredSections: p.requiredSections ?? acc.requiredSections,
299
+ tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
300
+ format: mergeFormat(acc.format, p.format),
301
+ rules: { ...acc.rules, ...p.rules },
302
+ ruleOrigins: {
303
+ ...acc.ruleOrigins,
304
+ ...Object.fromEntries(Object.keys(p.rules).map((id) => [id, file])),
305
+ },
306
+ registerAnchors: p.registerAnchors ?? acc.registerAnchors,
307
+ talk: p.talk ?? acc.talk,
308
+ portal: p.portal ?? acc.portal,
309
+ });
310
+
216
311
  /** The chain, root first, merged into one preset. Pure. */
217
312
  function merged(
218
313
  spec: string,
219
314
  rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
315
+ leaf: { readonly preset: PresetFile },
220
316
  ): Preset {
221
- const base: Omit<Preset, "label" | "chain"> & {
222
- readonly name: string | null;
223
- } = {
224
- name: null,
225
- template: null,
226
- aliases: [],
227
- blind: false,
228
- requiredSections: [],
229
- tex: NO_REQUIREMENTS,
230
- format: NO_FORMAT,
231
- rules: {},
232
- registerAnchors: [],
233
- talk: null,
234
- portal: null,
235
- };
236
- const m = rootFirst.reduce(
237
- (acc, { preset: p, file }) => ({
238
- name: p.name ?? acc.name,
239
- template: p.template === null ? acc.template : { text: p.template, file },
240
- aliases: [
241
- ...new Set([
242
- ...acc.aliases,
243
- ...(p.name === null ? [] : [p.name]),
244
- ...p.aliases,
245
- ]),
246
- ],
247
- blind: p.blind ?? acc.blind,
248
- requiredSections: p.requiredSections ?? acc.requiredSections,
249
- tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
250
- format: mergeFormat(acc.format, p.format),
251
- rules: { ...acc.rules, ...p.rules },
252
- registerAnchors: p.registerAnchors ?? acc.registerAnchors,
253
- talk: p.talk ?? acc.talk,
254
- portal: p.portal ?? acc.portal,
255
- }),
256
- base,
257
- );
258
- const { name, ...rest } = m;
317
+ const { name, ...rest } = rootFirst.reduce(mergeOne, NOTHING_MERGED);
259
318
  return {
319
+ id: labelOf(spec),
260
320
  label: name ?? labelOf(spec),
261
321
  chain: rootFirst.map((x) => x.file),
322
+ identity: leaf.preset.identity,
262
323
  ...rest,
263
324
  };
264
325
  }
@@ -272,13 +333,17 @@ export function resolvePreset(
272
333
  fromFile: string,
273
334
  deps: PresetDeps,
274
335
  ): Result<Preset, PresetProblem> {
275
- const chain = chainOf(spec, fromFile, deps);
276
- if (!chain.ok)
277
- return chain.error.kind === "not-found"
278
- ? err({ ...chain.error, shipped: shippedPresets(deps.venuesDir) })
279
- : chain;
280
- const rootFirst = [...chain.value].reverse();
281
- return ok(merged(spec, rootFirst));
336
+ const refused = (p: PresetProblem): Result<Preset, PresetProblem> =>
337
+ err(
338
+ p.kind === "not-found"
339
+ ? { ...p, shipped: shippedPresets(deps.venuesDir) }
340
+ : p,
341
+ );
342
+ const leaf = readPreset(spec, fromFile, deps);
343
+ if (!leaf.ok) return refused(leaf.error);
344
+ const chain = chainUp([leaf.value], leaf.value, deps);
345
+ if (!chain.ok) return refused(chain.error);
346
+ return ok(merged(spec, [...chain.value].reverse(), leaf.value));
282
347
  }
283
348
 
284
349
  /** The shipped presets' names (each is spelled `paperlint:<name>`). */
@@ -336,6 +401,10 @@ export function presetProblemText(p: PresetProblem): string {
336
401
  return `the preset chain is longer than ${String(MAX_PRESET_DEPTH)}: ${p.chain.join(" → ")}`;
337
402
  case "broken":
338
403
  return `the preset ${p.file} does not parse: ${p.why}`;
404
+ case "wrong-parent":
405
+ return p.parentType === "base"
406
+ ? `the preset ${p.file} extends ${p.parent}, the TeX base set — every paper gets that set already, and no preset extends it`
407
+ : `the preset ${p.file} is a template family and extends ${p.parent}, a venue — a family builds only on a family; a venue extends a family or another venue`;
339
408
  }
340
409
  }
341
410