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
@@ -0,0 +1,267 @@
1
+ /**
2
+ * `preset/card-rules` through real ESLint, on a copy of the shipped presets and the rule pages: the
3
+ * section a card must hold, the findings for each way a card can be wrong, and what `--fix` writes.
4
+ */
5
+ import {
6
+ cpSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { ESLint } from "eslint";
14
+ import markdown from "@eslint/markdown";
15
+ import { describe, expect, it } from "vitest";
16
+ import { nodeFiles } from "./adapters/node/index.ts";
17
+ import { eslintConfig } from "./cli.ts";
18
+ import { presetsDir } from "./package-dirs.ts";
19
+ import {
20
+ entryParts,
21
+ presetCardRules,
22
+ SECTION_END,
23
+ SECTION_START,
24
+ } from "./preset-card-rule.ts";
25
+ import { useTempDir } from "../test/support.ts";
26
+
27
+ const REPO = join(presetsDir(), "..");
28
+ const root = useTempDir("paperlint-card-rule-");
29
+ cpSync(presetsDir(), join(root, "presets"), { recursive: true });
30
+ cpSync(join(REPO, "docs", "rules"), join(root, "docs", "rules"), {
31
+ recursive: true,
32
+ });
33
+ cpSync(join(REPO, "docs", "rules.md"), join(root, "docs", "rules.md"));
34
+ const P = join(root, "presets");
35
+
36
+ const eslint = (fix = false) =>
37
+ new ESLint({
38
+ cwd: root,
39
+ fix,
40
+ overrideConfigFile: true,
41
+ // Through cli.ts's one adapter from paperlint's blocks to ESLint's type (`eslintConfig`).
42
+ overrideConfig: eslintConfig([
43
+ {
44
+ files: ["presets/*.md"],
45
+ plugins: {
46
+ markdown,
47
+ preset: {
48
+ rules: presetCardRules({
49
+ files: nodeFiles,
50
+ venuesDir: P,
51
+ docsRoot: root,
52
+ }),
53
+ },
54
+ },
55
+ language: "markdown/gfm",
56
+ rules: { "preset/card-rules": "error" },
57
+ },
58
+ ]),
59
+ });
60
+
61
+ /** The findings for one card, as `messageId`-free text: rule id and message. */
62
+ async function findings(card: string): Promise<string[]> {
63
+ const [r] = await eslint().lintFiles([join(P, card)]);
64
+ return (r?.messages ?? []).map((m) => `${String(m.ruleId)}: ${m.message}`);
65
+ }
66
+
67
+ /** The card after `eslint --fix`, written to disk as ESLint would. */
68
+ async function fixed(card: string): Promise<string> {
69
+ const results = await eslint(true).lintFiles([join(P, card)]);
70
+ await ESLint.outputFixes(results);
71
+ return readFileSync(join(P, card), "utf8");
72
+ }
73
+
74
+ /** The generated section of a card's text, markers included. */
75
+ const sectionOf = (text: string): string =>
76
+ text.slice(
77
+ text.indexOf(SECTION_START),
78
+ text.indexOf(SECTION_END) + SECTION_END.length,
79
+ );
80
+
81
+ const STALE =
82
+ "preset/card-rules: the rules section does not match the preset beside this card — run `npx eslint --fix` on this file to rewrite it from the preset";
83
+
84
+ describe("the shipped cards", () => {
85
+ it("every shipped card holds the section its preset resolves to", async () => {
86
+ const results = await eslint().lintFiles([join(P, "*.md")]);
87
+ // Guards: a glob that matched nothing lints nothing and reports clean.
88
+ expect(results.map((r) => r.filePath.slice(P.length + 1)).sort()).toEqual([
89
+ "acm-sigconf.md",
90
+ "agenticdev.md",
91
+ "aidc.md",
92
+ "aisec.md",
93
+ "ieee-conference.md",
94
+ "msr.md",
95
+ "realm.md",
96
+ "tex-base.md",
97
+ ]);
98
+ expect(results.flatMap((r) => r.messages)).toEqual([]);
99
+ });
100
+
101
+ it("agenticdev: its own rules, linked to their page or to the index, and what it extends", async () => {
102
+ const text = readFileSync(join(P, "agenticdev.md"), "utf8");
103
+ writeFileSync(
104
+ join(P, "agenticdev.md"),
105
+ text.replace(sectionOf(text), `${SECTION_START}\n${SECTION_END}`),
106
+ );
107
+ expect(await findings("agenticdev.md")).toEqual([STALE]);
108
+ // Guards: --fix writes the resolved preset — the section is the generated output, compared whole.
109
+ expect(sectionOf(await fixed("agenticdev.md"))).toBe(
110
+ [
111
+ SECTION_START,
112
+ "<!-- prettier-ignore-start -->",
113
+ "<!-- Generated from agenticdev.jsonc by `npx eslint --fix` (rule preset/card-rules). Edit the preset, not this section. -->",
114
+ "",
115
+ "**`paperlint:agenticdev`** — venue preset for AgenticDev · call for papers: <https://conf.researchr.org/home/ase-2026/agenticdev-2026> · extends `paperlint:acm-sigconf`",
116
+ "",
117
+ "| rule | severity | options | set in |",
118
+ "| --- | --- | --- | --- |",
119
+ '| [`pdf/last-page-balance`](../docs/rules.md) | error | `{"tolerancePt":120}` | this preset |',
120
+ '| [`tex/heading-case`](../docs/rules/tex/heading-case.md) | error | `{"title":"chicago-headline","headings":"chicago-headline"}` | this preset |',
121
+ "",
122
+ "<!-- prettier-ignore-end -->",
123
+ SECTION_END,
124
+ ].join("\n"),
125
+ );
126
+ expect(await findings("agenticdev.md")).toEqual([]);
127
+ });
128
+ });
129
+
130
+ describe("the origin column: set here, or inherited from the file of the chain that set it", () => {
131
+ it("a venue over agenticdev: its own rule is its own, agenticdev's are inherited", async () => {
132
+ writeFileSync(
133
+ join(P, "fixture-venue.jsonc"),
134
+ JSON.stringify({
135
+ type: "venue",
136
+ name: "Fixture",
137
+ url: "https://example.org/cfp",
138
+ extends: "paperlint:agenticdev",
139
+ rules: { "pdf/body-size": "off", "tex/register": ["warn", "a|b"] },
140
+ }),
141
+ );
142
+ writeFileSync(join(P, "fixture-venue.md"), "# Fixture\n");
143
+ expect(await findings("fixture-venue.md")).toEqual([
144
+ `preset/card-rules: this card has no generated rules section — run \`npx eslint --fix\` on this file to append one (between ${SECTION_START} and ${SECTION_END})`,
145
+ ]);
146
+ const text = await fixed("fixture-venue.md");
147
+ // Guards: an inherited rule names its parent, an own rule says so; an entry without options
148
+ // shows a dash, and a `|` in the options is escaped so the table keeps its columns.
149
+ expect(text.split("\n").filter((l) => l.startsWith("| ["))).toEqual([
150
+ "| [`pdf/body-size`](../docs/rules/pdf/body-size.md) | off | — | this preset |",
151
+ '| [`pdf/last-page-balance`](../docs/rules.md) | error | `{"tolerancePt":120}` | inherited from `paperlint:agenticdev` |',
152
+ '| [`tex/heading-case`](../docs/rules/tex/heading-case.md) | error | `{"title":"chicago-headline","headings":"chicago-headline"}` | inherited from `paperlint:agenticdev` |',
153
+ '| [`tex/register`](../docs/rules/tex/register.md) | warn | `"a\\|b"` | this preset |',
154
+ ]);
155
+ // Appended after one blank line to a card that ended with a newline.
156
+ expect(text.startsWith(`# Fixture\n\n${SECTION_START}\n`)).toBe(true);
157
+ expect(await findings("fixture-venue.md")).toEqual([]);
158
+ });
159
+
160
+ it("a card without a final newline gets one before the appended section", async () => {
161
+ writeFileSync(join(P, "fixture-venue.md"), "# Fixture");
162
+ expect(
163
+ (await fixed("fixture-venue.md")).startsWith(
164
+ `# Fixture\n\n${SECTION_START}\n`,
165
+ ),
166
+ ).toBe(true);
167
+ });
168
+ });
169
+
170
+ describe("a chain that leaves presets/", () => {
171
+ it("a rule set by a preset outside presets/ names that file by its path from the card", async () => {
172
+ mkdirSync(join(root, "shared"), { recursive: true });
173
+ writeFileSync(
174
+ join(root, "shared", "fixture-base.jsonc"),
175
+ JSON.stringify({
176
+ type: "family",
177
+ extends: "paperlint:acm-sigconf",
178
+ rules: { "pdf/body-size": "warn" },
179
+ }),
180
+ );
181
+ writeFileSync(
182
+ join(P, "fixture-outside.jsonc"),
183
+ JSON.stringify({
184
+ type: "venue",
185
+ name: "Outside",
186
+ url: "https://example.org/cfp",
187
+ extends: "../shared/fixture-base.jsonc",
188
+ }),
189
+ );
190
+ writeFileSync(join(P, "fixture-outside.md"), "# Outside\n");
191
+ // Guards: a file of the chain that is not a shipped preset is not given a `paperlint:` name
192
+ // that would resolve nowhere; it is the path a reader can open from the card.
193
+ expect(
194
+ (await fixed("fixture-outside.md"))
195
+ .split("\n")
196
+ .filter((l) => l.startsWith("| [")),
197
+ ).toEqual([
198
+ "| [`pdf/body-size`](../docs/rules/pdf/body-size.md) | warn | — | inherited from `../shared/fixture-base.jsonc` |",
199
+ ]);
200
+ rmSync(join(P, "fixture-outside.jsonc"));
201
+ rmSync(join(P, "fixture-outside.md"));
202
+ });
203
+ });
204
+
205
+ describe("a family the shipped presets do not extend", () => {
206
+ it("says so, and that its chain sets no rule", async () => {
207
+ writeFileSync(
208
+ join(P, "fixture-family.jsonc"),
209
+ JSON.stringify({ type: "family", extends: "paperlint:acm-sigconf" }),
210
+ );
211
+ writeFileSync(join(P, "fixture-family.md"), "# Family\n");
212
+ expect(
213
+ sectionOf(await fixed("fixture-family.md"))
214
+ .split("\n")
215
+ .slice(4, 7),
216
+ ).toEqual([
217
+ "**`paperlint:fixture-family`** — template family, no venue of its own · extends `paperlint:acm-sigconf` · extended by no shipped preset",
218
+ "",
219
+ "No preset of this chain sets a rule: under it, every rule keeps paperlint's default.",
220
+ ]);
221
+ rmSync(join(P, "fixture-family.jsonc"));
222
+ rmSync(join(P, "fixture-family.md"));
223
+ });
224
+ });
225
+
226
+ describe("a card that cannot be judged, or whose markers are not a pair", () => {
227
+ it("one marker without the other is reported, and not fixed", async () => {
228
+ for (const marker of [SECTION_START, SECTION_END]) {
229
+ writeFileSync(join(P, "fixture-venue.md"), `# Fixture\n\n${marker}\n`);
230
+ expect(await findings("fixture-venue.md")).toEqual([
231
+ `preset/card-rules: the generated rules section needs both markers, ${SECTION_START} and ${SECTION_END}, each on its own line`,
232
+ ]);
233
+ expect(await fixed("fixture-venue.md")).toBe(`# Fixture\n\n${marker}\n`);
234
+ }
235
+ });
236
+
237
+ it("a card with no preset beside it names the file it looked for", async () => {
238
+ writeFileSync(join(P, "orphan.md"), "# Orphan\n");
239
+ expect(await findings("orphan.md")).toEqual([
240
+ `preset/card-rules: a preset card describes the preset beside it, and there is none: ${join(P, "orphan.jsonc")}`,
241
+ ]);
242
+ rmSync(join(P, "orphan.md"));
243
+ });
244
+
245
+ it("a card beside a preset that does not resolve says why", async () => {
246
+ writeFileSync(
247
+ join(P, "broken.jsonc"),
248
+ JSON.stringify({ type: "family", extends: "x" }),
249
+ );
250
+ writeFileSync(join(P, "broken.md"), "# Broken\n");
251
+ expect(await findings("broken.md")).toEqual([
252
+ `preset/card-rules: the preset beside this card does not resolve, so its rules cannot be listed: "extends": "x" — a preset is \`paperlint:<name>\` (shipped) or a path starting with ./ or ../ (your own); npm presets: not yet supported. Did you mean "paperlint:x"?`,
253
+ ]);
254
+ rmSync(join(P, "broken.jsonc"));
255
+ rmSync(join(P, "broken.md"));
256
+ });
257
+ });
258
+
259
+ describe("entryParts", () => {
260
+ it.each<[unknown, { severity: string; options: string }]>([
261
+ ["error", { severity: "error", options: "" }],
262
+ [2, { severity: "2", options: "" }],
263
+ [["warn", { a: 1 }, "b"], { severity: "warn", options: '{"a":1}, "b"' }],
264
+ ])("%j", (entry, want) => {
265
+ expect(entryParts(entry)).toEqual(want);
266
+ });
267
+ });
@@ -0,0 +1,384 @@
1
+ /**
2
+ * `preset/card-rules` — the rules table in a preset's card, generated from the preset beside it.
3
+ *
4
+ * Every shipped preset has a card, `presets/<name>.md` beside `presets/<name>.jsonc`: what the venue
5
+ * or the template family is, its call for papers, its kinds and limits — prose a person writes. One
6
+ * part of the card is NOT prose: which rules a paper extending the preset runs with, at which
7
+ * severity and options, and which file of the chain set each. That part is generated between two
8
+ * markers and checked on every lint, so it cannot say anything the preset does not:
9
+ *
10
+ * <!-- paperlint:preset-rules -->
11
+ * …generated: what the preset is, its call for papers, the resolved rules…
12
+ * <!-- /paperlint:preset-rules -->
13
+ *
14
+ * A section that differs from what the preset resolves to is reported, and `eslint --fix` writes it.
15
+ * A card with no markers gets the section appended by `--fix`; a card with one marker and not the
16
+ * other is reported and left alone, since where the section should end is a person's call.
17
+ *
18
+ * ── WHY OUR OWN RULE, AND NOT eslint-doc-generator ───────────────────────────────
19
+ * eslint-doc-generator documents an ESLint PLUGIN: its rules, and which of the plugin's exported
20
+ * configs enable each. A preset is not an ESLint config — it is paperlint's own format, merged along
21
+ * an `extends` chain of files (`src/presets.ts`), and the column that matters here (which file of
22
+ * the chain set a rule) has no counterpart in a plugin's configs.
23
+ *
24
+ * ── WHAT IT READS ────────────────────────────────────────────────────────────────
25
+ * The card's markdown tree (the two markers are top-level `html` nodes), the preset beside it through
26
+ * `resolvePreset` — the reader every rule uses — and whether `docs/rules/<rule>.md` exists, to link
27
+ * a rule to its page or, before it has one, to the index `docs/rules.md`.
28
+ */
29
+ import { basename, dirname, join, relative } from "node:path";
30
+ import { callerPath } from "./caller-path.ts";
31
+ import type { Files } from "./ports/files.ts";
32
+ import {
33
+ presetProblemText,
34
+ resolvePreset,
35
+ resolveShipped,
36
+ SHIPPED_PREFIX,
37
+ type Preset,
38
+ } from "./presets.ts";
39
+
40
+ /** The first line of the generated section. */
41
+ export const SECTION_START = "<!-- paperlint:preset-rules -->";
42
+ /** The last line of the generated section. */
43
+ export const SECTION_END = "<!-- /paperlint:preset-rules -->";
44
+
45
+ const PRESET_EXT = ".jsonc";
46
+ const CARD_EXT = ".md";
47
+
48
+ export interface PresetCardDeps {
49
+ readonly files: Files;
50
+ /** The shipped presets and their schema: where `paperlint:<name>` resolves. */
51
+ readonly venuesDir: string;
52
+ /** The repository root: the rule pages are `docs/rules/<group>/<rule>.md` under it. */
53
+ readonly docsRoot: string;
54
+ }
55
+
56
+ /** A point of a markdown node, as mdast gives it: @eslint/markdown's parser sets the offset on every node. */
57
+ interface Point {
58
+ readonly line: number;
59
+ readonly column: number;
60
+ readonly offset: number;
61
+ }
62
+
63
+ /** The slice of an mdast node the rule reads. */
64
+ export interface MdNode {
65
+ readonly type: string;
66
+ readonly value?: string;
67
+ readonly position: { readonly start: Point; readonly end: Point };
68
+ }
69
+
70
+ /** The card's root: its top-level blocks. */
71
+ export interface MdRoot extends MdNode {
72
+ readonly children: readonly MdNode[];
73
+ }
74
+
75
+ /** What `--fix` may do: replace a range of the card's text, or insert after one. */
76
+ export interface CardFixer {
77
+ replaceTextRange(range: readonly [number, number], text: string): unknown;
78
+ insertTextAfterRange(range: readonly [number, number], text: string): unknown;
79
+ }
80
+
81
+ /** The slice of ESLint's rule context this rule uses. */
82
+ export interface CardRuleContext {
83
+ readonly filename: string;
84
+ readonly sourceCode: { readonly text: string };
85
+ report(d: {
86
+ readonly node: MdNode;
87
+ readonly messageId: string;
88
+ readonly data?: Readonly<Record<string, string>>;
89
+ readonly fix?: (fixer: CardFixer) => unknown;
90
+ }): void;
91
+ }
92
+
93
+ export interface CardRuleModule {
94
+ readonly meta: {
95
+ readonly type: "problem";
96
+ readonly fixable: "code";
97
+ readonly docs: { readonly description: string };
98
+ readonly schema: readonly object[];
99
+ readonly messages: Readonly<Record<string, string>>;
100
+ };
101
+ create(context: CardRuleContext): { readonly root: (node: MdRoot) => void };
102
+ }
103
+
104
+ /** One row of the table: a rule of the resolved preset. */
105
+ export interface RuleRow {
106
+ readonly id: string;
107
+ readonly severity: string;
108
+ /** The options after the severity, as JSON; empty when the entry has none. */
109
+ readonly options: string;
110
+ /** The file of the chain that set the entry. */
111
+ readonly origin: string;
112
+ }
113
+
114
+ /** A rule entry (`"error"`, `2`, `["error", { … }]`) → its severity and its options as JSON. Pure. */
115
+ export function entryParts(entry: unknown): {
116
+ readonly severity: string;
117
+ readonly options: string;
118
+ } {
119
+ // One level of `flat`: an array entry gives its elements, a bare severity itself.
120
+ const [severity, ...options] = [entry].flat();
121
+ return {
122
+ severity: String(severity),
123
+ options: options.map((o) => JSON.stringify(o)).join(", "),
124
+ };
125
+ }
126
+
127
+ /** The resolved preset's rules, one row each, by rule id. Pure. */
128
+ export const ruleRows = (preset: Preset): readonly RuleRow[] =>
129
+ Object.entries(preset.ruleOrigins)
130
+ .sort(([a], [b]) => a.localeCompare(b))
131
+ .map(([id, origin]) => ({ id, ...entryParts(preset.rules[id]), origin }));
132
+
133
+ /** How a card names a preset file: `paperlint:<name>` when it is shipped, else its path from the card. */
134
+ const specOf = (file: string, card: string, venuesDir: string): string =>
135
+ dirname(file) === venuesDir
136
+ ? `${SHIPPED_PREFIX}${basename(file, PRESET_EXT)}`
137
+ : relative(dirname(card), file);
138
+
139
+ /** A table cell's text with GFM's column separator escaped. Pure. */
140
+ const cell = (text: string): string => text.split("|").join("\\|");
141
+
142
+ /** What the card says about where to look for the section's source, and how to rewrite it. */
143
+ const generatedNote = (preset: string): string =>
144
+ `<!-- Generated from ${preset} by \`npx eslint --fix\` (rule preset/card-rules). Edit the preset, not this section. -->`;
145
+
146
+ /** The section's first paragraph: what the preset is, its call for papers or its relatives. */
147
+ function identityLine(o: {
148
+ readonly preset: Preset;
149
+ readonly own: string;
150
+ readonly specOf: (file: string) => string;
151
+ readonly extendedBy: readonly string[];
152
+ }): string {
153
+ const spec = o.specOf(o.own);
154
+ const extendsText = o.preset.chain
155
+ .slice(-2, -1)
156
+ .map((parent) => ` · extends \`${o.specOf(parent)}\``)
157
+ .join("");
158
+ switch (o.preset.identity.type) {
159
+ case "venue":
160
+ return `**\`${spec}\`** — venue preset for ${o.preset.identity.name} · call for papers: <${o.preset.identity.url}>${extendsText}`;
161
+ case "family": {
162
+ const by = o.extendedBy.length
163
+ ? o.extendedBy.map((s) => `\`${s}\``).join(", ")
164
+ : "no shipped preset";
165
+ return `**\`${spec}\`** — template family, no venue of its own${extendsText} · extended by ${by}`;
166
+ }
167
+ case "base":
168
+ return `**\`${basename(o.own)}\`** — the TeX base set: every paper gets it, whatever it extends, and no preset extends it`;
169
+ }
170
+ }
171
+
172
+ /** The table, or the sentence that stands for an empty one. Pure. */
173
+ function rulesTable(
174
+ rows: readonly RuleRow[],
175
+ own: string,
176
+ link: (id: string) => string,
177
+ originSpec: (file: string) => string,
178
+ ): string {
179
+ if (rows.length === 0)
180
+ return "No preset of this chain sets a rule: under it, every rule keeps paperlint's default.";
181
+ return [
182
+ "| rule | severity | options | set in |",
183
+ "| --- | --- | --- | --- |",
184
+ ...rows.map(
185
+ (r) =>
186
+ `| [\`${r.id}\`](${link(r.id)}) | ${r.severity} | ${r.options === "" ? "—" : `\`${cell(r.options)}\``} | ${r.origin === own ? "this preset" : `inherited from \`${originSpec(r.origin)}\``} |`,
187
+ ),
188
+ ].join("\n");
189
+ }
190
+
191
+ /** A rule's link from the card: its page under `docs/rules/`, or the index before it has one. */
192
+ const ruleLink =
193
+ (o: {
194
+ readonly card: string;
195
+ readonly docsRoot: string;
196
+ readonly docExists: (file: string) => boolean;
197
+ }) =>
198
+ (id: string): string => {
199
+ const page = join(o.docsRoot, "docs", "rules", `${id}.md`);
200
+ return relative(
201
+ dirname(o.card),
202
+ o.docExists(page) ? page : join(o.docsRoot, "docs", "rules.md"),
203
+ );
204
+ };
205
+
206
+ /**
207
+ * The section between the markers for the preset `own`, resolved, as the card `card` shows it —
208
+ * without the markers. Wrapped in prettier-ignore, so the formatter and this rule never disagree
209
+ * about the table's spacing. Pure but for `docExists`.
210
+ */
211
+ export function cardSection(o: {
212
+ readonly preset: Preset;
213
+ readonly own: string;
214
+ readonly card: string;
215
+ readonly venuesDir: string;
216
+ readonly docsRoot: string;
217
+ readonly docExists: (file: string) => boolean;
218
+ readonly extendedBy: readonly string[];
219
+ }): string {
220
+ const link = ruleLink(o);
221
+ const spec = (file: string): string => specOf(file, o.card, o.venuesDir);
222
+ return [
223
+ "<!-- prettier-ignore-start -->",
224
+ generatedNote(basename(o.own)),
225
+ "",
226
+ identityLine({
227
+ preset: o.preset,
228
+ own: o.own,
229
+ specOf: spec,
230
+ extendedBy: o.extendedBy,
231
+ }),
232
+ "",
233
+ rulesTable(ruleRows(o.preset), o.own, link, spec),
234
+ "",
235
+ "<!-- prettier-ignore-end -->",
236
+ ].join("\n");
237
+ }
238
+
239
+ /** The shipped presets whose chain runs through `file`, by their spec, `file` itself left out. */
240
+ const extendedByOf = (file: string, deps: PresetCardDeps): readonly string[] =>
241
+ resolveShipped(deps)
242
+ .flatMap((p) => (p.chain.includes(file) ? p.chain.slice(-1) : []))
243
+ .filter((leaf) => leaf !== file)
244
+ .map((leaf) => specOf(leaf, file, deps.venuesDir));
245
+
246
+ /** The section a card should hold, or the finding that stands in its place. */
247
+ type Expected =
248
+ | { readonly ok: true; readonly text: string }
249
+ | {
250
+ readonly ok: false;
251
+ readonly messageId: string;
252
+ readonly data: Readonly<Record<string, string>>;
253
+ };
254
+
255
+ /** The expected section for the card at `card`, or the finding that stands in its place. */
256
+ function expectedSection(card: string, deps: PresetCardDeps): Expected {
257
+ const own = join(dirname(card), `${basename(card, CARD_EXT)}${PRESET_EXT}`);
258
+ if (!deps.files.isFile(callerPath(own)))
259
+ return { ok: false, messageId: "noPreset", data: { preset: own } };
260
+ const r = resolvePreset(`./${basename(own)}`, card, deps);
261
+ if (!r.ok) {
262
+ const why = presetProblemText(r.error);
263
+ return { ok: false, messageId: "presetProblem", data: { why } };
264
+ }
265
+ return {
266
+ ok: true,
267
+ text: cardSection({
268
+ preset: r.value,
269
+ own,
270
+ card,
271
+ venuesDir: deps.venuesDir,
272
+ docsRoot: deps.docsRoot,
273
+ docExists: (f) => deps.files.isFile(callerPath(f)),
274
+ extendedBy: extendedByOf(own, deps),
275
+ }),
276
+ };
277
+ }
278
+
279
+ /** The first top-level `html` node of the card that is exactly `marker`, or null. */
280
+ const markerIn = (root: MdRoot, marker: string): MdNode | null =>
281
+ root.children
282
+ .filter((n) => n.type === "html")
283
+ .find((n) => String(n.value).trim() === marker) ?? null;
284
+
285
+ /** The section to append to a card that has none, after its last line. */
286
+ const appended = (text: string, section: string): string =>
287
+ `${text.endsWith("\n") ? "" : "\n"}\n${SECTION_START}\n${section}\n${SECTION_END}\n`;
288
+
289
+ /** One finding, as the rule hands it to ESLint. */
290
+ type CardReport = Parameters<CardRuleContext["report"]>[0];
291
+
292
+ /** The finding for the section between `start` and `end`: stale, with the fix; null when it is right. */
293
+ function staleSection(
294
+ between: { readonly start: MdNode; readonly end: MdNode },
295
+ text: string,
296
+ section: string,
297
+ ): CardReport | null {
298
+ const from = between.start.position.end.offset;
299
+ const to = between.end.position.start.offset;
300
+ const expected = `\n${section}\n`;
301
+ return text.slice(from, to) === expected
302
+ ? null
303
+ : {
304
+ node: between.start,
305
+ messageId: "stale",
306
+ fix: (fixer) => fixer.replaceTextRange([from, to], expected),
307
+ };
308
+ }
309
+
310
+ /** The card's section against `section`, the one its preset resolves to: missing, unpaired, stale, or right (null). */
311
+ function sectionFinding(
312
+ root: MdRoot,
313
+ text: string,
314
+ section: string,
315
+ ): CardReport | null {
316
+ const start = markerIn(root, SECTION_START);
317
+ const end = markerIn(root, SECTION_END);
318
+ if (start === null)
319
+ return end === null
320
+ ? {
321
+ node: root,
322
+ messageId: "missing",
323
+ fix: (fixer) =>
324
+ fixer.insertTextAfterRange(
325
+ [text.length, text.length],
326
+ appended(text, section),
327
+ ),
328
+ }
329
+ : { node: end, messageId: "unpaired" };
330
+ if (end === null) return { node: start, messageId: "unpaired" };
331
+ return staleSection({ start, end }, text, section);
332
+ }
333
+
334
+ /** Judge the card `context.filename`: its preset beside it, then its generated section. */
335
+ function judgeCard(
336
+ context: CardRuleContext,
337
+ root: MdRoot,
338
+ deps: PresetCardDeps,
339
+ ): void {
340
+ const want = expectedSection(context.filename, deps);
341
+ const finding = want.ok
342
+ ? sectionFinding(root, context.sourceCode.text, want.text)
343
+ : { node: root, messageId: want.messageId, data: want.data };
344
+ if (finding !== null) context.report(finding);
345
+ }
346
+
347
+ /** The rule, over `deps`. */
348
+ export function presetCardRule(deps: PresetCardDeps): CardRuleModule {
349
+ return {
350
+ meta: {
351
+ type: "problem",
352
+ fixable: "code",
353
+ docs: {
354
+ description:
355
+ "a preset card's rules table is the one its preset resolves to — generated, never hand-written",
356
+ },
357
+ schema: [],
358
+ messages: {
359
+ stale:
360
+ "the rules section does not match the preset beside this card — run `npx eslint --fix` on this file to rewrite it from the preset",
361
+ missing: `this card has no generated rules section — run \`npx eslint --fix\` on this file to append one (between ${SECTION_START} and ${SECTION_END})`,
362
+ unpaired: `the generated rules section needs both markers, ${SECTION_START} and ${SECTION_END}, each on its own line`,
363
+ noPreset:
364
+ "a preset card describes the preset beside it, and there is none: {{preset}}",
365
+ presetProblem:
366
+ "the preset beside this card does not resolve, so its rules cannot be listed: {{why}}",
367
+ },
368
+ },
369
+ create(context) {
370
+ return {
371
+ root(node) {
372
+ judgeCard(context, node, deps);
373
+ },
374
+ };
375
+ },
376
+ };
377
+ }
378
+
379
+ /** The plugin's rules: `preset/card-rules` once registered under the name `preset`. */
380
+ export const presetCardRules = (
381
+ deps: PresetCardDeps,
382
+ ): Readonly<Record<string, CardRuleModule>> => ({
383
+ "card-rules": presetCardRule(deps),
384
+ });