paperlint 4.1.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 (113) hide show
  1. package/CLAUDE.md +24 -0
  2. package/CONTRIBUTING.md +7 -0
  3. package/dist/adapters/latex/document-class.d.ts +6 -5
  4. package/dist/adapters/latex/document-class.d.ts.map +1 -1
  5. package/dist/adapters/latex/document-class.js +18 -10
  6. package/dist/adapters/latex/document-class.js.map +1 -1
  7. package/dist/adapters/latex/includes.d.ts +12 -0
  8. package/dist/adapters/latex/includes.d.ts.map +1 -0
  9. package/dist/adapters/latex/includes.js +42 -0
  10. package/dist/adapters/latex/includes.js.map +1 -0
  11. package/dist/adapters/latex/index.d.ts +9 -5
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +8 -1
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/nodes.d.ts +5 -1
  16. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  17. package/dist/adapters/latex/nodes.js +6 -2
  18. package/dist/adapters/latex/nodes.js.map +1 -1
  19. package/dist/adapters/latex/parse.d.ts.map +1 -1
  20. package/dist/adapters/latex/parse.js +1 -0
  21. package/dist/adapters/latex/parse.js.map +1 -1
  22. package/dist/adapters/latex/prose.d.ts +8 -0
  23. package/dist/adapters/latex/prose.d.ts.map +1 -0
  24. package/dist/adapters/latex/prose.js +353 -0
  25. package/dist/adapters/latex/prose.js.map +1 -0
  26. package/dist/claim-provenance.d.ts +34 -0
  27. package/dist/claim-provenance.d.ts.map +1 -0
  28. package/dist/claim-provenance.js +146 -0
  29. package/dist/claim-provenance.js.map +1 -0
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +20 -0
  32. package/dist/cli.js.map +1 -1
  33. package/dist/domain/paper-source.d.ts +58 -0
  34. package/dist/domain/paper-source.d.ts.map +1 -0
  35. package/dist/domain/paper-source.js +109 -0
  36. package/dist/domain/paper-source.js.map +1 -0
  37. package/dist/domain/sentences.d.ts +26 -0
  38. package/dist/domain/sentences.d.ts.map +1 -0
  39. package/dist/domain/sentences.js +48 -0
  40. package/dist/domain/sentences.js.map +1 -0
  41. package/dist/domain/tex-document.d.ts +39 -4
  42. package/dist/domain/tex-document.d.ts.map +1 -1
  43. package/dist/domain/tex-document.js.map +1 -1
  44. package/dist/eslint-rules/paper-typography.d.ts +7 -0
  45. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  46. package/dist/eslint-rules/paper-typography.js +53 -1
  47. package/dist/eslint-rules/paper-typography.js.map +1 -1
  48. package/dist/ports/latex.d.ts +12 -1
  49. package/dist/ports/latex.d.ts.map +1 -1
  50. package/dist/register.d.ts +40 -0
  51. package/dist/register.d.ts.map +1 -0
  52. package/dist/register.js +134 -0
  53. package/dist/register.js.map +1 -0
  54. package/dist/tex-paper.d.ts +19 -0
  55. package/dist/tex-paper.d.ts.map +1 -0
  56. package/dist/tex-paper.js +105 -0
  57. package/dist/tex-paper.js.map +1 -0
  58. package/dist/tex-venue-rules.d.ts +7 -2
  59. package/dist/tex-venue-rules.d.ts.map +1 -1
  60. package/dist/tex-venue-rules.js +31 -17
  61. package/dist/tex-venue-rules.js.map +1 -1
  62. package/docs/rules/tex/claim-provenance.md +83 -0
  63. package/docs/rules/tex/missing-input.md +58 -0
  64. package/docs/rules/tex/register.md +77 -0
  65. package/docs/rules/tex/required-section.md +3 -1
  66. package/docs/rules/tex/template.md +12 -0
  67. package/docs/rules/tex/venue-leftover.md +4 -2
  68. package/docs/rules.md +3 -0
  69. package/eslint-rules/paper-typography.test.mjs +48 -1
  70. package/eslint-rules/paper-typography.ts +72 -1
  71. package/eslint.config.mjs +1 -0
  72. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  73. package/package.json +2 -1
  74. package/skills/cold-read-diff/SKILL.md +31 -10
  75. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  76. package/skills/draft-paper/SKILL.md +2 -2
  77. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  78. package/skills/grade-paper-writing/SKILL.md +109 -44
  79. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  80. package/skills/harden-paper/SKILL.md +35 -36
  81. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  82. package/skills/paper-adversarial-review/SKILL.md +2 -2
  83. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  84. package/skills/paper-pipeline/SKILL.md +3 -3
  85. package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
  86. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  87. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  88. package/skills/pc-panel-review/SKILL.md +36 -11
  89. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  90. package/src/adapters/latex/document-class.ts +20 -12
  91. package/src/adapters/latex/includes.ts +66 -0
  92. package/src/adapters/latex/index.test.ts +175 -1
  93. package/src/adapters/latex/index.ts +14 -4
  94. package/src/adapters/latex/nodes.ts +11 -2
  95. package/src/adapters/latex/parse.ts +1 -0
  96. package/src/adapters/latex/prose.ts +484 -0
  97. package/src/claim-provenance.test.ts +352 -0
  98. package/src/claim-provenance.ts +217 -0
  99. package/src/cli.ts +20 -0
  100. package/src/domain/paper-source.test.ts +156 -0
  101. package/src/domain/paper-source.ts +233 -0
  102. package/src/domain/sentences.ts +67 -0
  103. package/src/domain/tex-document.ts +36 -3
  104. package/src/ports/latex.ts +13 -0
  105. package/src/register.test.ts +165 -0
  106. package/src/register.ts +177 -0
  107. package/src/rule-cases.test.ts +54 -4
  108. package/src/tex-paper.test.ts +131 -0
  109. package/src/tex-paper.ts +152 -0
  110. package/src/tex-venue-rules.test.ts +87 -5
  111. package/src/tex-venue-rules.ts +62 -30
  112. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  113. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The paper as one text: includes spliced where they stand, every character mapped back to its
