paperlint 4.0.0 → 4.2.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 (236) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +55 -11
  3. package/CONTRIBUTING.md +30 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +23 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +110 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/includes.d.ts +12 -0
  15. package/dist/adapters/latex/includes.d.ts.map +1 -0
  16. package/dist/adapters/latex/includes.js +42 -0
  17. package/dist/adapters/latex/includes.js.map +1 -0
  18. package/dist/adapters/latex/index.d.ts +33 -0
  19. package/dist/adapters/latex/index.d.ts.map +1 -0
  20. package/dist/adapters/latex/index.js +25 -0
  21. package/dist/adapters/latex/index.js.map +1 -0
  22. package/dist/adapters/latex/nodes.d.ts +58 -0
  23. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  24. package/dist/adapters/latex/nodes.js +97 -0
  25. package/dist/adapters/latex/nodes.js.map +1 -0
  26. package/dist/adapters/latex/outline.d.ts +10 -0
  27. package/dist/adapters/latex/outline.d.ts.map +1 -0
  28. package/dist/adapters/latex/outline.js +39 -0
  29. package/dist/adapters/latex/outline.js.map +1 -0
  30. package/dist/adapters/latex/parse.d.ts +42 -0
  31. package/dist/adapters/latex/parse.d.ts.map +1 -0
  32. package/dist/adapters/latex/parse.js +95 -0
  33. package/dist/adapters/latex/parse.js.map +1 -0
  34. package/dist/adapters/latex/prose.d.ts +8 -0
  35. package/dist/adapters/latex/prose.d.ts.map +1 -0
  36. package/dist/adapters/latex/prose.js +353 -0
  37. package/dist/adapters/latex/prose.js.map +1 -0
  38. package/dist/adapters/latex/rendered.d.ts +10 -0
  39. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  40. package/dist/adapters/latex/rendered.js +39 -0
  41. package/dist/adapters/latex/rendered.js.map +1 -0
  42. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  43. package/dist/adapters/pdfjs/fill.js +12 -12
  44. package/dist/adapters/pdfjs/fill.js.map +1 -1
  45. package/dist/build.js +4 -4
  46. package/dist/build.js.map +1 -1
  47. package/dist/claim-provenance.d.ts +34 -0
  48. package/dist/claim-provenance.d.ts.map +1 -0
  49. package/dist/claim-provenance.js +146 -0
  50. package/dist/claim-provenance.js.map +1 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +49 -5
  53. package/dist/cli.js.map +1 -1
  54. package/dist/domain/paper-source.d.ts +58 -0
  55. package/dist/domain/paper-source.d.ts.map +1 -0
  56. package/dist/domain/paper-source.js +109 -0
  57. package/dist/domain/paper-source.js.map +1 -0
  58. package/dist/domain/sentences.d.ts +26 -0
  59. package/dist/domain/sentences.d.ts.map +1 -0
  60. package/dist/domain/sentences.js +48 -0
  61. package/dist/domain/sentences.js.map +1 -0
  62. package/dist/domain/tex-document.d.ts +119 -0
  63. package/dist/domain/tex-document.d.ts.map +1 -0
  64. package/dist/domain/tex-document.js +43 -0
  65. package/dist/domain/tex-document.js.map +1 -0
  66. package/dist/eslint-rules/paper-typography.d.ts +7 -0
  67. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  68. package/dist/eslint-rules/paper-typography.js +53 -1
  69. package/dist/eslint-rules/paper-typography.js.map +1 -1
  70. package/dist/facts-file.d.ts.map +1 -1
  71. package/dist/facts-file.js +2 -2
  72. package/dist/facts-file.js.map +1 -1
  73. package/dist/new-paper.d.ts +5 -0
  74. package/dist/new-paper.d.ts.map +1 -1
  75. package/dist/new-paper.js +3 -0
  76. package/dist/new-paper.js.map +1 -1
  77. package/dist/package-dirs.d.ts +5 -0
  78. package/dist/package-dirs.d.ts.map +1 -0
  79. package/dist/package-dirs.js +14 -0
  80. package/dist/package-dirs.js.map +1 -0
  81. package/dist/ports/latex.d.ts +35 -0
  82. package/dist/ports/latex.d.ts.map +1 -0
  83. package/dist/ports/latex.js +2 -0
  84. package/dist/ports/latex.js.map +1 -0
  85. package/dist/presets.d.ts +15 -2
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -6
  88. package/dist/presets.js.map +1 -1
  89. package/dist/register.d.ts +40 -0
  90. package/dist/register.d.ts.map +1 -0
  91. package/dist/register.js +134 -0
  92. package/dist/register.js.map +1 -0
  93. package/dist/tex-paper.d.ts +19 -0
  94. package/dist/tex-paper.d.ts.map +1 -0
  95. package/dist/tex-paper.js +105 -0
  96. package/dist/tex-paper.js.map +1 -0
  97. package/dist/tex-requirements.d.ts +11 -0
  98. package/dist/tex-requirements.d.ts.map +1 -1
  99. package/dist/tex-requirements.js +14 -8
  100. package/dist/tex-requirements.js.map +1 -1
  101. package/dist/tex-venue-rules.d.ts +88 -0
  102. package/dist/tex-venue-rules.d.ts.map +1 -0
  103. package/dist/tex-venue-rules.js +268 -0
  104. package/dist/tex-venue-rules.js.map +1 -0
  105. package/dist/venue-rules.d.ts +1 -1
  106. package/dist/venue-rules.d.ts.map +1 -1
  107. package/docs/configuration.md +3 -2
  108. package/docs/e2e.md +69 -40
  109. package/docs/install.md +1 -1
  110. package/docs/prior-art/README.md +1 -1
  111. package/docs/prior-art/package-location.md +5 -5
  112. package/docs/prior-art/repro/README.md +1 -1
  113. package/docs/prior-art/test-tooling.md +21 -10
  114. package/docs/rules/tex/claim-provenance.md +83 -0
  115. package/docs/rules/tex/missing-input.md +58 -0
  116. package/docs/rules/tex/register.md +77 -0
  117. package/docs/rules/tex/required-section.md +75 -0
  118. package/docs/rules/tex/template.md +74 -0
  119. package/docs/rules/tex/venue-leftover.md +73 -0
  120. package/docs/rules.md +73 -48
  121. package/docs/testing.md +17 -4
  122. package/docs/texlive-install-decision.md +1 -1
  123. package/docs/toolchain.md +2 -2
  124. package/eslint-rules/paper-typography.test.mjs +48 -1
  125. package/eslint-rules/paper-typography.ts +72 -1
  126. package/eslint.config.mjs +156 -15
  127. package/fixtures/build-e2e/acmart/expect.json +26 -0
  128. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  129. package/fixtures/build-e2e/aidc/expect.json +14 -0
  130. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  131. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  132. package/fixtures/build-e2e/broken/expect.json +11 -0
  133. package/fixtures/build-e2e/cite/expect.json +27 -0
  134. package/fixtures/build-e2e/empty/expect.json +12 -0
  135. package/fixtures/build-e2e/fallback/expect.json +53 -0
  136. package/fixtures/build-e2e/guards/expect.json +6 -0
  137. package/fixtures/build-e2e/no-source/expect.json +10 -0
  138. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  139. package/fixtures/pdf-facts/README.md +1 -1
  140. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  141. package/fixtures/render-paper/build-clean.sh +3 -5
  142. package/package.json +14 -2
  143. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  144. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  145. package/presets/aidc.jsonc +73 -0
  146. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  147. package/presets/ieee-conference.jsonc +66 -0
  148. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  149. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  150. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  151. package/scripts/check.harness.ts +64 -10
  152. package/scripts/check.test.ts +60 -13
  153. package/scripts/check.ts +83 -30
  154. package/scripts/test.test.ts +1 -1
  155. package/scripts/test.ts +1 -1
  156. package/skills/cold-read-diff/SKILL.md +31 -10
  157. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  158. package/skills/draft-paper/SKILL.md +2 -2
  159. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  160. package/skills/grade-paper-writing/SKILL.md +109 -44
  161. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  162. package/skills/harden-paper/SKILL.md +35 -36
  163. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  164. package/skills/paper-adversarial-review/SKILL.md +2 -2
  165. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  166. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  167. package/skills/paper-pipeline/SKILL.md +4 -4
  168. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -3
  169. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  170. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  171. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  172. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  173. package/skills/pc-panel-review/SKILL.md +36 -11
  174. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  175. package/skills/render-paper/SKILL.md +2 -2
  176. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  177. package/skills/render-paper/check-render.sh +16 -63
  178. package/skills/render-paper/render-paper.harness.mjs +17 -59
  179. package/src/adapters/banal/index.test.ts +1 -1
  180. package/src/adapters/banal/invocation.ts +1 -1
  181. package/src/adapters/banal/output.test.ts +1 -1
  182. package/src/adapters/banal/run.test.ts +1 -1
  183. package/src/adapters/banal/xml.test.ts +1 -1
  184. package/src/adapters/latex/document-class.ts +155 -0
  185. package/src/adapters/latex/hidden.ts +70 -0
  186. package/src/adapters/latex/includes.ts +66 -0
  187. package/src/adapters/latex/index.test.ts +535 -0
  188. package/src/adapters/latex/index.ts +65 -0
  189. package/src/adapters/latex/nodes.ts +150 -0
  190. package/src/adapters/latex/outline.ts +64 -0
  191. package/src/adapters/latex/parse.ts +137 -0
  192. package/src/adapters/latex/prose.ts +484 -0
  193. package/src/adapters/latex/rendered.ts +59 -0
  194. package/src/adapters/pdfjs/fill.test.ts +1 -1
  195. package/src/adapters/pdfjs/fill.ts +18 -10
  196. package/src/build.harness.mjs +5 -7
  197. package/src/build.ts +4 -4
  198. package/src/claim-provenance.test.ts +352 -0
  199. package/src/claim-provenance.ts +217 -0
  200. package/src/cli.harness.mjs +1 -1
  201. package/src/cli.test.ts +2 -8
  202. package/src/cli.ts +53 -8
  203. package/src/domain/paper-source.test.ts +156 -0
  204. package/src/domain/paper-source.ts +233 -0
  205. package/src/domain/sentences.ts +67 -0
  206. package/src/domain/tex-document.test.ts +80 -0
  207. package/src/domain/tex-document.ts +143 -0
  208. package/src/engine.harness.mjs +1 -1
  209. package/src/facts-file.test.ts +4 -7
  210. package/src/facts-file.ts +2 -2
  211. package/src/latex-loop.harness.ts +1 -1
  212. package/src/new-paper.test.ts +28 -3
  213. package/src/new-paper.ts +8 -0
  214. package/src/package-dirs.ts +17 -0
  215. package/src/paper-settings-commands.test.ts +6 -1
  216. package/src/ports/latex.ts +42 -0
  217. package/src/presets.test.ts +65 -4
  218. package/src/presets.ts +38 -15
  219. package/src/register.test.ts +165 -0
  220. package/src/register.ts +177 -0
  221. package/src/rule-cases.test.ts +524 -0
  222. package/src/rule-docs.test.ts +159 -0
  223. package/src/tex-paper.test.ts +131 -0
  224. package/src/tex-paper.ts +152 -0
  225. package/src/tex-requirements.harness.ts +13 -23
  226. package/src/tex-requirements.ts +35 -8
  227. package/src/tex-venue-rules.test.ts +741 -0
  228. package/src/tex-venue-rules.ts +461 -0
  229. package/src/toolchain.harness.mjs +1 -1
  230. package/src/venue-rules.test.ts +96 -4
  231. package/src/venue-rules.ts +1 -1
  232. package/templates/paper/paper.tex +2 -1
  233. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  234. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  235. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  236. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `tex/register` on real parses: sentences of the body opening with And, So, But, Nor, Or or Yet per
