paperlint 4.0.0 → 4.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/.github/workflows/ci.yml +16 -23
- package/CLAUDE.md +31 -11
- package/CONTRIBUTING.md +23 -7
- package/README.md +11 -8
- package/dist/adapters/banal/invocation.d.ts +1 -1
- package/dist/adapters/latex/document-class.d.ts +22 -0
- package/dist/adapters/latex/document-class.d.ts.map +1 -0
- package/dist/adapters/latex/document-class.js +102 -0
- package/dist/adapters/latex/document-class.js.map +1 -0
- package/dist/adapters/latex/hidden.d.ts +16 -0
- package/dist/adapters/latex/hidden.d.ts.map +1 -0
- package/dist/adapters/latex/hidden.js +49 -0
- package/dist/adapters/latex/hidden.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +29 -0
- package/dist/adapters/latex/index.d.ts.map +1 -0
- package/dist/adapters/latex/index.js +18 -0
- package/dist/adapters/latex/index.js.map +1 -0
- package/dist/adapters/latex/nodes.d.ts +54 -0
- package/dist/adapters/latex/nodes.d.ts.map +1 -0
- package/dist/adapters/latex/nodes.js +93 -0
- package/dist/adapters/latex/nodes.js.map +1 -0
- package/dist/adapters/latex/outline.d.ts +10 -0
- package/dist/adapters/latex/outline.d.ts.map +1 -0
- package/dist/adapters/latex/outline.js +39 -0
- package/dist/adapters/latex/outline.js.map +1 -0
- package/dist/adapters/latex/parse.d.ts +42 -0
- package/dist/adapters/latex/parse.d.ts.map +1 -0
- package/dist/adapters/latex/parse.js +94 -0
- package/dist/adapters/latex/parse.js.map +1 -0
- package/dist/adapters/latex/rendered.d.ts +10 -0
- package/dist/adapters/latex/rendered.d.ts.map +1 -0
- package/dist/adapters/latex/rendered.js +39 -0
- package/dist/adapters/latex/rendered.js.map +1 -0
- package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
- package/dist/adapters/pdfjs/fill.js +12 -12
- package/dist/adapters/pdfjs/fill.js.map +1 -1
- package/dist/build.js +4 -4
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +29 -5
- package/dist/cli.js.map +1 -1
- package/dist/domain/tex-document.d.ts +84 -0
- package/dist/domain/tex-document.d.ts.map +1 -0
- package/dist/domain/tex-document.js +43 -0
- package/dist/domain/tex-document.js.map +1 -0
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +2 -2
- package/dist/facts-file.js.map +1 -1
- package/dist/new-paper.d.ts +5 -0
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +3 -0
- package/dist/new-paper.js.map +1 -1
- package/dist/package-dirs.d.ts +5 -0
- package/dist/package-dirs.d.ts.map +1 -0
- package/dist/package-dirs.js +14 -0
- package/dist/package-dirs.js.map +1 -0
- package/dist/ports/latex.d.ts +24 -0
- package/dist/ports/latex.d.ts.map +1 -0
- package/dist/ports/latex.js +2 -0
- package/dist/ports/latex.js.map +1 -0
- package/dist/presets.d.ts +15 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +19 -6
- package/dist/presets.js.map +1 -1
- package/dist/tex-requirements.d.ts +11 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +14 -8
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +83 -0
- package/dist/tex-venue-rules.d.ts.map +1 -0
- package/dist/tex-venue-rules.js +254 -0
- package/dist/tex-venue-rules.js.map +1 -0
- package/dist/venue-rules.d.ts +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/docs/configuration.md +3 -2
- package/docs/e2e.md +69 -40
- package/docs/install.md +1 -1
- package/docs/prior-art/README.md +1 -1
- package/docs/prior-art/package-location.md +5 -5
- package/docs/prior-art/repro/README.md +1 -1
- package/docs/prior-art/test-tooling.md +21 -10
- package/docs/rules/tex/required-section.md +73 -0
- package/docs/rules/tex/template.md +62 -0
- package/docs/rules/tex/venue-leftover.md +71 -0
- package/docs/rules.md +70 -48
- package/docs/testing.md +17 -4
- package/docs/texlive-install-decision.md +1 -1
- package/docs/toolchain.md +2 -2
- package/eslint.config.mjs +155 -15
- package/fixtures/build-e2e/acmart/expect.json +26 -0
- package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/aidc/expect.json +14 -0
- package/fixtures/build-e2e/aidc/paper.tex +46 -0
- package/fixtures/build-e2e/aidc/paperlint.json +1 -0
- package/fixtures/build-e2e/broken/expect.json +11 -0
- package/fixtures/build-e2e/cite/expect.json +27 -0
- package/fixtures/build-e2e/empty/expect.json +12 -0
- package/fixtures/build-e2e/fallback/expect.json +53 -0
- package/fixtures/build-e2e/guards/expect.json +6 -0
- package/fixtures/build-e2e/no-source/expect.json +10 -0
- package/fixtures/build-e2e/unbalanced/expect.json +60 -0
- package/fixtures/pdf-facts/README.md +1 -1
- package/fixtures/real-markdown-paper/baseline.ts +1 -1
- package/fixtures/render-paper/build-clean.sh +3 -5
- package/package.json +13 -2
- package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
- package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
- package/presets/aidc.jsonc +73 -0
- package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
- package/presets/ieee-conference.jsonc +66 -0
- package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
- package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
- package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
- package/scripts/check.harness.ts +64 -10
- package/scripts/check.test.ts +60 -13
- package/scripts/check.ts +83 -30
- package/scripts/test.test.ts +1 -1
- package/scripts/test.ts +1 -1
- package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
- package/skills/paper-pipeline/SKILL.md +2 -2
- package/skills/paper-pipeline/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
- package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
- package/skills/render-paper/SKILL.md +2 -2
- package/skills/render-paper/SKILL.md.spec.ts +1 -1
- package/skills/render-paper/check-render.sh +16 -63
- package/skills/render-paper/render-paper.harness.mjs +17 -59
- package/src/adapters/banal/index.test.ts +1 -1
- package/src/adapters/banal/invocation.ts +1 -1
- package/src/adapters/banal/output.test.ts +1 -1
- package/src/adapters/banal/run.test.ts +1 -1
- package/src/adapters/banal/xml.test.ts +1 -1
- package/src/adapters/latex/document-class.ts +147 -0
- package/src/adapters/latex/hidden.ts +70 -0
- package/src/adapters/latex/index.test.ts +361 -0
- package/src/adapters/latex/index.ts +55 -0
- package/src/adapters/latex/nodes.ts +141 -0
- package/src/adapters/latex/outline.ts +64 -0
- package/src/adapters/latex/parse.ts +136 -0
- package/src/adapters/latex/rendered.ts +59 -0
- package/src/adapters/pdfjs/fill.test.ts +1 -1
- package/src/adapters/pdfjs/fill.ts +18 -10
- package/src/build.harness.mjs +5 -7
- package/src/build.ts +4 -4
- package/src/cli.harness.mjs +1 -1
- package/src/cli.test.ts +2 -8
- package/src/cli.ts +33 -8
- package/src/domain/tex-document.test.ts +80 -0
- package/src/domain/tex-document.ts +110 -0
- package/src/engine.harness.mjs +1 -1
- package/src/facts-file.test.ts +4 -7
- package/src/facts-file.ts +2 -2
- package/src/latex-loop.harness.ts +1 -1
- package/src/new-paper.test.ts +28 -3
- package/src/new-paper.ts +8 -0
- package/src/package-dirs.ts +17 -0
- package/src/paper-settings-commands.test.ts +6 -1
- package/src/ports/latex.ts +29 -0
- package/src/presets.test.ts +65 -4
- package/src/presets.ts +38 -15
- package/src/rule-cases.test.ts +474 -0
- package/src/rule-docs.test.ts +159 -0
- package/src/tex-requirements.harness.ts +13 -23
- package/src/tex-requirements.ts +35 -8
- package/src/tex-venue-rules.test.ts +659 -0
- package/src/tex-venue-rules.ts +429 -0
- package/src/toolchain.harness.mjs +1 -1
- package/src/venue-rules.test.ts +96 -4
- package/src/venue-rules.ts +1 -1
- package/templates/paper/paper.tex +2 -1
- /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
- /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** The values a LaTeX source is read into: a run's text and the span of a range inside it. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
collapse,
|
|
5
|
+
documentClassLine,
|
|
6
|
+
missingOptions,
|
|
7
|
+
runText,
|
|
8
|
+
spanIn,
|
|
9
|
+
venueClass,
|
|
10
|
+
type TextRun,
|
|
11
|
+
} from "./tex-document.ts";
|
|
12
|
+
|
|
13
|
+
// "ab" at 10, then " " at 20 (one character standing for a longer whitespace), then "cd" at 30.
|
|
14
|
+
const RUN: TextRun = {
|
|
15
|
+
segments: [
|
|
16
|
+
{ text: "ab", at: 10 },
|
|
17
|
+
{ text: " ", at: 20 },
|
|
18
|
+
{ text: "cd", at: 30 },
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("a text run", () => {
|
|
23
|
+
it("its text is its segments' text, in order", () => {
|
|
24
|
+
expect(runText(RUN)).toBe("ab cd");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("spanIn: from the first character's offset to past the last's, across segments", () => {
|
|
28
|
+
expect(spanIn(RUN, 0, 2)).toEqual({ start: 10, end: 12 });
|
|
29
|
+
expect(spanIn(RUN, 1, 4)).toEqual({ start: 11, end: 31 });
|
|
30
|
+
expect(spanIn(RUN, 3, 5)).toEqual({ start: 30, end: 32 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("🔴 spanIn: an empty range, or one reaching outside the run, has no span — never {0, 0}", () => {
|
|
34
|
+
expect(spanIn(RUN, 2, 2)).toBe(null);
|
|
35
|
+
expect(spanIn(RUN, -1, 2)).toBe(null);
|
|
36
|
+
expect(spanIn(RUN, 3, 9)).toBe(null);
|
|
37
|
+
expect(spanIn(RUN, 9, 10)).toBe(null);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("a document class", () => {
|
|
42
|
+
it("documentClassLine writes it back; with no options, no brackets", () => {
|
|
43
|
+
expect(
|
|
44
|
+
documentClassLine({
|
|
45
|
+
cls: "IEEEtran",
|
|
46
|
+
options: ["conference", "compsoc"],
|
|
47
|
+
}),
|
|
48
|
+
).toBe("\\documentclass[conference,compsoc]{IEEEtran}");
|
|
49
|
+
expect(documentClassLine({ cls: "article", options: [] })).toBe(
|
|
50
|
+
"\\documentclass{article}",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("missingOptions: every option of the wanted class the paper lacks, in the wanted order", () => {
|
|
55
|
+
expect(
|
|
56
|
+
missingOptions(
|
|
57
|
+
{ cls: "x", options: ["b"] },
|
|
58
|
+
{ cls: "x", options: ["a", "b", "c"] },
|
|
59
|
+
),
|
|
60
|
+
).toEqual(["a", "c"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("venueClass: another class is replaced; the same class gains the missing options, or is kept", () => {
|
|
64
|
+
const want = { cls: "IEEEtran", options: ["conference", "compsoc"] };
|
|
65
|
+
expect(venueClass({ cls: "article", options: ["11pt"] }, want)).toEqual({
|
|
66
|
+
kind: "replace",
|
|
67
|
+
by: want,
|
|
68
|
+
});
|
|
69
|
+
expect(
|
|
70
|
+
venueClass({ cls: "IEEEtran", options: ["review", "conference"] }, want),
|
|
71
|
+
).toEqual({ kind: "add", options: ["compsoc"] });
|
|
72
|
+
expect(
|
|
73
|
+
venueClass({ cls: "IEEEtran", options: ["compsoc", "conference"] }, want),
|
|
74
|
+
).toEqual({ kind: "keep" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("collapse: every run of whitespace, newlines included, and trims", () => {
|
|
78
|
+
expect(collapse(" LLM Usage\n Statement ")).toBe("LLM Usage Statement");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a paper's LaTeX source says, in the paper's vocabulary — its document class, its sections,
|
|
3
|
+
* the text a reader sees — as values. No parser here: the LaTeX adapter (`src/adapters/latex/`)
|
|
4
|
+
* produces these, and the rules and `paperlint new` read them through the `LatexReader` port.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A span of the source: `[start, end)` offsets. */
|
|
8
|
+
export interface Span {
|
|
9
|
+
readonly start: number;
|
|
10
|
+
readonly end: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Where something stands in the source, or that the parser gave it no position. */
|
|
14
|
+
export type Place =
|
|
15
|
+
{ readonly kind: "at"; readonly span: Span } | { readonly kind: "unplaced" };
|
|
16
|
+
|
|
17
|
+
/** Whitespace collapsed to single spaces and trimmed — how a title or a class name is compared. */
|
|
18
|
+
export const collapse = (s: string): string =>
|
|
19
|
+
s.split(/\s+/u).filter(Boolean).join(" ");
|
|
20
|
+
|
|
21
|
+
/** A LaTeX document class and the options it is loaded with, in source order. */
|
|
22
|
+
export interface DocumentClass {
|
|
23
|
+
readonly cls: string;
|
|
24
|
+
readonly options: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A paper's `\documentclass`: none, one that names no class, or a class with its options. */
|
|
28
|
+
export type ClassLine =
|
|
29
|
+
| { readonly kind: "missing" }
|
|
30
|
+
| { readonly kind: "empty"; readonly place: Place }
|
|
31
|
+
| ({ readonly kind: "class"; readonly place: Place } & DocumentClass);
|
|
32
|
+
|
|
33
|
+
/** The options of `want` that `got` does not carry — every one, in `want`'s order. */
|
|
34
|
+
export const missingOptions = (
|
|
35
|
+
got: DocumentClass,
|
|
36
|
+
want: DocumentClass,
|
|
37
|
+
): readonly string[] => want.options.filter((o) => !got.options.includes(o));
|
|
38
|
+
|
|
39
|
+
/** What a paper's class line needs to satisfy a venue: nothing, options added, or the venue's line. */
|
|
40
|
+
export type ClassChange =
|
|
41
|
+
| { readonly kind: "keep" }
|
|
42
|
+
| { readonly kind: "add"; readonly options: readonly string[] }
|
|
43
|
+
| { readonly kind: "replace"; readonly by: DocumentClass };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a paper's class must change to satisfy a venue's `want`: another class is replaced by `want`;
|
|
47
|
+
* the same class keeps its options and gains the ones it lacks, in `want`'s order. This decides WHAT
|
|
48
|
+
* changes; writing it into a source is the LaTeX adapter's.
|
|
49
|
+
*/
|
|
50
|
+
export function venueClass(
|
|
51
|
+
have: DocumentClass,
|
|
52
|
+
want: DocumentClass,
|
|
53
|
+
): ClassChange {
|
|
54
|
+
if (have.cls !== want.cls) return { kind: "replace", by: want };
|
|
55
|
+
const missing = missingOptions(have, want);
|
|
56
|
+
return missing.length === 0
|
|
57
|
+
? { kind: "keep" }
|
|
58
|
+
: { kind: "add", options: missing };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A class as a `\documentclass` line, for a message and for the line `paperlint new` writes. */
|
|
62
|
+
export const documentClassLine = (d: DocumentClass): string =>
|
|
63
|
+
`\\documentclass${d.options.length > 0 ? `[${d.options.join(",")}]` : ""}{${d.cls}}`;
|
|
64
|
+
|
|
65
|
+
/** One `\section` or `\section*`, its title's text with whitespace collapsed. */
|
|
66
|
+
export interface Heading {
|
|
67
|
+
readonly title: string;
|
|
68
|
+
readonly place: Place;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The document's sections in source order, where its back matter begins, and where it ends. */
|
|
72
|
+
export interface Outline {
|
|
73
|
+
readonly sections: readonly Heading[];
|
|
74
|
+
/** The first `\appendix` or bibliography, or null: sections from here on are not body. */
|
|
75
|
+
readonly backMatter: number | null;
|
|
76
|
+
/** Where `\end{document}` stands, or null when there is no document environment. */
|
|
77
|
+
readonly end: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Text a reader sees, copied from one place: `text[i]` stands at source offset `at + i`. */
|
|
81
|
+
export interface Segment {
|
|
82
|
+
readonly text: string;
|
|
83
|
+
readonly at: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A run of rendered text: contiguous segments, never empty. */
|
|
87
|
+
export interface TextRun {
|
|
88
|
+
readonly segments: readonly [Segment, ...Segment[]];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The run's text. */
|
|
92
|
+
export const runText = (run: TextRun): string =>
|
|
93
|
+
run.segments.map((s) => s.text).join("");
|
|
94
|
+
|
|
95
|
+
/** The source offset of the `i`-th character of the text these segments make, or null outside it. */
|
|
96
|
+
function offsetIn(segments: readonly Segment[], i: number): number | null {
|
|
97
|
+
const [s, ...rest] = segments;
|
|
98
|
+
if (s === undefined || i < 0) return null;
|
|
99
|
+
return i < s.text.length ? s.at + i : offsetIn(rest, i - s.text.length);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The source span of `runText(run).slice(from, to)`: from its first character to past its last.
|
|
104
|
+
* Null when the range is empty or reaches outside the run — never an invented position.
|
|
105
|
+
*/
|
|
106
|
+
export function spanIn(run: TextRun, from: number, to: number): Span | null {
|
|
107
|
+
const start = offsetIn(run.segments, from);
|
|
108
|
+
const last = to > from ? offsetIn(run.segments, to - 1) : null;
|
|
109
|
+
return start === null || last === null ? null : { start, end: last + 1 };
|
|
110
|
+
}
|
package/src/engine.harness.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. `resolveEngine` over `EngineFacts` — one row per branch of the order;
|
|
6
6
|
* 2. the fact readers: `missingPackages` over kpsewhich output, `missingTools`, `whichOnPath`,
|
|
7
7
|
* `probeTree` through a fake runner.
|
|
8
|
-
* All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/build.ts`.
|
|
8
|
+
* All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/tex/build.e2e.ts`.
|
|
9
9
|
*/
|
|
10
10
|
import {
|
|
11
11
|
chmodSync,
|
package/src/facts-file.test.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* in-memory files with a FAKE pdf.js reader and a FAKE measurer, so each outcome — read failed, not
|
|
6
6
|
* measured, measured — is chosen rather than hoped for. Reading real PDFs is
|
|
7
7
|
* `pdf-facts.harness.ts`'s job; banal as the measurer is `adapters/banal/index.test.ts`, and the
|
|
8
|
-
* real banal is `test/e2e/banal.ts`.
|
|
8
|
+
* real banal is `test/e2e/tex/banal.e2e.ts`.
|
|
9
9
|
*/
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
11
|
import { readdirSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
14
14
|
import { test } from "vitest";
|
|
15
15
|
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
16
16
|
import {
|
|
@@ -154,12 +154,9 @@ const good: PdfReader = () =>
|
|
|
154
154
|
});
|
|
155
155
|
/** The shipped presets at their real paths: `extends` resolves through the same Files port. */
|
|
156
156
|
const SHIPPED_PRESETS = Object.fromEntries(
|
|
157
|
-
readdirSync(
|
|
157
|
+
readdirSync(presetsDir())
|
|
158
158
|
.filter((f) => /\.jsonc?$/.test(f))
|
|
159
|
-
.map((f) => [
|
|
160
|
-
join(packageVenuesDir(), f),
|
|
161
|
-
readFileSync(join(packageVenuesDir(), f)),
|
|
162
|
-
]),
|
|
159
|
+
.map((f) => [join(presetsDir(), f), readFileSync(join(presetsDir(), f))]),
|
|
163
160
|
);
|
|
164
161
|
|
|
165
162
|
/** Options over in-memory files: the PDF and paperlint.json on "disk", a measurer answering `geometry`. */
|
package/src/facts-file.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { callerPath } from "./caller-path.ts";
|
|
|
49
49
|
import type { Files } from "./ports/files.ts";
|
|
50
50
|
import { err, ok, type Result } from "./domain/result.ts";
|
|
51
51
|
import { paperPreset, paperPresetProblem } from "./presets.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
53
53
|
import { messageOf } from "./domain/text.ts";
|
|
54
54
|
|
|
55
55
|
export const FACTS_SCHEMA = 2;
|
|
@@ -76,7 +76,7 @@ export interface VenueDecl {
|
|
|
76
76
|
export function declaredVenue(
|
|
77
77
|
files: Files,
|
|
78
78
|
paperDir: string,
|
|
79
|
-
venuesDir: string =
|
|
79
|
+
venuesDir: string = presetsDir(),
|
|
80
80
|
): VenueDecl | null {
|
|
81
81
|
const p = paperPreset(paperDir, { files, venuesDir });
|
|
82
82
|
const problem = paperPresetProblem(paperDir, p);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* A defect in `nextStep` fails in table 1, one in `summarize` in table 2, before table 3 runs.
|
|
7
7
|
*
|
|
8
8
|
* The decision is pure, so none of this needs TeX. The real-pdflatex half lives in
|
|
9
|
-
* `test/e2e/build.ts`.
|
|
9
|
+
* `test/e2e/tex/build.e2e.ts`.
|
|
10
10
|
*/
|
|
11
11
|
import { createChecker } from "../lib/check.ts";
|
|
12
12
|
import type {
|
package/src/new-paper.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { newPaper, OVERRIDE_DIR, reportNewPaper } from "./new-paper.ts";
|
|
|
19
19
|
import { parsePaperSettings } from "./paper-settings.ts";
|
|
20
20
|
import { chooseVenue, run } from "./cli.ts";
|
|
21
21
|
import { shippedPresets } from "./presets.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
23
23
|
import { lintReport } from "../test/lint-report.ts";
|
|
24
24
|
import { z } from "zod";
|
|
25
25
|
|
|
@@ -177,6 +177,31 @@ describe("paperlint new --venue / --kind", () => {
|
|
|
177
177
|
expect(r.code).toBe(0);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
it("🔴 the new paper.tex is set in the venue's class, so tex/template passes on what new wrote; the required section is named", async () => {
|
|
181
|
+
const r = await newIn(["--venue", "aidc", "--kind", "regular"]);
|
|
182
|
+
const tex = readFileSync(join(r.dir, "paper.tex"), "utf8");
|
|
183
|
+
expect(tex).toContain("\\documentclass[conference,compsoc]{IEEEtran}\n");
|
|
184
|
+
expect(tex).not.toContain("{article}");
|
|
185
|
+
expect(r.out).not.toMatch(/tex\/template/);
|
|
186
|
+
// What the stub still lacks is what AIDC requires of the text, and lint says so right away.
|
|
187
|
+
expect(r.out).toMatch(/tex\/required-section/);
|
|
188
|
+
expect(r.code).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("a preset that names no template, or one no reader can read, leaves the template's class as it is", async () => {
|
|
192
|
+
for (const template of [undefined, "\\documentclass[a]{}"]) {
|
|
193
|
+
const r = await newIn(["--venue", "./venues/bare.jsonc"], {
|
|
194
|
+
"venues/bare.jsonc": JSON.stringify({
|
|
195
|
+
...(template === undefined ? {} : { template }),
|
|
196
|
+
tex: { packages: { x: ["x.sty"] } },
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
expect(readFileSync(join(r.dir, "paper.tex"), "utf8")).toContain(
|
|
200
|
+
"\\documentclass{article}",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
180
205
|
it("🔴 a path is relative to where you run it, and written relative to the paper's file", async () => {
|
|
181
206
|
const r = await newIn(
|
|
182
207
|
["--venue", "./venues/my-workshop.jsonc", "--kind", "short"],
|
|
@@ -197,7 +222,7 @@ describe("paperlint new --venue / --kind", () => {
|
|
|
197
222
|
const r = await newIn(["--venue", "icse"]);
|
|
198
223
|
expect(r.code).toBe(2);
|
|
199
224
|
// Guards: the list is read from the shipped presets, the same list lint prints.
|
|
200
|
-
expect(r.err).toContain(shippedPresets(
|
|
225
|
+
expect(r.err).toContain(shippedPresets(presetsDir()).join(", "));
|
|
201
226
|
expect(r.err).toMatch(/--venue icse: no such venue preset/);
|
|
202
227
|
expect(existsSync(r.dir)).toBe(false);
|
|
203
228
|
});
|
|
@@ -277,7 +302,7 @@ describe("chooseVenue — on a terminal", () => {
|
|
|
277
302
|
value: { extends: "paperlint:agenticdev", kind: "full" },
|
|
278
303
|
});
|
|
279
304
|
expect(t.asked[0]).toContain(
|
|
280
|
-
[...shippedPresets(
|
|
305
|
+
[...shippedPresets(presetsDir()), "none"].join(" / "),
|
|
281
306
|
);
|
|
282
307
|
expect(t.asked[1]).toMatch(/kind: short \/ full \/ demo \/ later/);
|
|
283
308
|
});
|
package/src/new-paper.ts
CHANGED
|
@@ -104,6 +104,11 @@ export interface VenueSetting {
|
|
|
104
104
|
/** `paperlint:<name>`, or a path relative to the paper's `paperlint.json`. */
|
|
105
105
|
readonly extends: string;
|
|
106
106
|
readonly kind: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* The new `paper.tex` set in the class the preset's template names, so `tex/template` passes on
|
|
109
|
+
* the paper `new` just wrote. Absent or null: the template's class is kept.
|
|
110
|
+
*/
|
|
111
|
+
readonly setClass?: ((paperTex: string) => string) | null;
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
/**
|
|
@@ -164,6 +169,9 @@ function fromTemplate(
|
|
|
164
169
|
if (!existsSync(src))
|
|
165
170
|
return { ok: false, reason: `no template for ${file}: ${src} is missing` };
|
|
166
171
|
const text = readFileSync(src, "utf8").split("{{name}}").join(name);
|
|
172
|
+
const setClass = venue?.setClass ?? null;
|
|
173
|
+
if (file === SOURCE_FILE.tex && setClass !== null)
|
|
174
|
+
return { ok: true, value: { file, text: setClass(text), from } };
|
|
167
175
|
if (file !== CONFIG_FILE || venue === null)
|
|
168
176
|
return { ok: true, value: { file, text, from } };
|
|
169
177
|
const edited = withVenue(text, src, venue);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the package's own data lives, wherever the package is installed (its checkout,
|
|
3
|
+
* `node_modules`, a plugin cache). This file sits one level below the package root both as a source
|
|
4
|
+
* (`src/`) and as a build (`dist/`), so `..` is the root in either.
|
|
5
|
+
*
|
|
6
|
+
* presets/ the venue presets (`paperlint:<name>`) and their JSON Schema
|
|
7
|
+
* presets/tex/ the LaTeX inputs `paperlint build` puts on TEXINPUTS
|
|
8
|
+
*/
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
/** The shipped venue presets and `venue-profile.schema.json`. */
|
|
12
|
+
export const presetsDir = (): string =>
|
|
13
|
+
fileURLToPath(new URL("../presets", import.meta.url));
|
|
14
|
+
|
|
15
|
+
/** The LaTeX inputs a paper `\input`s by name (`paper-guards`, a venue's numbers). */
|
|
16
|
+
export const texInputsDir = (): string =>
|
|
17
|
+
fileURLToPath(new URL("../presets/tex", import.meta.url));
|
|
@@ -232,6 +232,9 @@ describe("paperlint lint — the venue preset's rules", () => {
|
|
|
232
232
|
extends: "paperlint:agenticdev",
|
|
233
233
|
kind: "short",
|
|
234
234
|
}),
|
|
235
|
+
// In agenticdev's class, or tex/template would be the finding.
|
|
236
|
+
"papers/a/paper.tex":
|
|
237
|
+
"\\documentclass[sigconf]{acmart}\n\\begin{document}x\\end{document}\n",
|
|
235
238
|
});
|
|
236
239
|
const r = await lint(root);
|
|
237
240
|
expect(r.code).toBe(0);
|
|
@@ -282,7 +285,9 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
|
|
|
282
285
|
});
|
|
283
286
|
const r = await lint(root);
|
|
284
287
|
expect(r.code).toBe(1);
|
|
285
|
-
expect(r.out).toMatch(
|
|
288
|
+
expect(r.out).toMatch(
|
|
289
|
+
/acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
|
|
290
|
+
);
|
|
286
291
|
});
|
|
287
292
|
});
|
|
288
293
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What paperlint needs from a LaTeX source, shaped by its rules and `paperlint new` — not by the
|
|
3
|
+
* parser. The one implementation is `src/adapters/latex/` (unified-latex); `src/cli.ts` wires it.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
ClassLine,
|
|
7
|
+
DocumentClass,
|
|
8
|
+
Outline,
|
|
9
|
+
TextRun,
|
|
10
|
+
} from "../domain/tex-document.ts";
|
|
11
|
+
|
|
12
|
+
export interface LatexReader {
|
|
13
|
+
/** The source's `\documentclass`, as one of its states. */
|
|
14
|
+
readonly documentClass: (src: string) => ClassLine;
|
|
15
|
+
/** Its sections, where the back matter begins, and where the document ends. */
|
|
16
|
+
readonly outline: (src: string) => Outline;
|
|
17
|
+
/** The text a reader sees: prose, the author block, footnotes — not comments, keys, math, code. */
|
|
18
|
+
readonly renderedRuns: (src: string) => readonly TextRun[];
|
|
19
|
+
/**
|
|
20
|
+
* A preset's `template` → the class it names: a whole `\documentclass[…]{…}` line, or a bare
|
|
21
|
+
* class name (no option). Null for anything else.
|
|
22
|
+
*/
|
|
23
|
+
readonly template: (text: string) => DocumentClass | null;
|
|
24
|
+
/**
|
|
25
|
+
* `src` with its `\documentclass` set to `want` — unless it already is `want`'s class with every
|
|
26
|
+
* option `want` names (the author's own options stay), or it has none.
|
|
27
|
+
*/
|
|
28
|
+
readonly withDocumentClass: (src: string, want: DocumentClass) => string;
|
|
29
|
+
}
|
package/src/presets.test.ts
CHANGED
|
@@ -10,15 +10,18 @@ import { readdirSync, readFileSync } from "node:fs";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { describe, expect, it } from "vitest";
|
|
12
12
|
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
14
|
+
import { parsePreset } from "./tex-requirements.ts";
|
|
14
15
|
import {
|
|
15
16
|
MAX_PRESET_DEPTH,
|
|
17
|
+
SHIPPED_PREFIX,
|
|
18
|
+
shippedPresets,
|
|
16
19
|
labelOf,
|
|
17
20
|
presetProblemText,
|
|
18
21
|
resolvePreset,
|
|
19
22
|
} from "./presets.ts";
|
|
20
23
|
|
|
21
|
-
const VENUES =
|
|
24
|
+
const VENUES = presetsDir();
|
|
22
25
|
const shipped = Object.fromEntries(
|
|
23
26
|
readdirSync(VENUES)
|
|
24
27
|
.filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
|
|
@@ -44,7 +47,11 @@ describe("shipped presets", () => {
|
|
|
44
47
|
expect(r.value.format.columns).toBe(2);
|
|
45
48
|
expect(r.value.format.fontsText).toBe("LinLibertine");
|
|
46
49
|
expect([...r.value.format.kinds.keys()]).toEqual(["short", "full", "demo"]);
|
|
47
|
-
|
|
50
|
+
// The family's template, as its file spells it: the child names none.
|
|
51
|
+
expect(r.value.template).toEqual({
|
|
52
|
+
text: "\\documentclass[sigconf]{acmart}",
|
|
53
|
+
file: join(VENUES, "acm-sigconf.jsonc"),
|
|
54
|
+
});
|
|
48
55
|
expect("acmart" in r.value.tex.packages).toBe(true);
|
|
49
56
|
expect(r.value.label).toBe("agenticdev");
|
|
50
57
|
});
|
|
@@ -70,6 +77,45 @@ describe("shipped presets", () => {
|
|
|
70
77
|
});
|
|
71
78
|
});
|
|
72
79
|
|
|
80
|
+
describe("the naming convention of shipped presets (docs/rules.md)", () => {
|
|
81
|
+
// Each shipped preset, parsed: its file name and what it extends. The families are DERIVED — a
|
|
82
|
+
// shipped preset another shipped preset extends — so a new family is checked the day it ships.
|
|
83
|
+
const presets = shippedPresets(VENUES).map((name) => {
|
|
84
|
+
const file = join(VENUES, `${name}.jsonc`);
|
|
85
|
+
return {
|
|
86
|
+
name,
|
|
87
|
+
extends: parsePreset(readFileSync(file, "utf8"), file, VENUES).extends,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const families = presets.filter((p) =>
|
|
91
|
+
presets.some((q) => q.extends === `${SHIPPED_PREFIX}${p.name}`),
|
|
92
|
+
);
|
|
93
|
+
const publishers = families.map((f) => f.name.slice(0, f.name.indexOf("-")));
|
|
94
|
+
|
|
95
|
+
it("there are families to check (else the checks below see nothing)", () => {
|
|
96
|
+
expect(families.map((f) => f.name).sort()).toEqual([
|
|
97
|
+
"acm-sigconf",
|
|
98
|
+
"ieee-conference",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.each(families.map((f) => [f.name]))(
|
|
103
|
+
"the family %s is <publisher>-<template variant>",
|
|
104
|
+
(name) => {
|
|
105
|
+
expect(name).toMatch(/^[a-z0-9]+-[a-z0-9-]+$/);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it.each(presets.filter((p) => p.extends !== null).map((p) => [p.name]))(
|
|
110
|
+
"the venue %s carries no publisher prefix — its template is said by `extends`",
|
|
111
|
+
(name) => {
|
|
112
|
+
expect(publishers.filter((pub) => name.startsWith(`${pub}-`))).toEqual(
|
|
113
|
+
[],
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
73
119
|
describe("a project's own preset, by relative path", () => {
|
|
74
120
|
const usenix = JSON.stringify({
|
|
75
121
|
extends: "paperlint:acm-sigconf",
|
|
@@ -149,7 +195,7 @@ describe("what does not resolve, and says why", () => {
|
|
|
149
195
|
"a shipped name with a typo",
|
|
150
196
|
"paperlint:agenticdve",
|
|
151
197
|
"not-found",
|
|
152
|
-
/acm-sigconf, agenticdev, aisec, realm/,
|
|
198
|
+
/acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
|
|
153
199
|
],
|
|
154
200
|
["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
|
|
155
201
|
[
|
|
@@ -225,6 +271,21 @@ describe("chains that do not resolve", () => {
|
|
|
225
271
|
});
|
|
226
272
|
});
|
|
227
273
|
|
|
274
|
+
describe("a preset's template: kept as its file spells it, with that file", () => {
|
|
275
|
+
it("a child's template replaces its parent's, and names its own file", () => {
|
|
276
|
+
const r = resolve("./own.jsonc", {
|
|
277
|
+
"/work/papers/p/own.jsonc": JSON.stringify({
|
|
278
|
+
extends: "paperlint:aidc",
|
|
279
|
+
template: "\\documentclass[conference]{IEEEtran}",
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
expect(r.ok && r.value.template).toEqual({
|
|
283
|
+
text: "\\documentclass[conference]{IEEEtran}",
|
|
284
|
+
file: "/work/papers/p/own.jsonc",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
228
289
|
describe("labelOf — display only, never used to resolve", () => {
|
|
229
290
|
it.each([
|
|
230
291
|
["paperlint:agenticdev", "agenticdev"],
|
package/src/presets.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
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`
|
|
22
|
+
* template the child wins; so does `name`, and `required_sections` (the whole list)
|
|
23
|
+
* aliases union, with every `name` — what the venue is called along the chain
|
|
23
24
|
*
|
|
24
25
|
* ── THE LABEL ─────────────────────────────────────────────────────────────────────
|
|
25
26
|
* Messages, the facts file and the build plan need a word for the venue. It is the most derived
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
profileFileOf,
|
|
37
38
|
venueNames,
|
|
38
39
|
type PresetFile,
|
|
40
|
+
type RequiredSection,
|
|
39
41
|
type TexRequirements,
|
|
40
42
|
type VenueFormat,
|
|
41
43
|
} from "./tex-requirements.ts";
|
|
@@ -61,13 +63,27 @@ export interface PresetDeps {
|
|
|
61
63
|
readonly venuesDir: string;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** A preset's `template`, as its file spells it. */
|
|
67
|
+
export interface PresetTemplate {
|
|
68
|
+
readonly text: string;
|
|
69
|
+
readonly file: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
/** A resolved chain, merged. */
|
|
65
73
|
export interface Preset {
|
|
66
74
|
/** The word for this venue in messages. */
|
|
67
75
|
readonly label: string;
|
|
68
76
|
/** Every file of the chain, root first. */
|
|
69
77
|
readonly chain: readonly string[];
|
|
70
|
-
|
|
78
|
+
/**
|
|
79
|
+
* The `\\documentclass` the venue's template uses, as the preset spells it, and the file that
|
|
80
|
+
* spells it; null when no file of the chain names one. Read by the rules and `paperlint new`,
|
|
81
|
+
* through the LaTeX reader.
|
|
82
|
+
*/
|
|
83
|
+
readonly template: PresetTemplate | null;
|
|
84
|
+
/** Every `name` and `aliases` along the chain: what this venue is called in a paper's text. */
|
|
85
|
+
readonly aliases: readonly string[];
|
|
86
|
+
readonly requiredSections: readonly RequiredSection[];
|
|
71
87
|
readonly tex: TexRequirements;
|
|
72
88
|
readonly format: VenueFormat;
|
|
73
89
|
readonly rules: Readonly<Record<string, unknown>>;
|
|
@@ -187,26 +203,37 @@ export function mergeFormat(
|
|
|
187
203
|
/** The chain, root first, merged into one preset. Pure. */
|
|
188
204
|
function merged(
|
|
189
205
|
spec: string,
|
|
190
|
-
rootFirst: readonly PresetFile[],
|
|
191
|
-
files: readonly string[],
|
|
206
|
+
rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
|
|
192
207
|
): Preset {
|
|
193
208
|
const base: {
|
|
194
209
|
name: string | null;
|
|
195
|
-
template:
|
|
210
|
+
template: PresetTemplate | null;
|
|
211
|
+
aliases: readonly string[];
|
|
212
|
+
requiredSections: readonly RequiredSection[];
|
|
196
213
|
tex: TexRequirements;
|
|
197
214
|
format: VenueFormat;
|
|
198
215
|
rules: Readonly<Record<string, unknown>>;
|
|
199
216
|
} = {
|
|
200
217
|
name: null,
|
|
201
218
|
template: null,
|
|
219
|
+
aliases: [],
|
|
220
|
+
requiredSections: [],
|
|
202
221
|
tex: NO_REQUIREMENTS,
|
|
203
222
|
format: NO_FORMAT,
|
|
204
223
|
rules: {},
|
|
205
224
|
};
|
|
206
225
|
const m = rootFirst.reduce(
|
|
207
|
-
(acc, p) => ({
|
|
226
|
+
(acc, { preset: p, file }) => ({
|
|
208
227
|
name: p.name ?? acc.name,
|
|
209
|
-
template: p.template
|
|
228
|
+
template: p.template === null ? acc.template : { text: p.template, file },
|
|
229
|
+
aliases: [
|
|
230
|
+
...new Set([
|
|
231
|
+
...acc.aliases,
|
|
232
|
+
...(p.name === null ? [] : [p.name]),
|
|
233
|
+
...p.aliases,
|
|
234
|
+
]),
|
|
235
|
+
],
|
|
236
|
+
requiredSections: p.requiredSections ?? acc.requiredSections,
|
|
210
237
|
tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
|
|
211
238
|
format: mergeFormat(acc.format, p.format),
|
|
212
239
|
rules: { ...acc.rules, ...p.rules },
|
|
@@ -215,8 +242,10 @@ function merged(
|
|
|
215
242
|
);
|
|
216
243
|
return {
|
|
217
244
|
label: m.name ?? labelOf(spec),
|
|
218
|
-
chain:
|
|
245
|
+
chain: rootFirst.map((x) => x.file),
|
|
219
246
|
template: m.template,
|
|
247
|
+
aliases: m.aliases,
|
|
248
|
+
requiredSections: m.requiredSections,
|
|
220
249
|
tex: m.tex,
|
|
221
250
|
format: m.format,
|
|
222
251
|
rules: m.rules,
|
|
@@ -238,13 +267,7 @@ export function resolvePreset(
|
|
|
238
267
|
? err({ ...chain.error, shipped: shippedPresets(deps.venuesDir) })
|
|
239
268
|
: chain;
|
|
240
269
|
const rootFirst = [...chain.value].reverse();
|
|
241
|
-
return ok(
|
|
242
|
-
merged(
|
|
243
|
-
spec,
|
|
244
|
-
rootFirst.map((x) => x.preset),
|
|
245
|
-
rootFirst.map((x) => x.file),
|
|
246
|
-
),
|
|
247
|
-
);
|
|
270
|
+
return ok(merged(spec, rootFirst));
|
|
248
271
|
}
|
|
249
272
|
|
|
250
273
|
/** The shipped presets' names (each is spelled `paperlint:<name>`). */
|