paperlint 5.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.
- package/CLAUDE.md +13 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +8 -8
- package/bin/paperlint.mjs +5 -1
- package/dist/adapters/latex/index.d.ts +2 -1
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +6 -2
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/layout.d.ts +27 -0
- package/dist/adapters/latex/layout.d.ts.map +1 -0
- package/dist/adapters/latex/layout.js +62 -0
- package/dist/adapters/latex/layout.js.map +1 -0
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +11 -1
- package/dist/adapters/latex/nodes.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +5 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/latex/prose.d.ts +6 -1
- package/dist/adapters/latex/prose.d.ts.map +1 -1
- package/dist/adapters/latex/prose.js +142 -31
- package/dist/adapters/latex/prose.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -18
- package/dist/cli.js.map +1 -1
- package/dist/domain/anonymity.d.ts +68 -0
- package/dist/domain/anonymity.d.ts.map +1 -0
- package/dist/domain/anonymity.js +94 -0
- package/dist/domain/anonymity.js.map +1 -0
- package/dist/domain/body-pages.d.ts +69 -0
- package/dist/domain/body-pages.d.ts.map +1 -0
- package/dist/domain/body-pages.js +141 -0
- package/dist/domain/body-pages.js.map +1 -0
- package/dist/domain/register.d.ts +96 -0
- package/dist/domain/register.d.ts.map +1 -0
- package/dist/domain/register.js +136 -0
- package/dist/domain/register.js.map +1 -0
- package/dist/domain/tex-document.d.ts +28 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +8 -3
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
- package/dist/facts-file.d.ts +28 -2
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +54 -9
- package/dist/facts-file.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +11 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +38 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/pdf-content.d.ts +40 -0
- package/dist/pdf-content.d.ts.map +1 -0
- package/dist/pdf-content.js +97 -0
- package/dist/pdf-content.js.map +1 -0
- package/dist/pdf-facts.d.ts +30 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +37 -1
- package/dist/pdf-facts.js.map +1 -1
- package/dist/ports/latex.d.ts +8 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/presets.d.ts +5 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +8 -1
- package/dist/presets.js.map +1 -1
- package/dist/register-bands.d.ts +37 -0
- package/dist/register-bands.d.ts.map +1 -0
- package/dist/register-bands.js +196 -0
- package/dist/register-bands.js.map +1 -0
- package/dist/register.d.ts +4 -2
- package/dist/register.d.ts.map +1 -1
- package/dist/register.js +6 -45
- package/dist/register.js.map +1 -1
- package/dist/tex-requirements.d.ts +10 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +29 -6
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +8 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +40 -0
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts +38 -8
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +179 -41
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +18 -16
- package/docs/e2e.md +32 -11
- package/docs/rules/anonymity/identity.md +111 -0
- package/docs/rules/format/layout-override.md +61 -0
- package/docs/rules/format/page-limit.md +108 -0
- package/docs/rules/pdf/body-size.md +87 -0
- package/docs/rules/tex/claim-emphasis.md +71 -0
- package/docs/rules/tex/contrast-frames.md +100 -0
- package/docs/rules/tex/relation-markers.md +72 -0
- package/docs/rules.md +59 -50
- package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
- package/eslint-rules/pdf-last-page-balance.ts +2 -2
- package/fixtures/build-e2e/aidc/expect.json +1 -1
- package/fixtures/build-e2e/aidc/paperlint.json +1 -1
- package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-ack/expect.json +19 -0
- package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
- package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-author/expect.json +19 -0
- package/fixtures/build-e2e/anon-author/paper.tex +29 -0
- package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-bib/expect.json +20 -0
- package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
- package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-href/expect.json +19 -0
- package/fixtures/build-e2e/anon-href/paper.tex +30 -0
- package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-macro/expect.json +19 -0
- package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
- package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
- package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
- package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
- package/fixtures/build-e2e/unbalanced/expect.json +2 -2
- package/lib/paper-config.ts +3 -1
- package/package.json +1 -1
- package/presets/acm-sigconf.jsonc +3 -1
- package/presets/aidc.jsonc +66 -17
- package/presets/ieee-conference.jsonc +8 -0
- package/presets/venue-profile.schema.json +57 -1
- package/skills/grade-paper-writing/SKILL.md +59 -27
- package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
- package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
- package/src/adapters/latex/index.test.ts +91 -0
- package/src/adapters/latex/index.ts +6 -2
- package/src/adapters/latex/layout.ts +106 -0
- package/src/adapters/latex/nodes.ts +11 -3
- package/src/adapters/latex/parse.ts +5 -1
- package/src/adapters/latex/prose.ts +188 -58
- package/src/build.harness.mjs +2 -2
- package/src/build.test.ts +5 -0
- package/src/cli.test.ts +13 -0
- package/src/cli.ts +50 -17
- package/src/domain/anonymity.test.ts +163 -0
- package/src/domain/anonymity.ts +129 -0
- package/src/domain/body-pages.test.ts +300 -0
- package/src/domain/body-pages.ts +223 -0
- package/src/domain/register.test.ts +153 -0
- package/src/domain/register.ts +261 -0
- package/src/domain/tex-document.ts +47 -3
- package/src/facts-file.test.ts +100 -4
- package/src/facts-file.ts +91 -9
- package/src/new-paper.test.ts +7 -1
- package/src/paper-settings.test.ts +63 -6
- package/src/paper-settings.ts +53 -5
- package/src/pdf-content.test.ts +71 -0
- package/src/pdf-content.ts +125 -0
- package/src/pdf-facts.test.ts +110 -2
- package/src/pdf-facts.ts +106 -8
- package/src/ports/latex.ts +9 -0
- package/src/presets.ts +15 -9
- package/src/register-bands.test.ts +327 -0
- package/src/register-bands.ts +291 -0
- package/src/register.ts +7 -37
- package/src/rule-cases.test.ts +96 -20
- package/src/rule-docs.test.ts +0 -2
- package/src/tex-requirements.ts +56 -8
- package/src/tex-venue-rules.test.ts +110 -5
- package/src/tex-venue-rules.ts +64 -3
- package/src/types.ts +2 -0
- package/src/venue-rules.test.ts +351 -39
- package/src/venue-rules.ts +247 -47
|
@@ -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 {
|
|
19
|
-
import {
|
|
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,
|
|
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
|
|
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
|
|
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),
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -43,11 +43,22 @@ const paper = (
|
|
|
43
43
|
|
|
44
44
|
const PDF = "%PDF-1.5 a stand-in for the built PDF";
|
|
45
45
|
const AGENTICDEV = { extends: "paperlint:agenticdev", kind: "short" };
|
|
46
|
+
/** A double-blind venue's paper that declares who wrote it (`anonymity/*`). */
|
|
47
|
+
const BLIND = {
|
|
48
|
+
extends: "paperlint:aidc",
|
|
49
|
+
kind: "regular",
|
|
50
|
+
identity: ["Ada Example", "adaexample"],
|
|
51
|
+
};
|
|
46
52
|
const ACM_TEX = tex("Text.", "\\documentclass[sigconf]{acmart}");
|
|
47
53
|
|
|
48
54
|
/** The facts of a short agenticdev paper that meets every number in its preset. */
|
|
49
55
|
const goodFacts = (): Record<string, unknown> => ({
|
|
50
|
-
schema:
|
|
56
|
+
schema: 3,
|
|
57
|
+
pages_text: [],
|
|
58
|
+
metadata: {},
|
|
59
|
+
links: [],
|
|
60
|
+
bib_anchor_page: null,
|
|
61
|
+
appendix_anchor_page: null,
|
|
51
62
|
pdf: "paper.pdf",
|
|
52
63
|
pdf_sha256: sha256Hex(new TextEncoder().encode(PDF)),
|
|
53
64
|
venue: "agenticdev",
|
|
@@ -157,6 +168,14 @@ const FORMAL = Array.from(
|
|
|
157
168
|
() =>
|
|
158
169
|
"The rule reads the body of the paper and counts every sentence it holds.",
|
|
159
170
|
).join(" ");
|
|
171
|
+
/** FORMAL with `n` more sentences `s`: a body whose rate of one register measure is set. */
|
|
172
|
+
const formalWith = (n: number, s: string): string =>
|
|
173
|
+
`${FORMAL} ${Array.from({ length: n }, () => s).join(" ")}`;
|
|
174
|
+
/** FORMAL with enough relation markers for AIDC's band (above 25.4 per 10,000 words). */
|
|
175
|
+
const LINKED = formalWith(
|
|
176
|
+
10,
|
|
177
|
+
"Therefore, the rule counts the sentence because the body holds it.",
|
|
178
|
+
);
|
|
160
179
|
const STATUS_FILE = `${P}/PIPELINE-STATUS.md`;
|
|
161
180
|
const SHIPPED_STATUS =
|
|
162
181
|
"---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/a.pdf\n bytes: 1\n---\n# PIPELINE-STATUS\n";
|
|
@@ -346,6 +365,32 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
346
365
|
},
|
|
347
366
|
silent: paper(tex(FORMAL)),
|
|
348
367
|
},
|
|
368
|
+
// The three register bands judge a body against the anchors of AIDC's preset; the body opens on
|
|
369
|
+
// line 3, right after the \\section, where the finding about the whole body stands.
|
|
370
|
+
"tex/contrast-frames": {
|
|
371
|
+
reports: {
|
|
372
|
+
tree: aidc(formalWith(5, "It reads the word, not the operation.")),
|
|
373
|
+
file: TEX_FILE,
|
|
374
|
+
severity: 1,
|
|
375
|
+
line: 3,
|
|
376
|
+
},
|
|
377
|
+
silent: aidc(LINKED),
|
|
378
|
+
},
|
|
379
|
+
"tex/claim-emphasis": {
|
|
380
|
+
reports: {
|
|
381
|
+
tree: aidc(
|
|
382
|
+
formalWith(6, "We find that \\textbf{the rule counts 46 sentences}."),
|
|
383
|
+
),
|
|
384
|
+
file: TEX_FILE,
|
|
385
|
+
severity: 1,
|
|
386
|
+
line: 3,
|
|
387
|
+
},
|
|
388
|
+
silent: aidc(LINKED),
|
|
389
|
+
},
|
|
390
|
+
"tex/relation-markers": {
|
|
391
|
+
reports: { tree: aidc(FORMAL), file: TEX_FILE, severity: 1, line: 3 },
|
|
392
|
+
silent: aidc(LINKED),
|
|
393
|
+
},
|
|
349
394
|
"bib/reachable-entry": {
|
|
350
395
|
reports: {
|
|
351
396
|
tree: paper(inlineBib("@book{a,\n title = {A},\n year = {2026}\n}")),
|
|
@@ -381,16 +426,31 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
381
426
|
reports: onPdf(built({ page_w_in: 8.264 })),
|
|
382
427
|
silent: built(),
|
|
383
428
|
},
|
|
384
|
-
"
|
|
429
|
+
"format/page-limit": {
|
|
385
430
|
reports: onPdf(
|
|
386
431
|
built({ body_pages: 9, pages_by_type: { body: 9, bib: 2 } }),
|
|
387
432
|
),
|
|
388
433
|
silent: built(),
|
|
389
434
|
},
|
|
390
435
|
"pdf/body-size": {
|
|
391
|
-
reports:
|
|
436
|
+
reports: onPdf(built({ body_pt: 12 })),
|
|
392
437
|
silent: built(),
|
|
393
438
|
},
|
|
439
|
+
"anonymity/identity": {
|
|
440
|
+
reports: onPdf(
|
|
441
|
+
built({ pages_text: ["Ada Example\n", "References\n"] }, BLIND),
|
|
442
|
+
),
|
|
443
|
+
silent: built({ pages_text: ["Anonymous\n", "References\n"] }, BLIND),
|
|
444
|
+
},
|
|
445
|
+
"format/layout-override": {
|
|
446
|
+
reports: {
|
|
447
|
+
tree: aidc("\\linespread{0.9}Text."),
|
|
448
|
+
file: TEX_FILE,
|
|
449
|
+
severity: 2,
|
|
450
|
+
line: 4,
|
|
451
|
+
},
|
|
452
|
+
silent: aidc("\\vspace{-2mm}Text."),
|
|
453
|
+
},
|
|
394
454
|
"pdf/last-page-balance": {
|
|
395
455
|
reports: onPdf(
|
|
396
456
|
built(
|
|
@@ -472,23 +532,39 @@ describe("every registered rule has cases, and every case names a registered rul
|
|
|
472
532
|
});
|
|
473
533
|
});
|
|
474
534
|
|
|
535
|
+
/**
|
|
536
|
+
* A case lints its tree through the whole CLI: a body long enough for a rate to be judged (2 000
|
|
537
|
+
* words and more) takes seconds under coverage, on a machine running every suite at once.
|
|
538
|
+
*/
|
|
539
|
+
const CASE_TIMEOUT_MS = 30_000;
|
|
540
|
+
|
|
475
541
|
describe.each(Object.entries(CASES))("%s, through real ESLint", (id, c) => {
|
|
476
|
-
it(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
542
|
+
it(
|
|
543
|
+
`reports on ${c.reports.file}:${String(c.reports.line)} at severity ${String(c.reports.severity)}`,
|
|
544
|
+
{ timeout: CASE_TIMEOUT_MS },
|
|
545
|
+
async () => {
|
|
546
|
+
const got = (await lintTree(c.reports.tree)).filter(
|
|
547
|
+
(m) => m.ruleId === id,
|
|
548
|
+
);
|
|
549
|
+
expect(
|
|
550
|
+
got.some(
|
|
551
|
+
(m) =>
|
|
552
|
+
m.file === c.reports.file &&
|
|
553
|
+
m.line === c.reports.line &&
|
|
554
|
+
m.severity === c.reports.severity,
|
|
555
|
+
),
|
|
556
|
+
JSON.stringify(got),
|
|
557
|
+
).toBe(true);
|
|
558
|
+
},
|
|
559
|
+
);
|
|
488
560
|
|
|
489
|
-
it(
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
)
|
|
493
|
-
|
|
561
|
+
it(
|
|
562
|
+
"stays silent on a conforming paper",
|
|
563
|
+
{ timeout: CASE_TIMEOUT_MS },
|
|
564
|
+
async () => {
|
|
565
|
+
expect((await lintTree(c.silent)).filter((m) => m.ruleId === id)).toEqual(
|
|
566
|
+
[],
|
|
567
|
+
);
|
|
568
|
+
},
|
|
569
|
+
);
|
|
494
570
|
});
|
package/src/rule-docs.test.ts
CHANGED
|
@@ -29,12 +29,10 @@ const AWAITING_PAGE: ReadonlySet<string> = new Set([
|
|
|
29
29
|
"paper/section-word",
|
|
30
30
|
"paper/source",
|
|
31
31
|
"paper/stages",
|
|
32
|
-
"pdf/body-size",
|
|
33
32
|
"pdf/fonts",
|
|
34
33
|
"pdf/fresh",
|
|
35
34
|
"pdf/geometry",
|
|
36
35
|
"pdf/last-page-balance",
|
|
37
|
-
"pdf/limits",
|
|
38
36
|
"pdf/measured",
|
|
39
37
|
"pdf/profile",
|
|
40
38
|
"review/frontmatter",
|