3
+ * 1000 words, reported once for the whole body when the rate runs above what accepted papers show.
4
+ * Both halves, what counts as a word and as a conjunction start, the floor under which a rate means
5
+ * nothing, and the option.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { latexReader } from "./adapters/latex/index.ts";
9
+ import { memoryFiles } from "./adapters/memory/index.ts";
10
+ import {
11
+ judgeRegister,
12
+ measureRegister,
13
+ MIN_WORDS,
14
+ registerRule,
15
+ } from "./register.ts";
16
+
17
+ const doc = (body: string): string =>
18
+ `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
19
+
20
+ /** Fourteen words. */
21
+ const PLAIN =
22
+ "The rule reads the body of the paper and counts every sentence it holds.";
23
+ /** Thirteen words, opening with a conjunction. */
24
+ const CONJ = "But the rule reads the body of the paper and counts each one.";
25
+
26
+ /** A body of `plain` plain sentences and `conj` conjunction starts, in paragraphs. */
27
+ const body = (plain: number, conj: number): string =>
28
+ [
29
+ ...Array.from({ length: plain }, () => PLAIN),
30
+ ...Array.from({ length: conj }, () => CONJ),
31
+ ]
32
+ .map((s, i) => (i % 10 === 9 ? `${s}\n\n` : `${s} `))
33
+ .join("");
34
+
35
+ const measured = (src: string) =>
36
+ measureRegister(latexReader.bodyProse(doc(src)));
37
+
38
+ describe("measureRegister — what the body is counted as", () => {
39
+ it("its words, and its sentences opening with a conjunction", () => {
40
+ expect(measured(`${PLAIN} ${CONJ} Yet it holds.`)).toMatchObject({
41
+ words: 14 + 13 + 3,
42
+ conjunctionStarts: 2,
43
+ });
44
+ });
45
+
46
+ it("a citation, a reference and math each read as a word", () => {
47
+ expect(
48
+ measured(
49
+ "We show a sample in Table~\\ref{t}. It follows~\\cite{k} with $n$ runs.",
50
+ ),
51
+ ).toMatchObject({ words: 7 + 6 });
52
+ });
53
+
54
+ it("🔴 a conjunction opens a sentence only as its first word and capitalised: a list item's «and», «Android», «Oracle» do not", () => {
55
+ expect(
56
+ measured(
57
+ "And it holds. Android ships it. Oracle runs it. Some sentences, and more.\n\\begin{itemize}\n\\item and the rest.\n\\end{itemize}",
58
+ ),
59
+ ).toMatchObject({ conjunctionStarts: 1 });
60
+ });
61
+
62
+ it("a sentence with no word in it — a stray mark, a lone full stop — adds nothing", () => {
63
+ expect(measured("\\cite{k}. . It holds here.")).toMatchObject({
64
+ words: 3,
65
+ });
66
+ expect(measured(". !")).toMatchObject({ words: 0 });
67
+ expect(measureRegister([])).toMatchObject({ words: 0, at: null });
68
+ });
69
+
70
+ it("the examples are the first three, as the reader sees them", () => {
71
+ expect(
72
+ measured(`${CONJ} So it goes. Or not. And again. Yet more.`).examples,
73
+ ).toEqual([
74
+ "But the rule reads the body of the paper and counts each one.",
75
+ "So it goes.",
76
+ "Or not.",
77
+ ]);
78
+ });
79
+ });
80
+
81
+ describe("judgeRegister — one finding when the rate runs above the limit", () => {
82
+ const judged = (src: string, limit?: number) =>
83
+ judgeRegister(measured(src), limit).map((f) => f.messageId);
84
+
85
+ it("a body like an accepted paper's is silent", () => {
86
+ // 200 × 14 + 13 = 2 813 words, 1 start: 0.36 per 1000.
87
+ expect(judged(body(200, 1))).toEqual([]);
88
+ });
89
+
90
+ it("🔴 conjunction starts above 0.8 per 1000 words are reported, once", () => {
91
+ // 200 × 14 + 3 × 13 = 2 839 words, 3 starts: 1.06 per 1000.
92
+ expect(judged(body(200, 3))).toEqual(["conjunctionStarts"]);
93
+ });
94
+
95
+ it("at the limit is not above it", () => {
96
+ expect(judged(body(200, 3), 1000 * (3 / (200 * 14 + 3 * 13)))).toEqual([]);
97
+ });
98
+
99
+ it("🔴 under the floor of words a rate means nothing, and nothing is reported", () => {
100
+ const tiny = "It holds here. But it holds.";
101
+ expect(measured(tiny).words).toBeLessThan(MIN_WORDS);
102
+ expect(judged(tiny)).toEqual([]);
103
+ });
104
+
105
+ it("the limit is an option: raised, the same body is silent; lowered, it reports", () => {
106
+ expect(judged(body(200, 3), 2)).toEqual([]);
107
+ expect(judged(body(200, 1), 0.2)).toEqual(["conjunctionStarts"]);
108
+ });
109
+
110
+ it("the finding carries the rate, the count, the limit and the examples", () => {
111
+ const [f] = judgeRegister(measured(body(200, 3)));
112
+ expect(f?.data).toMatchObject({
113
+ count: 3,
114
+ rate: "1.06",
115
+ limit: 0.8,
116
+ examples: `«${CONJ}», «${CONJ}», «${CONJ}»`,
117
+ });
118
+ });
119
+ });
120
+
121
+ describe("the rule — reported at the start of the body, the option read from the config", () => {
122
+ interface Seen {
123
+ readonly line: number;
124
+ readonly messageId: string;
125
+ }
126
+ const run = (src: string, options?: readonly unknown[]): Seen[] => {
127
+ const seen: Seen[] = [];
128
+ registerRule({ files: memoryFiles({}), latex: latexReader })
129
+ .create({
130
+ filename: "/p/paper.tex",
131
+ ...(options === undefined ? {} : { options }),
132
+ sourceCode: {
133
+ text: src,
134
+ getLocFromIndex: (i) => {
135
+ const line = src.slice(0, i).split("\n").length;
136
+ return { line, column: i - src.lastIndexOf("\n", i - 1) };
137
+ },
138
+ },
139
+ report: (d) => {
140
+ seen.push({
141
+ line: d.loc.start.line,
142
+ messageId: "messageId" in d ? d.messageId : d.message,
143
+ });
144
+ },
145
+ })
146
+ .root?.();
147
+ return seen;
148
+ };
149
+ const FOUND = { messageId: "conjunctionStarts" };
150
+
151
+ it("🔴 one finding, where the body's first sentence starts", () => {
152
+ expect(run(doc(body(200, 4)))).toEqual([{ line: 3, ...FOUND }]);
153
+ });
154
+
155
+ it("the option in the config changes the limit; an option object without it keeps the default", () => {
156
+ expect(run(doc(body(200, 4)), [{ conjunctionStartsPer1000: 2 }])).toEqual(
157
+ [],
158
+ );
159
+ expect(run(doc(body(200, 4)), [{}])).toEqual([{ line: 3, ...FOUND }]);
160
+ });
161
+
162
+ it("a body read without a document environment is reported at the top", () => {
163
+ expect(run(body(200, 4))).toEqual([{ line: 1, ...FOUND }]);
164
+ });
165
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * `tex/register` — the body reads at the register of a paper, not of a post: sentences that open
3
+ * with a coordinating conjunction stay as rare as they are in accepted papers.
4
+ *
5
+ * warn more than 0.8 sentences per 1000 words of the body open with And, So, But, Nor, Or or Yet
6
+ *
7
+ * ── WHY ──────────────────────────────────────────────────────────────────────────
8
+ * Reviewers called our prose "too informal" and "a blogpost" twice. Measured against eleven accepted
9
+ * papers, sentence-initial conjunctions set our drafts apart (accepted 0–0.58 per 1000 words). The
10
+ * limit is an option; its default and the measurement behind it are on the rule's page.
11
+ *
12
+ * ── WHAT IT READS ────────────────────────────────────────────────────────────────
13
+ * The body's prose through the `LatexReader` port (`bodyProse`) — the parse tree of the whole paper,
14
+ * includes spliced — cut into sentences by the same splitter `tex/claim-provenance` uses
15
+ * (src/domain/sentences.ts). A citation, a cross-reference or math reads as one word, as it does on
16
+ * the page. A body under 2 000 words is not judged: there one sentence decides the rate.
17
+ */
18
+ import { runText, type Passage, type Span } from "./domain/tex-document.ts";
19
+ import { runOf, sentences } from "./domain/sentences.ts";
20
+ import { readPaper, reportInPaper, type PaperDeps } from "./tex-paper.ts";
21
+ import {
22
+ ruleDocsUrl,
23
+ type Located,
24
+ type TexRuleModule,
25
+ } from "./tex-venue-rules.ts";
26
+
27
+ /** Under this many words in the body, the rate is not judged. */
28
+ export const MIN_WORDS = 2000;
29
+
30
+ /** The limit when the config sets none, from the measurement on the rule's page. */
31
+ export const DEFAULT_LIMIT = 0.8;
32
+
33
+ /** The words that, opening a sentence, make it a conjunction start. Capitalised: only a first word. */
34
+ const CONJUNCTIONS: ReadonlySet<string> = new Set([
35
+ "And",
36
+ "So",
37
+ "But",
38
+ "Nor",
39
+ "Or",
40
+ "Yet",
41
+ ]);
42
+
43
+ /** The characters a mark or math stands as in a sentence's text (src/domain/sentences.ts). */
44
+ const MARK = /[-]/gu;
45
+
46
+ const WORDS = new Intl.Segmenter("en", { granularity: "word" });
47
+
48
+ /** A sentence as the rate sees it: its text, its words, and its first word. */
49
+ interface Sentence {
50
+ readonly text: string;
51
+ readonly words: number;
52
+ readonly first: string;
53
+ }
54
+
55
+ /** A sentence's words: the word-like ones, and one for each mark or math. Null when it has none of its own. */
56
+ function sentenceOf(text: string): Sentence | null {
57
+ const words = [...WORDS.segment(text)].filter((w) => w.isWordLike);
58
+ const [first] = words;
59
+ if (first === undefined) return null;
60
+ return {
61
+ text: text.replace(MARK, "").replace(/\s+/gu, " ").trim(),
62
+ words: words.length + (text.match(MARK)?.length ?? 0),
63
+ first: first.segment,
64
+ };
65
+ }
66
+
67
+ /** What the body measures. */
68
+ export interface RegisterMeasure {
69
+ readonly words: number;
70
+ readonly conjunctionStarts: number;
71
+ /** The first three, as the reader sees them. */
72
+ readonly examples: readonly string[];
73
+ /** Where the body's first passage starts, for a finding about the whole body. */
74
+ readonly at: Span | null;
75
+ }
76
+
77
+ /** Where a passage's first piece stands. */
78
+ function startOf(p: Passage): Span {
79
+ const first = p.pieces[0];
80
+ return first.kind === "text"
81
+ ? {
82
+ start: first.segment.at,
83
+ end: first.segment.at + first.segment.text.length,
84
+ }
85
+ : first.span;
86
+ }
87
+
88
+ /** The body's words, and its sentences that open with a conjunction. */
89
+ export function measureRegister(passages: readonly Passage[]): RegisterMeasure {
90
+ const all = passages.flatMap((p) => {
91
+ const text = runText(runOf(p));
92
+ return sentences(text).flatMap(([from, to]) => {
93
+ const s = sentenceOf(text.slice(from, to));
94
+ return s === null ? [] : [s];
95
+ });
96
+ });
97
+ const conj = all.filter((s) => CONJUNCTIONS.has(s.first));
98
+ const [first] = passages;
99
+ return {
100
+ words: all.reduce((n, s) => n + s.words, 0),
101
+ conjunctionStarts: conj.length,
102
+ examples: conj.slice(0, 3).map((s) => s.text),
103
+ at: first === undefined ? null : startOf(first),
104
+ };
105
+ }
106
+
107
+ /** One finding, at the start of the body, when the rate runs above `limit` per 1000 words. */
108
+ export function judgeRegister(
109
+ m: RegisterMeasure,
110
+ limit: number = DEFAULT_LIMIT,
111
+ ): readonly Located[] {
112
+ if (m.words < MIN_WORDS) return [];
113
+ const rate = (1000 * m.conjunctionStarts) / m.words;
114
+ if (rate <= limit) return [];
115
+ const data = {
116
+ count: m.conjunctionStarts,
117
+ rate: rate.toFixed(2),
118
+ limit,
119
+ examples: m.examples.map((x) => `«${x}»`).join(", "),
120
+ };
121
+ return [{ messageId: "conjunctionStarts", data, at: m.at }];
122
+ }
123
+
124
+ /** The limit the config sets, or the default: ESLint has validated the option against the schema. */
125
+ function limitOf(options: readonly unknown[] | undefined): number {
126
+ const [o] = options ?? [];
127
+ const given: Readonly<Record<string, unknown>> =
128
+ typeof o === "object" && o !== null
129
+ ? Object.fromEntries(Object.entries(o))
130
+ : {};
131
+ const v = given["conjunctionStartsPer1000"];
132
+ return typeof v === "number" ? v : DEFAULT_LIMIT;
133
+ }
134
+
135
+ const META: TexRuleModule["meta"] = {
136
+ type: "suggestion",
137
+ docs: {
138
+ description:
139
+ "the body reads at a paper's register: few sentences open with And, So, But, Nor, Or or Yet",
140
+ url: ruleDocsUrl("register"),
141
+ },
142
+ schema: [
143
+ {
144
+ type: "object",
145
+ properties: {
146
+ conjunctionStartsPer1000: { type: "number", minimum: 0 },
147
+ },
148
+ additionalProperties: false,
149
+ },
150
+ ],
151
+ messages: {
152
+ conjunctionStarts:
153
+ "{{count}} sentences open with And, So, But, Nor, Or or Yet — {{rate}} per 1000 words (first: {{examples}}). Accepted papers measured 0–0.58; this rule warns above {{limit}}. Join the sentence to the one before, or open with the connective a paper uses (However, Therefore, Moreover)",
154
+ },
155
+ };
156
+
157
+ /** The rule, over the body's prose of the whole paper, its includes spliced (`readPaper`). */
158
+ export const registerRule = (deps: PaperDeps): TexRuleModule => ({
159
+ meta: META,
160
+ create(context) {
161
+ return {
162
+ root() {
163
+ const sc = context.sourceCode;
164
+ const paper = readPaper(context.filename, sc.raw ?? sc.text, deps);
165
+ reportInPaper(
166
+ context,
167
+ META.messages,
168
+ paper,
169
+ judgeRegister(
170
+ measureRegister(deps.latex.bodyProse(paper.text)),
171
+ limitOf(context.options),
172
+ ),
173
+ );
174
+ },
175
+ };
176
+ },
177
+ });