paperlint 3.0.0 → 3.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/README.md +179 -180
- package/dist/build-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +22 -3
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +66 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +230 -30
- package/dist/cli.js.map +1 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -0
- package/dist/engine.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/new-paper.d.ts +8 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +50 -15
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +30 -8
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +56 -29
- package/dist/paper-files.js.map +1 -1
- package/dist/structure.js +1 -1
- package/dist/structure.js.map +1 -1
- package/dist/toolchain.d.ts +5 -1
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +22 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.js +1 -1
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +48 -4
- package/docs/rules.md +29 -25
- package/docs/skills.md +80 -0
- package/eslint-rules/paper-typography.mjs +47 -7
- package/eslint-rules/paper-typography.test.mjs +40 -0
- package/eslint-rules/review-frontmatter.schema.json +1 -1
- package/fixtures/toolchain-mirror/catalog.txt +1 -0
- package/fixtures/toolchain-mirror/install-tl +4 -1
- package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
- package/package.json +1 -1
- package/scripts/mutation-batteries.frozen.json +1 -1
- package/src/build-engine.ts +6 -1
- package/src/build.harness.mjs +46 -2
- package/src/build.ts +27 -5
- package/src/cli.harness.mjs +2 -2
- package/src/cli.ts +301 -32
- package/src/engine.harness.mjs +50 -0
- package/src/engine.ts +35 -0
- package/src/init.ts +1 -1
- package/src/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +2 -0
- package/src/new-paper.mutations.mjs +0 -14
- package/src/new-paper.test.ts +172 -1
- package/src/new-paper.ts +84 -17
- package/src/paper-files.test.ts +110 -5
- package/src/paper-files.ts +93 -37
- package/src/paper-settings-commands.test.ts +41 -5
- package/src/parse-args.test.ts +48 -0
- package/src/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +2 -2
- package/src/structure.ts +1 -1
- package/src/toolchain.harness.mjs +41 -6
- package/src/toolchain.ts +29 -5
- package/src/types.ts +6 -0
- package/src/venue-rules.test.ts +3 -0
- package/src/venue-rules.ts +1 -1
- package/templates/paper/paperlint.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LINTED SET IS EXACTLY THE OWNED FILES — checked on the whole result, not one route in.
|
|
3
|
+
*
|
|
4
|
+
* Each test in `paper-files.test.ts` closes one way a stray file got into `paperlint lint`: vendored
|
|
5
|
+
* JavaScript under a paper (#99), a rule source that reached the siblings index (#101). This one
|
|
6
|
+
* does not care about the route. A paper with every kind of neighbour a real one has, settings from
|
|
7
|
+
* every level that generates blocks (a venue preset with `rules`, the root `rules`), and one
|
|
8
|
+
* assertion: the files ESLint returned are exactly the four paperlint owns — set equality, so a
|
|
9
|
+
* route nobody has thought of yet shows up as an extra member.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
realpathSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join, relative } from "node:path";
|
|
20
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
21
|
+
import { run } from "./cli.ts";
|
|
22
|
+
|
|
23
|
+
const dirs: string[] = [];
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const TEX = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
|
|
29
|
+
const JS = "export const x = 1;\n";
|
|
30
|
+
|
|
31
|
+
/** The files paperlint owns in the fixture paper. */
|
|
32
|
+
const OWNED = [
|
|
33
|
+
"papers/a/paper.tex",
|
|
34
|
+
"papers/a/PIPELINE-STATUS.md",
|
|
35
|
+
"papers/a/reviews/r1.md",
|
|
36
|
+
"papers/a/siblings/smith2025.md",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Everything else a real paper folder and project carry. None of it may be linted. */
|
|
40
|
+
const NEIGHBOURS = {
|
|
41
|
+
"papers/a/siblings/README.md": "# Siblings\n\n- [Smith](smith2025.md)\n",
|
|
42
|
+
"papers/a/README.md": "# Paper a\n",
|
|
43
|
+
"papers/a/notes/idea.md": "# An idea\n",
|
|
44
|
+
"papers/a/repro/x.js": "return 1;\n",
|
|
45
|
+
"papers/a/repro/lib/y.mjs": JS,
|
|
46
|
+
"papers/a/repro/z.cjs": "module.exports = 1;\n",
|
|
47
|
+
"papers/a/repro/t.ts": "const t: number = 1;\n",
|
|
48
|
+
"script.js": JS,
|
|
49
|
+
"node_modules/pkg/index.js": JS,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function project(): string {
|
|
53
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-set-")));
|
|
54
|
+
dirs.push(root);
|
|
55
|
+
const all: Record<string, string> = {
|
|
56
|
+
"package.json": JSON.stringify({ name: "c", private: true }),
|
|
57
|
+
// A root `rules` block over every file of the project: the widest glob a consumer can write.
|
|
58
|
+
"paperlint.json": JSON.stringify({
|
|
59
|
+
rules: [
|
|
60
|
+
{
|
|
61
|
+
files: ["**/*.md", "**/*.tex", "**/*.js"],
|
|
62
|
+
rules: {
|
|
63
|
+
"pdf/last-page-balance": "warn",
|
|
64
|
+
"paper/section-word": "warn",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}),
|
|
69
|
+
// A venue preset that carries `rules` of its own.
|
|
70
|
+
"papers/a/paperlint.json": JSON.stringify({
|
|
71
|
+
extends: "paperlint:agenticdev",
|
|
72
|
+
}),
|
|
73
|
+
"papers/a/paper.tex": TEX,
|
|
74
|
+
"papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
|
|
75
|
+
"papers/a/reviews/r1.md": "# review\n",
|
|
76
|
+
"papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
|
|
77
|
+
...NEIGHBOURS,
|
|
78
|
+
};
|
|
79
|
+
for (const [p, text] of Object.entries(all)) {
|
|
80
|
+
mkdirSync(join(root, p, ".."), { recursive: true });
|
|
81
|
+
writeFileSync(join(root, p), text);
|
|
82
|
+
}
|
|
83
|
+
return root;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `paperlint lint --json <args>` → the files it linted, relative to the project, sorted. */
|
|
87
|
+
async function lintedSet(root: string, args: string[]): Promise<string[]> {
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
await run(["lint", "--json", ...args], {
|
|
90
|
+
cwd: root,
|
|
91
|
+
log: (s: string) => out.push(s),
|
|
92
|
+
err: () => {},
|
|
93
|
+
});
|
|
94
|
+
type Result = { filePath: string; messages: { ruleId: string | null }[] };
|
|
95
|
+
return (
|
|
96
|
+
(JSON.parse(out.join("\n")) as Result[])
|
|
97
|
+
// A structure finding reports a MISSING file in the same shape; it was not linted.
|
|
98
|
+
.filter(
|
|
99
|
+
(r) => !r.messages.some((m) => m.ruleId === "structure/required-file"),
|
|
100
|
+
)
|
|
101
|
+
.map((r) => relative(root, r.filePath))
|
|
102
|
+
.sort()
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("🔴 paperlint lint — the linted set is EXACTLY the owned files", () => {
|
|
107
|
+
it.each([
|
|
108
|
+
["the papers directory from the config", []],
|
|
109
|
+
["the paper named on the command line", ["papers/a"]],
|
|
110
|
+
// The widest scope: ESLint is handed the project root, so it enumerates `script.js` and
|
|
111
|
+
// `node_modules/` too, and only the scope keeps them out.
|
|
112
|
+
["the whole project named on the command line", ["."]],
|
|
113
|
+
])("%s", async (_, args) => {
|
|
114
|
+
// Guards: every route in at once — vendored JS/TS, a root script, node_modules, a README next
|
|
115
|
+
// to the paper, the siblings index, notes. An extra member names the file that got through.
|
|
116
|
+
expect(await lintedSet(project(), args)).toEqual([...OWNED].sort());
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -191,6 +191,8 @@ try {
|
|
|
191
191
|
"---\n---\n# House scorecard for {{name}}\n",
|
|
192
192
|
);
|
|
193
193
|
const own = newPaper(papers, "house", "md");
|
|
194
|
+
// Guards: the override slot — `fromTemplate` in new-paper.ts reads `<papers>/.template/<file>`
|
|
195
|
+
// before the package's copy. Replace that lookup with the package's alone and this goes red.
|
|
194
196
|
check(
|
|
195
197
|
"🔴 <papers>/.template/ is preferred for the file it holds, the package fills the rest",
|
|
196
198
|
own.ok &&
|
|
@@ -51,20 +51,6 @@ process.exit(
|
|
|
51
51
|
],
|
|
52
52
|
],
|
|
53
53
|
},
|
|
54
|
-
{
|
|
55
|
-
name: "the project's template is ignored",
|
|
56
|
-
harness: HARNESS,
|
|
57
|
-
expect: "is preferred for the file it holds",
|
|
58
|
-
disables:
|
|
59
|
-
"the override slot. A project with its own richer scorecard gets the package's every time",
|
|
60
|
-
edits: [
|
|
61
|
-
[
|
|
62
|
-
SRC,
|
|
63
|
-
" const [src, from] = existsSync(project)",
|
|
64
|
-
" const [src, from] = false",
|
|
65
|
-
],
|
|
66
|
-
],
|
|
67
|
-
},
|
|
68
54
|
{
|
|
69
55
|
name: "a leading dot is accepted",
|
|
70
56
|
harness: HARNESS,
|
package/src/new-paper.test.ts
CHANGED
|
@@ -14,9 +14,12 @@ import {
|
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { afterEach, describe, expect, it } from "vitest";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
17
18
|
import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
|
|
18
19
|
import { parsePaperSettings } from "./paper-settings.ts";
|
|
19
|
-
import { run } from "./cli.ts";
|
|
20
|
+
import { chooseVenue, run } from "./cli.ts";
|
|
21
|
+
import { shippedPresets } from "./presets.ts";
|
|
22
|
+
import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
|
|
20
23
|
|
|
21
24
|
const dirs: string[] = [];
|
|
22
25
|
afterEach(() => {
|
|
@@ -131,3 +134,171 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
|
|
|
131
134
|
);
|
|
132
135
|
});
|
|
133
136
|
});
|
|
137
|
+
|
|
138
|
+
/** `paperlint new <args>` in a fresh project (not a terminal: vitest's stdin is not a TTY). */
|
|
139
|
+
async function newIn(args: string[], files: Record<string, string> = {}) {
|
|
140
|
+
const root = tmp();
|
|
141
|
+
const all = {
|
|
142
|
+
"package.json": JSON.stringify({ name: "c", private: true }),
|
|
143
|
+
...files,
|
|
144
|
+
};
|
|
145
|
+
for (const [p, text] of Object.entries(all)) {
|
|
146
|
+
mkdirSync(join(root, p, ".."), { recursive: true });
|
|
147
|
+
writeFileSync(join(root, p), text);
|
|
148
|
+
}
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
const err: string[] = [];
|
|
151
|
+
const code = await run(["new", "demo", ...args], {
|
|
152
|
+
cwd: root,
|
|
153
|
+
log: (s: string) => out.push(s),
|
|
154
|
+
err: (s: string) => err.push(s),
|
|
155
|
+
});
|
|
156
|
+
const dir = join(root, "papers", "demo");
|
|
157
|
+
return {
|
|
158
|
+
code,
|
|
159
|
+
out: out.join("\n"),
|
|
160
|
+
err: err.join("\n"),
|
|
161
|
+
dir,
|
|
162
|
+
settings: () => settingsOf(dir),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe("paperlint new --venue / --kind", () => {
|
|
167
|
+
const PRESET = `{
|
|
168
|
+
"extends": "paperlint:acm-sigconf",
|
|
169
|
+
"format": { "kinds": { "short": { "body_pages_max": 4 } } },
|
|
170
|
+
}
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
it("a shipped venue and its kind → extends and kind in paperlint.json, lint resolves it", async () => {
|
|
174
|
+
const r = await newIn(["--venue", "agenticdev", "--kind", "short"]);
|
|
175
|
+
expect(r.settings()).toEqual({
|
|
176
|
+
extends: "paperlint:agenticdev",
|
|
177
|
+
kind: "short",
|
|
178
|
+
});
|
|
179
|
+
expect(r.out).not.toMatch(/pdf\/profile/);
|
|
180
|
+
expect(r.code).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("🔴 a path is relative to where you run it, and written relative to the paper's file", async () => {
|
|
184
|
+
const r = await newIn(
|
|
185
|
+
["--venue", "./venues/my-workshop.jsonc", "--kind", "short"],
|
|
186
|
+
{
|
|
187
|
+
"venues/my-workshop.jsonc": PRESET,
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
// Guards: written as typed, `./venues/…` would name papers/demo/venues/…, which does not exist.
|
|
191
|
+
expect(r.settings()).toEqual({
|
|
192
|
+
extends: "../../venues/my-workshop.jsonc",
|
|
193
|
+
kind: "short",
|
|
194
|
+
});
|
|
195
|
+
expect(r.out).not.toMatch(/pdf\/profile/);
|
|
196
|
+
expect(r.code).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
|
|
200
|
+
const r = await newIn(["--venue", "icse"]);
|
|
201
|
+
expect(r.code).toBe(2);
|
|
202
|
+
// Guards: the list is read from the shipped presets, the same list lint prints.
|
|
203
|
+
expect(r.err).toContain(shippedPresets(packageVenuesDir()).join(", "));
|
|
204
|
+
expect(r.err).toMatch(/--venue icse: no such venue preset/);
|
|
205
|
+
expect(existsSync(r.dir)).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("paperlint new --venue / --kind — refusals and defaults", () => {
|
|
210
|
+
it.each([
|
|
211
|
+
[
|
|
212
|
+
"a kind the preset lacks",
|
|
213
|
+
["--venue", "agenticdev", "--kind", "long"],
|
|
214
|
+
/`agenticdev` has no kind `long`; its kinds: short, full, demo/,
|
|
215
|
+
],
|
|
216
|
+
["a kind without a venue", ["--kind", "short"], /--kind needs --venue/],
|
|
217
|
+
[
|
|
218
|
+
"a kind for a preset with no kinds",
|
|
219
|
+
["--venue", "acm-sigconf", "--kind", "short"],
|
|
220
|
+
/`acm-sigconf` has no kinds/,
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
"a path that does not exist",
|
|
224
|
+
["--venue", "./venues/nope.jsonc"],
|
|
225
|
+
/no such file .* relative to where you run the command/,
|
|
226
|
+
],
|
|
227
|
+
])("%s → exit 2, nothing created", async (_, args, message) => {
|
|
228
|
+
const r = await newIn(args);
|
|
229
|
+
expect(r.err).toMatch(message);
|
|
230
|
+
expect(r.code).toBe(2);
|
|
231
|
+
expect(existsSync(r.dir)).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("a venue with kinds and no --kind: written, and new says what lint will report", async () => {
|
|
235
|
+
const r = await newIn(["--venue", "agenticdev"]);
|
|
236
|
+
expect(r.settings()).toEqual({ extends: "paperlint:agenticdev" });
|
|
237
|
+
expect(r.out).toMatch(
|
|
238
|
+
/kind: not set — `agenticdev` sets a page limit per kind/,
|
|
239
|
+
);
|
|
240
|
+
expect(r.out).toMatch(/pdf\/profile/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("without a terminal and no --venue: as before, and the hint names --venue", async () => {
|
|
244
|
+
const r = await newIn([]);
|
|
245
|
+
expect(r.settings()["extends"]).toBeNull();
|
|
246
|
+
expect(r.out).toMatch(/venue: none yet .*--venue <preset>/);
|
|
247
|
+
expect(r.code).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("an existing paperlint.json is never overwritten: --venue is refused", async () => {
|
|
251
|
+
const r = await newIn(["--venue", "aisec"], {
|
|
252
|
+
"papers/demo/paperlint.json": '{ "extends": null }\n',
|
|
253
|
+
});
|
|
254
|
+
expect(r.code).toBe(2);
|
|
255
|
+
expect(r.err).toMatch(/already exists and is never overwritten/);
|
|
256
|
+
expect(r.settings()).toEqual({ extends: null });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("chooseVenue — on a terminal", () => {
|
|
261
|
+
const at = (answers: string[]) => {
|
|
262
|
+
const asked: string[] = [];
|
|
263
|
+
return {
|
|
264
|
+
asked,
|
|
265
|
+
opts: {
|
|
266
|
+
paperDir: "/p/papers/demo",
|
|
267
|
+
cwd: "/p",
|
|
268
|
+
interactive: true,
|
|
269
|
+
ask: async (q: string) => (asked.push(q), answers.shift() ?? ""),
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
it("asks the venue from the shipped list, then its kind", async () => {
|
|
275
|
+
const t = at(["agenticdev", "full"]);
|
|
276
|
+
const r = await chooseVenue({ venue: null, kind: null }, t.opts);
|
|
277
|
+
expect(r).toMatchObject({
|
|
278
|
+
ok: true,
|
|
279
|
+
value: { extends: "paperlint:agenticdev", kind: "full" },
|
|
280
|
+
});
|
|
281
|
+
expect(t.asked[0]).toContain(
|
|
282
|
+
[...shippedPresets(packageVenuesDir()), "none"].join(" / "),
|
|
283
|
+
);
|
|
284
|
+
expect(t.asked[1]).toMatch(/kind: short \/ full \/ demo \/ later/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("the default answer is none: no venue written", async () => {
|
|
288
|
+
const t = at([""]);
|
|
289
|
+
expect(await chooseVenue({ venue: null, kind: null }, t.opts)).toEqual({
|
|
290
|
+
ok: true,
|
|
291
|
+
value: null,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("without a terminal nothing is asked", async () => {
|
|
296
|
+
const t = at(["agenticdev"]);
|
|
297
|
+
const r = await chooseVenue(
|
|
298
|
+
{ venue: null, kind: null },
|
|
299
|
+
{ ...t.opts, interactive: false },
|
|
300
|
+
);
|
|
301
|
+
expect(r).toEqual({ ok: true, value: null });
|
|
302
|
+
expect(t.asked).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/new-paper.ts
CHANGED
|
@@ -98,12 +98,85 @@ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
|
|
|
98
98
|
return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/** The venue `paperlint new --venue` chose: what goes into the new paper's `paperlint.json`. */
|
|
102
|
+
export interface VenueSetting {
|
|
103
|
+
/** `paperlint:<name>`, or a path relative to the paper's `paperlint.json`. */
|
|
104
|
+
readonly extends: string;
|
|
105
|
+
readonly kind: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The template's `paperlint.json` with the chosen venue written in. The template's `$comment`
|
|
110
|
+
* explains how to choose one, so it goes once one is chosen. A template that is not plain JSON (a
|
|
111
|
+
* project's own, with comments) cannot be edited safely and is refused by name.
|
|
112
|
+
*/
|
|
113
|
+
function withVenue(
|
|
114
|
+
text: string,
|
|
115
|
+
src: string,
|
|
116
|
+
venue: VenueSetting,
|
|
117
|
+
): { ok: true; text: string } | { ok: false; reason: string } {
|
|
118
|
+
let obj: Record<string, unknown>;
|
|
119
|
+
try {
|
|
120
|
+
obj = JSON.parse(text) as Record<string, unknown>;
|
|
121
|
+
} catch {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: `--venue cannot be written into ${src}: it is not plain JSON — set "extends" by hand`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const out: Record<string, unknown> = { ...obj, extends: venue.extends };
|
|
128
|
+
delete out["$comment"];
|
|
129
|
+
if (venue.kind !== null) out["kind"] = venue.kind;
|
|
130
|
+
return { ok: true, text: `${JSON.stringify(out, null, 2)}\n` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A file `newPaper` will write: its text, and which template it came from. */
|
|
134
|
+
interface Planned {
|
|
135
|
+
readonly file: string;
|
|
136
|
+
readonly text: string;
|
|
137
|
+
readonly from: "project" | "package";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One file's text from its template — the project's override first, the package's second. */
|
|
141
|
+
function fromTemplate(
|
|
142
|
+
file: string,
|
|
143
|
+
{
|
|
144
|
+
papersRoot,
|
|
145
|
+
packageTemplates,
|
|
146
|
+
name,
|
|
147
|
+
venue,
|
|
148
|
+
}: {
|
|
149
|
+
papersRoot: string;
|
|
150
|
+
packageTemplates: string;
|
|
151
|
+
name: string;
|
|
152
|
+
venue: VenueSetting | null;
|
|
153
|
+
},
|
|
154
|
+
): { ok: true; value: Planned } | { ok: false; reason: string } {
|
|
155
|
+
const project = join(papersRoot, OVERRIDE_DIR, file);
|
|
156
|
+
const [src, from] = existsSync(project)
|
|
157
|
+
? [project, "project" as const]
|
|
158
|
+
: [join(packageTemplates, file), "package" as const];
|
|
159
|
+
// A missing package template is a broken install, not a reason to write an empty file.
|
|
160
|
+
if (!existsSync(src))
|
|
161
|
+
return { ok: false, reason: `no template for ${file}: ${src} is missing` };
|
|
162
|
+
const text = readFileSync(src, "utf8").split("{{name}}").join(name);
|
|
163
|
+
if (file !== CONFIG_FILE || venue === null)
|
|
164
|
+
return { ok: true, value: { file, text, from } };
|
|
165
|
+
const edited = withVenue(text, src, venue);
|
|
166
|
+
return edited.ok
|
|
167
|
+
? { ok: true, value: { file, text: edited.text, from } }
|
|
168
|
+
: edited;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Documented in README.md#getting-started — update it when this changes.
|
|
102
172
|
export function newPaper(
|
|
103
173
|
papersRoot: string,
|
|
104
174
|
name: string,
|
|
105
175
|
format: PaperFormat,
|
|
106
|
-
{
|
|
176
|
+
{
|
|
177
|
+
packageTemplates = PACKAGE_TEMPLATES,
|
|
178
|
+
venue = null,
|
|
179
|
+
}: { packageTemplates?: string; venue?: VenueSetting | null } = {},
|
|
107
180
|
): NewPaperResult {
|
|
108
181
|
const problem = nameProblem(name);
|
|
109
182
|
if (problem) return { ok: false, reason: problem };
|
|
@@ -113,26 +186,20 @@ export function newPaper(
|
|
|
113
186
|
return { ok: false, reason: `${dir} exists and is not a directory` };
|
|
114
187
|
|
|
115
188
|
const files: FileOutcome[] = [];
|
|
116
|
-
const toWrite:
|
|
117
|
-
[];
|
|
189
|
+
const toWrite: Planned[] = [];
|
|
118
190
|
for (const file of wantedFiles(dir, format)) {
|
|
119
191
|
if (existsSync(join(dir, file))) {
|
|
120
192
|
files.push({ file, status: "kept" });
|
|
121
193
|
continue;
|
|
122
194
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (!
|
|
130
|
-
|
|
131
|
-
ok: false,
|
|
132
|
-
reason: `no template for ${file}: ${src} is missing`,
|
|
133
|
-
};
|
|
134
|
-
const text = readFileSync(src, "utf8").split("{{name}}").join(name);
|
|
135
|
-
toWrite.push({ file, text, from });
|
|
195
|
+
const planned = fromTemplate(file, {
|
|
196
|
+
papersRoot,
|
|
197
|
+
packageTemplates,
|
|
198
|
+
name,
|
|
199
|
+
venue,
|
|
200
|
+
});
|
|
201
|
+
if (!planned.ok) return planned;
|
|
202
|
+
toWrite.push(planned.value);
|
|
136
203
|
}
|
|
137
204
|
// Read every template before writing any file, so a missing one leaves no half-made folder.
|
|
138
205
|
mkdirSync(dir, { recursive: true });
|
package/src/paper-files.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { buildConfig, run } from "./cli.ts";
|
|
|
23
23
|
import {
|
|
24
24
|
narrowToOwners,
|
|
25
25
|
ownedPatterns,
|
|
26
|
+
ownedScopes,
|
|
26
27
|
ruleOwners,
|
|
27
28
|
scopeToOwned,
|
|
28
29
|
} from "./paper-files.ts";
|
|
@@ -160,6 +161,73 @@ describe("`rules` blocks reach only the files each rule is written for", () => {
|
|
|
160
161
|
});
|
|
161
162
|
});
|
|
162
163
|
|
|
164
|
+
describe("🔴 a generated block never reaches a file paperlint does not lint (#101)", () => {
|
|
165
|
+
// A venue preset with `rules` (agenticdev turns on pdf/last-page-balance) became a block over
|
|
166
|
+
// every paper file. `pdf` is registered for every file, so the block was not narrowed, and the
|
|
167
|
+
// siblings index — which the sibling-card block excludes — matched a block with no language:
|
|
168
|
+
// ESLint parsed it as JavaScript ("Parsing error: Assigning to rvalue").
|
|
169
|
+
const SIBLINGS = {
|
|
170
|
+
"papers/a/siblings/README.md": "# Siblings\n\n- [x](smith2025.md)\n",
|
|
171
|
+
"papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
it.each([
|
|
175
|
+
[
|
|
176
|
+
"a venue preset's rules",
|
|
177
|
+
{
|
|
178
|
+
"papers/a/paperlint.json": JSON.stringify({
|
|
179
|
+
extends: "paperlint:agenticdev",
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
[
|
|
184
|
+
// `papers/**` alone would not show it: ESLint does not lint a file on a pattern ending in
|
|
185
|
+
// `/**`, it only applies such a block to files something else claims.
|
|
186
|
+
"a root block over every markdown file",
|
|
187
|
+
{
|
|
188
|
+
"paperlint.json": JSON.stringify({
|
|
189
|
+
rules: [
|
|
190
|
+
{
|
|
191
|
+
files: ["papers/**/*.md", "papers/**/*.tex"],
|
|
192
|
+
rules: { "pdf/last-page-balance": "warn" },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
[
|
|
199
|
+
"a paper's own { id: severity }",
|
|
200
|
+
{
|
|
201
|
+
"papers/a/paperlint.json": JSON.stringify({
|
|
202
|
+
rules: { "pdf/last-page-balance": "warn" },
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
])(
|
|
207
|
+
"%s: the siblings index is not parsed, the cards still are",
|
|
208
|
+
async (_, settings) => {
|
|
209
|
+
const root = project({ ...SIBLINGS, ...settings });
|
|
210
|
+
const r = await lint(root);
|
|
211
|
+
const results = JSON.parse(r.out) as {
|
|
212
|
+
filePath: string;
|
|
213
|
+
messages: { ruleId: string | null; message: string }[];
|
|
214
|
+
}[];
|
|
215
|
+
// Guards: the class — no file reaches ESLint's default JavaScript parser.
|
|
216
|
+
expect(
|
|
217
|
+
results.flatMap((x) => x.messages).filter((m) => m.ruleId === null),
|
|
218
|
+
).toEqual([]);
|
|
219
|
+
expect(linted(r.out)).not.toContain(
|
|
220
|
+
join(root, "papers/a/siblings/README.md"),
|
|
221
|
+
);
|
|
222
|
+
// Guards: the other half — narrowing did not drop the files the block is for.
|
|
223
|
+
expect(linted(r.out)).toContain(
|
|
224
|
+
join(root, "papers/a/siblings/smith2025.md"),
|
|
225
|
+
);
|
|
226
|
+
expect(linted(r.out)).toContain(join(root, "papers/a/paper.tex"));
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
163
231
|
describe("`rules` blocks — where the rule lands", () => {
|
|
164
232
|
it("on its own files and on no other", async () => {
|
|
165
233
|
const root = project();
|
|
@@ -206,11 +274,48 @@ describe("paper-files — the pieces", () => {
|
|
|
206
274
|
expect(ownedPatterns(own)).toEqual(["**/a.md", "**/b.md", "**/c.md"]);
|
|
207
275
|
});
|
|
208
276
|
|
|
209
|
-
it("a rule's owner is where its plugin is registered; a global plugin owns every file", () => {
|
|
277
|
+
it("a rule's owner is where its plugin is registered; a global plugin owns every OWNED file", () => {
|
|
210
278
|
const owners = ruleOwners(own);
|
|
211
|
-
expect(owners.get("g/any")).
|
|
212
|
-
|
|
213
|
-
|
|
279
|
+
expect(owners.get("g/any")).toEqual([
|
|
280
|
+
{ files: ["**/a.md", "**/b.md", "**/c.md"], ignores: [] },
|
|
281
|
+
]);
|
|
282
|
+
expect(owners.get("p/one")).toEqual([{ files: ["**/a.md"], ignores: [] }]);
|
|
283
|
+
expect(owners.get("p/two")).toEqual([
|
|
284
|
+
{ files: ["**/b.md", "**/c.md"], ignores: [] },
|
|
285
|
+
]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("🔴 a scope keeps its block's exceptions, and a global plugin inherits them (#101)", () => {
|
|
289
|
+
const withIndex = [
|
|
290
|
+
...own,
|
|
291
|
+
{
|
|
292
|
+
files: ["**/s/*.md"],
|
|
293
|
+
ignores: ["**/s/README.md"],
|
|
294
|
+
plugins: { s: { rules: { card: {} } } },
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
expect(ownedScopes(withIndex)).toEqual([
|
|
298
|
+
{ files: ["**/a.md", "**/b.md", "**/c.md"], ignores: [] },
|
|
299
|
+
{ files: ["**/s/*.md"], ignores: ["**/s/README.md"] },
|
|
300
|
+
]);
|
|
301
|
+
// Guards: the defect — the plugin registered for every file used to keep any glob, and so
|
|
302
|
+
// reached the index the card block excludes.
|
|
303
|
+
expect(ruleOwners(withIndex).get("g/any")).toEqual(ownedScopes(withIndex));
|
|
304
|
+
expect(
|
|
305
|
+
narrowToOwners(
|
|
306
|
+
{
|
|
307
|
+
basePath: "/x",
|
|
308
|
+
files: ["papers/**/*.md"],
|
|
309
|
+
rules: { "g/any": "warn" },
|
|
310
|
+
},
|
|
311
|
+
ruleOwners(withIndex),
|
|
312
|
+
).at(-1),
|
|
313
|
+
).toEqual({
|
|
314
|
+
basePath: "/x",
|
|
315
|
+
files: [["papers/**/*.md", "**/s/*.md"]],
|
|
316
|
+
ignores: ["**/s/README.md"],
|
|
317
|
+
rules: { "g/any": "warn" },
|
|
318
|
+
});
|
|
214
319
|
});
|
|
215
320
|
});
|
|
216
321
|
|
|
@@ -249,7 +354,7 @@ describe("paper-files — narrowing a block", () => {
|
|
|
249
354
|
},
|
|
250
355
|
{
|
|
251
356
|
basePath: "/x",
|
|
252
|
-
files: ["papers/**"],
|
|
357
|
+
files: ["**/a.md", "**/b.md", "**/c.md"].map((o) => ["papers/**", o]),
|
|
253
358
|
ignores: ["papers/old/**"],
|
|
254
359
|
rules: { "g/any": "off" },
|
|
255
360
|
},
|