3
+ * file, a missing file reported, a subfile's body only, and a file that includes itself entered once.
4
+ * The reader is a stand-in that finds `\input{…}` by position, so these tests are about assembling,
5
+ * not about parsing LaTeX (the LaTeX adapter's own tests cover `includes`).
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ assemblePaper,
10
+ originOf,
11
+ type AssembleDeps,
12
+ type Include,
13
+ } from "./paper-source.ts";
14
+
15
+ /** `\input{x}` / `\include{x}` / `\subfile{x}` in `src`, by a scan good enough for these inputs. */
16
+ const includes = (src: string): readonly Include[] =>
17
+ [...src.matchAll(/\\(input|include|subfile)\{([^}]*)\}/gu)].map((m) => ({
18
+ macro:
19
+ m[1] === "subfile" ? "subfile" : m[1] === "include" ? "include" : "input",
20
+ target: m[2] ?? "",
21
+ span: { start: m.index, end: m.index + m[0].length },
22
+ }));
23
+
24
+ const deps = (files: Record<string, string>): AssembleDeps => ({
25
+ includes,
26
+ documentBody: (src) => {
27
+ const a = src.indexOf("\\begin{document}");
28
+ const b = src.indexOf("\\end{document}");
29
+ return a < 0 || b < 0
30
+ ? null
31
+ : { start: a + "\\begin{document}".length, end: b };
32
+ },
33
+ read: (p) => files[p] ?? null,
34
+ });
35
+
36
+ describe("assemblePaper — the text TeX reads", () => {
37
+ it("splices each file where its include stands; `.tex` is tried first", () => {
38
+ const main = "A\\input{s/one}B\\include{two.tex}C";
39
+ const p = assemblePaper(
40
+ "paper.tex",
41
+ main,
42
+ deps({ "s/one.tex": "one", "two.tex": "two" }),
43
+ );
44
+ expect(p.text).toBe("AoneBtwoC");
45
+ expect(p.missing).toEqual([]);
46
+ expect([...new Set(p.segments.map((g) => g.file))]).toEqual([
47
+ "paper.tex",
48
+ "s/one.tex",
49
+ "two.tex",
50
+ ]);
51
+ });
52
+
53
+ it("nested includes resolve from the paper directory, and map back through the outermost include", () => {
54
+ const main = "x\\input{a}y";
55
+ const p = assemblePaper(
56
+ "paper.tex",
57
+ main,
58
+ deps({ "a.tex": "[\\input{s/b}]", "s/b.tex": "BB" }),
59
+ );
60
+ expect(p.text).toBe("x[BB]y");
61
+ expect(originOf(p, { start: 2, end: 4 })).toEqual({
62
+ file: "s/b.tex",
63
+ source: "BB",
64
+ span: { start: 0, end: 2 },
65
+ via: { start: 1, end: 10 },
66
+ });
67
+ });
68
+
69
+ it("🔴 a missing file is reported where it stands, and left out", () => {
70
+ const main = "a\\input{gone}b";
71
+ const p = assemblePaper("paper.tex", main, deps({}));
72
+ expect(p.text).toBe("ab");
73
+ expect(p.missing).toEqual([
74
+ {
75
+ file: "paper.tex",
76
+ target: "gone",
77
+ span: { start: 1, end: 13 },
78
+ via: { start: 1, end: 13 },
79
+ },
80
+ ]);
81
+ });
82
+ });
83
+
84
+ describe("assemblePaper — subfiles and a file that includes itself", () => {
85
+ it("a subfile contributes its document body, not its preamble", () => {
86
+ const sub =
87
+ "\\documentclass[paper]{subfiles}\\begin{document}BODY\\end{document}";
88
+ const p = assemblePaper(
89
+ "paper.tex",
90
+ "<\\subfile{s}>",
91
+ deps({ "s.tex": sub }),
92
+ );
93
+ expect(p.text).toBe("<BODY>");
94
+ });
95
+
96
+ it("a subfile with no document environment is read whole", () => {
97
+ const p = assemblePaper(
98
+ "paper.tex",
99
+ "<\\subfile{s}>",
100
+ deps({ "s.tex": "ALL" }),
101
+ );
102
+ expect(p.text).toBe("<ALL>");
103
+ });
104
+
105
+ it("a file that includes itself is entered once", () => {
106
+ const p = assemblePaper(
107
+ "paper.tex",
108
+ "m\\input{a}",
109
+ deps({ "a.tex": "a\\input{a}\\input{paper.tex}" }),
110
+ );
111
+ expect(p.text).toBe("ma");
112
+ });
113
+ });
114
+
115
+ describe("originOf — where a span of the assembled text comes from", () => {
116
+ const p = assemblePaper(
117
+ "paper.tex",
118
+ "ab\\input{x}cd",
119
+ deps({ "x.tex": "XYZ" }),
120
+ );
121
+
122
+ it("in the main file: its own offsets, via nothing", () => {
123
+ expect(originOf(p, { start: 0, end: 2 })).toEqual({
124
+ file: "paper.tex",
125
+ source: "ab\\input{x}cd",
126
+ span: { start: 0, end: 2 },
127
+ via: null,
128
+ });
129
+ });
130
+
131
+ it("after the include: offsets in the main file past the macro", () => {
132
+ expect(originOf(p, { start: 5, end: 7 })).toEqual({
133
+ file: "paper.tex",
134
+ source: "ab\\input{x}cd",
135
+ span: { start: 11, end: 13 },
136
+ via: null,
137
+ });
138
+ });
139
+
140
+ it("a span running out of an included file is cut at its end", () => {
141
+ expect(originOf(p, { start: 3, end: 7 })).toEqual({
142
+ file: "x.tex",
143
+ source: "XYZ",
144
+ span: { start: 1, end: 3 },
145
+ via: { start: 2, end: 11 },
146
+ });
147
+ });
148
+
149
+ it("an empty span at the very end belongs to the last stretch; outside the text there is none", () => {
150
+ expect(originOf(p, { start: 7, end: 7 })?.span).toEqual({
151
+ start: 13,
152
+ end: 13,
153
+ });
154
+ expect(originOf(p, { start: 9, end: 9 })).toBe(null);
155
+ });
156
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * A paper as one text: `paper.tex` with every `\input`, `\include` and `\subfile` replaced by the
3
+ * file it names — what TeX itself does — and a map from every character of that text back to the
4
+ * file and offset it came from. The rules read the whole paper; a finding still points at its file.
5
+ *
6
+ * ── WHY SPLICE THE TEXT ──────────────────────────────────────────────────────────
7
+ * `\input` is textual inclusion: TeX reads the named file where the macro stands. Replacing the
8
+ * macro by the file's text gives the parser exactly what TeX reads, so every question the reader
9
+ * answers about one file (the class, the outline, the rendered text, the body's passages) is
10
+ * answered about the paper, unchanged. A subfile is a document of its own; only its body is spliced.
11
+ *
12
+ * ── WHAT IS NOT DONE ─────────────────────────────────────────────────────────────
13
+ * No macro is expanded, so a path built from a macro (`\input{\dir/intro}`) is read as written. A
14
+ * file that cannot be found is reported and left out; a file that includes itself, directly or
15
+ * through others, is spliced once.
16
+ */
17
+ import { extname, normalize } from "node:path";
18
+ import type { Span } from "./tex-document.ts";
19
+
20
+ /** One `\input`, `\include` or `\subfile` in a source: the path as written and where the macro stands. */
21
+ export interface Include {
22
+ readonly macro: "input" | "include" | "subfile";
23
+ readonly target: string;
24
+ readonly span: Span;
25
+ }
26
+
27
+ /** What assembling needs from the LaTeX reader and from the disk, relative to the paper directory. */
28
+ export interface AssembleDeps {
29
+ /** The includes of a source, in source order. */
30
+ readonly includes: (src: string) => readonly Include[];
31
+ /** The span of a source's `document` environment body, or null when it has none. */
32
+ readonly documentBody: (src: string) => Span | null;
33
+ /** A file's text by its path relative to the paper directory, or null when there is none. */
34
+ readonly read: (path: string) => string | null;
35
+ }
36
+
37
+ /** A stretch of the assembled text copied from one file: `[start, end)` here is `from…` there. */
38
+ export interface Segment {
39
+ readonly start: number;
40
+ readonly end: number;
41
+ readonly file: string;
42
+ /** The whole text of `file`, so a place in it can be told as a line and a column. */
43
+ readonly source: string;
44
+ readonly from: number;
45
+ /** Where, in the main file, the outermost include that brought this text in stands. */
46
+ readonly via: Span | null;
47
+ }
48
+
49
+ /** An include whose file was not found, where it stands. */
50
+ export interface MissingInclude {
51
+ readonly file: string;
52
+ readonly target: string;
53
+ readonly span: Span;
54
+ /** Where it stands in the main file (the include itself, or the one that brought its file in). */
55
+ readonly via: Span;
56
+ }
57
+
58
+ /** The paper as one text, the map back to its files, and the includes that were not found. */
59
+ export interface PaperSource {
60
+ readonly main: string;
61
+ readonly text: string;
62
+ readonly segments: readonly Segment[];
63
+ readonly missing: readonly MissingInclude[];
64
+ }
65
+
66
+ /** The paths TeX tries for a target: `name.tex`, then `name` — or the name alone if it has one. */
67
+ const candidates = (target: string): readonly string[] => {
68
+ const t = normalize(target.trim());
69
+ return extname(t) === "" ? [`${t}.tex`, t] : [t];
70
+ };
71
+
72
+ /** The first candidate path that exists, with its text. */
73
+ function found(
74
+ target: string,
75
+ read: AssembleDeps["read"],
76
+ ): { readonly path: string; readonly text: string } | null {
77
+ const hits = candidates(target).flatMap((path) => {
78
+ const text = read(path);
79
+ return text === null ? [] : [{ path, text }];
80
+ });
81
+ return hits[0] ?? null;
82
+ }
83
+
84
+ /** An assembled piece of text: the text, its segments from 0, and the includes not found. */
85
+ interface Piece {
86
+ readonly text: string;
87
+ readonly segments: readonly Segment[];
88
+ readonly missing: readonly MissingInclude[];
89
+ }
90
+
91
+ /** `segments` moved by `by` characters. */
92
+ const shifted = (
93
+ segments: readonly Segment[],
94
+ by: number,
95
+ ): readonly Segment[] =>
96
+ segments.map((s) => ({ ...s, start: s.start + by, end: s.end + by }));
97
+
98
+ /** `[from, to)` of `file`'s text, copied as it is, brought in via `via`. */
99
+ interface Stretch {
100
+ readonly file: string;
101
+ readonly text: string;
102
+ readonly from: number;
103
+ readonly to: number;
104
+ readonly via: Span | null;
105
+ }
106
+
107
+ /** A stretch as a piece: its text, and one segment mapping it back. */
108
+ const copied = ({ file, text, from, to, via }: Stretch): Piece => ({
109
+ text: text.slice(from, to),
110
+ segments:
111
+ to > from
112
+ ? [{ start: 0, end: to - from, file, source: text, from, via }]
113
+ : [],
114
+ missing: [],
115
+ });
116
+
117
+ /** Nothing: an include left out. */
118
+ const NOTHING: Piece = { text: "", segments: [], missing: [] };
119
+
120
+ /** Pieces joined in order. */
121
+ const joined = (pieces: readonly Piece[]): Piece =>
122
+ pieces.reduce<Piece>(
123
+ (acc, p) => ({
124
+ text: acc.text + p.text,
125
+ segments: [...acc.segments, ...shifted(p.segments, acc.text.length)],
126
+ missing: [...acc.missing, ...p.missing],
127
+ }),
128
+ NOTHING,
129
+ );
130
+
131
+ /**
132
+ * `[from, to)` of `file`, its includes spliced. `stack` is the chain of files being spliced, so a
133
+ * file that includes itself is not entered again; `via` is where the chain began in the main file.
134
+ */
135
+ /** What splicing carries down the chain of files. */
136
+ interface Context {
137
+ readonly deps: AssembleDeps;
138
+ readonly stack: readonly string[];
139
+ readonly via: Span | null;
140
+ }
141
+
142
+ function spliced(file: string, text: string, range: Span, ctx: Context): Piece {
143
+ const incs = ctx.deps
144
+ .includes(text)
145
+ .filter((i) => i.span.start >= range.start && i.span.end <= range.end);
146
+ const at = (from: number, to: number): Piece =>
147
+ copied({ file, text, from, to, via: ctx.via });
148
+ const { pieces, cursor } = incs.reduce<{
149
+ readonly pieces: readonly Piece[];
150
+ readonly cursor: number;
151
+ }>(
152
+ (acc, inc) => ({
153
+ pieces: [
154
+ ...acc.pieces,
155
+ at(acc.cursor, inc.span.start),
156
+ included(file, inc, ctx),
157
+ ],
158
+ cursor: inc.span.end,
159
+ }),
160
+ { pieces: [], cursor: range.start },
161
+ );
162
+ return joined([...pieces, at(cursor, range.end)]);
163
+ }
164
+
165
+ /** One include: the file it names, spliced, or — when not found — nothing, and a report. */
166
+ function included(file: string, inc: Include, ctx: Context): Piece {
167
+ const via = ctx.via ?? inc.span;
168
+ const hit = found(inc.target, ctx.deps.read);
169
+ if (hit === null)
170
+ return {
171
+ ...NOTHING,
172
+ missing: [{ file, target: inc.target, span: inc.span, via }],
173
+ };
174
+ if (ctx.stack.includes(hit.path)) return NOTHING;
175
+ const body = inc.macro === "subfile" ? ctx.deps.documentBody(hit.text) : null;
176
+ return spliced(
177
+ hit.path,
178
+ hit.text,
179
+ body ?? { start: 0, end: hit.text.length },
180
+ { deps: ctx.deps, stack: [...ctx.stack, hit.path], via },
181
+ );
182
+ }
183
+
184
+ /** The paper whose main file is `main` (a path relative to the paper directory) with `text`. */
185
+ export function assemblePaper(
186
+ main: string,
187
+ text: string,
188
+ deps: AssembleDeps,
189
+ ): PaperSource {
190
+ const p = spliced(
191
+ main,
192
+ text,
193
+ { start: 0, end: text.length },
194
+ { deps, stack: [main], via: null },
195
+ );
196
+ return {
197
+ main,
198
+ text: p.text,
199
+ segments: p.segments,
200
+ missing: p.missing,
201
+ };
202
+ }
203
+
204
+ /** Where a span of the assembled text stands: in which file, at which offsets, and via which include. */
205
+ export interface Origin {
206
+ readonly file: string;
207
+ /** The whole text of `file`. */
208
+ readonly source: string;
209
+ readonly span: Span;
210
+ readonly via: Span | null;
211
+ }
212
+
213
+ /**
214
+ * The origin of `span` of the assembled text: the file of its first character. A span that runs
215
+ * past that file's stretch is cut at its end — a sentence cannot point into two files.
216
+ */
217
+ export function originOf(p: PaperSource, span: Span): Origin | null {
218
+ // The character at `start`; an empty span at the very end belongs to the last stretch.
219
+ const seg =
220
+ p.segments.find((s) => span.start >= s.start && span.start < s.end) ??
221
+ p.segments.find((s) => span.start === s.end);
222
+ if (seg === undefined) return null;
223
+ const end = Math.min(span.end, seg.end);
224
+ return {
225
+ file: seg.file,
226
+ source: seg.source,
227
+ span: {
228
+ start: seg.from + span.start - seg.start,
229
+ end: seg.from + end - seg.start,
230
+ },
231
+ via: seg.via,
232
+ };
233
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The sentences of body prose — one splitter, so every rule that counts or judges sentences cuts
3
+ * them the same way (`tex/claim-provenance` judges each; `tex/register` counts them).
4
+ *
5
+ * A passage becomes one text: its prose as itself, and each mark (a citation, a cross-reference) or
6
+ * math as one character no prose contains, where it stands. `Intl.Segmenter` cuts that text; a break
7
+ * after an abbreviation is undone, and marks set right after a full stop belong to the sentence the
8
+ * full stop ends.
9
+ */
10
+ import type { Passage, ProsePiece, Segment, TextRun } from "./tex-document.ts";
11
+
12
+ /** What stands in a sentence's text for a mark: characters no prose contains (private use). */
13
+ export const CITATION = "";
14
+ export const REFERENCE = "";
15
+ export const MATH = "";
16
+
17
+ /** A piece as the text of its sentence sees it: text as itself, a mark or math as one character. */
18
+ export function segmentOf(p: ProsePiece): Segment {
19
+ switch (p.kind) {
20
+ case "text":
21
+ return p.segment;
22
+ case "owner":
23
+ return {
24
+ text: p.owner === "citation" ? CITATION : REFERENCE,
25
+ at: p.span.start,
26
+ };
27
+ case "math":
28
+ return { text: MATH, at: p.span.start };
29
+ }
30
+ }
31
+
32
+ /** A passage as one run of text, each mark a character where it stands. */
33
+ export const runOf = (p: Passage): TextRun => {
34
+ const [first, ...rest] = p.pieces;
35
+ return { segments: [segmentOf(first), ...rest.map(segmentOf)] };
36
+ };
37
+
38
+ /** An abbreviation whose full stop does not end a sentence, at the end of a segment. */
39
+ const ABBREVIATION =
40
+ /(?:^|[^\p{L}])(?:e\.g|i\.e|cf|vs|et al|Figs?|Secs?|Tab|Eqs?|No|App|approx|resp)\.\s*$/u;
41
+
42
+ const SEGMENTER = new Intl.Segmenter("en", { granularity: "sentence" });
43
+
44
+ /** Marks, and the spaces among them, that open a segment: a `\cite` or a footnote set after the full stop. */
45
+ const LEADING_MARKS = /^[\s]*[]/u;
46
+
47
+ /** `[from, to)` of a text. */
48
+ export type Range = readonly [number, number];
49
+
50
+ /**
51
+ * The sentences of `text`, as `[from, to)` ranges. A break after an abbreviation is undone, and
52
+ * marks standing right after a full stop belong to the sentence the full stop ends.
53
+ */
54
+ export function sentences(text: string): readonly Range[] {
55
+ return [...SEGMENTER.segment(text)].reduce<readonly Range[]>((acc, s) => {
56
+ const prev = acc.at(-1);
57
+ const end = s.index + s.segment.length;
58
+ if (prev === undefined) return [[s.index, end]];
59
+ const before = acc.slice(0, -1);
60
+ if (ABBREVIATION.test(text.slice(...prev)))
61
+ return [...before, [prev[0], end]];
62
+ const split = s.index + (LEADING_MARKS.exec(s.segment)?.[0].length ?? 0);
63
+ return split < end
64
+ ? [...before, [prev[0], split], [split, end]]
65
+ : [...before, [prev[0], end]];
66
+ }, []);
67
+ }
@@ -24,12 +24,28 @@ export interface DocumentClass {
24
24
  readonly options: readonly string[];
25
25
  }
26
26
 
27
- /** A paper's `\documentclass`: none, one that names no class, or a class with its options. */
28
- export type ClassLine =
29
- | { readonly kind: "missing" }
27
+ /** One `\documentclass` line: one that names no class, or a class with its options. */
28
+ export type ClassCandidate =
30
29
  | { readonly kind: "empty"; readonly place: Place }
31
30
  | ({ readonly kind: "class"; readonly place: Place } & DocumentClass);
32
31
 
32
+ /**
33
+ * A paper's `\documentclass`: none, exactly one, or several — a source that picks its class behind
34
+ * a TeX switch (`\if\venue1 \documentclass{article} \fi …`). Which branch builds is not
35
+ * evaluated, so several lines are candidates, in source order, and none of them is THE class.
36
+ */
37
+ export type ClassLine =
38
+ | { readonly kind: "missing" }
39
+ | ClassCandidate
40
+ | {
41
+ readonly kind: "ambiguous";
42
+ readonly candidates: readonly [
43
+ ClassCandidate,
44
+ ClassCandidate,
45
+ ...ClassCandidate[],
46
+ ];
47
+ };
48
+
33
49
  /** The options of `want` that `got` does not carry — every one, in `want`'s order. */
34
50
  export const missingOptions = (
35
51
  got: DocumentClass,
@@ -108,3 +124,20 @@ export function spanIn(run: TextRun, from: number, to: number): Span | null {
108
124
  const last = to > from ? offsetIn(run.segments, to - 1) : null;
109
125
  return start === null || last === null ? null : { start, end: last + 1 };
110
126
  }
127
+
128
+ /** What a mark in prose says about where a claim comes from: a work outside the paper, or a place in it. */
129
+ export type Owner = "citation" | "reference";
130
+
131
+ /**
132
+ * One piece of body prose, in source order: text a reader sees, a mark the markup leaves in the
133
+ * sentence (a `\cite`, a link, a `\ref`), or math, with its source.
134
+ */
135
+ export type ProsePiece =
136
+ | { readonly kind: "text"; readonly segment: Segment }
137
+ | { readonly kind: "owner"; readonly owner: Owner; readonly span: Span }
138
+ | { readonly kind: "math"; readonly tex: string; readonly span: Span };
139
+
140
+ /** A stretch of body prose no sentence crosses — a paragraph, a list item, a footnote. Never empty. */
141
+ export interface Passage {
142
+ readonly pieces: readonly [ProsePiece, ...ProsePiece[]];
143
+ }
@@ -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.