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.
- package/CLAUDE.md +24 -0
- package/CONTRIBUTING.md +7 -0
- package/dist/adapters/latex/document-class.d.ts +6 -5
- package/dist/adapters/latex/document-class.d.ts.map +1 -1
- package/dist/adapters/latex/document-class.js +18 -10
- package/dist/adapters/latex/document-class.js.map +1 -1
- package/dist/adapters/latex/includes.d.ts +12 -0
- package/dist/adapters/latex/includes.d.ts.map +1 -0
- package/dist/adapters/latex/includes.js +42 -0
- package/dist/adapters/latex/includes.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +9 -5
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +8 -1
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/nodes.d.ts +5 -1
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +6 -2
- 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 +1 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/latex/prose.d.ts +8 -0
- package/dist/adapters/latex/prose.d.ts.map +1 -0
- package/dist/adapters/latex/prose.js +353 -0
- package/dist/adapters/latex/prose.js.map +1 -0
- package/dist/claim-provenance.d.ts +34 -0
- package/dist/claim-provenance.d.ts.map +1 -0
- package/dist/claim-provenance.js +146 -0
- package/dist/claim-provenance.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -1
- package/dist/domain/paper-source.d.ts +58 -0
- package/dist/domain/paper-source.d.ts.map +1 -0
- package/dist/domain/paper-source.js +109 -0
- package/dist/domain/paper-source.js.map +1 -0
- package/dist/domain/sentences.d.ts +26 -0
- package/dist/domain/sentences.d.ts.map +1 -0
- package/dist/domain/sentences.js +48 -0
- package/dist/domain/sentences.js.map +1 -0
- package/dist/domain/tex-document.d.ts +39 -4
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/paper-typography.d.ts +7 -0
- package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
- package/dist/eslint-rules/paper-typography.js +53 -1
- package/dist/eslint-rules/paper-typography.js.map +1 -1
- package/dist/ports/latex.d.ts +12 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/register.d.ts +40 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +134 -0
- package/dist/register.js.map +1 -0
- package/dist/tex-paper.d.ts +19 -0
- package/dist/tex-paper.d.ts.map +1 -0
- package/dist/tex-paper.js +105 -0
- package/dist/tex-paper.js.map +1 -0
- package/dist/tex-venue-rules.d.ts +7 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +31 -17
- package/dist/tex-venue-rules.js.map +1 -1
- package/docs/rules/tex/claim-provenance.md +83 -0
- package/docs/rules/tex/missing-input.md +58 -0
- package/docs/rules/tex/register.md +77 -0
- package/docs/rules/tex/required-section.md +3 -1
- package/docs/rules/tex/template.md +12 -0
- package/docs/rules/tex/venue-leftover.md +4 -2
- package/docs/rules.md +3 -0
- package/eslint-rules/paper-typography.test.mjs +48 -1
- package/eslint-rules/paper-typography.ts +72 -1
- package/eslint.config.mjs +1 -0
- package/fixtures/real-markdown-paper/baseline.ts +8 -7
- package/package.json +2 -1
- package/skills/cold-read-diff/SKILL.md +31 -10
- package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
- package/skills/draft-paper/SKILL.md +2 -2
- package/skills/draft-paper/SKILL.md.spec.ts +1 -1
- package/skills/grade-paper-writing/SKILL.md +109 -44
- package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
- package/skills/harden-paper/SKILL.md +35 -36
- package/skills/harden-paper/SKILL.md.spec.ts +34 -35
- package/skills/paper-adversarial-review/SKILL.md +2 -2
- package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +3 -3
- package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
- package/skills/paper-pipeline/references/writing-craft.md +53 -18
- package/skills/pc-panel-review/SKILL.md +36 -11
- package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
- package/src/adapters/latex/document-class.ts +20 -12
- package/src/adapters/latex/includes.ts +66 -0
- package/src/adapters/latex/index.test.ts +175 -1
- package/src/adapters/latex/index.ts +14 -4
- package/src/adapters/latex/nodes.ts +11 -2
- package/src/adapters/latex/parse.ts +1 -0
- package/src/adapters/latex/prose.ts +484 -0
- package/src/claim-provenance.test.ts +352 -0
- package/src/claim-provenance.ts +217 -0
- package/src/cli.ts +20 -0
- package/src/domain/paper-source.test.ts +156 -0
- package/src/domain/paper-source.ts +233 -0
- package/src/domain/sentences.ts +67 -0
- package/src/domain/tex-document.ts +36 -3
- package/src/ports/latex.ts +13 -0
- package/src/register.test.ts +165 -0
- package/src/register.ts +177 -0
- package/src/rule-cases.test.ts +54 -4
- package/src/tex-paper.test.ts +131 -0
- package/src/tex-paper.ts +152 -0
- package/src/tex-venue-rules.test.ts +87 -5
- package/src/tex-venue-rules.ts +62 -30
- package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
- package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
|
@@ -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
|
+
});
|
package/src/register.ts
ADDED
|
@@ -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
|
+
});
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -128,6 +128,9 @@ const AIDC = {
|
|
|
128
128
|
};
|
|
129
129
|
const IEEE = "\\documentclass[conference,compsoc]{IEEEtran}";
|
|
130
130
|
const STATEMENT = "\\section*{LLM Usage Statement}\nNone.";
|
|
131
|
+
/** Two `\\documentclass` lines behind a switch: `article` for one venue, `second` for this one. */
|
|
132
|
+
const switched = (second: string): string =>
|
|
133
|
+
`\\def\\venue{2}\n\\if\\venue1\n\\documentclass{article}\n\\fi\n\\if\\venue2\n${second}\n\\fi`;
|
|
131
134
|
const aidc = (body: string, cls = IEEE) =>
|
|
132
135
|
paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
|
|
133
136
|
|
|
@@ -147,6 +150,13 @@ interface RuleCases {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
const TEX_FILE = `${P}/paper.tex`;
|
|
153
|
+
|
|
154
|
+
/** A body of 200 sentences of 14 words: enough words for `tex/register` to judge a rate. */
|
|
155
|
+
const FORMAL = Array.from(
|
|
156
|
+
{ length: 200 },
|
|
157
|
+
() =>
|
|
158
|
+
"The rule reads the body of the paper and counts every sentence it holds.",
|
|
159
|
+
).join(" ");
|
|
150
160
|
const STATUS_FILE = `${P}/PIPELINE-STATUS.md`;
|
|
151
161
|
const SHIPPED_STATUS =
|
|
152
162
|
"---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/a.pdf\n bytes: 1\n---\n# PIPELINE-STATUS\n";
|
|
@@ -202,7 +212,13 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
202
212
|
severity: 1,
|
|
203
213
|
line: 3,
|
|
204
214
|
},
|
|
205
|
-
|
|
215
|
+
// A cleveref name definition sets a label, it is not prose.
|
|
216
|
+
silent: paper(
|
|
217
|
+
tex(
|
|
218
|
+
"As in Section~2.",
|
|
219
|
+
"\\documentclass{article}\n\\usepackage{cleveref}\n\\crefname{section}{§}{§§}",
|
|
220
|
+
),
|
|
221
|
+
),
|
|
206
222
|
},
|
|
207
223
|
"paper/leading-zero": {
|
|
208
224
|
reports: {
|
|
@@ -299,14 +315,15 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
299
315
|
},
|
|
300
316
|
silent: paper(fixture("tex-build/frontmatter-clean.tex")),
|
|
301
317
|
},
|
|
318
|
+
// The class picked behind a TeX switch: two candidates, one of them the venue's or none.
|
|
302
319
|
"tex/template": {
|
|
303
320
|
reports: {
|
|
304
|
-
tree: aidc("Text.", "\\documentclass[conference]{IEEEtran}"),
|
|
321
|
+
tree: aidc("Text.", switched("\\documentclass[conference]{IEEEtran}")),
|
|
305
322
|
file: TEX_FILE,
|
|
306
323
|
severity: 2,
|
|
307
|
-
line:
|
|
324
|
+
line: 3,
|
|
308
325
|
},
|
|
309
|
-
silent: aidc("Text."),
|
|
326
|
+
silent: aidc("Text.", switched(IEEE)),
|
|
310
327
|
},
|
|
311
328
|
"tex/required-section": {
|
|
312
329
|
reports: {
|
|
@@ -326,6 +343,39 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
326
343
|
},
|
|
327
344
|
silent: aidc("Text."),
|
|
328
345
|
},
|
|
346
|
+
"tex/claim-provenance": {
|
|
347
|
+
reports: {
|
|
348
|
+
tree: paper(tex("Text.\nAgents ignore 42\\% of the rules.")),
|
|
349
|
+
file: TEX_FILE,
|
|
350
|
+
severity: 1,
|
|
351
|
+
line: 4,
|
|
352
|
+
},
|
|
353
|
+
silent: paper(
|
|
354
|
+
tex(
|
|
355
|
+
"Text.\nWe measured that agents ignore 42\\% of the rules.\nThey run on Claude 3 and Python 3.12.",
|
|
356
|
+
),
|
|
357
|
+
),
|
|
358
|
+
},
|
|
359
|
+
"tex/register": {
|
|
360
|
+
reports: {
|
|
361
|
+
tree: paper(tex(`${FORMAL} But it holds. So it goes. And it ends.`)),
|
|
362
|
+
file: TEX_FILE,
|
|
363
|
+
severity: 1,
|
|
364
|
+
line: 3,
|
|
365
|
+
},
|
|
366
|
+
silent: paper(tex(FORMAL)),
|
|
367
|
+
},
|
|
368
|
+
"tex/missing-input": {
|
|
369
|
+
reports: {
|
|
370
|
+
tree: paper(tex("Text.\n\\input{sections/gone}")),
|
|
371
|
+
file: TEX_FILE,
|
|
372
|
+
severity: 1,
|
|
373
|
+
line: 4,
|
|
374
|
+
},
|
|
375
|
+
silent: paper(tex("Text.\n\\input{sections/here}"), {
|
|
376
|
+
[`${P}/sections/here.tex`]: "More text.\n",
|
|
377
|
+
}),
|
|
378
|
+
},
|
|
329
379
|
"bib/reachable-entry": {
|
|
330
380
|
reports: {
|
|
331
381
|
tree: paper(inlineBib("@book{a,\n title = {A},\n year = {2026}\n}")),
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tex/missing-input` and the reading every parse-tree rule shares (`readPaper`): an include that
|
|
3
|
+
* names no file is reported where it stands in `paper.tex`, once, whether `paper.tex` wrote it or a
|
|
4
|
+
* file it includes did; an include that resolves says nothing.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { latexReader } from "./adapters/latex/index.ts";
|
|
8
|
+
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
9
|
+
import { missingInputRule, readPaper, reportInPaper } from "./tex-paper.ts";
|
|
10
|
+
|
|
11
|
+
const doc = (body: string): string =>
|
|
12
|
+
`\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
|
|
13
|
+
|
|
14
|
+
/** What the rule reports over `/p/paper.tex` holding `src`, with `files` on disk. */
|
|
15
|
+
function run(src: string, files: Record<string, string> = {}) {
|
|
16
|
+
const out: { line: number; messageId: string; data: unknown }[] = [];
|
|
17
|
+
const rule = missingInputRule({
|
|
18
|
+
files: memoryFiles(files),
|
|
19
|
+
latex: latexReader,
|
|
20
|
+
});
|
|
21
|
+
rule
|
|
22
|
+
.create({
|
|
23
|
+
filename: "/p/paper.tex",
|
|
24
|
+
sourceCode: {
|
|
25
|
+
text: src,
|
|
26
|
+
getLocFromIndex: (i) => ({
|
|
27
|
+
line: src.slice(0, i).split("\n").length,
|
|
28
|
+
column: 0,
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
report: (d) => {
|
|
32
|
+
if ("messageId" in d)
|
|
33
|
+
out.push({
|
|
34
|
+
line: d.loc.start.line,
|
|
35
|
+
messageId: d.messageId,
|
|
36
|
+
data: d.data,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
.root?.();
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("tex/missing-input", () => {
|
|
45
|
+
it("an \\input naming no file is reported at its line, naming the target", () => {
|
|
46
|
+
expect(run(doc("Text.\n\\input{sections/gone}"))).toEqual([
|
|
47
|
+
{
|
|
48
|
+
line: 4,
|
|
49
|
+
messageId: "missing",
|
|
50
|
+
data: { target: "sections/gone", file: "paper.tex" },
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("one missing in an included file is reported at the include that brought that file in", () => {
|
|
56
|
+
expect(run(doc("\\input{a}"), { "/p/a.tex": "A\n\\input{b}\n" })).toEqual([
|
|
57
|
+
{ line: 3, messageId: "missingIn", data: { target: "b", file: "a.tex" } },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("silent when every include resolves, with `.tex` added or not", () => {
|
|
62
|
+
expect(
|
|
63
|
+
run(doc("\\input{a}\\include{b.tex}"), {
|
|
64
|
+
"/p/a.tex": "A",
|
|
65
|
+
"/p/b.tex": "B",
|
|
66
|
+
}),
|
|
67
|
+
).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("readPaper — the paper as the rules read it", () => {
|
|
72
|
+
it("reads includes from the paper's own directory", () => {
|
|
73
|
+
const p = readPaper("/p/paper.tex", "x\\input{s/a}y", {
|
|
74
|
+
files: memoryFiles({ "/p/s/a.tex": "AAA" }),
|
|
75
|
+
latex: latexReader,
|
|
76
|
+
});
|
|
77
|
+
expect(p.text).toBe("xAAAy");
|
|
78
|
+
expect(p.main).toBe("paper.tex");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("reportInPaper — where a finding of the assembled text is reported", () => {
|
|
83
|
+
const paper = readPaper("/p/paper.tex", "x\n\\input{a}\ny", {
|
|
84
|
+
files: memoryFiles({ "/p/a.tex": "one\ntwo" }),
|
|
85
|
+
latex: latexReader,
|
|
86
|
+
});
|
|
87
|
+
const report = (
|
|
88
|
+
at: { start: number; end: number } | null,
|
|
89
|
+
messageId = "m",
|
|
90
|
+
) => {
|
|
91
|
+
const out: unknown[] = [];
|
|
92
|
+
reportInPaper(
|
|
93
|
+
{
|
|
94
|
+
filename: "/p/paper.tex",
|
|
95
|
+
sourceCode: {
|
|
96
|
+
text: "",
|
|
97
|
+
getLocFromIndex: (i) => ({ line: i, column: 0 }),
|
|
98
|
+
},
|
|
99
|
+
report: (d) =>
|
|
100
|
+
out.push(
|
|
101
|
+
"messageId" in d
|
|
102
|
+
? { at: d.loc.start.line, id: d.messageId }
|
|
103
|
+
: { at: d.loc.start.line, message: d.message },
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
{ m: "said {{w}}" },
|
|
107
|
+
paper,
|
|
108
|
+
[{ messageId, data: { w: "so" }, at }],
|
|
109
|
+
);
|
|
110
|
+
return out;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
it("in the main file: at its own place, by its id", () => {
|
|
114
|
+
expect(report({ start: 0, end: 1 })).toEqual([{ at: 0, id: "m" }]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("in an included file: at the include, the file, line and column in front of the filled message", () => {
|
|
118
|
+
// "x\n" then "one\ntwo": offset 6 is the `t` of `two`, line 2 column 1 of a.tex.
|
|
119
|
+
expect(report({ start: 6, end: 9 })).toEqual([
|
|
120
|
+
{ at: 2, message: "a.tex:2:1: said so" },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("no place, or a place outside the text: the top of the file; an unknown id is printed as itself", () => {
|
|
125
|
+
expect(report(null)).toEqual([{ at: 0, id: "m" }]);
|
|
126
|
+
expect(report({ start: 99, end: 99 })).toEqual([{ at: 0, id: "m" }]);
|
|
127
|
+
expect(report({ start: 6, end: 9 }, "unknown")).toEqual([
|
|
128
|
+
{ at: 2, message: "a.tex:2:1: unknown" },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|