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/cli.ts
CHANGED
|
@@ -49,12 +49,29 @@ import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
|
|
|
49
49
|
import { curlDownload } from "./adapters/curl/index.ts";
|
|
50
50
|
import { latexReader } from "./adapters/latex/index.ts";
|
|
51
51
|
import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
|
|
52
|
-
import {
|
|
53
|
-
|
|
52
|
+
import {
|
|
53
|
+
ANONYMITY_RULE_LEVELS,
|
|
54
|
+
anonymityRules,
|
|
55
|
+
PAGE_LIMIT_RULE_LEVELS,
|
|
56
|
+
pageLimitRules,
|
|
57
|
+
VENUE_RULE_LEVELS,
|
|
58
|
+
venueRules,
|
|
59
|
+
} from "./venue-rules.ts";
|
|
60
|
+
import {
|
|
61
|
+
FORMAT_RULE_LEVELS,
|
|
62
|
+
formatRules,
|
|
63
|
+
TEX_VENUE_RULE_LEVELS,
|
|
64
|
+
texVenueRules,
|
|
65
|
+
} from "./tex-venue-rules.ts";
|
|
54
66
|
import { claimProvenanceRule } from "./claim-provenance.ts";
|
|
55
67
|
import { registerRule } from "./register.ts";
|
|
68
|
+
import {
|
|
69
|
+
REGISTER_BAND_RULE_LEVELS,
|
|
70
|
+
registerBandRules,
|
|
71
|
+
} from "./register-bands.ts";
|
|
56
72
|
import {
|
|
57
73
|
paperRules,
|
|
74
|
+
identityField,
|
|
58
75
|
stringFields,
|
|
59
76
|
type PaperSettings,
|
|
60
77
|
} from "./paper-settings.ts";
|
|
@@ -218,10 +235,18 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
|
218
235
|
blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
|
|
219
236
|
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
220
237
|
pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
|
|
221
|
-
pdf/
|
|
222
|
-
"off" to skip it. An unknown key, in either
|
|
238
|
+
pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
|
|
239
|
+
on for every paper with a venue preset; set one to "off" to skip it. An unknown key, in either
|
|
240
|
+
file, is an error.
|
|
223
241
|
`;
|
|
224
242
|
|
|
243
|
+
/** What the rules over a paper.tex read with: the disk, the shipped presets, the LaTeX reader. */
|
|
244
|
+
const TEX_RULE_DEPS = {
|
|
245
|
+
files: nodeFiles,
|
|
246
|
+
venuesDir: presetsDir(),
|
|
247
|
+
latex: latexReader,
|
|
248
|
+
};
|
|
249
|
+
|
|
225
250
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
226
251
|
export function buildConfig(
|
|
227
252
|
opts: PaperlintConfig = {},
|
|
@@ -262,6 +287,9 @@ export function buildConfig(
|
|
|
262
287
|
...venueRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
263
288
|
},
|
|
264
289
|
},
|
|
290
|
+
anonymity: {
|
|
291
|
+
rules: anonymityRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
292
|
+
},
|
|
265
293
|
},
|
|
266
294
|
},
|
|
267
295
|
{
|
|
@@ -308,23 +336,20 @@ export function buildConfig(
|
|
|
308
336
|
languages: { latex: texLanguage },
|
|
309
337
|
rules: {
|
|
310
338
|
...texBuild,
|
|
311
|
-
...texVenueRules(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}),
|
|
316
|
-
"claim-provenance": claimProvenanceRule({
|
|
317
|
-
files: nodeFiles,
|
|
318
|
-
latex: latexReader,
|
|
319
|
-
}),
|
|
320
|
-
register: registerRule({
|
|
321
|
-
files: nodeFiles,
|
|
322
|
-
latex: latexReader,
|
|
323
|
-
}),
|
|
339
|
+
...texVenueRules(TEX_RULE_DEPS),
|
|
340
|
+
"claim-provenance": claimProvenanceRule(TEX_RULE_DEPS),
|
|
341
|
+
register: registerRule(TEX_RULE_DEPS),
|
|
342
|
+
...registerBandRules(TEX_RULE_DEPS),
|
|
324
343
|
},
|
|
325
344
|
},
|
|
326
345
|
paper: { rules: texPaperRules },
|
|
327
346
|
bib: bibReachable,
|
|
347
|
+
format: {
|
|
348
|
+
rules: {
|
|
349
|
+
...formatRules(TEX_RULE_DEPS),
|
|
350
|
+
...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
351
|
+
},
|
|
352
|
+
},
|
|
328
353
|
},
|
|
329
354
|
language: "tex/latex",
|
|
330
355
|
rules: {
|
|
@@ -337,10 +362,16 @@ export function buildConfig(
|
|
|
337
362
|
"tex/claim-provenance": "warn",
|
|
338
363
|
// Sentence-initial conjunctions over the whole body: register, not vocabulary.
|
|
339
364
|
"tex/register": "warn",
|
|
365
|
+
// Contrast frames, claims in bold, relation markers: each against the band the venue
|
|
366
|
+
// preset's anchors set, silent for a preset without anchors (src/register-bands.ts).
|
|
367
|
+
...REGISTER_BAND_RULE_LEVELS,
|
|
340
368
|
"tex/acm-frontmatter-override": "error",
|
|
341
369
|
// Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
|
|
342
370
|
...VENUE_RULE_LEVELS,
|
|
371
|
+
...ANONYMITY_RULE_LEVELS,
|
|
343
372
|
...TEX_VENUE_RULE_LEVELS,
|
|
373
|
+
...FORMAT_RULE_LEVELS,
|
|
374
|
+
...PAGE_LIMIT_RULE_LEVELS,
|
|
344
375
|
},
|
|
345
376
|
});
|
|
346
377
|
// THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
|
|
@@ -667,6 +698,8 @@ export function parseSettings(
|
|
|
667
698
|
};
|
|
668
699
|
const defaults = stringFields(raw);
|
|
669
700
|
if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
|
|
701
|
+
const identity = identityField(raw);
|
|
702
|
+
if (!identity.ok) return { ok: false, error: `${where}: ${identity.error}` };
|
|
670
703
|
const structure = Structure.safeParse(raw["structure"]);
|
|
671
704
|
if (!structure.success)
|
|
672
705
|
return {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
findLeaks,
|
|
4
|
+
spellingsOf,
|
|
5
|
+
tokenPattern,
|
|
6
|
+
type Searchable,
|
|
7
|
+
} from "./anonymity.ts";
|
|
8
|
+
|
|
9
|
+
const page = (text: string, n = 1): Searchable => ({
|
|
10
|
+
place: { kind: "page", page: n },
|
|
11
|
+
text,
|
|
12
|
+
});
|
|
13
|
+
const found = (token: string, text: string): string[] =>
|
|
14
|
+
findLeaks([token], [page(text)]).map((l) => l.found);
|
|
15
|
+
|
|
16
|
+
describe("findLeaks — the spellings of one name a PDF produces", () => {
|
|
17
|
+
it("plain, and in any case (the flags fold Unicode, no locale)", () => {
|
|
18
|
+
expect(found("Ada Example", "by Ada Example, 2026")).toEqual([
|
|
19
|
+
"Ada Example",
|
|
20
|
+
]);
|
|
21
|
+
expect(found("Ada Example", "ADA EXAMPLE")).toEqual(["ADA EXAMPLE"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("Cyrillic folds too", () => {
|
|
25
|
+
expect(found("Ада Пример", "АДА ПРИМЕР пишет")).toEqual(["АДА ПРИМЕР"]);
|
|
26
|
+
expect(found("АДА", "ада")).toEqual(["ада"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("items joined without a space where kerning split a word", () => {
|
|
30
|
+
expect(found("Ada Example", "AdaExample")).toEqual(["AdaExample"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("letter-spaced text", () => {
|
|
34
|
+
expect(found("Ada Example", "A d a E x a m p l e")).toEqual([
|
|
35
|
+
"A d a E x a m p l e",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("a word hyphenated across a line break, and a name broken across lines", () => {
|
|
40
|
+
expect(found("Ada Example", "Ada Exam-\nple wrote")).toEqual([
|
|
41
|
+
"Ada Exam-\nple",
|
|
42
|
+
]);
|
|
43
|
+
expect(found("Ada Example", "by Ada\nExample")).toEqual(["Ada\nExample"]);
|
|
44
|
+
expect(found("adaexample", "adaexample")).toEqual(["adaexample"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ligatures (NFKC on both sides)", () => {
|
|
48
|
+
expect(found("Fiona", "fiona")).toEqual(["fiona"]);
|
|
49
|
+
expect(found("fiona", "Fiona")).toEqual(["Fiona"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("handles and addresses, as whole tokens", () => {
|
|
53
|
+
expect(found("adaexample", "https://github.com/adaexample/tool")).toEqual([
|
|
54
|
+
"adaexample",
|
|
55
|
+
]);
|
|
56
|
+
expect(found("ada@example.org", "mail ada@example.org.")).toEqual([
|
|
57
|
+
"ada@example.org",
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not match inside another word", () => {
|
|
62
|
+
expect(found("Ada", "Canada and adamant")).toEqual([]);
|
|
63
|
+
expect(found("MIT", "submitted")).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("says nothing about a clean text", () => {
|
|
67
|
+
expect(found("Ada Example", "Anonymous Authors")).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("findLeaks — places and tokens", () => {
|
|
72
|
+
it("reports each token once per place, with the place", () => {
|
|
73
|
+
const leaks = findLeaks(
|
|
74
|
+
["Ada Example", "ada@example.org"],
|
|
75
|
+
[
|
|
76
|
+
page("Ada Example and Ada Example", 2),
|
|
77
|
+
{
|
|
78
|
+
place: { kind: "metadata", field: "Author" },
|
|
79
|
+
text: "ada@example.org",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
place: {
|
|
83
|
+
kind: "link",
|
|
84
|
+
page: 3,
|
|
85
|
+
uri: "https://github.com/adaexample",
|
|
86
|
+
},
|
|
87
|
+
text: "https://github.com/adaexample",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
);
|
|
91
|
+
expect(leaks).toEqual([
|
|
92
|
+
{
|
|
93
|
+
token: "Ada Example",
|
|
94
|
+
place: { kind: "page", page: 2 },
|
|
95
|
+
found: "Ada Example",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
token: "ada@example.org",
|
|
99
|
+
place: { kind: "metadata", field: "Author" },
|
|
100
|
+
found: "ada@example.org",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
token: "Ada Example",
|
|
104
|
+
place: { kind: "link", page: 3, uri: "https://github.com/adaexample" },
|
|
105
|
+
found: "adaexample",
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a token of separators only has no pattern and finds nothing", () => {
|
|
111
|
+
expect(tokenPattern(" - ")).toBeNull();
|
|
112
|
+
expect(findLeaks([" "], [page("anything at all")])).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("findLeaks — one text, two tokens", () => {
|
|
117
|
+
it("is one leak, under the first token declared", () => {
|
|
118
|
+
expect(
|
|
119
|
+
findLeaks(["Ada Example", "adaexample"], [page("by AdaExample")]),
|
|
120
|
+
).toEqual([
|
|
121
|
+
{
|
|
122
|
+
token: "Ada Example",
|
|
123
|
+
place: { kind: "page", page: 1 },
|
|
124
|
+
found: "AdaExample",
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("spellingsOf — a personal name as a reference list prints it", () => {
|
|
131
|
+
it("adds initials and surname-first forms to a name of two to four words", () => {
|
|
132
|
+
expect(spellingsOf("Ada Example")).toEqual([
|
|
133
|
+
"Ada Example",
|
|
134
|
+
"A. Example",
|
|
135
|
+
"Example, Ada",
|
|
136
|
+
"Example, A.",
|
|
137
|
+
]);
|
|
138
|
+
expect(spellingsOf("Ada Byron Example")).toEqual([
|
|
139
|
+
"Ada Byron Example",
|
|
140
|
+
"A. B. Example",
|
|
141
|
+
"Example, Ada Byron",
|
|
142
|
+
"Example, A. B.",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("leaves a handle, an address, one word, or lowercase words alone", () => {
|
|
147
|
+
expect(spellingsOf("adaexample")).toEqual(["adaexample"]);
|
|
148
|
+
expect(spellingsOf("ada@example.org")).toEqual(["ada@example.org"]);
|
|
149
|
+
expect(spellingsOf("Example")).toEqual(["Example"]);
|
|
150
|
+
expect(spellingsOf("example tool")).toEqual(["example tool"]);
|
|
151
|
+
expect(spellingsOf("A B C D E")).toEqual(["A B C D E"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("a self-citation's author list finds the declared name", () => {
|
|
155
|
+
expect(
|
|
156
|
+
found("Ada Example", "[3] A. Example and B. Other, “A tool,” 2025."),
|
|
157
|
+
).toEqual(["A. Example"]);
|
|
158
|
+
expect(found("Ada Example", "Example, A., and Other, B. (2025)")).toEqual([
|
|
159
|
+
"Example, A.",
|
|
160
|
+
]);
|
|
161
|
+
expect(found("Ada Example", "B. Example wrote")).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO WROTE THIS PAPER, FOUND IN THE PAPER — the matching `anonymity/identity` judges a blind submission
|
|
3
|
+
* by. A rule cannot know who the author is, so the author DECLARES it (`identity` in
|
|
4
|
+
* `paperlint.json`); given that list, finding it is mechanical, and this module is the mechanism.
|
|
5
|
+
*
|
|
6
|
+
* ── WHAT A MATCH TOLERATES, AND WHY EACH ─────────────────────────────────────────
|
|
7
|
+
* PDF text is not the source text. pdf.js hands back runs of glyphs, and the same name comes out
|
|
8
|
+
* in several spellings of the same letters:
|
|
9
|
+
*
|
|
10
|
+
* ligatures `fi` for `fi` → both sides NFKC-normalized
|
|
11
|
+
* case `ADA EXAMPLE` in small caps → Unicode case folding (the `iu` flags: no
|
|
12
|
+
* locale is consulted, Cyrillic folds too)
|
|
13
|
+
* kerning splits `Ex` `ample` as two items → the page text joins items without a space
|
|
14
|
+
* letter-spacing `A d a E x a m p l e` → whitespace may stand between any two letters
|
|
15
|
+
* line breaks `Ada` ⏎ `Example`, `Exam-` ⏎ `ple` → so may a hyphen and a line break
|
|
16
|
+
* soft hyphens U+00AD inside a word → treated like a hyphen
|
|
17
|
+
* a reference list `A. Example`, `Example, A.` → a personal name's bibliographic forms
|
|
18
|
+
* are searched too (`spellingsOf`)
|
|
19
|
+
*
|
|
20
|
+
* Only the ENDS of a token are anchored: the letter or digit before its first character and after
|
|
21
|
+
* its last must be absent, so `Ada` does not match inside `Canada`. Between its characters anything
|
|
22
|
+
* of the above may stand. The token's own spaces and hyphens are separators too, so
|
|
23
|
+
* `Ada Example` also finds `Ada-Example` and `AdaExample` — the same person.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Where a declared string was found: a page's text, a metadata field, or a link's target. */
|
|
27
|
+
export type LeakPlace =
|
|
28
|
+
| { readonly kind: "page"; readonly page: number }
|
|
29
|
+
| { readonly kind: "metadata"; readonly field: string }
|
|
30
|
+
| { readonly kind: "link"; readonly page: number; readonly uri: string };
|
|
31
|
+
|
|
32
|
+
/** One text to search, and where it came from. */
|
|
33
|
+
export interface Searchable {
|
|
34
|
+
readonly place: LeakPlace;
|
|
35
|
+
readonly text: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One declared string found in one place, with the text it matched as it stands there. */
|
|
39
|
+
export interface Leak {
|
|
40
|
+
readonly token: string;
|
|
41
|
+
readonly place: LeakPlace;
|
|
42
|
+
readonly found: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What may stand between two characters of a token: whitespace, hyphens, soft hyphens. */
|
|
46
|
+
const SEPARATOR = "[\\s\\u00AD-]*";
|
|
47
|
+
const BEFORE = "(?<![\\p{L}\\p{N}])";
|
|
48
|
+
const AFTER = "(?![\\p{L}\\p{N}])";
|
|
49
|
+
|
|
50
|
+
/** `c` as a pattern that matches it literally. */
|
|
51
|
+
const literal = (c: string): string =>
|
|
52
|
+
c.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
|
|
53
|
+
|
|
54
|
+
/** The characters of a token that must appear: NFKC, without its own separators. */
|
|
55
|
+
const lettersOf = (token: string): readonly string[] =>
|
|
56
|
+
token
|
|
57
|
+
.normalize("NFKC")
|
|
58
|
+
.replace(/[\s\u00AD-]/gu, "")
|
|
59
|
+
.match(/./gsu) ?? [];
|
|
60
|
+
|
|
61
|
+
/** A word of a personal name: letters, with an apostrophe or a hyphen inside. */
|
|
62
|
+
const NAME_WORD = /^\p{Lu}[\p{L}'’-]*$/u;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The spellings a bibliography gives a personal name, besides the name itself. A token of two to
|
|
66
|
+
* four capitalized words (`Ada Example`, `Ada B. Example` is not one: an initial is not a word)
|
|
67
|
+
* is read as given names and a surname, and the reference list's forms are added: initials
|
|
68
|
+
* (`A. Example`), surname first (`Example, Ada`, `Example, A.`). A token that is not a name — a
|
|
69
|
+
* handle, an address, one word — has only itself.
|
|
70
|
+
*/
|
|
71
|
+
export function spellingsOf(token: string): readonly string[] {
|
|
72
|
+
const words = token.trim().split(/\s+/u);
|
|
73
|
+
if (
|
|
74
|
+
words.length < 2 ||
|
|
75
|
+
words.length > 4 ||
|
|
76
|
+
!words.every((w) => NAME_WORD.test(w))
|
|
77
|
+
)
|
|
78
|
+
return [token];
|
|
79
|
+
const surname = words.slice(-1).join("");
|
|
80
|
+
const given = words.slice(0, -1);
|
|
81
|
+
const initials = given.map((w) => `${w.slice(0, 1)}.`).join(" ");
|
|
82
|
+
return [
|
|
83
|
+
token,
|
|
84
|
+
`${initials} ${surname}`,
|
|
85
|
+
`${surname}, ${given.join(" ")}`,
|
|
86
|
+
`${surname}, ${initials}`,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One spelling as a pattern body: its characters, separators allowed between any two. */
|
|
91
|
+
const bodyOf = (spelling: string): string =>
|
|
92
|
+
lettersOf(spelling).map(literal).join(SEPARATOR);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A declared token as the pattern that finds it and its bibliographic spellings (see the module
|
|
96
|
+
* header), or null for a token with nothing but separators in it — which would match everywhere,
|
|
97
|
+
* and is refused where it is read.
|
|
98
|
+
*/
|
|
99
|
+
export function tokenPattern(token: string): Readonly<RegExp> | null {
|
|
100
|
+
if (lettersOf(token).length === 0) return null;
|
|
101
|
+
const bodies = spellingsOf(token).map(bodyOf).join("|");
|
|
102
|
+
return new RegExp(`${BEFORE}(?:${bodies})${AFTER}`, "iu");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every token found in the sources, once per place: its first match there. Two tokens that match
|
|
107
|
+
* the same text in the same place (`Ada Example` and `adaexample` both finding `AdaExample`) are one
|
|
108
|
+
* leak, reported under the first token declared. Pure.
|
|
109
|
+
*/
|
|
110
|
+
export function findLeaks(
|
|
111
|
+
tokens: readonly string[],
|
|
112
|
+
sources: readonly Searchable[],
|
|
113
|
+
): readonly Leak[] {
|
|
114
|
+
const patterns = tokens.flatMap((token) => {
|
|
115
|
+
const re = tokenPattern(token);
|
|
116
|
+
return re === null ? [] : [{ token, re }];
|
|
117
|
+
});
|
|
118
|
+
return sources.flatMap((s) => {
|
|
119
|
+
const text = s.text.normalize("NFKC");
|
|
120
|
+
const leaks = patterns.flatMap(({ token, re }) => {
|
|
121
|
+
const m = re.exec(text);
|
|
122
|
+
return m === null ? [] : [{ token, place: s.place, found: m[0] }];
|
|
123
|
+
});
|
|
124
|
+
const key = (l: Leak): string => l.found.toLowerCase();
|
|
125
|
+
return leaks.filter(
|
|
126
|
+
(l, i) => leaks.findIndex((x) => key(x) === key(l)) === i,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|