paperlint 6.3.0 → 7.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/CONTRIBUTING.md +4 -2
- package/README.md +20 -13
- package/dist/adapters/latex/conditionals.d.ts +21 -0
- package/dist/adapters/latex/conditionals.d.ts.map +1 -0
- package/dist/adapters/latex/conditionals.js +75 -0
- package/dist/adapters/latex/conditionals.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +10 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -0
- package/dist/adapters/latex/headings.js +129 -0
- package/dist/adapters/latex/headings.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +3 -2
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -5
- package/dist/cli.js.map +1 -1
- package/dist/domain/body-pages.d.ts +29 -0
- package/dist/domain/body-pages.d.ts.map +1 -1
- package/dist/domain/body-pages.js +25 -0
- package/dist/domain/body-pages.js.map +1 -1
- package/dist/domain/heading-case.d.ts +100 -0
- package/dist/domain/heading-case.d.ts.map +1 -0
- package/dist/domain/heading-case.js +365 -0
- package/dist/domain/heading-case.js.map +1 -0
- package/dist/domain/tex-document.d.ts +32 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +11 -0
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +16 -3
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/facts-file.d.ts +3 -3
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +3 -3
- package/dist/facts-file.js.map +1 -1
- package/dist/folder-venue-rule.js +6 -6
- package/dist/folder-venue-rule.js.map +1 -1
- package/dist/heading-case.d.ts +48 -0
- package/dist/heading-case.d.ts.map +1 -0
- package/dist/heading-case.js +191 -0
- package/dist/heading-case.js.map +1 -0
- package/dist/paper-includes.d.ts +3 -0
- package/dist/paper-includes.d.ts.map +1 -1
- package/dist/paper-includes.js +17 -2
- package/dist/paper-includes.js.map +1 -1
- package/dist/ports/latex.d.ts +7 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/preset-card-rule.d.ts +100 -0
- package/dist/preset-card-rule.d.ts.map +1 -0
- package/dist/preset-card-rule.js +221 -0
- package/dist/preset-card-rule.js.map +1 -0
- package/dist/presets.d.ts +29 -5
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +104 -68
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +8 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +19 -7
- package/dist/submission.js.map +1 -1
- package/dist/tex-requirements.d.ts +42 -3
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +34 -5
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +6 -3
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +29 -10
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/venue-rules.d.ts +2 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +55 -7
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +13 -1
- package/docs/e2e.md +14 -12
- package/docs/optional-rules.md +4 -3
- package/docs/rules/format/page-limit.md +19 -2
- package/docs/rules/paper/folder-venue-leftover.md +1 -1
- package/docs/rules/talk/profile.md +1 -1
- package/docs/rules/tex/heading-case.md +424 -0
- package/docs/rules/tex/template.md +25 -5
- package/docs/rules/tex/venue-leftover.md +13 -0
- package/docs/rules.md +74 -57
- package/docs/submission.md +5 -1
- package/eslint-rules/latex-language.test.mjs +12 -0
- package/eslint-rules/latex-language.ts +17 -4
- package/eslint-rules/paper-typography.test.mjs +12 -0
- package/eslint.config.mjs +59 -1
- package/fixtures/build-e2e/acmart/paper.tex +1 -1
- package/fixtures/build-e2e/fallback/expect.json +3 -3
- package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr/expect.json +18 -0
- package/fixtures/build-e2e/msr/paper.tex +49 -0
- package/fixtures/build-e2e/msr/paperlint.json +1 -0
- package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
- package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
- package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
- package/lib/skill-checks.mjs +31 -0
- package/package.json +7 -1
- package/presets/acm-sigconf.jsonc +5 -0
- package/presets/acm-sigconf.md +43 -0
- package/presets/agenticdev.jsonc +27 -6
- package/presets/agenticdev.md +151 -0
- package/presets/aidc.jsonc +6 -0
- package/presets/aidc.md +75 -0
- package/presets/aisec.jsonc +5 -1
- package/presets/aisec.md +117 -0
- package/presets/ieee-conference.jsonc +5 -0
- package/presets/ieee-conference.md +38 -0
- package/presets/msr.jsonc +71 -0
- package/presets/msr.md +94 -0
- package/presets/realm.jsonc +7 -2
- package/presets/realm.md +166 -0
- package/presets/tex/agenticdev.tex +1 -1
- package/presets/tex-base.jsonc +6 -0
- package/presets/tex-base.md +30 -0
- package/presets/venue-profile.schema.json +65 -1
- package/skills/camera-ready/SKILL.md +8 -1
- package/skills/camera-ready/SKILL.md.spec.ts +7 -0
- package/skills/find-venue/SKILL.md +6 -5
- package/skills/find-venue/SKILL.md.spec.ts +5 -4
- package/skills/paper-pipeline/SKILL.md +5 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
- package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
- package/skills/study-accepted-papers/SKILL.md +3 -3
- package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
- package/skills/submission-portal/SKILL.md +2 -2
- package/skills/submission-portal/SKILL.md.spec.ts +1 -1
- package/skills/submit-paper/SKILL.md +34 -22
- package/skills/submit-paper/SKILL.md.spec.ts +33 -21
- package/skills/submit-paper/references/publishers/acm.md +1 -1
- package/src/adapters/latex/conditionals.ts +130 -0
- package/src/adapters/latex/headings.ts +163 -0
- package/src/adapters/latex/index.test.ts +65 -0
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/parse.ts +3 -2
- package/src/build.ts +2 -2
- package/src/cli.test.ts +50 -7
- package/src/cli.ts +28 -6
- package/src/domain/body-pages.test.ts +144 -0
- package/src/domain/body-pages.ts +61 -1
- package/src/domain/heading-case.test.ts +660 -0
- package/src/domain/heading-case.ts +538 -0
- package/src/domain/tex-document.ts +36 -0
- package/src/domain/venue-name.test.ts +13 -7
- package/src/facts-file.test.ts +4 -4
- package/src/facts-file.ts +6 -6
- package/src/folder-venue-rule.test.ts +10 -10
- package/src/folder-venue-rule.ts +6 -6
- package/src/heading-case.test.ts +500 -0
- package/src/heading-case.ts +275 -0
- package/src/init.test.ts +1 -1
- package/src/new-paper.test.ts +37 -6
- package/src/paper-includes.test.ts +26 -0
- package/src/paper-includes.ts +20 -2
- package/src/paper-settings-commands.test.ts +26 -11
- package/src/ports/latex.ts +7 -0
- package/src/preset-card-rule.test.ts +267 -0
- package/src/preset-card-rule.ts +384 -0
- package/src/presets.test.ts +259 -41
- package/src/presets.ts +161 -75
- package/src/rule-cases.test.ts +136 -50
- package/src/submission.test.ts +44 -4
- package/src/submission.ts +28 -7
- package/src/talk-rules.test.ts +6 -3
- package/src/tex-requirements.harness.ts +25 -11
- package/src/tex-requirements.test.ts +127 -1
- package/src/tex-requirements.ts +91 -12
- package/src/tex-venue-rules.test.ts +196 -21
- package/src/tex-venue-rules.ts +47 -16
- package/src/venue-rules.test.ts +180 -8
- package/src/venue-rules.ts +71 -10
- package/templates/paper/paper.tex +1 -1
- package/skills/submit-paper/references/venues/agenticdev.md +0 -171
- package/skills/submit-paper/references/venues/aisec.md +0 -105
- package/skills/submit-paper/references/venues/realm.md +0 -155
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RULE THAT JUDGES THE CASE OF TITLES — `tex/heading-case`, over the paper's `\title` and the
|
|
3
|
+
* titles of `\section`, `\subsection`, `\subsubsection`, `\paragraph` and `\subparagraph`
|
|
4
|
+
* (starred or not, and each optional short title).
|
|
5
|
+
*
|
|
6
|
+
* tex/heading-case off unless turned on every word of a title is in the case its scope asks:
|
|
7
|
+
* { title, headings, levels? }, each a CaseStyle
|
|
8
|
+
*
|
|
9
|
+
* ── WHY IT IS AN OPTION AND NOT A DEFAULT ────────────────────────────────────────
|
|
10
|
+
* Which style a venue asks is the venue's, and venues name different authorities: Conference
|
|
11
|
+
* Publishing (AgenticDev's proceedings) quotes the Chicago Manual of Style, ACL links APA title case,
|
|
12
|
+
* IEEE's manual has its own list. So the rule is optional, like `pdf/last-page-balance`: a venue
|
|
13
|
+
* preset whose author kit was read turns it on in its `rules` — today only `presets/agenticdev.jsonc`
|
|
14
|
+
* — and a paper may turn it on in its own `paperlint.json`.
|
|
15
|
+
*
|
|
16
|
+
* ── THE OPTIONS ARE PARSED ONCE, WHEN ESLINT LOADS THE RULE ──────────────────────
|
|
17
|
+
* ESLint checks the SHAPE against `meta.schema`: `title` and `headings` required, no other key, no
|
|
18
|
+
* unknown level. The VALUES are judged by `parseOptions`, because ajv's enum message («should be
|
|
19
|
+
* equal to one of the allowed values») names none of them: a misspelt style, the retired words
|
|
20
|
+
* `headline` and `off`, and `any` for both scopes each throw from `create`, which ESLint reports as
|
|
21
|
+
* «Error while loading rule» and stops the run. The judge then gets `HeadingCaseOptions` and never
|
|
22
|
+
* looks at the raw object again. A bare severity (`"error"`, no options) passes ESLint's schema —
|
|
23
|
+
* it validates the options it is given, not their count — so it is reported once, at the top of the
|
|
24
|
+
* file, rather than passing as a rule that checked nothing.
|
|
25
|
+
*
|
|
26
|
+
* ── ONE FILE AT A TIME ───────────────────────────────────────────────────────────
|
|
27
|
+
* The rule reads the file ESLint hands it, like `paper/section-word`: it runs on `paper.tex` and on
|
|
28
|
+
* every file `paper.tex` includes from its body (the `**` block of `src/cli.ts`), so a heading in
|
|
29
|
+
* `sections/results.tex` is reported at that file and line, and `--fix` edits that file. The
|
|
30
|
+
* preamble is read too: that is where `\title` stands.
|
|
31
|
+
*
|
|
32
|
+
* What a word is asked is decided in `src/domain/heading-case.ts`.
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
CASE_STYLES,
|
|
36
|
+
HEADING_LEVELS,
|
|
37
|
+
judgeHeadingCase,
|
|
38
|
+
type CaseFinding,
|
|
39
|
+
type CaseStyle,
|
|
40
|
+
type HeadingCaseOptions,
|
|
41
|
+
} from "./domain/heading-case.ts";
|
|
42
|
+
import { fieldOf, isRecord } from "./domain/record.ts";
|
|
43
|
+
import type { HeadingLevel } from "./domain/tex-document.ts";
|
|
44
|
+
import type { LatexReader } from "./ports/latex.ts";
|
|
45
|
+
import { ruleDocsUrl } from "./tex-venue-rules.ts";
|
|
46
|
+
|
|
47
|
+
/** What the rule reads with: the LaTeX reader. */
|
|
48
|
+
export interface HeadingCaseDeps {
|
|
49
|
+
readonly latex: LatexReader;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Loc = { readonly line: number; readonly column: number };
|
|
53
|
+
|
|
54
|
+
/** The slice of ESLint's rule context this rule uses. `raw` exists on the `.tex` language's. */
|
|
55
|
+
export interface HeadingCaseContext {
|
|
56
|
+
readonly options: readonly unknown[];
|
|
57
|
+
readonly sourceCode: {
|
|
58
|
+
readonly text: string;
|
|
59
|
+
readonly raw?: string;
|
|
60
|
+
getLocFromIndex(index: number): Loc;
|
|
61
|
+
};
|
|
62
|
+
report(d: {
|
|
63
|
+
readonly loc: { readonly start: Loc; readonly end: Loc };
|
|
64
|
+
readonly messageId: string;
|
|
65
|
+
readonly data?: Readonly<Record<string, string>>;
|
|
66
|
+
readonly fix?: (fixer: {
|
|
67
|
+
replaceTextRange(range: readonly [number, number], text: string): unknown;
|
|
68
|
+
}) => unknown;
|
|
69
|
+
}): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HeadingCaseRule {
|
|
73
|
+
readonly meta: {
|
|
74
|
+
readonly type: "suggestion";
|
|
75
|
+
readonly fixable: "code";
|
|
76
|
+
readonly docs: { readonly description: string; readonly url: string };
|
|
77
|
+
readonly schema: readonly object[];
|
|
78
|
+
readonly messages: Readonly<Record<string, string>>;
|
|
79
|
+
};
|
|
80
|
+
create(context: HeadingCaseContext): { root?: () => void };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DOCS_URL = ruleDocsUrl("heading-case");
|
|
84
|
+
|
|
85
|
+
/** The rule's options as ESLint gave them: a configuration, none at all, or one it refuses. */
|
|
86
|
+
type Asked =
|
|
87
|
+
| { readonly kind: "options"; readonly options: HeadingCaseOptions }
|
|
88
|
+
| { readonly kind: "none" }
|
|
89
|
+
| { readonly kind: "refused"; readonly reason: string };
|
|
90
|
+
|
|
91
|
+
const refused = (reason: string): Asked => ({
|
|
92
|
+
kind: "refused",
|
|
93
|
+
reason: `${reason} — ${DOCS_URL}`,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const quoted = (xs: readonly string[]): string =>
|
|
97
|
+
xs.map((x) => JSON.stringify(x)).join(", ");
|
|
98
|
+
|
|
99
|
+
/** One style value, or why it is none; `key` is how the message names where it stands. */
|
|
100
|
+
function styleOf(
|
|
101
|
+
key: string,
|
|
102
|
+
v: unknown,
|
|
103
|
+
): CaseStyle | { readonly why: string } {
|
|
104
|
+
const style = CASE_STYLES.find((s) => s === v);
|
|
105
|
+
if (style !== undefined) return style;
|
|
106
|
+
if (v === "headline")
|
|
107
|
+
return {
|
|
108
|
+
why: `"${key}" is "headline", which names no single style: write "chicago-headline" for the Chicago Manual of Style headline style. Other headline variants (APA title case, IEEE, Springer) are not implemented yet, and they disagree with Chicago on words such as With, Between and From`,
|
|
109
|
+
};
|
|
110
|
+
if (v === "off")
|
|
111
|
+
return {
|
|
112
|
+
why: `"${key}" is "off", which is not a style: write "any" to leave this scope unconstrained, or turn the whole rule off with "tex/heading-case": "off"`,
|
|
113
|
+
};
|
|
114
|
+
const shown = v === undefined ? "missing" : JSON.stringify(v);
|
|
115
|
+
return {
|
|
116
|
+
why: `"${key}" is ${shown}, which is not a style: one of ${quoted(CASE_STYLES)}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type Levels = Readonly<Partial<Record<HeadingLevel, CaseStyle>>>;
|
|
121
|
+
|
|
122
|
+
/** The `levels` object: the levels it names, each with its style, or why it is refused. */
|
|
123
|
+
function levelsOf(v: unknown): Levels | { readonly why: string } {
|
|
124
|
+
if (v === undefined) return {};
|
|
125
|
+
if (!isRecord(v))
|
|
126
|
+
return {
|
|
127
|
+
why: `"levels" is ${JSON.stringify(v)}, not an object such as {"paragraph": "sentence"}`,
|
|
128
|
+
};
|
|
129
|
+
return HEADING_LEVELS.reduce<Levels | { readonly why: string }>(
|
|
130
|
+
(acc, level) => {
|
|
131
|
+
if ("why" in acc || !(level in v)) return acc;
|
|
132
|
+
const style = styleOf(`levels.${level}`, v[level]);
|
|
133
|
+
return typeof style === "string" ? { ...acc, [level]: style } : style;
|
|
134
|
+
},
|
|
135
|
+
{},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether every scope is `any`: the rule turned off, said in a second way. */
|
|
140
|
+
const asksNothing = (
|
|
141
|
+
o: HeadingCaseOptions & { readonly levels: Levels },
|
|
142
|
+
): boolean =>
|
|
143
|
+
o.title === "any" &&
|
|
144
|
+
o.headings === "any" &&
|
|
145
|
+
Object.values(o.levels).every((s) => s === "any");
|
|
146
|
+
|
|
147
|
+
/** ESLint's options for the rule → what it is asked, parsed once. */
|
|
148
|
+
function parseOptions(options: readonly unknown[]): Asked {
|
|
149
|
+
const [raw] = options;
|
|
150
|
+
if (raw === undefined) return { kind: "none" };
|
|
151
|
+
if (!isRecord(raw))
|
|
152
|
+
return refused(
|
|
153
|
+
`the options are ${JSON.stringify(raw)}, not an object such as {"title": "chicago-headline", "headings": "chicago-headline"}`,
|
|
154
|
+
);
|
|
155
|
+
const title = styleOf("title", fieldOf(raw, "title"));
|
|
156
|
+
if (typeof title !== "string") return refused(title.why);
|
|
157
|
+
const headings = styleOf("headings", fieldOf(raw, "headings"));
|
|
158
|
+
if (typeof headings !== "string") return refused(headings.why);
|
|
159
|
+
const levels = levelsOf(fieldOf(raw, "levels"));
|
|
160
|
+
if ("why" in levels) return refused(levels.why);
|
|
161
|
+
const parsed = { title, headings, levels };
|
|
162
|
+
return asksNothing(parsed)
|
|
163
|
+
? refused(
|
|
164
|
+
`"title" and "headings" are both "any" and no level asks for more: that is the rule turned off — remove it from "rules", or set it to "off"`,
|
|
165
|
+
)
|
|
166
|
+
: { kind: "options", options: parsed };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const MESSAGES: Readonly<Record<string, string>> = {
|
|
170
|
+
capitalize:
|
|
171
|
+
"{{where}}: capitalize «{{word}}» → «{{expected}}» — {{why}} (chicago-headline)",
|
|
172
|
+
lowercase:
|
|
173
|
+
"{{where}}: lowercase «{{word}}» → «{{expected}}» — {{why}} (chicago-headline)",
|
|
174
|
+
sentenceFirst:
|
|
175
|
+
"{{where}}: capitalize the first word, «{{word}}» → «{{expected}}» (sentence; not fixed: a product name written in lowercase looks the same)",
|
|
176
|
+
noOptions: `tex/heading-case is on with no options, so it judged nothing: give it the styles the venue asks, e.g. ["error", {"title": "chicago-headline", "headings": "chicago-headline"}] (styles: ${quoted(CASE_STYLES)})`,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** One finding reported where its word stands, with the fix when the judge is sure of it. */
|
|
180
|
+
function report(context: HeadingCaseContext, f: CaseFinding): void {
|
|
181
|
+
const sc = context.sourceCode;
|
|
182
|
+
const { fix } = f;
|
|
183
|
+
context.report({
|
|
184
|
+
loc: {
|
|
185
|
+
start: sc.getLocFromIndex(f.at.start),
|
|
186
|
+
end: sc.getLocFromIndex(f.at.end),
|
|
187
|
+
},
|
|
188
|
+
messageId: f.messageId,
|
|
189
|
+
data: f.data,
|
|
190
|
+
...(fix === null
|
|
191
|
+
? {}
|
|
192
|
+
: {
|
|
193
|
+
fix: (fixer) =>
|
|
194
|
+
fixer.replaceTextRange([fix.span.start, fix.span.end], fix.text),
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A style value, as the schema checks it: a string. Which strings are styles is `parseOptions`'s to
|
|
201
|
+
* say, so that the refusal can name them (see the header).
|
|
202
|
+
*/
|
|
203
|
+
const STYLE = { type: "string" } as const;
|
|
204
|
+
|
|
205
|
+
const META: HeadingCaseRule["meta"] = {
|
|
206
|
+
type: "suggestion",
|
|
207
|
+
fixable: "code",
|
|
208
|
+
docs: {
|
|
209
|
+
description:
|
|
210
|
+
"the words of the paper's title and of its headings are in the capitalization the venue asks: chicago-headline or sentence",
|
|
211
|
+
url: DOCS_URL,
|
|
212
|
+
},
|
|
213
|
+
schema: [
|
|
214
|
+
{
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: {
|
|
217
|
+
title: STYLE,
|
|
218
|
+
headings: STYLE,
|
|
219
|
+
levels: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: Object.fromEntries(
|
|
222
|
+
HEADING_LEVELS.map((level) => [level, STYLE]),
|
|
223
|
+
),
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
required: ["title", "headings"],
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
messages: MESSAGES,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** The rule turned on with no options: said once, at the top of the file. */
|
|
235
|
+
function reportNoOptions(context: HeadingCaseContext): void {
|
|
236
|
+
const start = context.sourceCode.getLocFromIndex(0);
|
|
237
|
+
context.report({ loc: { start, end: start }, messageId: "noOptions" });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One file against the options the rule was given. */
|
|
241
|
+
function judgeFile(
|
|
242
|
+
context: HeadingCaseContext,
|
|
243
|
+
deps: HeadingCaseDeps,
|
|
244
|
+
options: HeadingCaseOptions,
|
|
245
|
+
): void {
|
|
246
|
+
const sc = context.sourceCode;
|
|
247
|
+
const src = sc.raw ?? sc.text;
|
|
248
|
+
judgeHeadingCase(deps.latex.headings(src), options, src).forEach((f) => {
|
|
249
|
+
report(context, f);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The rule: its options are parsed when ESLint loads it, and a refused one stops the run. */
|
|
254
|
+
export const headingCaseRule = (deps: HeadingCaseDeps): HeadingCaseRule => ({
|
|
255
|
+
meta: META,
|
|
256
|
+
create: (context) => {
|
|
257
|
+
const asked = parseOptions(context.options);
|
|
258
|
+
switch (asked.kind) {
|
|
259
|
+
case "refused":
|
|
260
|
+
throw new Error(asked.reason);
|
|
261
|
+
case "none":
|
|
262
|
+
return {
|
|
263
|
+
root: () => {
|
|
264
|
+
reportNoOptions(context);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
case "options":
|
|
268
|
+
return {
|
|
269
|
+
root: () => {
|
|
270
|
+
judgeFile(context, deps, asked.options);
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
package/src/init.test.ts
CHANGED
|
@@ -532,7 +532,7 @@ describe("paperlint init — a first paper named after a venue", () => {
|
|
|
532
532
|
expect(made).toEqual([]);
|
|
533
533
|
expect(asked.filter((q) => /format/.test(q))).toEqual([]);
|
|
534
534
|
expect(r.out[r.out.indexOf("first paper") + 1]).toBe(
|
|
535
|
-
' ✗ no paper created — `aisec-2026` — the paper folder names a venue ("
|
|
535
|
+
' ✗ no paper created — `aisec-2026` — the paper folder names a venue ("AISec"); venues change on resubmission, so name it after the work\n if the venue really belongs in the name, rerun `paperlint new` with --allow-venue-name',
|
|
536
536
|
);
|
|
537
537
|
});
|
|
538
538
|
});
|
package/src/new-paper.test.ts
CHANGED
|
@@ -175,6 +175,7 @@ async function newIn(
|
|
|
175
175
|
|
|
176
176
|
describe("paperlint new --venue / --kind", () => {
|
|
177
177
|
const PRESET = `{
|
|
178
|
+
"type": "venue", "name": "my-workshop", "url": "https://example.org/cfp",
|
|
178
179
|
"extends": "paperlint:acm-sigconf",
|
|
179
180
|
"format": { "kinds": { "short": { "body_pages_max": 4 } } },
|
|
180
181
|
}
|
|
@@ -205,6 +206,7 @@ describe("paperlint new --venue / --kind", () => {
|
|
|
205
206
|
for (const template of [undefined, "\\documentclass[a]{}"]) {
|
|
206
207
|
const r = await newIn(["--venue", "./venues/bare.jsonc"], {
|
|
207
208
|
"venues/bare.jsonc": JSON.stringify({
|
|
209
|
+
type: "family",
|
|
208
210
|
...(template === undefined ? {} : { template }),
|
|
209
211
|
tex: { packages: { x: ["x.sty"] } },
|
|
210
212
|
}),
|
|
@@ -230,7 +232,36 @@ describe("paperlint new --venue / --kind", () => {
|
|
|
230
232
|
expect(r.out).not.toMatch(/pdf\/profile/);
|
|
231
233
|
expect(r.code).toBe(0);
|
|
232
234
|
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("paperlint new --venue msr", () => {
|
|
238
|
+
it("`new --venue msr` writes MSR's class line, and once the paper declares who wrote it, lint is clean", async () => {
|
|
239
|
+
const r = await newIn(["--venue", "msr", "--kind", "technical"]);
|
|
240
|
+
const tex = readFileSync(join(r.dir, "paper.tex"), "utf8");
|
|
241
|
+
expect(tex).toContain("\\documentclass[10pt,conference]{IEEEtran}\n");
|
|
242
|
+
expect(r.out).not.toMatch(/tex\/template|format\/page-limit|pdf\/profile/);
|
|
243
|
+
// MSR reviews double-anonymously: the one thing the stub lacks is the authors' identity.
|
|
244
|
+
expect(r.out).toMatch(/anonymity\/identity/);
|
|
245
|
+
expect(r.code).toBe(1);
|
|
246
|
+
writeFileSync(
|
|
247
|
+
join(r.dir, "paperlint.json"),
|
|
248
|
+
JSON.stringify({ ...r.settings(), identity: ["Ada Example"] }),
|
|
249
|
+
);
|
|
250
|
+
const out: string[] = [];
|
|
251
|
+
const code = await run(["lint", r.dir], {
|
|
252
|
+
cwd: r.dir,
|
|
253
|
+
log: (s: string) => out.push(s),
|
|
254
|
+
err: () => {},
|
|
255
|
+
});
|
|
256
|
+
// Guards: what remains is the reminder that the PDF is not built yet — a warning, not a finding
|
|
257
|
+
// about the paper.
|
|
258
|
+
expect(out.join("\n")).toMatch(/\(0 errors, 1 warning\)/);
|
|
259
|
+
expect(out.join("\n")).toMatch(/pdf\/measured/);
|
|
260
|
+
expect(code).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
233
263
|
|
|
264
|
+
describe("paperlint new --venue — a venue it cannot use", () => {
|
|
234
265
|
it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
|
|
235
266
|
const r = await newIn(["--venue", "icse"]);
|
|
236
267
|
expect(r.code).toBe(2);
|
|
@@ -246,7 +277,7 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
|
|
|
246
277
|
[
|
|
247
278
|
"a kind the preset lacks",
|
|
248
279
|
["--venue", "agenticdev", "--kind", "long"],
|
|
249
|
-
/`
|
|
280
|
+
/`AgenticDev` has no kind `long`; its kinds: short, full, demo/,
|
|
250
281
|
],
|
|
251
282
|
["a kind without a venue", ["--kind", "short"], /--kind needs --venue/],
|
|
252
283
|
[
|
|
@@ -270,7 +301,7 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
|
|
|
270
301
|
const r = await newIn(["--venue", "agenticdev"]);
|
|
271
302
|
expect(r.settings()).toEqual({ extends: "paperlint:agenticdev" });
|
|
272
303
|
expect(r.out).toMatch(
|
|
273
|
-
/kind: not set — `
|
|
304
|
+
/kind: not set — `AgenticDev` sets a page limit per kind/,
|
|
274
305
|
);
|
|
275
306
|
expect(r.out).toMatch(/pdf\/profile/);
|
|
276
307
|
});
|
|
@@ -298,19 +329,19 @@ describe("paperlint new — a name that names a venue is refused", () => {
|
|
|
298
329
|
"a venue's label, with the work's name left to suggest",
|
|
299
330
|
"aisec-agent-drift",
|
|
300
331
|
[],
|
|
301
|
-
'`aisec-agent-drift` — the paper folder names a venue ("
|
|
332
|
+
'`aisec-agent-drift` — the paper folder names a venue ("AISec"); venues change on resubmission, so name it after the work (e.g. "agent-drift")',
|
|
302
333
|
],
|
|
303
334
|
[
|
|
304
335
|
"nothing but a year left: no example",
|
|
305
336
|
"aisec-2026",
|
|
306
337
|
[],
|
|
307
|
-
'`aisec-2026` — the paper folder names a venue ("
|
|
338
|
+
'`aisec-2026` — the paper folder names a venue ("AISec"); venues change on resubmission, so name it after the work',
|
|
308
339
|
],
|
|
309
340
|
[
|
|
310
341
|
"its own venue too: right today, stale on the next resubmission",
|
|
311
342
|
"aidc-2026",
|
|
312
343
|
["--venue", "aidc"],
|
|
313
|
-
'`aidc-2026` — the paper folder names a venue ("
|
|
344
|
+
'`aidc-2026` — the paper folder names a venue ("AIDC"); venues change on resubmission, so name it after the work',
|
|
314
345
|
],
|
|
315
346
|
[
|
|
316
347
|
"by an alias, glued to a year",
|
|
@@ -322,7 +353,7 @@ describe("paperlint new — a name that names a venue is refused", () => {
|
|
|
322
353
|
"two venues",
|
|
323
354
|
"realm-aisec-rules",
|
|
324
355
|
[],
|
|
325
|
-
'`realm-aisec-rules` — the paper folder names a venue ("
|
|
356
|
+
'`realm-aisec-rules` — the paper folder names a venue ("AISec", "REALM"); venues change on resubmission, so name it after the work (e.g. "rules")',
|
|
326
357
|
],
|
|
327
358
|
])("%s: exit 2, nothing written", async (_, name, args, reason) => {
|
|
328
359
|
const r = await newIn(args, {}, name);
|
|
@@ -102,6 +102,32 @@ describe("lint reads the files paper.tex includes from its body", () => {
|
|
|
102
102
|
]);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
it("🔴 an included file that is not `.tex` — a `.bbl` — is not handed to ESLint, which parsed it as JavaScript (#153)", async () => {
|
|
106
|
+
const { byFile, err } = await lint(
|
|
107
|
+
project({
|
|
108
|
+
"papers/a/paper.tex": doc(
|
|
109
|
+
"{article}",
|
|
110
|
+
"",
|
|
111
|
+
"Text, as Figure~\\ref{f1} shows.\n\\input{paper.bbl}",
|
|
112
|
+
),
|
|
113
|
+
// What bibtex writes: a comment first, which ESLint's JavaScript parser stops at.
|
|
114
|
+
"papers/a/paper.bbl":
|
|
115
|
+
"% Generated by IEEEtran.bst\n\\begin{thebibliography}{1}\n" +
|
|
116
|
+
"\\bibitem{a} A. Author, ``A result with p < .05,'' 2024.\n" +
|
|
117
|
+
"\\end{thebibliography}\n",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
// Guards: the `.bbl` was the one file of the run with a parse error («Unexpected token %»).
|
|
121
|
+
// It is bibtex's output, not prose the author edits: no fragment rule reports in it, and
|
|
122
|
+
// paper.tex — which reads it, spliced, as TeX typesets it — draws no finding from its `.05`.
|
|
123
|
+
expect(byFile).toEqual({
|
|
124
|
+
"papers/a/PIPELINE-STATUS.md": [],
|
|
125
|
+
"papers/a/paper.tex": [],
|
|
126
|
+
});
|
|
127
|
+
// Read, not missing: no «lint did not read» note names it.
|
|
128
|
+
expect(err).not.toMatch(/paper\.bbl/);
|
|
129
|
+
});
|
|
130
|
+
|
|
105
131
|
it("--fix rewrites the included file, to the paper's majority form", async () => {
|
|
106
132
|
const root = project({});
|
|
107
133
|
const before = readFileSync(join(root, "papers/a/paper.tex"), "utf8");
|
package/src/paper-includes.ts
CHANGED
|
@@ -14,18 +14,35 @@
|
|
|
14
14
|
* `paper.tex`, so only the files named here are ever enumerated;
|
|
15
15
|
* 2. `settings.paperlint.paper` for `paper.tex` and its body files: the main file and the whole set,
|
|
16
16
|
* for the rules whose verdict is the paper's (eslint-rules/paper-context.ts).
|
|
17
|
+
*
|
|
18
|
+
* ── ONLY `.tex` FILES ARE HANDED TO ESLint (#153) ────────────────────────────────
|
|
19
|
+
* An include may resolve to a file of another extension: `\input{paper.bbl}` in a paper that ships
|
|
20
|
+
* bibtex's output instead of a `.bib`, a plot exported as `.pgf`. Handed to ESLint, such a file got
|
|
21
|
+
* no language — the fragment block claims `.tex` only — and the JavaScript parser stopped at its
|
|
22
|
+
* first `%`. It is not given the LaTeX language either: the fragment rules judge and `--fix` the
|
|
23
|
+
* prose an author writes in that file, and a file of another extension is in practice a tool's
|
|
24
|
+
* output, whose words are someone else's (the titles of cited works) and whose fix the next run of
|
|
25
|
+
* the tool overwrites. TeX still reads it, and so does `readPaper`: the rules over the whole paper
|
|
26
|
+
* see it spliced into `paper.tex`, where TeX typesets it.
|
|
17
27
|
*/
|
|
18
|
-
import { join, relative } from "node:path";
|
|
28
|
+
import { extname, join, relative } from "node:path";
|
|
19
29
|
import { PAPERLINT_SETTINGS } from "#eslint-rules/paper-context";
|
|
20
30
|
import { callerPath } from "./caller-path.ts";
|
|
21
31
|
import type { AbsolutePath } from "./domain/paths.ts";
|
|
22
32
|
import type { ConfigBlock } from "./rules-config.ts";
|
|
23
33
|
import { bodyFiles, type PaperDeps, type Unread } from "./tex-paper.ts";
|
|
24
34
|
|
|
35
|
+
/** The files the fragment block (`buildConfig`) claims: the only body files ESLint is handed. */
|
|
36
|
+
export const FRAGMENT_FILES: readonly string[] = ["**/*.tex"];
|
|
37
|
+
|
|
38
|
+
/** Whether a body file is one the fragment block claims — the extension its glob names. */
|
|
39
|
+
const isFragment = (file: string): boolean => extname(file) === ".tex";
|
|
40
|
+
|
|
25
41
|
/** One paper's main file and body files, and the includes that resolved nowhere. */
|
|
26
42
|
export interface PaperBody {
|
|
27
43
|
readonly dir: string;
|
|
28
44
|
readonly main: AbsolutePath;
|
|
45
|
+
/** The body files ESLint lints, each as a fragment: the `.tex` ones. */
|
|
29
46
|
readonly files: readonly AbsolutePath[];
|
|
30
47
|
readonly missing: readonly Unread[];
|
|
31
48
|
}
|
|
@@ -40,7 +57,8 @@ export function paperBodies(
|
|
|
40
57
|
const bytes = deps.files.readBytes(main);
|
|
41
58
|
if (bytes === null) return [];
|
|
42
59
|
const body = bodyFiles(main, new TextDecoder().decode(bytes), deps);
|
|
43
|
-
|
|
60
|
+
const files = body.files.filter(isFragment);
|
|
61
|
+
return [{ dir, main, files, missing: body.missing }];
|
|
44
62
|
});
|
|
45
63
|
}
|
|
46
64
|
|
|
@@ -14,6 +14,7 @@ import { join } from "node:path";
|
|
|
14
14
|
import { afterEach, describe, expect, it } from "vitest";
|
|
15
15
|
import { run, toolchainTex } from "./cli.ts";
|
|
16
16
|
import { lintReport } from "../test/lint-report.ts";
|
|
17
|
+
import { venuePreset } from "../test/support.ts";
|
|
17
18
|
|
|
18
19
|
const dirs: string[] = [];
|
|
19
20
|
afterEach(() => {
|
|
@@ -203,7 +204,7 @@ describe("papersDir — optional, `papers` by default", () => {
|
|
|
203
204
|
});
|
|
204
205
|
|
|
205
206
|
describe("paperlint lint — the venue preset's rules", () => {
|
|
206
|
-
it("agenticdev's preset turns pdf/last-page-balance on for its paper alone", async () => {
|
|
207
|
+
it("agenticdev's preset turns pdf/last-page-balance and tex/heading-case on for its paper alone", async () => {
|
|
207
208
|
const root = project({
|
|
208
209
|
"papers/a/paperlint.json": JSON.stringify({
|
|
209
210
|
extends: "paperlint:agenticdev",
|
|
@@ -219,7 +220,13 @@ describe("paperlint lint — the venue preset's rules", () => {
|
|
|
219
220
|
// Scoped to the paper by `basePath` alone: which of its files the rule reaches is decided
|
|
220
221
|
// once, by narrowing to paperlint's owned scopes (#101), not by a copy of their globs.
|
|
221
222
|
basePath: join(root, "papers/a"),
|
|
222
|
-
rules: {
|
|
223
|
+
rules: {
|
|
224
|
+
"pdf/last-page-balance": ["error", { tolerancePt: 120 }],
|
|
225
|
+
"tex/heading-case": [
|
|
226
|
+
"error",
|
|
227
|
+
{ title: "chicago-headline", headings: "chicago-headline" },
|
|
228
|
+
],
|
|
229
|
+
},
|
|
223
230
|
},
|
|
224
231
|
],
|
|
225
232
|
own: [],
|
|
@@ -256,6 +263,10 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
|
|
|
256
263
|
// The preset's block comes first and the paper's own after it: the later block wins.
|
|
257
264
|
expect(blocks.ok && blocks.value.preset[0]?.rules).toEqual({
|
|
258
265
|
"pdf/last-page-balance": ["error", { tolerancePt: 120 }],
|
|
266
|
+
"tex/heading-case": [
|
|
267
|
+
"error",
|
|
268
|
+
{ title: "chicago-headline", headings: "chicago-headline" },
|
|
269
|
+
],
|
|
259
270
|
});
|
|
260
271
|
expect(blocks.ok && blocks.value.own[0]?.rules).toEqual({
|
|
261
272
|
"pdf/last-page-balance": "off",
|
|
@@ -264,10 +275,12 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
|
|
|
264
275
|
|
|
265
276
|
it("a project's own preset, by relative path: its rules apply, an unknown rule id is refused", async () => {
|
|
266
277
|
const root = project({
|
|
267
|
-
"venues/usenix-sec.jsonc": JSON.stringify(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
"venues/usenix-sec.jsonc": JSON.stringify(
|
|
279
|
+
venuePreset("usenix-sec", {
|
|
280
|
+
extends: "paperlint:acm-sigconf",
|
|
281
|
+
rules: { "pdf/no-such-rule": "error" },
|
|
282
|
+
}),
|
|
283
|
+
),
|
|
271
284
|
"papers/a/paperlint.json": JSON.stringify({
|
|
272
285
|
extends: "../../venues/usenix-sec.jsonc",
|
|
273
286
|
}),
|
|
@@ -286,7 +299,7 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
|
|
|
286
299
|
const r = await lint(root);
|
|
287
300
|
expect(r.code).toBe(1);
|
|
288
301
|
expect(r.out).toMatch(
|
|
289
|
-
/acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
|
|
302
|
+
/acm-sigconf, agenticdev, aidc, aisec, ieee-conference, msr, realm/,
|
|
290
303
|
);
|
|
291
304
|
});
|
|
292
305
|
});
|
|
@@ -294,10 +307,12 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
|
|
|
294
307
|
describe("paperlint toolchain — installs what the project's own presets need", () => {
|
|
295
308
|
it("the shipped union plus a relative preset's tex packages", () => {
|
|
296
309
|
const root = project({
|
|
297
|
-
"venues/usenix-sec.jsonc": JSON.stringify(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
310
|
+
"venues/usenix-sec.jsonc": JSON.stringify(
|
|
311
|
+
venuePreset("usenix-sec", {
|
|
312
|
+
extends: "paperlint:acm-sigconf",
|
|
313
|
+
tex: { packages: { usenix: ["usenix.sty"] } },
|
|
314
|
+
}),
|
|
315
|
+
),
|
|
301
316
|
"papers/a/paperlint.json": JSON.stringify({
|
|
302
317
|
extends: "../../venues/usenix-sec.jsonc",
|
|
303
318
|
}),
|
package/src/ports/latex.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
Passage,
|
|
13
13
|
Span,
|
|
14
14
|
TextRun,
|
|
15
|
+
TitledHeading,
|
|
15
16
|
} from "../domain/tex-document.ts";
|
|
16
17
|
|
|
17
18
|
export interface LatexReader {
|
|
@@ -19,6 +20,12 @@ export interface LatexReader {
|
|
|
19
20
|
readonly documentClass: (src: string) => ClassLine;
|
|
20
21
|
/** Its sections, where the back matter begins, and where the document ends. */
|
|
21
22
|
readonly outline: (src: string) => Outline;
|
|
23
|
+
/**
|
|
24
|
+
* Its `\title` and the titles of its `\section`, `\subsection`, `\subsubsection`, `\paragraph`
|
|
25
|
+
* and `\subparagraph` (starred or not, and each optional short title), each as the text the case
|
|
26
|
+
* judge reads: in document order.
|
|
27
|
+
*/
|
|
28
|
+
readonly headings: (src: string) => readonly TitledHeading[];
|
|
22
29
|
/** The text a reader sees: prose, the author block, footnotes — not comments, keys, math, code. */
|
|
23
30
|
readonly renderedRuns: (src: string) => readonly TextRun[];
|
|
24
31
|
/**
|