paperlint 6.0.0 → 6.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 (64) hide show
  1. package/dist/adapters/latex/index.d.ts +1 -1
  2. package/dist/adapters/latex/index.d.ts.map +1 -1
  3. package/dist/adapters/latex/index.js +3 -2
  4. package/dist/adapters/latex/index.js.map +1 -1
  5. package/dist/adapters/latex/prose.d.ts +6 -1
  6. package/dist/adapters/latex/prose.d.ts.map +1 -1
  7. package/dist/adapters/latex/prose.js +142 -31
  8. package/dist/adapters/latex/prose.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +15 -18
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/register.d.ts +96 -0
  13. package/dist/domain/register.d.ts.map +1 -0
  14. package/dist/domain/register.js +136 -0
  15. package/dist/domain/register.js.map +1 -0
  16. package/dist/domain/tex-document.d.ts +19 -0
  17. package/dist/domain/tex-document.d.ts.map +1 -1
  18. package/dist/domain/tex-document.js +8 -3
  19. package/dist/domain/tex-document.js.map +1 -1
  20. package/dist/ports/latex.d.ts +6 -1
  21. package/dist/ports/latex.d.ts.map +1 -1
  22. package/dist/presets.d.ts +3 -0
  23. package/dist/presets.d.ts.map +1 -1
  24. package/dist/presets.js +4 -1
  25. package/dist/presets.js.map +1 -1
  26. package/dist/register-bands.d.ts +37 -0
  27. package/dist/register-bands.d.ts.map +1 -0
  28. package/dist/register-bands.js +196 -0
  29. package/dist/register-bands.js.map +1 -0
  30. package/dist/register.d.ts +4 -2
  31. package/dist/register.d.ts.map +1 -1
  32. package/dist/register.js +6 -45
  33. package/dist/register.js.map +1 -1
  34. package/dist/tex-requirements.d.ts +6 -0
  35. package/dist/tex-requirements.d.ts.map +1 -1
  36. package/dist/tex-requirements.js +26 -6
  37. package/dist/tex-requirements.js.map +1 -1
  38. package/dist/tex-venue-rules.d.ts +1 -1
  39. package/dist/tex-venue-rules.d.ts.map +1 -1
  40. package/dist/tex-venue-rules.js.map +1 -1
  41. package/docs/rules/tex/claim-emphasis.md +71 -0
  42. package/docs/rules/tex/contrast-frames.md +100 -0
  43. package/docs/rules/tex/relation-markers.md +72 -0
  44. package/docs/rules.md +46 -42
  45. package/package.json +1 -1
  46. package/presets/aidc.jsonc +50 -0
  47. package/presets/venue-profile.schema.json +48 -1
  48. package/skills/grade-paper-writing/SKILL.md +59 -27
  49. package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
  50. package/src/adapters/latex/index.test.ts +91 -0
  51. package/src/adapters/latex/index.ts +3 -2
  52. package/src/adapters/latex/prose.ts +188 -58
  53. package/src/cli.ts +19 -18
  54. package/src/domain/register.test.ts +153 -0
  55. package/src/domain/register.ts +261 -0
  56. package/src/domain/tex-document.ts +37 -3
  57. package/src/ports/latex.ts +6 -0
  58. package/src/presets.ts +7 -1
  59. package/src/register-bands.test.ts +327 -0
  60. package/src/register-bands.ts +291 -0
  61. package/src/register.ts +7 -37
  62. package/src/rule-cases.test.ts +67 -17
  63. package/src/tex-requirements.ts +47 -8
  64. package/src/tex-venue-rules.ts +7 -1
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The register band rules on REAL papers — the accepted ones in `fixtures/accepted-papers/`, and
3
+ * variants derived from their sources, one change each:
4
+ *
5
+ * - every anchor the AIDC preset records is re-measured from its fixture and must equal the preset's
6
+ * numbers, so a counter cannot change without the anchors (and so the band) changing with it;
7
+ * - every accepted ACSAC paper stands inside AIDC's band on every measure — a rule that fires on a
8
+ * paper the venue accepted is wrong about the venue;
9
+ * - a variant that adds contrast frames, sets numbers in bold, or strips the relation markers from
10
+ * one of those papers leaves the band on exactly that measure.
11
+ *
12
+ * `judgeBand` itself, on constructed measures: both ends, the floor of words, and what it reports.
13
+ */
14
+ import { readdirSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { describe, expect, it } from "vitest";
18
+ import { latexReader } from "./adapters/latex/index.ts";
19
+ import { nodeFiles } from "./adapters/node/index.ts";
20
+ import {
21
+ bandOf,
22
+ rateOf,
23
+ REGISTER_MEASURES,
24
+ type Band,
25
+ type RegisterAnchor,
26
+ type RegisterMeasureName,
27
+ } from "./domain/register.ts";
28
+ import { presetsDir } from "./package-dirs.ts";
29
+ import { resolvePreset } from "./presets.ts";
30
+ import { MIN_WORDS } from "./register.ts";
31
+ import {
32
+ anchorOf,
33
+ judgeBand,
34
+ measureBody,
35
+ registerBandRules,
36
+ standingOf,
37
+ } from "./register-bands.ts";
38
+ import { readPaper } from "./tex-paper.ts";
39
+ import { useTempDir, writeTree } from "../test/support.ts";
40
+
41
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
42
+ const CORPUS = join(ROOT, "fixtures", "accepted-papers");
43
+
44
+ /** The AIDC preset, resolved as a paper extending it would resolve it. */
45
+ const aidc = (() => {
46
+ const r = resolvePreset(
47
+ "paperlint:aidc",
48
+ join(ROOT, "papers", "p", "paperlint.json"),
49
+ { files: nodeFiles, venuesDir: presetsDir() },
50
+ );
51
+ if (!r.ok) throw new Error(JSON.stringify(r.error));
52
+ return r.value;
53
+ })();
54
+
55
+ /** A fixture paper's text, its includes spliced, as the rules read it. */
56
+ const sourceOf = (paper: string): string => {
57
+ const file = join(CORPUS, paper, "paper.tex");
58
+ return readPaper(file, readFileSync(file, "utf8"), {
59
+ files: nodeFiles,
60
+ latex: latexReader,
61
+ }).text;
62
+ };
63
+
64
+ const measured = new Map<string, RegisterAnchor>();
65
+ /** A fixture paper measured the way an anchor is (cached: each parse costs a second). */
66
+ const anchorFor = (paper: string, src = sourceOf(paper)): RegisterAnchor => {
67
+ const key = `${paper}\n${src}`;
68
+ const hit = measured.get(key);
69
+ if (hit) return hit;
70
+ const a = anchorOf(paper, src, latexReader);
71
+ measured.set(key, a);
72
+ return a;
73
+ };
74
+
75
+ /** Where each measure of a paper stands against AIDC's band. */
76
+ const standings = (
77
+ a: RegisterAnchor,
78
+ ): Readonly<Record<RegisterMeasureName, string>> => {
79
+ const at = (m: RegisterMeasureName): string => {
80
+ const band = bandOf(aidc.registerAnchors, m);
81
+ return band === null
82
+ ? "no band"
83
+ : standingOf(rateOf(a.counts[m], a.words), band);
84
+ };
85
+ return {
86
+ contrast_frames: at("contrast_frames"),
87
+ claim_emphasis: at("claim_emphasis"),
88
+ relation_markers: at("relation_markers"),
89
+ };
90
+ };
91
+
92
+ const WITHIN = {
93
+ contrast_frames: "within",
94
+ claim_emphasis: "within",
95
+ relation_markers: "within",
96
+ };
97
+
98
+ const ACSAC = readdirSync(CORPUS).filter((d) => /acsac\d\d$/u.test(d));
99
+
100
+ /** Parsing a real paper takes a second or more, several under coverage with every suite running. */
101
+ const PAPER_TIMEOUT_MS = 60_000;
102
+
103
+ describe("AIDC's anchors are the accepted ACSAC papers, as the counters measure them today", () => {
104
+ it("🔴 the preset records every ACSAC paper of the corpus, and nothing else", () => {
105
+ expect(aidc.registerAnchors.map((a) => a.paper).sort()).toEqual(
106
+ [...ACSAC].sort(),
107
+ );
108
+ });
109
+
110
+ it.each(aidc.registerAnchors.map((a) => [a.paper, a] as const))(
111
+ "🔴 %s: words and every count equal the fixture's, re-measured",
112
+ { timeout: PAPER_TIMEOUT_MS },
113
+ (paper, recorded) => {
114
+ expect(anchorFor(paper)).toEqual(recorded);
115
+ },
116
+ );
117
+ });
118
+
119
+ describe("every accepted ACSAC paper stands inside AIDC's band on every measure", () => {
120
+ it.each(ACSAC)("%s", { timeout: PAPER_TIMEOUT_MS }, (paper) => {
121
+ expect(standings(anchorFor(paper))).toEqual(WITHIN);
122
+ });
123
+ });
124
+
125
+ /** `src` with the first `n` matches of `re` after `\begin{document}` rewritten by `to`. */
126
+ function inBody(
127
+ src: string,
128
+ re: RegExp,
129
+ to: (match: string) => string,
130
+ n: number,
131
+ ): string {
132
+ const start = src.indexOf("\\begin{document}");
133
+ const matches = new Set(
134
+ [...src.slice(start).matchAll(re)].slice(0, n).map((m) => m.index + start),
135
+ );
136
+ return src.replace(re, (m: string, ...rest: readonly unknown[]) => {
137
+ const at = rest.find((x) => typeof x === "number");
138
+ return typeof at === "number" && matches.has(at) ? to(m) : m;
139
+ });
140
+ }
141
+
142
+ /** The markers `tex/relation-markers` counts, as a writer who leaves the relation to the reader drops them. */
143
+ const ADVERBIAL =
144
+ /\b(?:however|therefore|thus|hence|consequently|as a result|in contrast|specifically|for example|for instance|in particular),\s*/giu;
145
+
146
+ describe("variants of real accepted papers, one change each, leave the band on that measure only", () => {
147
+ it(
148
+ "🔴 secure-acsac24 with a contrast frame after 20 mentions of the model: contrast frames above",
149
+ { timeout: PAPER_TIMEOUT_MS },
150
+ () => {
151
+ const src = inBody(
152
+ sourceOf("secure-acsac24"),
153
+ /\bthe model\b/gu,
154
+ () => "the model, not the prompt",
155
+ 20,
156
+ );
157
+ expect(standings(anchorFor("secure-acsac24", src))).toEqual({
158
+ ...WITHIN,
159
+ contrast_frames: "above",
160
+ });
161
+ },
162
+ );
163
+
164
+ it(
165
+ "🔴 llm-splained-acsac25 with its first 20 percentages set in bold: claims in bold above",
166
+ { timeout: PAPER_TIMEOUT_MS },
167
+ () => {
168
+ const src = inBody(
169
+ sourceOf("llm-splained-acsac25"),
170
+ /\b\d+(?:\.\d+)?\\%/gu,
171
+ (m) => `\\textbf{${m}}`,
172
+ 20,
173
+ );
174
+ expect(standings(anchorFor("llm-splained-acsac25", src))).toEqual({
175
+ ...WITHIN,
176
+ claim_emphasis: "above",
177
+ });
178
+ },
179
+ );
180
+
181
+ it(
182
+ "🔴 rr-dataset-quality-acsac24 with its relations left to the reader: relation markers below",
183
+ { timeout: PAPER_TIMEOUT_MS },
184
+ () => {
185
+ // One change: the sentence adverbials that name a relation are dropped, and «because» becomes
186
+ // «as» — the same claims, the relation between them no longer named.
187
+ const dropped = inBody(
188
+ sourceOf("rr-dataset-quality-acsac24"),
189
+ ADVERBIAL,
190
+ () => "",
191
+ Number.POSITIVE_INFINITY,
192
+ );
193
+ const src = inBody(
194
+ dropped,
195
+ /\bbecause\b/gu,
196
+ () => "as",
197
+ Number.POSITIVE_INFINITY,
198
+ );
199
+ expect(standings(anchorFor("rr-dataset-quality-acsac24", src))).toEqual({
200
+ ...WITHIN,
201
+ relation_markers: "below",
202
+ });
203
+ },
204
+ );
205
+ });
206
+
207
+ describe("judgeBand — a body's rate against a band", () => {
208
+ const band: Band = {
209
+ min: 20,
210
+ max: 10_000 * (5 / MIN_WORDS),
211
+ anchorMin: 30,
212
+ anchorMax: 20,
213
+ anchors: 5,
214
+ };
215
+ const occurrence = (i: number) => ({
216
+ match: `m${String(i)}`,
217
+ sentence: `s${String(i)}`,
218
+ at: { start: i, end: i + 1 },
219
+ });
220
+ const body = (n: number, words = MIN_WORDS) => ({
221
+ words,
222
+ occurrences: Array.from({ length: n }, (_, i) => occurrence(i)),
223
+ at: { start: 0, end: 1 },
224
+ });
225
+ const ids = (n: number, each = true, words = MIN_WORDS) =>
226
+ judgeBand(body(n, words), band, "aidc", each).map((f) => f.messageId);
227
+
228
+ it("inside the band, including its edges, nothing", () => {
229
+ expect(ids(5)).toEqual([]);
230
+ expect(ids(4)).toEqual([]);
231
+ });
232
+
233
+ it("🔴 above it: one finding for the body, then one at every occurrence", () => {
234
+ expect(ids(6)).toEqual([
235
+ "above",
236
+ ...Array.from({ length: 6 }, () => "each"),
237
+ ]);
238
+ expect(ids(6, false)).toEqual(["above"]);
239
+ });
240
+
241
+ it("🔴 below it: one finding for the body, never one per occurrence", () => {
242
+ expect(ids(3)).toEqual(["below"]);
243
+ });
244
+
245
+ it("under the floor of words, or without a band, nothing", () => {
246
+ expect(ids(1, true, MIN_WORDS - 1)).toEqual([]);
247
+ expect(judgeBand(body(99), null, "aidc", true)).toEqual([]);
248
+ });
249
+
250
+ it("the finding names the count, the rate, the band, the anchors' range and the first examples", () => {
251
+ const [f] = judgeBand(body(6), band, "aidc", true);
252
+ expect(f?.data).toMatchObject({
253
+ count: 6,
254
+ rate: "30.0",
255
+ min: "20.0",
256
+ max: "25.0",
257
+ anchors: 5,
258
+ venue: "aidc",
259
+ examples: "«s0», «s1», «s2»",
260
+ });
261
+ });
262
+ });
263
+
264
+ describe("the measures a preset may set a band for", () => {
265
+ it("each of them is recorded for every anchor", () => {
266
+ aidc.registerAnchors.forEach((a) => {
267
+ expect(Object.keys(a.counts).sort()).toEqual(
268
+ [...REGISTER_MEASURES].sort(),
269
+ );
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("the rules, as ESLint calls them", () => {
275
+ /** A paper directory extending `extends`, and what one rule reports on its paper.tex. */
276
+ const reported = (
277
+ rule: "contrast-frames" | "claim-emphasis" | "relation-markers",
278
+ src: string,
279
+ file = "paper.tex",
280
+ ): string[] => {
281
+ const dir = writeTree(useTempDir("paperlint-register-bands-"), {
282
+ "paperlint.json": JSON.stringify({ extends: "paperlint:aidc" }),
283
+ });
284
+ const seen: string[] = [];
285
+ const deps = {
286
+ files: nodeFiles,
287
+ venuesDir: presetsDir(),
288
+ latex: latexReader,
289
+ };
290
+ registerBandRules(deps)
291
+ [rule].create({
292
+ filename: join(dir, file),
293
+ // No `raw`: the rule falls back to the text ESLint gives every language.
294
+ sourceCode: {
295
+ text: src,
296
+ getLocFromIndex: () => ({ line: 1, column: 0 }),
297
+ },
298
+ report: (d) => {
299
+ seen.push("messageId" in d ? d.messageId : d.message);
300
+ },
301
+ })
302
+ .root?.();
303
+ return seen;
304
+ };
305
+ const LONG = `\\documentclass{article}\n\\begin{document}\n${Array.from(
306
+ { length: 200 },
307
+ () =>
308
+ "The rule reads the body of the paper and counts every sentence it holds.",
309
+ ).join(" ")}\n\\end{document}\n`;
310
+
311
+ it("🔴 reads the paper's text when the language gives no raw source", () => {
312
+ expect(reported("relation-markers", LONG)).toEqual(["below"]);
313
+ });
314
+
315
+ it("judges a paper once, on its paper.tex, and nothing else", () => {
316
+ expect(reported("relation-markers", LONG, "intro.tex")).toEqual([]);
317
+ });
318
+
319
+ it("an empty body is measured as no words, reported nowhere", () => {
320
+ const m = measureBody(
321
+ "contrast-frames",
322
+ "\\documentclass{article}\n\\begin{document}\n\\end{document}\n",
323
+ latexReader,
324
+ );
325
+ expect(m).toEqual({ words: 0, occurrences: [], at: null });
326
+ });
327
+ });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * THE REGISTER RULES THAT MEASURE A BODY AGAINST ITS VENUE'S ACCEPTED PAPERS — each a rate per
3
+ * 10,000 words, judged against the band the venue preset's anchors set (`src/domain/register.ts`):
4
+ *
5
+ * tex/contrast-frames warn «X, not Y», «rather than», «instead of» … outside the band; above it,
6
+ * each frame is reported where it stands
7
+ * tex/claim-emphasis warn claims set in bold inside a sentence outside the band; above it, each
8
+ * is reported where it stands
9
+ * tex/relation-markers warn words naming how a sentence relates to the one before (because,
10
+ * therefore, however, for example …) outside the band
11
+ *
12
+ * ── WHY ──────────────────────────────────────────────────────────────────────────
13
+ * Reviewers called a paper "too informal" and "a blogpost". Its text differed from the accepted
14
+ * papers of the venue's parent conference in exactly these three counts, each several times over
15
+ * the anchors' range, while sentence length, contractions, questions and the second person did not
16
+ * separate them. The measurements are on the rules' pages.
17
+ *
18
+ * ── TWO-SIDED, AND THE VENUE'S OWN ───────────────────────────────────────────────
19
+ * Every rule judges both ends of its band: a rewrite that strips every relation marker is as far
20
+ * from the venue as one that stacks contrast frames. The band is the venue preset's, derived from
21
+ * its anchors, so a venue with other accepted papers gets another band, and a preset without
22
+ * anchors is not judged — there is nothing to compare, as `tex/template` is silent for a preset that
23
+ * names no template. A body under `MIN_WORDS` is not judged either: one sentence would decide it.
24
+ */
25
+ import { basename, dirname } from "node:path";
26
+ import {
27
+ bandOf,
28
+ bodySentences,
29
+ claimEmphasis,
30
+ contrastFrames,
31
+ rateOf,
32
+ relationMarkers,
33
+ wordsOf,
34
+ type Band,
35
+ type Occurrence,
36
+ type RegisterAnchor,
37
+ type RegisterMeasureName,
38
+ } from "./domain/register.ts";
39
+ import type { Emphasis, Passage, Span } from "./domain/tex-document.ts";
40
+ import type { LatexReader } from "./ports/latex.ts";
41
+ import { paperPreset } from "./presets.ts";
42
+ import { MIN_WORDS, startOf } from "./register.ts";
43
+ import { readPaper, reportInPaper } from "./tex-paper.ts";
44
+ import {
45
+ ruleDocsUrl,
46
+ type Located,
47
+ type TexRuleContext,
48
+ type TexRuleModule,
49
+ } from "./tex-venue-rules.ts";
50
+ import type { VenueRuleDeps } from "./venue-rules.ts";
51
+
52
+ /** What the three rules read: the preset store, and the LaTeX reader. */
53
+ export interface RegisterBandDeps extends VenueRuleDeps {
54
+ readonly latex: LatexReader;
55
+ }
56
+
57
+ export type RegisterBandRuleName =
58
+ "contrast-frames" | "claim-emphasis" | "relation-markers";
59
+
60
+ /** What one body measures for one rule: its words, the occurrences, where the body starts. */
61
+ export interface BodyMeasure {
62
+ readonly words: number;
63
+ readonly occurrences: readonly Occurrence[];
64
+ readonly at: Span | null;
65
+ }
66
+
67
+ /** How a rate stands against a band. */
68
+ export type Standing = "below" | "within" | "above";
69
+
70
+ export const standingOf = (rate: number, band: Band): Standing =>
71
+ rate < band.min ? "below" : rate > band.max ? "above" : "within";
72
+
73
+ const fixed = (n: number): string => n.toFixed(1);
74
+
75
+ /** How many examples a finding about the whole body quotes. */
76
+ const EXAMPLES = 3;
77
+
78
+ /** What every message about a body names: its count and rate, the band, the anchors' range, examples. */
79
+ function dataOf(
80
+ m: BodyMeasure,
81
+ band: Band,
82
+ venue: string,
83
+ ): Readonly<Record<string, string | number>> {
84
+ return {
85
+ count: m.occurrences.length,
86
+ words: m.words,
87
+ rate: fixed(rateOf(m.occurrences.length, m.words)),
88
+ venue,
89
+ min: fixed(band.min),
90
+ max: fixed(band.max),
91
+ anchors: band.anchors,
92
+ anchorMin: fixed(band.anchorMin),
93
+ anchorMax: fixed(band.anchorMax),
94
+ examples: m.occurrences
95
+ .slice(0, EXAMPLES)
96
+ .map((o) => `«${o.sentence}»`)
97
+ .join(", "),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * A body against a band: nothing within it; outside it, one finding at the body's start naming the
103
+ * count, the rate, the band and the anchors' range — and, above it when `each` is set, one finding
104
+ * at every occurrence, so an editor shows the author where to rewrite.
105
+ */
106
+ export function judgeBand(
107
+ m: BodyMeasure,
108
+ band: Band | null,
109
+ venue: string,
110
+ each: boolean,
111
+ ): readonly Located[] {
112
+ if (band === null || m.words < MIN_WORDS) return [];
113
+ const standing = standingOf(rateOf(m.occurrences.length, m.words), band);
114
+ if (standing === "within") return [];
115
+ const data = dataOf(m, band, venue);
116
+ const whole: Located = { messageId: standing, data, at: m.at };
117
+ if (standing === "below" || !each) return [whole];
118
+ const at = (o: Occurrence, i: number): Located => ({
119
+ messageId: "each",
120
+ data: { ...data, match: o.match, index: i + 1 },
121
+ at: o.at,
122
+ });
123
+ return [whole, ...m.occurrences.map(at)];
124
+ }
125
+
126
+ /** The band words every message shares: the rate, and where the venue's anchors put it. */
127
+ const AGAINST =
128
+ "{{rate}} per 10,000 words; {{venue}}'s {{anchors}} anchor papers measure {{anchorMin}}–{{anchorMax}}, and this rule warns outside {{min}}–{{max}}";
129
+
130
+ /** The body as the measures read it: its prose, and the phrases emphasised in it (read on demand). */
131
+ interface Body {
132
+ readonly passages: readonly Passage[];
133
+ readonly emphasis: () => readonly Emphasis[];
134
+ }
135
+
136
+ /** What each rule counts, how it reads the paper, and what it says. */
137
+ interface Measure {
138
+ readonly name: RegisterMeasureName;
139
+ readonly description: string;
140
+ /** Above the band, report every occurrence where it stands. */
141
+ readonly each: boolean;
142
+ readonly count: (body: Body) => readonly Occurrence[];
143
+ readonly messages: Readonly<Record<string, string>>;
144
+ }
145
+
146
+ const MEASURES: Readonly<Record<RegisterBandRuleName, Measure>> = {
147
+ "contrast-frames": {
148
+ name: "contrast_frames",
149
+ description:
150
+ "contrast frames («X, not Y», «rather than», «instead of») stay within the rate the venue's accepted papers show",
151
+ each: true,
152
+ count: (b) => contrastFrames(bodySentences(b.passages)),
153
+ messages: {
154
+ above: `{{count}} contrast frames («X, not Y», «not X but Y», «rather than», «instead of», «as opposed to») — ${AGAINST}. First: {{examples}}. Say what the thing is and why, with the evidence; keep a frame only where a reader would otherwise assume the rejected alternative`,
155
+ below: `{{count}} contrast frames — ${AGAINST}. A rewrite may have dropped relations the argument needs: check that each claim still says what it is set against`,
156
+ each: "contrast frame «{{match}}» ({{index}} of {{count}}; the body is at {{rate}} per 10,000 words, above {{venue}}'s {{max}})",
157
+ },
158
+ },
159
+ "claim-emphasis": {
160
+ name: "claim_emphasis",
161
+ description:
162
+ "claims set in bold inside a sentence stay within the rate the venue's accepted papers show",
163
+ each: true,
164
+ count: (b) => claimEmphasis(b.emphasis()),
165
+ messages: {
166
+ above: `{{count}} claims set in bold inside a sentence (a bold phrase of six words or more, or holding a number) — ${AGAINST}. First: {{examples}}. Accepted papers set a term or a label in bold; take the bold off a finding and let the sentence, a table or a figure carry it`,
167
+ below: `{{count}} claims set in bold inside a sentence — ${AGAINST}`,
168
+ each: "a claim set in bold, «{{match}}» ({{index}} of {{count}}; the body is at {{rate}} per 10,000 words, above {{venue}}'s {{max}})",
169
+ },
170
+ },
171
+ "relation-markers": {
172
+ name: "relation_markers",
173
+ description:
174
+ "words that name how a sentence relates to the one before (because, therefore, however, for example) stay within the rate the venue's accepted papers show",
175
+ each: false,
176
+ count: (b) => relationMarkers(bodySentences(b.passages)),
177
+ messages: {
178
+ below: `{{count}} words name how a sentence relates to the one before (because, therefore, however, in contrast, for example, that is …) — ${AGAINST}. Where two sentences stand side by side, say whether the second is the cause, the consequence, an example or a limit of the first`,
179
+ above: `{{count}} relation markers — ${AGAINST}. First: {{examples}}`,
180
+ },
181
+ },
182
+ };
183
+
184
+ /** What a body measures for a rule: its words, its occurrences, and where it starts. */
185
+ export function measureBody(
186
+ rule: RegisterBandRuleName,
187
+ src: string,
188
+ latex: LatexReader,
189
+ ): BodyMeasure {
190
+ const passages = latex.bodyProse(src);
191
+ const [first] = passages;
192
+ const body: Body = { passages, emphasis: () => latex.bodyEmphasis(src) };
193
+ return {
194
+ words: wordsOf(bodySentences(passages)),
195
+ occurrences: MEASURES[rule].count(body),
196
+ at: first === undefined ? null : startOf(first),
197
+ };
198
+ }
199
+
200
+ /** Which rule counts each measure an anchor records. */
201
+ const RULE_OF: Readonly<Record<RegisterMeasureName, RegisterBandRuleName>> = {
202
+ contrast_frames: "contrast-frames",
203
+ claim_emphasis: "claim-emphasis",
204
+ relation_markers: "relation-markers",
205
+ };
206
+
207
+ /**
208
+ * A paper as a preset records it among its anchors: its body's words and its count of every
209
+ * measure, by the rules' own counters — the one way an anchor is measured.
210
+ */
211
+ export function anchorOf(
212
+ paper: string,
213
+ src: string,
214
+ latex: LatexReader,
215
+ ): RegisterAnchor {
216
+ const passages = latex.bodyProse(src);
217
+ const body: Body = { passages, emphasis: () => latex.bodyEmphasis(src) };
218
+ const count = (m: RegisterMeasureName): number =>
219
+ MEASURES[RULE_OF[m]].count(body).length;
220
+ return {
221
+ paper,
222
+ words: wordsOf(bodySentences(passages)),
223
+ counts: {
224
+ contrast_frames: count("contrast_frames"),
225
+ claim_emphasis: count("claim_emphasis"),
226
+ relation_markers: count("relation_markers"),
227
+ },
228
+ };
229
+ }
230
+
231
+ /** The findings for the paper whose `paper.tex` is `filename`; none when its preset has no anchors. */
232
+ function judgePaper(
233
+ name: RegisterBandRuleName,
234
+ context: TexRuleContext,
235
+ deps: RegisterBandDeps,
236
+ ): void {
237
+ const p = paperPreset(dirname(context.filename), deps);
238
+ if (p.kind !== "resolved") return;
239
+ const measure = MEASURES[name];
240
+ const band = bandOf(p.preset.registerAnchors, measure.name);
241
+ if (band === null) return;
242
+ const sc = context.sourceCode;
243
+ const paper = readPaper(context.filename, sc.raw ?? sc.text, deps);
244
+ const m = measureBody(name, paper.text, deps.latex);
245
+ const findings = judgeBand(m, band, p.preset.label, measure.each);
246
+ reportInPaper(context, measure.messages, paper, findings);
247
+ }
248
+
249
+ function rule(
250
+ name: RegisterBandRuleName,
251
+ deps: RegisterBandDeps,
252
+ ): TexRuleModule {
253
+ const measure = MEASURES[name];
254
+ return {
255
+ meta: {
256
+ type: "suggestion",
257
+ docs: { description: measure.description, url: ruleDocsUrl(name) },
258
+ schema: [],
259
+ messages: measure.messages,
260
+ },
261
+ create(context) {
262
+ // Judged once per paper, on its paper.tex: the paper directory is the file's directory.
263
+ if (basename(context.filename) !== "paper.tex") return {};
264
+ return {
265
+ root: () => {
266
+ judgePaper(name, context, deps);
267
+ },
268
+ };
269
+ },
270
+ };
271
+ }
272
+
273
+ /** The three rules, as rules of the `tex` plugin. */
274
+ export function registerBandRules(
275
+ deps: RegisterBandDeps,
276
+ ): Readonly<Record<RegisterBandRuleName, TexRuleModule>> {
277
+ return {
278
+ "contrast-frames": rule("contrast-frames", deps),
279
+ "claim-emphasis": rule("claim-emphasis", deps),
280
+ "relation-markers": rule("relation-markers", deps),
281
+ };
282
+ }
283
+
284
+ /** The level each rule is on at in paperlint's own config, for every `paper.tex`. */
285
+ export const REGISTER_BAND_RULE_LEVELS: Readonly<
286
+ Record<`tex/${RegisterBandRuleName}`, "warn">
287
+ > = {
288
+ "tex/contrast-frames": "warn",
289
+ "tex/claim-emphasis": "warn",
290
+ "tex/relation-markers": "warn",
291
+ };
package/src/register.ts CHANGED
@@ -15,8 +15,8 @@
15
15
  * (src/domain/sentences.ts). A citation, a cross-reference or math reads as one word, as it does on
16
16
  * the page. A body under 2 000 words is not judged: there one sentence decides the rate.
17
17
  */
18
- import { runText, type Passage, type Span } from "./domain/tex-document.ts";
19
- import { runOf, sentences } from "./domain/sentences.ts";
18
+ import type { Passage, Span } from "./domain/tex-document.ts";
19
+ import { bodySentences, wordsOf } from "./domain/register.ts";
20
20
  import { readPaper, reportInPaper, type PaperDeps } from "./tex-paper.ts";
21
21
  import {
22
22
  ruleDocsUrl,
@@ -24,7 +24,7 @@ import {
24
24
  type TexRuleModule,
25
25
  } from "./tex-venue-rules.ts";
26
26
 
27
- /** Under this many words in the body, the rate is not judged. */
27
+ /** Under this many words in the body, a rate is not judged: one sentence would decide it. */
28
28
  export const MIN_WORDS = 2000;
29
29
 
30
30
  /** The limit when the config sets none, from the measurement on the rule's page. */
@@ -40,30 +40,6 @@ const CONJUNCTIONS: ReadonlySet<string> = new Set([
40
40
  "Yet",
41
41
  ]);
42
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
43
  /** What the body measures. */
68
44
  export interface RegisterMeasure {
69
45
  readonly words: number;
@@ -74,8 +50,8 @@ export interface RegisterMeasure {
74
50
  readonly at: Span | null;
75
51
  }
76
52
 
77
- /** Where a passage's first piece stands. */
78
- function startOf(p: Passage): Span {
53
+ /** Where a passage's first piece stands: where a finding about the whole body points. */
54
+ export function startOf(p: Passage): Span {
79
55
  const first = p.pieces[0];
80
56
  return first.kind === "text"
81
57
  ? {
@@ -87,17 +63,11 @@ function startOf(p: Passage): Span {
87
63
 
88
64
  /** The body's words, and its sentences that open with a conjunction. */
89
65
  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
- });
66
+ const all = bodySentences(passages);
97
67
  const conj = all.filter((s) => CONJUNCTIONS.has(s.first));
98
68
  const [first] = passages;
99
69
  return {
100
- words: all.reduce((n, s) => n + s.words, 0),
70
+ words: wordsOf(all),
101
71
  conjunctionStarts: conj.length,
102
72
  examples: conj.slice(0, 3).map((s) => s.text),
103
73
  at: first === undefined ? null : startOf(first),