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
package/src/pdf-facts.ts
CHANGED
|
@@ -34,6 +34,15 @@ import {
|
|
|
34
34
|
import type { PageLayout, TextBox } from "./domain/page-layout.ts";
|
|
35
35
|
// eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
|
|
36
36
|
import { fillsFor, isUpright } from "./adapters/pdfjs/fill.ts";
|
|
37
|
+
import {
|
|
38
|
+
appendixOutlineDests,
|
|
39
|
+
linkTargetsOf,
|
|
40
|
+
metadataOf,
|
|
41
|
+
namedRefs,
|
|
42
|
+
pageTextOf,
|
|
43
|
+
refOf,
|
|
44
|
+
xmpOf,
|
|
45
|
+
} from "./pdf-content.ts";
|
|
37
46
|
import { messageOf } from "./domain/text.ts";
|
|
38
47
|
import { fieldOf, isRecord, numbersOf } from "./domain/record.ts";
|
|
39
48
|
|
|
@@ -83,12 +92,27 @@ interface Page {
|
|
|
83
92
|
[Symbol.iterator](): Iterator<readonly unknown[]>;
|
|
84
93
|
};
|
|
85
94
|
getViewport(params: { scale: number }): Viewport;
|
|
95
|
+
/** pdf.js's annotations; the link ones carry `url`. */
|
|
96
|
+
getAnnotations(): Promise<readonly unknown[]>;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
/** A document as this module reads it; pdf.js's `PDFDocumentProxy` is one. */
|
|
89
100
|
interface Doc {
|
|
90
101
|
readonly numPages: number;
|
|
91
102
|
getPage(pageNumber: number): Promise<Page>;
|
|
103
|
+
/** The Info dictionary, and the XMP packet when there is one. */
|
|
104
|
+
getMetadata(): Promise<{
|
|
105
|
+
readonly info: unknown;
|
|
106
|
+
readonly metadata: Iterable<unknown> | null;
|
|
107
|
+
}>;
|
|
108
|
+
/** Every named destination: name → explicit destination. */
|
|
109
|
+
getDestinations(): Promise<unknown>;
|
|
110
|
+
/** The 0-based index of the page a destination's reference names. */
|
|
111
|
+
getPageIndex(ref: object): Promise<number>;
|
|
112
|
+
/** One named destination, or null. */
|
|
113
|
+
getDestination(id: string): Promise<unknown>;
|
|
114
|
+
/** The bookmarks: `{ title, dest, items }`, or null. */
|
|
115
|
+
getOutline(): Promise<unknown>;
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
/** A viewport point, which pdf.js types as `any[]`: the two numbers it holds. */
|
|
@@ -106,6 +130,22 @@ export interface PdfFacts {
|
|
|
106
130
|
readonly fonts: Fonts;
|
|
107
131
|
readonly last: PageText;
|
|
108
132
|
readonly layout: readonly PageLayout[];
|
|
133
|
+
/** Each page's text, in content order, a line break after each line (`pageTextOf`). */
|
|
134
|
+
readonly pageTexts: readonly string[];
|
|
135
|
+
/** The target of every link, with its 1-based page. */
|
|
136
|
+
readonly links: readonly PdfLink[];
|
|
137
|
+
/** The 1-based page of the first bibliography entry hyperref anchored, or null without one. */
|
|
138
|
+
readonly bibAnchorPage: number | null;
|
|
139
|
+
/** The 1-based page the appendix starts on, from hyperref's destinations and bookmarks, or null. */
|
|
140
|
+
readonly appendixAnchorPage: number | null;
|
|
141
|
+
/** The Info dictionary and XMP packet, flat (`metadataOf`). */
|
|
142
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A link annotation's target, and the page it is on. */
|
|
146
|
+
export interface PdfLink {
|
|
147
|
+
readonly page: number;
|
|
148
|
+
readonly uri: string;
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
export type PdfReadFailure =
|
|
@@ -198,10 +238,17 @@ async function readPage(
|
|
|
198
238
|
page: Page,
|
|
199
239
|
seen: ReadonlySet<string>,
|
|
200
240
|
lib: PdfJs,
|
|
201
|
-
): Promise<{
|
|
241
|
+
): Promise<{
|
|
242
|
+
raw: RawPage;
|
|
243
|
+
items: TextItem[];
|
|
244
|
+
layout: PageLayout;
|
|
245
|
+
text: string;
|
|
246
|
+
links: readonly string[];
|
|
247
|
+
}> {
|
|
202
248
|
// The operator list is what makes pdf.js load a page's fonts into `commonObjs`.
|
|
203
249
|
const ops = await page.getOperatorList();
|
|
204
|
-
const
|
|
250
|
+
const all = (await page.getTextContent()).items;
|
|
251
|
+
const items = all.filter(isText);
|
|
205
252
|
const loaded = loadedFonts(page);
|
|
206
253
|
const named = new Set(items.map((it) => it.fontName));
|
|
207
254
|
const fonts = [...loaded.values()].filter(
|
|
@@ -211,6 +258,8 @@ async function readPage(
|
|
|
211
258
|
raw: { hasText: items.length > 0, fonts },
|
|
212
259
|
items,
|
|
213
260
|
layout: layoutOf(page, items, ops, lib),
|
|
261
|
+
text: pageTextOf(all),
|
|
262
|
+
links: linkTargetsOf(await page.getAnnotations()),
|
|
214
263
|
};
|
|
215
264
|
}
|
|
216
265
|
|
|
@@ -310,12 +359,7 @@ function layoutOf(
|
|
|
310
359
|
* transform, no pages) — `readPdf` below is the one production caller.
|
|
311
360
|
*/
|
|
312
361
|
export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
|
|
313
|
-
const read: {
|
|
314
|
-
page: Page;
|
|
315
|
-
raw: RawPage;
|
|
316
|
-
items: TextItem[];
|
|
317
|
-
layout: PageLayout;
|
|
318
|
-
}[] = [];
|
|
362
|
+
const read: (Awaited<ReturnType<typeof readPage>> & { page: Page })[] = [];
|
|
319
363
|
const seen = new Set<string>();
|
|
320
364
|
for (let i = 1; i <= doc.numPages; i++) {
|
|
321
365
|
const page = await doc.getPage(i);
|
|
@@ -340,10 +384,64 @@ export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
|
|
|
340
384
|
fonts,
|
|
341
385
|
last: pageText(last.page, last.items),
|
|
342
386
|
layout: read.map((r) => r.layout),
|
|
387
|
+
...saidOf(read, await doc.getMetadata()),
|
|
388
|
+
...(await anchorsOf(doc)),
|
|
343
389
|
},
|
|
344
390
|
};
|
|
345
391
|
}
|
|
346
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Where hyperref anchored the bibliography's first entry (the lowest page of a `cite.<key>`
|
|
395
|
+
* destination) and the appendix (the lowest page of an `appendix.<letter>` destination or of a
|
|
396
|
+
* bookmark titled «Appendix…»). A reference pdf.js cannot place is skipped.
|
|
397
|
+
*/
|
|
398
|
+
async function anchorsOf(
|
|
399
|
+
doc: Doc,
|
|
400
|
+
): Promise<Pick<PdfFacts, "bibAnchorPage" | "appendixAnchorPage">> {
|
|
401
|
+
const destinations = await doc.getDestinations();
|
|
402
|
+
const bookmarked = await Promise.all(
|
|
403
|
+
appendixOutlineDests(await doc.getOutline()).map(async (d) =>
|
|
404
|
+
refOf(typeof d === "string" ? await doc.getDestination(d) : d),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
return {
|
|
408
|
+
bibAnchorPage: await lowestPage(doc, namedRefs(destinations, "cite.")),
|
|
409
|
+
appendixAnchorPage: await lowestPage(doc, [
|
|
410
|
+
...namedRefs(destinations, "appendix."),
|
|
411
|
+
...bookmarked.filter((r): r is object => r !== null),
|
|
412
|
+
]),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The lowest 1-based page any of `refs` names, or null when none can be placed. */
|
|
417
|
+
async function lowestPage(
|
|
418
|
+
doc: Doc,
|
|
419
|
+
refs: readonly object[],
|
|
420
|
+
): Promise<number | null> {
|
|
421
|
+
const pages = await Promise.all(
|
|
422
|
+
refs.map((r) =>
|
|
423
|
+
doc.getPageIndex(r).then(
|
|
424
|
+
(i) => [i + 1],
|
|
425
|
+
() => [],
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
const found = pages.flat();
|
|
430
|
+
return found.length === 0 ? null : Math.min(...found);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** What the pages say, from each page's read, and the document's metadata. */
|
|
434
|
+
function saidOf(
|
|
435
|
+
read: readonly Pick<Awaited<ReturnType<typeof readPage>>, "text" | "links">[],
|
|
436
|
+
meta: Awaited<ReturnType<Doc["getMetadata"]>>,
|
|
437
|
+
): Pick<PdfFacts, "pageTexts" | "links" | "metadata"> {
|
|
438
|
+
return {
|
|
439
|
+
pageTexts: read.map((r) => r.text),
|
|
440
|
+
links: read.flatMap((r, i) => r.links.map((uri) => ({ page: i + 1, uri }))),
|
|
441
|
+
metadata: metadataOf(meta.info, xmpOf(meta.metadata)),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
347
445
|
/** pdf.js's own exception names for the two conditions a caller can act on. */
|
|
348
446
|
export function failureOf(e: unknown): PdfRead {
|
|
349
447
|
const name = fieldOf(e, "name");
|
package/src/ports/latex.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { Include } from "../domain/paper-source.ts";
|
|
|
6
6
|
import type {
|
|
7
7
|
ClassLine,
|
|
8
8
|
DocumentClass,
|
|
9
|
+
Emphasis,
|
|
10
|
+
LayoutOverride,
|
|
9
11
|
Outline,
|
|
10
12
|
Passage,
|
|
11
13
|
Span,
|
|
@@ -25,6 +27,11 @@ export interface LatexReader {
|
|
|
25
27
|
* code or the title block. Citations, links and cross-references stay in place as marks.
|
|
26
28
|
*/
|
|
27
29
|
readonly bodyProse: (src: string) => readonly Passage[];
|
|
30
|
+
/**
|
|
31
|
+
* The phrases set in bold, italics or underline inside that same prose, in document order — never
|
|
32
|
+
* a heading, a run-in heading (`\textbf{Threats.} …`), a caption, a float or a table.
|
|
33
|
+
*/
|
|
34
|
+
readonly bodyEmphasis: (src: string) => readonly Emphasis[];
|
|
28
35
|
/** Every `\input`, `\include` and `\subfile`, in source order: the path as written, where it stands. */
|
|
29
36
|
readonly includes: (src: string) => readonly Include[];
|
|
30
37
|
/** The span of the `document` environment's body, or null when the source has none. */
|
|
@@ -39,4 +46,6 @@ export interface LatexReader {
|
|
|
39
46
|
* option `want` names (the author's own options stay), or it has none.
|
|
40
47
|
*/
|
|
41
48
|
readonly withDocumentClass: (src: string, want: DocumentClass) => string;
|
|
49
|
+
/** Every command that changes the page layout the class sets: margins, text block, line spacing. */
|
|
50
|
+
readonly layoutOverrides: (src: string) => readonly LayoutOverride[];
|
|
42
51
|
}
|
package/src/presets.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* tex union — a child never removes a package its parent needs
|
|
20
20
|
* format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
|
|
21
21
|
* rules per rule id, the child wins
|
|
22
|
-
* template the child wins; so does `name`, and `required_sections` (
|
|
22
|
+
* template the child wins; so does `name`, and `required_sections` and `register` (each whole)
|
|
23
23
|
* aliases union, with every `name` — what the venue is called along the chain
|
|
24
24
|
*
|
|
25
25
|
* ── THE LABEL ─────────────────────────────────────────────────────────────────────
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "./paper-settings.ts";
|
|
52
52
|
import { CONFIG_FILE } from "#lib/paper-config";
|
|
53
53
|
import { messageOf } from "./domain/text.ts";
|
|
54
|
+
import type { RegisterAnchor } from "./domain/register.ts";
|
|
54
55
|
|
|
55
56
|
/** The prefix of a shipped preset's spec. */
|
|
56
57
|
export const SHIPPED_PREFIX = "paperlint:";
|
|
@@ -83,10 +84,14 @@ export interface Preset {
|
|
|
83
84
|
readonly template: PresetTemplate | null;
|
|
84
85
|
/** Every `name` and `aliases` along the chain: what this venue is called in a paper's text. */
|
|
85
86
|
readonly aliases: readonly string[];
|
|
87
|
+
/** Whether the venue reviews double-blind (`anonymity/identity`); false when no file of the chain says. */
|
|
88
|
+
readonly blind: boolean;
|
|
86
89
|
readonly requiredSections: readonly RequiredSection[];
|
|
87
90
|
readonly tex: TexRequirements;
|
|
88
91
|
readonly format: VenueFormat;
|
|
89
92
|
readonly rules: Readonly<Record<string, unknown>>;
|
|
93
|
+
/** The accepted papers the register rules measure a body against; empty when no file names any. */
|
|
94
|
+
readonly registerAnchors: readonly RegisterAnchor[];
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
/** Why a spec does not resolve. */
|
|
@@ -196,6 +201,7 @@ export function mergeFormat(
|
|
|
196
201
|
refPtMax: pick("refPtMax"),
|
|
197
202
|
fontsText: pick("fontsText"),
|
|
198
203
|
fontsTitle: pick("fontsTitle"),
|
|
204
|
+
bodyEndsAt: pick("bodyEndsAt"),
|
|
199
205
|
kinds: new Map([...parent.kinds, ...child.kinds]),
|
|
200
206
|
};
|
|
201
207
|
}
|
|
@@ -205,22 +211,18 @@ function merged(
|
|
|
205
211
|
spec: string,
|
|
206
212
|
rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
|
|
207
213
|
): Preset {
|
|
208
|
-
const base: {
|
|
209
|
-
name: string | null;
|
|
210
|
-
template: PresetTemplate | null;
|
|
211
|
-
aliases: readonly string[];
|
|
212
|
-
requiredSections: readonly RequiredSection[];
|
|
213
|
-
tex: TexRequirements;
|
|
214
|
-
format: VenueFormat;
|
|
215
|
-
rules: Readonly<Record<string, unknown>>;
|
|
214
|
+
const base: Omit<Preset, "label" | "chain"> & {
|
|
215
|
+
readonly name: string | null;
|
|
216
216
|
} = {
|
|
217
217
|
name: null,
|
|
218
218
|
template: null,
|
|
219
219
|
aliases: [],
|
|
220
|
+
blind: false,
|
|
220
221
|
requiredSections: [],
|
|
221
222
|
tex: NO_REQUIREMENTS,
|
|
222
223
|
format: NO_FORMAT,
|
|
223
224
|
rules: {},
|
|
225
|
+
registerAnchors: [],
|
|
224
226
|
};
|
|
225
227
|
const m = rootFirst.reduce(
|
|
226
228
|
(acc, { preset: p, file }) => ({
|
|
@@ -233,10 +235,12 @@ function merged(
|
|
|
233
235
|
...p.aliases,
|
|
234
236
|
]),
|
|
235
237
|
],
|
|
238
|
+
blind: p.blind ?? acc.blind,
|
|
236
239
|
requiredSections: p.requiredSections ?? acc.requiredSections,
|
|
237
240
|
tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
|
|
238
241
|
format: mergeFormat(acc.format, p.format),
|
|
239
242
|
rules: { ...acc.rules, ...p.rules },
|
|
243
|
+
registerAnchors: p.registerAnchors ?? acc.registerAnchors,
|
|
240
244
|
}),
|
|
241
245
|
base,
|
|
242
246
|
);
|
|
@@ -245,10 +249,12 @@ function merged(
|
|
|
245
249
|
chain: rootFirst.map((x) => x.file),
|
|
246
250
|
template: m.template,
|
|
247
251
|
aliases: m.aliases,
|
|
252
|
+
blind: m.blind,
|
|
248
253
|
requiredSections: m.requiredSections,
|
|
249
254
|
tex: m.tex,
|
|
250
255
|
format: m.format,
|
|
251
256
|
rules: m.rules,
|
|
257
|
+
registerAnchors: m.registerAnchors,
|
|
252
258
|
};
|
|
253
259
|
}
|
|
254
260
|
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The register band rules on REAL papers — the accepted ones in `fixtures/accepted-papers/`, and
|
|
3
|
+
* variants derived from their sources, one change each:
|
|
4
|
+
*
|
|
5
|
+
* - every anchor the AIDC preset records is re-measured from its fixture and must equal the preset's
|
|
6
|
+
* numbers, so a counter cannot change without the anchors (and so the band) changing with it;
|
|
7
|
+
* - every accepted ACSAC paper stands inside AIDC's band on every measure — a rule that fires on a
|
|
8
|
+
* paper the venue accepted is wrong about the venue;
|
|
9
|
+
* - a variant that adds contrast frames, sets numbers in bold, or strips the relation markers from
|
|
10
|
+
* one of those papers leaves the band on exactly that measure.
|
|
11
|
+
*
|
|
12
|
+
* `judgeBand` itself, on constructed measures: both ends, the floor of words, and what it reports.
|
|
13
|
+
*/
|
|
14
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { latexReader } from "./adapters/latex/index.ts";
|
|
19
|
+
import { nodeFiles } from "./adapters/node/index.ts";
|
|
20
|
+
import {
|
|
21
|
+
bandOf,
|
|
22
|
+
rateOf,
|
|
23
|
+
REGISTER_MEASURES,
|
|
24
|
+
type Band,
|
|
25
|
+
type RegisterAnchor,
|
|
26
|
+
type RegisterMeasureName,
|
|
27
|
+
} from "./domain/register.ts";
|
|
28
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
29
|
+
import { resolvePreset } from "./presets.ts";
|
|
30
|
+
import { MIN_WORDS } from "./register.ts";
|
|
31
|
+
import {
|
|
32
|
+
anchorOf,
|
|
33
|
+
judgeBand,
|
|
34
|
+
measureBody,
|
|
35
|
+
registerBandRules,
|
|
36
|
+
standingOf,
|
|
37
|
+
} from "./register-bands.ts";
|
|
38
|
+
import { readPaper } from "./tex-paper.ts";
|
|
39
|
+
import { useTempDir, writeTree } from "../test/support.ts";
|
|
40
|
+
|
|
41
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
42
|
+
const CORPUS = join(ROOT, "fixtures", "accepted-papers");
|
|
43
|
+
|
|
44
|
+
/** The AIDC preset, resolved as a paper extending it would resolve it. */
|
|
45
|
+
const aidc = (() => {
|
|
46
|
+
const r = resolvePreset(
|
|
47
|
+
"paperlint:aidc",
|
|
48
|
+
join(ROOT, "papers", "p", "paperlint.json"),
|
|
49
|
+
{ files: nodeFiles, venuesDir: presetsDir() },
|
|
50
|
+
);
|
|
51
|
+
if (!r.ok) throw new Error(JSON.stringify(r.error));
|
|
52
|
+
return r.value;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
/** A fixture paper's text, its includes spliced, as the rules read it. */
|
|
56
|
+
const sourceOf = (paper: string): string => {
|
|
57
|
+
const file = join(CORPUS, paper, "paper.tex");
|
|
58
|
+
return readPaper(file, readFileSync(file, "utf8"), {
|
|
59
|
+
files: nodeFiles,
|
|
60
|
+
latex: latexReader,
|
|
61
|
+
}).text;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const measured = new Map<string, RegisterAnchor>();
|
|
65
|
+
/** A fixture paper measured the way an anchor is (cached: each parse costs a second). */
|
|
66
|
+
const anchorFor = (paper: string, src = sourceOf(paper)): RegisterAnchor => {
|
|
67
|
+
const key = `${paper}\n${src}`;
|
|
68
|
+
const hit = measured.get(key);
|
|
69
|
+
if (hit) return hit;
|
|
70
|
+
const a = anchorOf(paper, src, latexReader);
|
|
71
|
+
measured.set(key, a);
|
|
72
|
+
return a;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Where each measure of a paper stands against AIDC's band. */
|
|
76
|
+
const standings = (
|
|
77
|
+
a: RegisterAnchor,
|
|
78
|
+
): Readonly<Record<RegisterMeasureName, string>> => {
|
|
79
|
+
const at = (m: RegisterMeasureName): string => {
|
|
80
|
+
const band = bandOf(aidc.registerAnchors, m);
|
|
81
|
+
return band === null
|
|
82
|
+
? "no band"
|
|
83
|
+
: standingOf(rateOf(a.counts[m], a.words), band);
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
contrast_frames: at("contrast_frames"),
|
|
87
|
+
claim_emphasis: at("claim_emphasis"),
|
|
88
|
+
relation_markers: at("relation_markers"),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const WITHIN = {
|
|
93
|
+
contrast_frames: "within",
|
|
94
|
+
claim_emphasis: "within",
|
|
95
|
+
relation_markers: "within",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const ACSAC = readdirSync(CORPUS).filter((d) => /acsac\d\d$/u.test(d));
|
|
99
|
+
|
|
100
|
+
/** Parsing a real paper takes a second or more, several under coverage with every suite running. */
|
|
101
|
+
const PAPER_TIMEOUT_MS = 60_000;
|
|
102
|
+
|
|
103
|
+
describe("AIDC's anchors are the accepted ACSAC papers, as the counters measure them today", () => {
|
|
104
|
+
it("🔴 the preset records every ACSAC paper of the corpus, and nothing else", () => {
|
|
105
|
+
expect(aidc.registerAnchors.map((a) => a.paper).sort()).toEqual(
|
|
106
|
+
[...ACSAC].sort(),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it.each(aidc.registerAnchors.map((a) => [a.paper, a] as const))(
|
|
111
|
+
"🔴 %s: words and every count equal the fixture's, re-measured",
|
|
112
|
+
{ timeout: PAPER_TIMEOUT_MS },
|
|
113
|
+
(paper, recorded) => {
|
|
114
|
+
expect(anchorFor(paper)).toEqual(recorded);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("every accepted ACSAC paper stands inside AIDC's band on every measure", () => {
|
|
120
|
+
it.each(ACSAC)("%s", { timeout: PAPER_TIMEOUT_MS }, (paper) => {
|
|
121
|
+
expect(standings(anchorFor(paper))).toEqual(WITHIN);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** `src` with the first `n` matches of `re` after `\begin{document}` rewritten by `to`. */
|
|
126
|
+
function inBody(
|
|
127
|
+
src: string,
|
|
128
|
+
re: RegExp,
|
|
129
|
+
to: (match: string) => string,
|
|
130
|
+
n: number,
|
|
131
|
+
): string {
|
|
132
|
+
const start = src.indexOf("\\begin{document}");
|
|
133
|
+
const matches = new Set(
|
|
134
|
+
[...src.slice(start).matchAll(re)].slice(0, n).map((m) => m.index + start),
|
|
135
|
+
);
|
|
136
|
+
return src.replace(re, (m: string, ...rest: readonly unknown[]) => {
|
|
137
|
+
const at = rest.find((x) => typeof x === "number");
|
|
138
|
+
return typeof at === "number" && matches.has(at) ? to(m) : m;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The markers `tex/relation-markers` counts, as a writer who leaves the relation to the reader drops them. */
|
|
143
|
+
const ADVERBIAL =
|
|
144
|
+
/\b(?:however|therefore|thus|hence|consequently|as a result|in contrast|specifically|for example|for instance|in particular),\s*/giu;
|
|
145
|
+
|
|
146
|
+
describe("variants of real accepted papers, one change each, leave the band on that measure only", () => {
|
|
147
|
+
it(
|
|
148
|
+
"🔴 secure-acsac24 with a contrast frame after 20 mentions of the model: contrast frames above",
|
|
149
|
+
{ timeout: PAPER_TIMEOUT_MS },
|
|
150
|
+
() => {
|
|
151
|
+
const src = inBody(
|
|
152
|
+
sourceOf("secure-acsac24"),
|
|
153
|
+
/\bthe model\b/gu,
|
|
154
|
+
() => "the model, not the prompt",
|
|
155
|
+
20,
|
|
156
|
+
);
|
|
157
|
+
expect(standings(anchorFor("secure-acsac24", src))).toEqual({
|
|
158
|
+
...WITHIN,
|
|
159
|
+
contrast_frames: "above",
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
it(
|
|
165
|
+
"🔴 llm-splained-acsac25 with its first 20 percentages set in bold: claims in bold above",
|
|
166
|
+
{ timeout: PAPER_TIMEOUT_MS },
|
|
167
|
+
() => {
|
|
168
|
+
const src = inBody(
|
|
169
|
+
sourceOf("llm-splained-acsac25"),
|
|
170
|
+
/\b\d+(?:\.\d+)?\\%/gu,
|
|
171
|
+
(m) => `\\textbf{${m}}`,
|
|
172
|
+
20,
|
|
173
|
+
);
|
|
174
|
+
expect(standings(anchorFor("llm-splained-acsac25", src))).toEqual({
|
|
175
|
+
...WITHIN,
|
|
176
|
+
claim_emphasis: "above",
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
it(
|
|
182
|
+
"🔴 rr-dataset-quality-acsac24 with its relations left to the reader: relation markers below",
|
|
183
|
+
{ timeout: PAPER_TIMEOUT_MS },
|
|
184
|
+
() => {
|
|
185
|
+
// One change: the sentence adverbials that name a relation are dropped, and «because» becomes
|
|
186
|
+
// «as» — the same claims, the relation between them no longer named.
|
|
187
|
+
const dropped = inBody(
|
|
188
|
+
sourceOf("rr-dataset-quality-acsac24"),
|
|
189
|
+
ADVERBIAL,
|
|
190
|
+
() => "",
|
|
191
|
+
Number.POSITIVE_INFINITY,
|
|
192
|
+
);
|
|
193
|
+
const src = inBody(
|
|
194
|
+
dropped,
|
|
195
|
+
/\bbecause\b/gu,
|
|
196
|
+
() => "as",
|
|
197
|
+
Number.POSITIVE_INFINITY,
|
|
198
|
+
);
|
|
199
|
+
expect(standings(anchorFor("rr-dataset-quality-acsac24", src))).toEqual({
|
|
200
|
+
...WITHIN,
|
|
201
|
+
relation_markers: "below",
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("judgeBand — a body's rate against a band", () => {
|
|
208
|
+
const band: Band = {
|
|
209
|
+
min: 20,
|
|
210
|
+
max: 10_000 * (5 / MIN_WORDS),
|
|
211
|
+
anchorMin: 30,
|
|
212
|
+
anchorMax: 20,
|
|
213
|
+
anchors: 5,
|
|
214
|
+
};
|
|
215
|
+
const occurrence = (i: number) => ({
|
|
216
|
+
match: `m${String(i)}`,
|
|
217
|
+
sentence: `s${String(i)}`,
|
|
218
|
+
at: { start: i, end: i + 1 },
|
|
219
|
+
});
|
|
220
|
+
const body = (n: number, words = MIN_WORDS) => ({
|
|
221
|
+
words,
|
|
222
|
+
occurrences: Array.from({ length: n }, (_, i) => occurrence(i)),
|
|
223
|
+
at: { start: 0, end: 1 },
|
|
224
|
+
});
|
|
225
|
+
const ids = (n: number, each = true, words = MIN_WORDS) =>
|
|
226
|
+
judgeBand(body(n, words), band, "aidc", each).map((f) => f.messageId);
|
|
227
|
+
|
|
228
|
+
it("inside the band, including its edges, nothing", () => {
|
|
229
|
+
expect(ids(5)).toEqual([]);
|
|
230
|
+
expect(ids(4)).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("🔴 above it: one finding for the body, then one at every occurrence", () => {
|
|
234
|
+
expect(ids(6)).toEqual([
|
|
235
|
+
"above",
|
|
236
|
+
...Array.from({ length: 6 }, () => "each"),
|
|
237
|
+
]);
|
|
238
|
+
expect(ids(6, false)).toEqual(["above"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("🔴 below it: one finding for the body, never one per occurrence", () => {
|
|
242
|
+
expect(ids(3)).toEqual(["below"]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("under the floor of words, or without a band, nothing", () => {
|
|
246
|
+
expect(ids(1, true, MIN_WORDS - 1)).toEqual([]);
|
|
247
|
+
expect(judgeBand(body(99), null, "aidc", true)).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("the finding names the count, the rate, the band, the anchors' range and the first examples", () => {
|
|
251
|
+
const [f] = judgeBand(body(6), band, "aidc", true);
|
|
252
|
+
expect(f?.data).toMatchObject({
|
|
253
|
+
count: 6,
|
|
254
|
+
rate: "30.0",
|
|
255
|
+
min: "20.0",
|
|
256
|
+
max: "25.0",
|
|
257
|
+
anchors: 5,
|
|
258
|
+
venue: "aidc",
|
|
259
|
+
examples: "«s0», «s1», «s2»",
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("the measures a preset may set a band for", () => {
|
|
265
|
+
it("each of them is recorded for every anchor", () => {
|
|
266
|
+
aidc.registerAnchors.forEach((a) => {
|
|
267
|
+
expect(Object.keys(a.counts).sort()).toEqual(
|
|
268
|
+
[...REGISTER_MEASURES].sort(),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("the rules, as ESLint calls them", () => {
|
|
275
|
+
/** A paper directory extending `extends`, and what one rule reports on its paper.tex. */
|
|
276
|
+
const reported = (
|
|
277
|
+
rule: "contrast-frames" | "claim-emphasis" | "relation-markers",
|
|
278
|
+
src: string,
|
|
279
|
+
file = "paper.tex",
|
|
280
|
+
): string[] => {
|
|
281
|
+
const dir = writeTree(useTempDir("paperlint-register-bands-"), {
|
|
282
|
+
"paperlint.json": JSON.stringify({ extends: "paperlint:aidc" }),
|
|
283
|
+
});
|
|
284
|
+
const seen: string[] = [];
|
|
285
|
+
const deps = {
|
|
286
|
+
files: nodeFiles,
|
|
287
|
+
venuesDir: presetsDir(),
|
|
288
|
+
latex: latexReader,
|
|
289
|
+
};
|
|
290
|
+
registerBandRules(deps)
|
|
291
|
+
[rule].create({
|
|
292
|
+
filename: join(dir, file),
|
|
293
|
+
// No `raw`: the rule falls back to the text ESLint gives every language.
|
|
294
|
+
sourceCode: {
|
|
295
|
+
text: src,
|
|
296
|
+
getLocFromIndex: () => ({ line: 1, column: 0 }),
|
|
297
|
+
},
|
|
298
|
+
report: (d) => {
|
|
299
|
+
seen.push("messageId" in d ? d.messageId : d.message);
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
.root?.();
|
|
303
|
+
return seen;
|
|
304
|
+
};
|
|
305
|
+
const LONG = `\\documentclass{article}\n\\begin{document}\n${Array.from(
|
|
306
|
+
{ length: 200 },
|
|
307
|
+
() =>
|
|
308
|
+
"The rule reads the body of the paper and counts every sentence it holds.",
|
|
309
|
+
).join(" ")}\n\\end{document}\n`;
|
|
310
|
+
|
|
311
|
+
it("🔴 reads the paper's text when the language gives no raw source", () => {
|
|
312
|
+
expect(reported("relation-markers", LONG)).toEqual(["below"]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("judges a paper once, on its paper.tex, and nothing else", () => {
|
|
316
|
+
expect(reported("relation-markers", LONG, "intro.tex")).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("an empty body is measured as no words, reported nowhere", () => {
|
|
320
|
+
const m = measureBody(
|
|
321
|
+
"contrast-frames",
|
|
322
|
+
"\\documentclass{article}\n\\begin{document}\n\\end{document}\n",
|
|
323
|
+
latexReader,
|
|
324
|
+
);
|
|
325
|
+
expect(m).toEqual({ words: 0, occurrences: [], at: null });
|
|
326
|
+
});
|
|
327
|
+
});
|