paperlint 4.1.0 → 5.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 (202) hide show
  1. package/CLAUDE.md +26 -2
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +15 -4
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/dist/adapters/latex/document-class.d.ts +6 -5
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -1
  8. package/dist/adapters/latex/document-class.js +18 -10
  9. package/dist/adapters/latex/document-class.js.map +1 -1
  10. package/dist/adapters/latex/includes.d.ts +16 -0
  11. package/dist/adapters/latex/includes.d.ts.map +1 -0
  12. package/dist/adapters/latex/includes.js +48 -0
  13. package/dist/adapters/latex/includes.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +9 -5
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +8 -1
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/nodes.d.ts +5 -1
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +6 -2
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +1 -0
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/adapters/latex/prose.d.ts +8 -0
  26. package/dist/adapters/latex/prose.d.ts.map +1 -0
  27. package/dist/adapters/latex/prose.js +353 -0
  28. package/dist/adapters/latex/prose.js.map +1 -0
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +7 -3
  31. package/dist/build.js.map +1 -1
  32. package/dist/claim-provenance.d.ts +34 -0
  33. package/dist/claim-provenance.d.ts.map +1 -0
  34. package/dist/claim-provenance.js +146 -0
  35. package/dist/claim-provenance.js.map +1 -0
  36. package/dist/cli.d.ts +2 -8
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +81 -31
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/paper-source.d.ts +58 -0
  41. package/dist/domain/paper-source.d.ts.map +1 -0
  42. package/dist/domain/paper-source.js +109 -0
  43. package/dist/domain/paper-source.js.map +1 -0
  44. package/dist/domain/sentences.d.ts +26 -0
  45. package/dist/domain/sentences.d.ts.map +1 -0
  46. package/dist/domain/sentences.js +48 -0
  47. package/dist/domain/sentences.js.map +1 -0
  48. package/dist/domain/tex-document.d.ts +39 -4
  49. package/dist/domain/tex-document.d.ts.map +1 -1
  50. package/dist/domain/tex-document.js.map +1 -1
  51. package/dist/eslint-rules/latex-language.d.ts +5 -1
  52. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  53. package/dist/eslint-rules/latex-language.js +6 -3
  54. package/dist/eslint-rules/latex-language.js.map +1 -1
  55. package/dist/eslint-rules/paper-context.d.ts +20 -0
  56. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  57. package/dist/eslint-rules/paper-context.js +44 -0
  58. package/dist/eslint-rules/paper-context.js.map +1 -0
  59. package/dist/eslint-rules/paper-typography.d.ts +8 -1
  60. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  61. package/dist/eslint-rules/paper-typography.js +85 -19
  62. package/dist/eslint-rules/paper-typography.js.map +1 -1
  63. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  64. package/dist/eslint-rules/tex-build.js +7 -1
  65. package/dist/eslint-rules/tex-build.js.map +1 -1
  66. package/dist/init.d.ts +4 -19
  67. package/dist/init.d.ts.map +1 -1
  68. package/dist/init.js +24 -29
  69. package/dist/init.js.map +1 -1
  70. package/dist/package-dirs.d.ts +2 -0
  71. package/dist/package-dirs.d.ts.map +1 -1
  72. package/dist/package-dirs.js +8 -0
  73. package/dist/package-dirs.js.map +1 -1
  74. package/dist/paper-files.d.ts +2 -2
  75. package/dist/paper-includes.d.ts +17 -0
  76. package/dist/paper-includes.d.ts.map +1 -0
  77. package/dist/paper-includes.js +51 -0
  78. package/dist/paper-includes.js.map +1 -0
  79. package/dist/ports/latex.d.ts +12 -1
  80. package/dist/ports/latex.d.ts.map +1 -1
  81. package/dist/register.d.ts +40 -0
  82. package/dist/register.d.ts.map +1 -0
  83. package/dist/register.js +134 -0
  84. package/dist/register.js.map +1 -0
  85. package/dist/rules-config.d.ts +1 -0
  86. package/dist/rules-config.d.ts.map +1 -1
  87. package/dist/rules-config.js.map +1 -1
  88. package/dist/structure.js +1 -1
  89. package/dist/structure.js.map +1 -1
  90. package/dist/tex-paper.d.ts +33 -0
  91. package/dist/tex-paper.d.ts.map +1 -0
  92. package/dist/tex-paper.js +103 -0
  93. package/dist/tex-paper.js.map +1 -0
  94. package/dist/tex-venue-rules.d.ts +7 -2
  95. package/dist/tex-venue-rules.d.ts.map +1 -1
  96. package/dist/tex-venue-rules.js +31 -17
  97. package/dist/tex-venue-rules.js.map +1 -1
  98. package/docs/configuration.md +5 -4
  99. package/docs/install.md +8 -8
  100. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  101. package/docs/rules/tex/claim-provenance.md +83 -0
  102. package/docs/rules/tex/register.md +77 -0
  103. package/docs/rules/tex/required-section.md +3 -1
  104. package/docs/rules/tex/template.md +12 -0
  105. package/docs/rules/tex/venue-leftover.md +4 -2
  106. package/docs/rules.md +17 -10
  107. package/eslint-rules/latex-language.ts +10 -4
  108. package/eslint-rules/paper-context.test.ts +47 -0
  109. package/eslint-rules/paper-context.ts +57 -0
  110. package/eslint-rules/paper-typography.test.mjs +48 -1
  111. package/eslint-rules/paper-typography.ts +139 -24
  112. package/eslint-rules/tex-build.ts +8 -1
  113. package/eslint.config.mjs +1 -0
  114. package/fixtures/real-markdown-paper/baseline.json +1 -2
  115. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  116. package/package.json +2 -1
  117. package/scripts/check.ts +2 -2
  118. package/scripts/eslint-report-guard.mjs +5 -2
  119. package/scripts/harness-api.frozen.json +0 -1
  120. package/skills/cold-read-diff/SKILL.md +31 -10
  121. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  122. package/skills/draft-paper/SKILL.md +2 -2
  123. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  124. package/skills/grade-paper-writing/SKILL.md +109 -44
  125. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  126. package/skills/harden-paper/SKILL.md +35 -36
  127. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  128. package/skills/paper-adversarial-review/SKILL.md +2 -2
  129. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  130. package/skills/paper-pipeline/SKILL.md +3 -3
  131. package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
  132. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  133. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  134. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  135. package/skills/pc-panel-review/SKILL.md +36 -11
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  137. package/src/adapters/latex/document-class.ts +20 -12
  138. package/src/adapters/latex/includes.ts +74 -0
  139. package/src/adapters/latex/index.test.ts +183 -1
  140. package/src/adapters/latex/index.ts +14 -4
  141. package/src/adapters/latex/nodes.ts +11 -2
  142. package/src/adapters/latex/parse.ts +1 -0
  143. package/src/adapters/latex/prose.ts +484 -0
  144. package/src/build.harness.mjs +4 -2
  145. package/src/build.test.ts +18 -1
  146. package/src/build.ts +7 -3
  147. package/src/claim-provenance.test.ts +352 -0
  148. package/src/claim-provenance.ts +217 -0
  149. package/src/cli.harness.mjs +22 -54
  150. package/src/cli.test.ts +2 -18
  151. package/src/cli.ts +92 -36
  152. package/src/domain/paper-source.test.ts +156 -0
  153. package/src/domain/paper-source.ts +233 -0
  154. package/src/domain/sentences.ts +67 -0
  155. package/src/domain/tex-document.ts +36 -3
  156. package/src/init.test.ts +33 -13
  157. package/src/init.ts +25 -48
  158. package/src/linted-set.test.ts +11 -1
  159. package/src/new-paper.harness.mjs +1 -5
  160. package/src/package-dirs.ts +9 -0
  161. package/src/paper-files.ts +2 -2
  162. package/src/paper-includes.test.ts +161 -0
  163. package/src/paper-includes.ts +77 -0
  164. package/src/ports/latex.ts +13 -0
  165. package/src/register.test.ts +165 -0
  166. package/src/register.ts +177 -0
  167. package/src/rule-cases.test.ts +43 -23
  168. package/src/rule-docs.test.ts +0 -1
  169. package/src/rules-config.ts +1 -0
  170. package/src/structure.harness.ts +1 -3
  171. package/src/structure.ts +1 -1
  172. package/src/tex-paper.test.ts +148 -0
  173. package/src/tex-paper.ts +161 -0
  174. package/src/tex-venue-rules.test.ts +87 -5
  175. package/src/tex-venue-rules.ts +62 -30
  176. package/templates/paper/PIPELINE-STATUS.md +0 -4
  177. package/dist/action-ref.d.ts +0 -12
  178. package/dist/action-ref.d.ts.map +0 -1
  179. package/dist/action-ref.js +0 -16
  180. package/dist/action-ref.js.map +0 -1
  181. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  182. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  183. package/dist/eslint-rules/paper-research-question.js +0 -188
  184. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  185. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  186. package/eslint-rules/paper-research-question.ts +0 -199
  187. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  188. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  189. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  190. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  191. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  192. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  193. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  194. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  195. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  196. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  197. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  198. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  199. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  200. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  201. package/src/action-ref.test.ts +0 -26
  202. package/src/action-ref.ts +0 -15
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `paperlint lint` over a paper whose body is in other files: the rules over ESLint's LaTeX text read
3
+ * every file `paper.tex` includes from its document body, report at that file's own path and line,
4
+ * and fix that file. What a rule decides for the whole paper — the majority reference form and
5
+ * whether the build is in review mode — it decides over all of the paper's files, not over the one
6
+ * it is reporting in.
7
+ */
8
+ import {
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, relative } from "node:path";
18
+ import { afterEach, describe, expect, it } from "vitest";
19
+ import { run } from "./cli.ts";
20
+ import { lintReport } from "../test/lint-report.ts";
21
+
22
+ const dirs: string[] = [];
23
+ afterEach(() => {
24
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
25
+ });
26
+
27
+ const doc = (cls: string, preamble: string, body: string): string =>
28
+ `\\documentclass${cls}\n${preamble}\n\\begin{document}\n${body}\n\\end{document}\n`;
29
+
30
+ const INTRO = "See §3 and p < .05.\nAs Fig.~\\ref{f3} shows.\n";
31
+
32
+ function project(files: Record<string, string>): string {
33
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-inc-")));
34
+ dirs.push(root);
35
+ const all: Record<string, string> = {
36
+ "package.json": JSON.stringify({ name: "c", private: true }),
37
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
38
+ "papers/a/paper.tex": doc(
39
+ "{article}",
40
+ "\\input{macros}",
41
+ "Figure~\\ref{f1} and Figure~\\ref{f2}.\n\\input{sections/intro}",
42
+ ),
43
+ // Prose in a preamble include is not the body: were it linted, `§2` would be reported.
44
+ "papers/a/macros.tex": "% See §2.\n\\newcommand{\\half}{0.5}\nSee §2.\n",
45
+ "papers/a/sections/intro.tex": INTRO,
46
+ // A frozen version beside the paper is not the paper.
47
+ "papers/a/versions/2026-01-01-submitted.tex": doc(
48
+ "{article}",
49
+ "",
50
+ "See §9.",
51
+ ),
52
+ ...files,
53
+ };
54
+ for (const [p, text] of Object.entries(all)) {
55
+ mkdirSync(join(root, p, ".."), { recursive: true });
56
+ writeFileSync(join(root, p), text);
57
+ }
58
+ return root;
59
+ }
60
+
61
+ /** `paperlint lint --json` → rule ids per linted file (relative to the project), and stderr. */
62
+ async function lint(
63
+ root: string,
64
+ args: string[] = [],
65
+ ): Promise<{ byFile: Record<string, string[]>; err: string }> {
66
+ const out: string[] = [];
67
+ const err: string[] = [];
68
+ await run(["lint", "--json", ...args], {
69
+ cwd: root,
70
+ log: (s: string) => out.push(s),
71
+ err: (s: string) => err.push(s),
72
+ });
73
+ const byFile = Object.fromEntries(
74
+ lintReport(out.join("\n")).map((r) => [
75
+ relative(root, r.filePath),
76
+ r.messages.map((m) => m.ruleId ?? "(fatal)").sort(),
77
+ ]),
78
+ );
79
+ return { byFile, err: err.join("\n") };
80
+ }
81
+
82
+ describe("lint reads the files paper.tex includes from its body", () => {
83
+ it("🔴 findings in an included file land at that file; the majority form is the paper's", async () => {
84
+ const { byFile } = await lint(project({}));
85
+ expect(byFile["papers/a/sections/intro.tex"]).toEqual([
86
+ "paper/figure-ref-style",
87
+ "paper/leading-zero",
88
+ "paper/section-word",
89
+ ]);
90
+ // Two `Figure` in paper.tex against one `Fig.` in the section: the section holds the minority.
91
+ expect(byFile["papers/a/paper.tex"]).not.toContain(
92
+ "paper/figure-ref-style",
93
+ );
94
+ });
95
+
96
+ it("a preamble include and a frozen version beside the paper are not linted", async () => {
97
+ const { byFile } = await lint(project({}));
98
+ expect(Object.keys(byFile).sort()).toEqual([
99
+ "papers/a/PIPELINE-STATUS.md",
100
+ "papers/a/paper.tex",
101
+ "papers/a/sections/intro.tex",
102
+ ]);
103
+ });
104
+
105
+ it("--fix rewrites the included file, to the paper's majority form", async () => {
106
+ const root = project({});
107
+ const before = readFileSync(join(root, "papers/a/paper.tex"), "utf8");
108
+ const { byFile } = await lint(root, ["--fix"]);
109
+ expect(byFile["papers/a/sections/intro.tex"]).toEqual([]);
110
+ expect(
111
+ readFileSync(join(root, "papers/a/sections/intro.tex"), "utf8"),
112
+ ).toBe("See Section 3 and p < 0.05.\nAs Figure~\\ref{f3} shows.\n");
113
+ expect(readFileSync(join(root, "papers/a/paper.tex"), "utf8")).toBe(before);
114
+ });
115
+ });
116
+
117
+ describe("what lint decides for the whole paper, it decides over every file of it", () => {
118
+ it("tex/future-promise in an included file follows the build mode paper.tex declares", async () => {
119
+ const promise = {
120
+ "papers/a/sections/intro.tex":
121
+ "The harness will be released at camera-ready.\n",
122
+ };
123
+ const final = await lint(project(promise));
124
+ expect(final.byFile["papers/a/sections/intro.tex"]).toEqual([
125
+ "tex/future-promise",
126
+ ]);
127
+ const review = await lint(
128
+ project({
129
+ ...promise,
130
+ "papers/a/paper.tex": doc(
131
+ "[review]{article}",
132
+ "",
133
+ "Text.\n\\input{sections/intro}",
134
+ ),
135
+ }),
136
+ );
137
+ expect(review.byFile["papers/a/sections/intro.tex"]).toEqual([]);
138
+ });
139
+
140
+ it("an include that resolves nowhere is named as a file lint did not read", async () => {
141
+ const { err } = await lint(
142
+ project({
143
+ "papers/a/paper.tex": doc("{article}", "", "\\input{sections/gone}"),
144
+ }),
145
+ );
146
+ expect(err).toMatch(
147
+ /papers\/a\/paper\.tex: lint did not read `sections\/gone`/,
148
+ );
149
+ });
150
+
151
+ it("the note is about the papers this run lints, not another paper of the project", async () => {
152
+ const root = project({
153
+ "papers/b/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
154
+ "papers/b/paper.tex": doc("{article}", "", "\\input{gone}"),
155
+ });
156
+ expect((await lint(root, ["papers/a"])).err).not.toMatch(/did not read/);
157
+ expect((await lint(root)).err).toMatch(
158
+ /papers\/b\/paper\.tex: lint did not read `gone`/,
159
+ );
160
+ });
161
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * THE FILES OF A PAPER'S BODY, handed to ESLint — so the rules over ESLint's LaTeX text read a paper
3
+ * whose body is in `sections/*.tex`, and report each finding at its own file and line (#144).
4
+ *
5
+ * The set is what TeX reads, resolved by the reader the parse-tree rules use (`bodyFiles`,
6
+ * src/tex-paper.ts): `paper.tex` and every file an include in its document body brings in, found in
7
+ * the paper's directory. Not a glob over every `.tex`: a frozen version under `versions/`, a macro
8
+ * file the preamble includes and a scratch file nothing includes are not the paper.
9
+ *
10
+ * Two blocks per paper, both relative to the paper's directory (`basePath`):
11
+ *
12
+ * 1. a global un-ignore of each body file by name. paperlint's scope ignores every file it does not
13
+ * claim (src/paper-files.ts); the fragment block (`buildConfig`) claims every `.tex` but
14
+ * `paper.tex`, so only the files named here are ever enumerated;
15
+ * 2. `settings.paperlint.paper` for `paper.tex` and its body files: the main file and the whole set,
16
+ * for the rules whose verdict is the paper's (eslint-rules/paper-context.ts).
17
+ */
18
+ import { join, relative } from "node:path";
19
+ import { PAPERLINT_SETTINGS } from "#eslint-rules/paper-context";
20
+ import { callerPath } from "./caller-path.ts";
21
+ import type { AbsolutePath } from "./domain/paths.ts";
22
+ import type { ConfigBlock } from "./rules-config.ts";
23
+ import { bodyFiles, type PaperDeps, type Unread } from "./tex-paper.ts";
24
+
25
+ /** One paper's main file and body files, and the includes that resolved nowhere. */
26
+ export interface PaperBody {
27
+ readonly dir: string;
28
+ readonly main: AbsolutePath;
29
+ readonly files: readonly AbsolutePath[];
30
+ readonly missing: readonly Unread[];
31
+ }
32
+
33
+ /** The body of each paper directory in `dirs` that has a `paper.tex`. */
34
+ export function paperBodies(
35
+ dirs: readonly string[],
36
+ deps: PaperDeps,
37
+ ): readonly PaperBody[] {
38
+ return dirs.flatMap((dir) => {
39
+ const main = callerPath(join(dir, "paper.tex"));
40
+ const bytes = deps.files.readBytes(main);
41
+ if (bytes === null) return [];
42
+ const body = bodyFiles(main, new TextDecoder().decode(bytes), deps);
43
+ return [{ dir, main, files: body.files, missing: body.missing }];
44
+ });
45
+ }
46
+
47
+ /** The config blocks that hand ESLint each paper's body files, and the paper to every one of them. */
48
+ export const includeBlocks = (
49
+ bodies: readonly PaperBody[],
50
+ ): readonly ConfigBlock[] =>
51
+ bodies.flatMap((b) => {
52
+ const rel = b.files.map((f) => relative(b.dir, f));
53
+ const paper = { main: b.main, files: [b.main, ...b.files] };
54
+ return [
55
+ ...(rel.length > 0
56
+ ? [{ basePath: b.dir, ignores: rel.map((r) => `!${r}`) }]
57
+ : []),
58
+ {
59
+ basePath: b.dir,
60
+ files: ["paper.tex", ...rel],
61
+ settings: { [PAPERLINT_SETTINGS]: { paper } },
62
+ },
63
+ ];
64
+ });
65
+
66
+ /** One line per include that resolved nowhere: which text lint did not read, and why. */
67
+ export const unreadLines = (
68
+ bodies: readonly PaperBody[],
69
+ shown: (path: string) => string,
70
+ ): readonly string[] =>
71
+ bodies.flatMap((b) =>
72
+ b.missing.map(
73
+ (m) =>
74
+ `${shown(b.main)}: lint did not read \`${m.target}\` (included by ${m.file}): ` +
75
+ "no such file in the paper's directory or paperlint's inputs",
76
+ ),
77
+ );
@@ -2,10 +2,13 @@
2
2
  * What paperlint needs from a LaTeX source, shaped by its rules and `paperlint new` — not by the
3
3
  * parser. The one implementation is `src/adapters/latex/` (unified-latex); `src/cli.ts` wires it.
4
4
  */
5
+ import type { Include } from "../domain/paper-source.ts";
5
6
  import type {
6
7
  ClassLine,
7
8
  DocumentClass,
8
9
  Outline,
10
+ Passage,
11
+ Span,
9
12
  TextRun,
10
13
  } from "../domain/tex-document.ts";
11
14
 
@@ -16,6 +19,16 @@ export interface LatexReader {
16
19
  readonly outline: (src: string) => Outline;
17
20
  /** The text a reader sees: prose, the author block, footnotes — not comments, keys, math, code. */
18
21
  readonly renderedRuns: (src: string) => readonly TextRun[];
22
+ /**
23
+ * The body's prose, as passages a sentence cannot cross: from the start of the document (the
24
+ * abstract included) to the appendix or the bibliography, without headings, captions, floats,
25
+ * code or the title block. Citations, links and cross-references stay in place as marks.
26
+ */
27
+ readonly bodyProse: (src: string) => readonly Passage[];
28
+ /** Every `\input`, `\include` and `\subfile`, in source order: the path as written, where it stands. */
29
+ readonly includes: (src: string) => readonly Include[];
30
+ /** The span of the `document` environment's body, or null when the source has none. */
31
+ readonly documentBody: (src: string) => Span | null;
19
32
  /**
20
33
  * A preset's `template` → the class it names: a whole `\documentclass[…]{…}` line, or a bare
21
34
  * class name (no option). Null for anything else.
@@ -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
+ });