paperlint 2.0.0 → 2.0.1
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 +4 -4
- package/CLAUDE.md +3 -3
- package/LICENSE +1 -1
- package/README.md +25 -10
- package/action.harness.mjs +10 -10
- package/action.mutations.mjs +3 -3
- package/action.yml +11 -11
- package/bin/{rpp.mjs → paperlint.mjs} +2 -2
- package/dist/adapters/banal/invocation.js +3 -3
- package/dist/adapters/banal/invocation.js.map +1 -1
- package/dist/adapters/banal/run.d.ts +1 -1
- package/dist/adapters/banal/run.js +1 -1
- package/dist/adapters/banal/run.js.map +1 -1
- package/dist/adapters/banal/settings.d.ts +4 -4
- package/dist/adapters/banal/settings.d.ts.map +1 -1
- package/dist/adapters/banal/settings.js +4 -4
- package/dist/adapters/banal/settings.js.map +1 -1
- package/dist/adapters/banal/xml.js +1 -1
- package/dist/adapters/banal/xml.js.map +1 -1
- package/dist/adapters/curl/download.io.d.ts.map +1 -1
- package/dist/adapters/curl/download.io.js +2 -2
- package/dist/adapters/curl/download.io.js.map +1 -1
- package/dist/build-engine.d.ts +1 -1
- package/dist/build-engine.js +1 -1
- package/dist/build.js +1 -1
- package/dist/cli.d.ts +14 -29
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -74
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +0 -2
- package/dist/doctor.js.map +1 -1
- package/dist/engine.d.ts +2 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -1
- package/dist/hooks-settings.d.ts +0 -18
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +70 -42
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +2 -10
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +7 -39
- package/dist/init.js.map +1 -1
- package/dist/link-skills.js +2 -2
- package/dist/link-skills.js.map +1 -1
- package/dist/rules-config.d.ts +1 -1
- package/dist/rules-config.js +2 -2
- package/dist/toolchain.d.ts +2 -2
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +8 -8
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +4 -4
- package/dist/types.d.ts.map +1 -1
- package/docs/configuration.md +6 -10
- package/docs/e2e.md +3 -4
- package/docs/install.md +11 -3
- package/docs/optional-rules.md +1 -1
- package/docs/rules.md +1 -1
- package/docs/toolchain.md +5 -5
- package/eslint-rules/paper-typography.mjs +1 -1
- package/eslint-rules/paper-typography.mutations.mjs +1 -1
- package/eslint-rules/pdf-last-page-balance.harness.mjs +5 -3
- package/fixtures/build-e2e/guards/paper.tex +1 -1
- package/fixtures/pdf-facts/README.md +1 -1
- package/fixtures/real-markdown-paper/baseline.json +1 -1
- package/fixtures/real-markdown-paper/baseline.mjs +2 -2
- package/fixtures/toolchain-mirror/install-tl +2 -2
- package/fixtures/toolchain-mirror/stub-pdflatex +1 -1
- package/fixtures/toolchain-mirror/stub-tlmgr +1 -1
- package/hooks/hooks.harness.mjs +1 -1
- package/lib/paper-config.harness.mjs +3 -3
- package/lib/paper-config.mjs +3 -3
- package/lib/skill-trigger-cases.harness.mjs +1 -1
- package/package.json +4 -4
- package/plugin/hooks/hooks.json +3 -3
- package/scripts/check.mjs +1 -1
- package/scripts/harness-api.frozen.json +1 -1
- package/scripts/harness-api.test.ts +2 -2
- package/scripts/layer-legacy.frozen.json +1 -1
- package/scripts/mutation-batteries-frozen.harness.mjs +1 -1
- package/scripts/mutation-batteries.frozen.json +2 -2
- package/scripts/release-config.test.ts +1 -1
- package/skills/paper-pipeline/description-language.eval.mjs +1 -1
- package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +1 -1
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +2 -2
- package/skills/paper-pipeline/pipeline-firing.eval.mjs +1 -1
- package/skills/paper-pipeline/pipeline-language.eval.mjs +1 -1
- package/skills/paper-pipeline/scripts/consumer.d.mts +1 -0
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +10 -4
- package/skills/paper-pipeline/scripts/consumer.mjs +8 -1
- package/skills/paper-pipeline/scripts/consumer.mutations.mjs +1 -1
- package/skills/render-paper/SKILL.md +3 -3
- package/skills/render-paper/SKILL.md.spec.ts +2 -2
- package/skills/render-paper/extract-pdf-facts.harness.mjs +1 -1
- package/skills/submit-paper/references/venues/venue-profile.schema.json +1 -1
- package/src/adapters/banal/index.test.ts +1 -1
- package/src/adapters/banal/invocation.test.ts +3 -3
- package/src/adapters/banal/invocation.ts +3 -3
- package/src/adapters/banal/locate.test.ts +4 -4
- package/src/adapters/banal/run.ts +2 -2
- package/src/adapters/banal/settings.test.ts +12 -6
- package/src/adapters/banal/settings.ts +9 -5
- package/src/adapters/banal/xml.test.ts +1 -1
- package/src/adapters/banal/xml.ts +1 -1
- package/src/adapters/curl/download.io.ts +4 -2
- package/src/adapters/curl/download.test.ts +3 -1
- package/src/adapters/node/files.test.ts +1 -1
- package/src/adapters/node/process.test.ts +2 -2
- package/src/adapters/node/workspace.test.ts +4 -4
- package/src/build-engine.harness.mjs +2 -2
- package/src/build-engine.ts +1 -1
- package/src/build.harness.mjs +6 -6
- package/src/build.mutations.mjs +3 -2
- package/src/build.ts +1 -1
- package/src/cli.harness.mjs +68 -129
- package/src/cli.mutations.mjs +7 -32
- package/src/cli.ts +46 -92
- package/src/doctor.harness.mjs +9 -25
- package/src/doctor.ts +0 -2
- package/src/engine.harness.mjs +4 -2
- package/src/engine.ts +2 -2
- package/src/hooks-settings.harness.mjs +24 -15
- package/src/hooks-settings.mutations.mjs +2 -4
- package/src/hooks-settings.ts +92 -49
- package/src/init.ts +7 -49
- package/src/latex-log.harness.mjs +1 -1
- package/src/link-skills.harness.mjs +3 -1
- package/src/link-skills.mutations.mjs +1 -1
- package/src/link-skills.ts +2 -2
- package/src/new-paper.harness.mjs +1 -1
- package/src/pdf-facts.harness.mjs +1 -1
- package/src/rules-config.ts +2 -2
- package/src/structure.harness.mjs +1 -1
- package/src/tex-requirements.harness.mjs +1 -1
- package/src/toolchain.harness.mjs +34 -25
- package/src/toolchain.ts +14 -8
- package/src/types.ts +4 -4
- package/plugin/.claude-plugin/plugin.json +0 -8
package/src/hooks-settings.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
27
27
|
import { basename, dirname, join, posix } from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
29
|
import {
|
|
30
|
+
BIN_FILE,
|
|
30
31
|
LEGACY_PACKAGE_NAME,
|
|
31
32
|
PACKAGE_NAME,
|
|
32
33
|
} from "../skills/paper-pipeline/scripts/consumer.mjs";
|
|
@@ -44,13 +45,7 @@ export const WIRING_FILE = fileURLToPath(
|
|
|
44
45
|
* and `${CLAUDE_PROJECT_DIR}/` before comparing, so this is the same file as the spelling in
|
|
45
46
|
* `hooks.json`, and nothing machine-specific lands in a committed file.
|
|
46
47
|
*/
|
|
47
|
-
export const MANAGED_BY = `node_modules/${PACKAGE_NAME}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The same token as an install under the package's old name wrote it. A command spelled this way
|
|
51
|
-
* is ours from before the rename: `init` removes it and writes the current one, `doctor` names it.
|
|
52
|
-
*/
|
|
53
|
-
export const LEGACY_MANAGED_BY = `node_modules/${LEGACY_PACKAGE_NAME}/bin/rpp.mjs`;
|
|
48
|
+
export const MANAGED_BY = `node_modules/${PACKAGE_NAME}/${BIN_FILE}`;
|
|
54
49
|
|
|
55
50
|
/** The names the package's directory under `node_modules` has had. */
|
|
56
51
|
const PKG_DIRS: readonly string[] = [PACKAGE_NAME, LEGACY_PACKAGE_NAME];
|
|
@@ -81,50 +76,91 @@ function bare(token: string): string {
|
|
|
81
76
|
return unquoted.replace(/^\$\{?CLAUDE_PROJECT_DIR\}?[/\\]/, "");
|
|
82
77
|
}
|
|
83
78
|
|
|
79
|
+
/** A hook command reduced to what it runs: the hook, and the path token that runs it. */
|
|
80
|
+
interface Run {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly ours: boolean;
|
|
83
|
+
readonly legacy: boolean;
|
|
84
|
+
readonly path: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
84
87
|
/**
|
|
85
88
|
* Which paperlint hook a command runs, if any, and whether it is spelled the way `init` writes it.
|
|
86
89
|
*
|
|
87
90
|
* Recognised spellings — each is ONE lexeme of a shell command, which is what a command is:
|
|
88
|
-
* `node <…>/node_modules/paperlint/bin/
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* `node <…>/node_modules/paperlint/bin/paperlint.mjs hook <name>`
|
|
92
|
+
* ours when the path is exactly MANAGED_BY
|
|
93
|
+
* `node node_modules/<pkg>/bin/<any other file> hook <name>`, <pkg> this name or the old one
|
|
94
|
+
* `legacy`: the project-relative form `init` writes, pointing at a file
|
|
95
|
+
* this version does not install — what an older `init` left behind
|
|
96
|
+
* (1.x: `research-paper-pipeline/bin/rpp.mjs`; 2.0.0: `paperlint/bin/rpp.mjs`)
|
|
97
|
+
* `npx paperlint hook <name>`, `node_modules/.bin/paperlint hook <name>` (or under the old package name),
|
|
98
|
+
* an absolute path into the package's `bin/`
|
|
99
|
+
* another spelling
|
|
93
100
|
* `vigiles … run-program <…>/node_modules/<pkg>/hooks/<name>.hook.mjs`
|
|
94
|
-
*
|
|
101
|
+
* another spelling
|
|
95
102
|
* The last is how the one real consumer wired all three by hand before `init` could.
|
|
103
|
+
*
|
|
104
|
+
* `legacy` is decided by the SHAPE `init` writes, not by a list of past file names: any file in
|
|
105
|
+
* our `bin/` other than BIN_FILE is one that no longer ships, so a future rename is covered too.
|
|
96
106
|
*/
|
|
107
|
+
function parseRun(command: string): Run | null {
|
|
108
|
+
const tokens = command.trim().split(/\s+/).map(bare);
|
|
109
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
110
|
+
const run = runAt(tokens, i);
|
|
111
|
+
if (run) return run;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The path segments after `node_modules/<pkg>/`, and whether the token starts there. */
|
|
117
|
+
function insidePackage(parts: readonly string[]): {
|
|
118
|
+
readonly inside: readonly string[];
|
|
119
|
+
readonly relative: boolean;
|
|
120
|
+
} {
|
|
121
|
+
const at = parts.findIndex(
|
|
122
|
+
(p, j) => p === "node_modules" && PKG_DIRS.includes(parts[j + 1] ?? ""),
|
|
123
|
+
);
|
|
124
|
+
return {
|
|
125
|
+
inside: at === -1 ? [] : parts.slice(at + 2),
|
|
126
|
+
relative: at === 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The run token `i` starts, if it names a paperlint hook. */
|
|
131
|
+
function runAt(tokens: readonly string[], i: number): Run | null {
|
|
132
|
+
const t = posix.normalize(tokens[i] ?? "");
|
|
133
|
+
const { inside, relative } = insidePackage(t.split("/"));
|
|
134
|
+
const inBin = inside[0] === "bin" && inside.length === 2;
|
|
135
|
+
const name = tokens[i + 2];
|
|
136
|
+
if (
|
|
137
|
+
(inBin || PKG_DIRS.includes(basename(t))) &&
|
|
138
|
+
tokens[i + 1] === "hook" &&
|
|
139
|
+
name
|
|
140
|
+
)
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
ours: t === MANAGED_BY,
|
|
144
|
+
legacy: inBin && relative && t !== MANAGED_BY,
|
|
145
|
+
path: t,
|
|
146
|
+
};
|
|
147
|
+
if (inside[0] === "hooks" && inside[1]?.endsWith(".hook.mjs"))
|
|
148
|
+
return {
|
|
149
|
+
name: basename(inside[1], ".hook.mjs"),
|
|
150
|
+
ours: false,
|
|
151
|
+
legacy: false,
|
|
152
|
+
path: t,
|
|
153
|
+
};
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
97
157
|
export function hookRun(command: string): {
|
|
98
158
|
readonly name: string;
|
|
99
159
|
readonly ours: boolean;
|
|
100
160
|
readonly legacy: boolean;
|
|
101
161
|
} | null {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const t = posix.normalize(tokens[i] ?? "");
|
|
105
|
-
const parts = t.split("/");
|
|
106
|
-
const at = parts.findIndex(
|
|
107
|
-
(p, j) => p === "node_modules" && PKG_DIRS.includes(parts[j + 1] ?? ""),
|
|
108
|
-
);
|
|
109
|
-
const inside = at === -1 ? [] : parts.slice(at + 2);
|
|
110
|
-
const isBin =
|
|
111
|
-
(inside[0] === "bin" && inside[1] === "rpp.mjs") ||
|
|
112
|
-
["rpp", ...PKG_DIRS].includes(basename(t));
|
|
113
|
-
const name = tokens[i + 2];
|
|
114
|
-
if (isBin && tokens[i + 1] === "hook" && name)
|
|
115
|
-
return {
|
|
116
|
-
name,
|
|
117
|
-
ours: t === MANAGED_BY,
|
|
118
|
-
legacy: t === LEGACY_MANAGED_BY,
|
|
119
|
-
};
|
|
120
|
-
if (inside[0] === "hooks" && inside[1]?.endsWith(".hook.mjs"))
|
|
121
|
-
return {
|
|
122
|
-
name: basename(inside[1], ".hook.mjs"),
|
|
123
|
-
ours: false,
|
|
124
|
-
legacy: false,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
return null;
|
|
162
|
+
const run = parseRun(command);
|
|
163
|
+
return run && { name: run.name, ours: run.ours, legacy: run.legacy };
|
|
128
164
|
}
|
|
129
165
|
|
|
130
166
|
/** Every command in a settings object, with the event it hangs off. */
|
|
@@ -356,16 +392,23 @@ export const FRESH_CLONE_NOTE = `the commands point into node_modules/${PACKAGE_
|
|
|
356
392
|
/** The instruction for a project that also enables the plugin (it only ever existed under the old name). */
|
|
357
393
|
export const UNINSTALL_PLUGIN = `/plugin uninstall ${LEGACY_PACKAGE_NAME}@${LEGACY_PACKAGE_NAME}`;
|
|
358
394
|
|
|
359
|
-
/**
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
395
|
+
/**
|
|
396
|
+
* The doctor lines for hook commands an older install left behind: each names the file it runs,
|
|
397
|
+
* which this version does not install, so the hook silently does nothing.
|
|
398
|
+
*/
|
|
399
|
+
function legacyLines(settings: Settings): string[] {
|
|
400
|
+
const stale = new Map<string, number>();
|
|
401
|
+
for (const { command } of commandsIn(settings)) {
|
|
402
|
+
const run = parseRun(command);
|
|
403
|
+
if (run?.legacy) stale.set(run.path, (stale.get(run.path) ?? 0) + 1);
|
|
404
|
+
}
|
|
405
|
+
return stale.size === 0
|
|
366
406
|
? []
|
|
367
407
|
: [
|
|
368
|
-
|
|
408
|
+
...[...stale].map(
|
|
409
|
+
([path, n]) =>
|
|
410
|
+
` ⚠ ${String(n)} hook command(s) run ${path}, which this version does not install — they no longer run.`,
|
|
411
|
+
),
|
|
369
412
|
` \`npx ${PACKAGE_NAME} init\` replaces them`,
|
|
370
413
|
];
|
|
371
414
|
}
|
|
@@ -400,7 +443,7 @@ export function doctorHooks(
|
|
|
400
443
|
out.push(` ⚠ wired TWICE — each of these runs more than once per event:`);
|
|
401
444
|
for (const n of twice) out.push(` ${n} ×${String(total(n))}`);
|
|
402
445
|
out.push(
|
|
403
|
-
` keep one command per hook; \`npx paperlint init\` writes the
|
|
446
|
+
` keep one command per hook; \`npx paperlint init\` writes the ${MANAGED_BY} form`,
|
|
404
447
|
);
|
|
405
448
|
}
|
|
406
449
|
if (missing.length === wiring.names.length)
|
|
@@ -418,7 +461,7 @@ export function doctorHooks(
|
|
|
418
461
|
` ✓ wired — ${wiring.names.join(", ")}, once each`,
|
|
419
462
|
` ${FRESH_CLONE_NOTE}`,
|
|
420
463
|
);
|
|
421
|
-
out.push(...legacyLines(
|
|
464
|
+
out.push(...legacyLines(read.settings));
|
|
422
465
|
const plugin = pluginEnabledHere(read.settings);
|
|
423
466
|
if (plugin.length > 0) {
|
|
424
467
|
out.push(
|
package/src/init.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `paperlint init` — the whole install, in the terminal it was typed in.
|
|
3
3
|
*
|
|
4
4
|
* 🔴 WHAT THIS COMMAND USED TO DO, AND WHY THAT WAS A DEFECT RATHER THAN A SHORTFALL. It wrote
|
|
5
|
-
*
|
|
5
|
+
* a separate config file with a GUESSED `"papers": "papers"` and never touched `package.json`. The three hooks
|
|
6
6
|
* read the papers directory out of `package.json` and nothing else, so a consumer who followed the
|
|
7
7
|
* documented install got a `paper-edit-guard` watching a directory that did not exist — and a guard
|
|
8
8
|
* watching nothing is byte-identical, from outside, to a guard that is working (issue #33).
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* carry a paper marker. Several hits is the only case a human is asked about.
|
|
16
16
|
* declaration WRITTEN into `package.json`, merged, never overwriting a value that is
|
|
17
17
|
* already there. Prior art: husky's `init` edits the consumer's package.json
|
|
18
|
-
* to add `prepare`.
|
|
18
|
+
* to add `prepare`. No second config file is created.
|
|
19
19
|
* skills LINKED — one relative symlink per shipped skill into `.claude/skills/`, the
|
|
20
20
|
* only place Claude Code looks for project skills (`link-skills.ts`). An
|
|
21
21
|
* entry of the same name that paperlint did not make is reported, never replaced.
|
|
@@ -204,8 +204,8 @@ export type DeclarationResult =
|
|
|
204
204
|
*
|
|
205
205
|
* 🔴 A HOOK CANNOT IMPORT CODE AND CANNOT WALK UP A TREE LOOKING FOR A CONFIG. It can read a path
|
|
206
206
|
* it is able to spell, and the only path it can always spell is the project's own `package.json`.
|
|
207
|
-
* That asymmetry is the whole reason the declaration
|
|
208
|
-
*
|
|
207
|
+
* That asymmetry is the whole reason the declaration lives here rather than in a file of its own:
|
|
208
|
+
* many readers against one (`docs/install.md`, "One declaration").
|
|
209
209
|
*
|
|
210
210
|
* ⚠️ Merged, not rewritten, and never over a value the consumer set — an `init` that silently
|
|
211
211
|
* replaces a setting is worse than an `init` that does nothing, because the consumer keeps
|
|
@@ -259,35 +259,6 @@ function renameKey(
|
|
|
259
259
|
);
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
export type RppJsonResult = "absent" | "kept" | "filled" | "unparsable";
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* `rpp.json` is no longer CREATED — but a consumer who already has one keeps it working, and it
|
|
266
|
-
* gets the same `papers` value rather than being left to disagree with `package.json` in silence.
|
|
267
|
-
* Two declarations that disagree is the defect `paperlint doctor` was written to catch; writing the
|
|
268
|
-
* second one on purpose would be handing it new work.
|
|
269
|
-
*/
|
|
270
|
-
export function syncRppJson(root: string, papers: string): RppJsonResult {
|
|
271
|
-
const path = join(root, "rpp.json");
|
|
272
|
-
if (!existsSync(path)) return "absent";
|
|
273
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
|
|
274
|
-
let cfg: Record<string, any>;
|
|
275
|
-
const raw = readFileSync(path, "utf8");
|
|
276
|
-
try {
|
|
277
|
-
cfg = JSON.parse(raw);
|
|
278
|
-
} catch {
|
|
279
|
-
return "unparsable";
|
|
280
|
-
}
|
|
281
|
-
if (cfg?.[PAPERS_DIR_FIELD] !== undefined) return "kept";
|
|
282
|
-
cfg[PAPERS_DIR_FIELD] = papers;
|
|
283
|
-
writeFileSync(
|
|
284
|
-
path,
|
|
285
|
-
JSON.stringify(cfg, null, 2) + (raw.endsWith("\n") ? "\n" : ""),
|
|
286
|
-
"utf8",
|
|
287
|
-
);
|
|
288
|
-
return "filled";
|
|
289
|
-
}
|
|
290
|
-
|
|
291
262
|
export const WORKFLOW_PATH = join(".github", "workflows", "papers.yml");
|
|
292
263
|
|
|
293
264
|
/** Where the action is pinned when no release tag is known — obviously a placeholder. */
|
|
@@ -310,7 +281,7 @@ export function workflowYaml(papers: string, ref: string | null): string {
|
|
|
310
281
|
` runs-on: ubuntu-latest`,
|
|
311
282
|
` steps:`,
|
|
312
283
|
` - uses: actions/checkout@v4`,
|
|
313
|
-
` - uses: zernie/
|
|
284
|
+
` - uses: zernie/paperlint@${ref ?? UNPINNED_REF}`,
|
|
314
285
|
` with:`,
|
|
315
286
|
` paths: ${papers}`,
|
|
316
287
|
``,
|
|
@@ -380,7 +351,7 @@ export function reportWorkflow(
|
|
|
380
351
|
if (wf !== "written" && wf !== "kept")
|
|
381
352
|
out.push(
|
|
382
353
|
` to run the same checks in CI, add this step to a workflow:`,
|
|
383
|
-
` - uses: zernie/
|
|
354
|
+
` - uses: zernie/paperlint@${ref ?? UNPINNED_REF}`,
|
|
384
355
|
` with:`,
|
|
385
356
|
` paths: ${papersDir}`,
|
|
386
357
|
);
|
|
@@ -772,23 +743,10 @@ export async function init(
|
|
|
772
743
|
` one declaration — the hooks, the rules and the CLI all read this one key`,
|
|
773
744
|
);
|
|
774
745
|
// Every step below uses the DECLARED directory. A kept declaration outranks what init
|
|
775
|
-
// measured or guessed: otherwise the first paper
|
|
746
|
+
// measured or guessed: otherwise the first paper and the workflow would land in the
|
|
776
747
|
// guessed directory while lint and the hooks keep reading the declared one.
|
|
777
748
|
const papersDir =
|
|
778
749
|
decl.status === "kept" ? (decl.papers as string) : choice.papers;
|
|
779
|
-
const rpp = syncRppJson(root, papersDir);
|
|
780
|
-
if (rpp === "filled")
|
|
781
|
-
log(
|
|
782
|
-
` ⚠ rpp.json was already here — gave it the same ${PAPERS_DIR_FIELD} value; it is deprecated`,
|
|
783
|
-
);
|
|
784
|
-
else if (rpp === "kept")
|
|
785
|
-
log(
|
|
786
|
-
` ⚠ rpp.json was already here and already declares ${PAPERS_DIR_FIELD} — left untouched; it is deprecated`,
|
|
787
|
-
);
|
|
788
|
-
else if (rpp === "unparsable")
|
|
789
|
-
log(
|
|
790
|
-
` ⚠ rpp.json is here and does not parse — left untouched; it is deprecated, delete it`,
|
|
791
|
-
);
|
|
792
750
|
|
|
793
751
|
// ── 3. the skills, linked where Claude Code looks for them ─────────────────────────────
|
|
794
752
|
for (const line of reportSkillLinks(link(root), here)) log(line);
|
|
@@ -112,7 +112,7 @@ const undefinedCs = [
|
|
|
112
112
|
"Here is how much of TeX's memory you used:",
|
|
113
113
|
];
|
|
114
114
|
const ex = errorExcerpt(undefinedCs);
|
|
115
|
-
// Guards: the spelling
|
|
115
|
+
// Guards: the spelling paperlint's own flags produce for most errors — with only the `!` form recognised,
|
|
116
116
|
// an undefined control sequence fails with no error line quoted.
|
|
117
117
|
check(
|
|
118
118
|
"-file-line-error form: the error line, the l.NNN line and the rest of the source line",
|
|
@@ -50,7 +50,9 @@ const check = (label, cond) => {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
const SKILLS = ["alpha", "beta", "gamma"];
|
|
53
|
-
const work = realpathSync(
|
|
53
|
+
const work = realpathSync(
|
|
54
|
+
mkdtempSync(join(tmpdir(), "paperlint-link-skills-")),
|
|
55
|
+
);
|
|
54
56
|
|
|
55
57
|
/** A package as a manager would unpack it: manifest, skills, one non-skill. */
|
|
56
58
|
function writePackage(dir) {
|
|
@@ -27,7 +27,7 @@ process.exit(
|
|
|
27
27
|
runner: "node",
|
|
28
28
|
cases: [
|
|
29
29
|
{
|
|
30
|
-
// The definition lives in the port since
|
|
30
|
+
// The definition lives in the port since paperlint#62 (`installedSkills` in consumer.mjs), so
|
|
31
31
|
// the defect is planted THERE and must still die in THIS harness — the evidence that the
|
|
32
32
|
// linker reads it through the shared function rather than through a copy of its own.
|
|
33
33
|
name: "any directory counts as a skill",
|
package/src/link-skills.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function locatePackage(project: string): Located {
|
|
|
66
66
|
let manifest: string;
|
|
67
67
|
try {
|
|
68
68
|
const req = createRequire(
|
|
69
|
-
pathToFileURL(join(project, "
|
|
69
|
+
pathToFileURL(join(project, "__paperlint_locate__.js")).href,
|
|
70
70
|
);
|
|
71
71
|
manifest = req.resolve(`${PACKAGE_NAME}/package.json`);
|
|
72
72
|
} catch (e) {
|
|
@@ -100,7 +100,7 @@ export function shippedSkills(
|
|
|
100
100
|
// clean run.
|
|
101
101
|
if (!existsSync(skillsDir))
|
|
102
102
|
return { error: `the package's skills directory is missing: ${skillsDir}` };
|
|
103
|
-
// The same function every reader of an installed skills directory calls (
|
|
103
|
+
// The same function every reader of an installed skills directory calls (paperlint#62): the writer
|
|
104
104
|
// and the readers of this fact share one definition of "a skill is here", links included.
|
|
105
105
|
try {
|
|
106
106
|
return { skillsDir, names: installedSkills(skillsDir) };
|
|
@@ -52,7 +52,7 @@ const check = (label, cond) => {
|
|
|
52
52
|
assert.ok(cond, label);
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
const work = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
55
|
+
const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
|
|
56
56
|
const read = (...p) => readFileSync(join(...p), "utf8");
|
|
57
57
|
const front = (text) => load(frontmatterBlock(text) ?? "") ?? {};
|
|
58
58
|
|
|
@@ -116,7 +116,7 @@ const named = (facts, name) =>
|
|
|
116
116
|
JSON.stringify(r),
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
|
-
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
119
|
+
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-pdf-facts-")));
|
|
120
120
|
try {
|
|
121
121
|
const notPdf = join(tmp, "paper.pdf");
|
|
122
122
|
writeFileSync(notPdf, "%PDF-stale\n");
|
package/src/rules-config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* "rules": [ { "files": ["papers/agenticdev-2026/**"],
|
|
10
10
|
* "rules": { "pdf/last-page-balance": ["error", { "tolerancePt": 120 }] } } ]
|
|
11
11
|
*
|
|
12
|
-
* The blocks are appended AFTER
|
|
12
|
+
* The blocks are appended AFTER paperlint's built-in config, so a later block wins, exactly as in ESLint.
|
|
13
13
|
* `files` and `ignores` are resolved relative to the directory of the file that holds the settings,
|
|
14
14
|
* as ESLint resolves them relative to its config file: each block gets that directory as ESLint's
|
|
15
15
|
* own `basePath`, so paperlint matches nothing itself.
|
|
@@ -159,7 +159,7 @@ export function parseRuleBlocks(
|
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* The rule ids a config defines through its own plugins, as `<plugin>/<rule>`. Plugins passed in
|
|
162
|
-
* `foreign` (a dependency's plugin, such as `@eslint/markdown`) are not
|
|
162
|
+
* `foreign` (a dependency's plugin, such as `@eslint/markdown`) are not paperlint's and are skipped.
|
|
163
163
|
*/
|
|
164
164
|
export function shippedRuleIds(
|
|
165
165
|
config: readonly unknown[],
|
|
@@ -28,7 +28,7 @@ const check = (label, cond) => {
|
|
|
28
28
|
n++;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
const root = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
31
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-struct-")));
|
|
32
32
|
const papers = join(root, "papers");
|
|
33
33
|
const paper = (name, files) => {
|
|
34
34
|
const dir = join(papers, name);
|
|
@@ -116,7 +116,7 @@ for (const [t, vs] of byTemplate)
|
|
|
116
116
|
);
|
|
117
117
|
|
|
118
118
|
// ── 2. the schema rejects ───────────────────────────────────────────────────────────────
|
|
119
|
-
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
119
|
+
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-texreq-h-")));
|
|
120
120
|
copyFileSync(join(VENUES, R.SCHEMA_FILE), join(tmp, R.SCHEMA_FILE));
|
|
121
121
|
const bad = (text) => throws(() => R.parseProfile(text, "bad.jsonc", tmp));
|
|
122
122
|
const BAD = [
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 🔴 NOT ONE BYTE OF TeX LIVE IS DOWNLOADED HERE. The mirror is a directory served by `file://`
|
|
5
5
|
* URLs: the real `curl` fetches from it, the real `tar` lists and unpacks it, and the installer
|
|
6
6
|
* inside is `fixtures/toolchain-mirror/install-tl`, which lays out a tree whose `kpsewhich` and
|
|
7
|
-
* `tlmgr` answer from two text files. So what runs is
|
|
7
|
+
* `tlmgr` answer from two text files. So what runs is paperlint's own download, fallback, unpack,
|
|
8
8
|
* install and verify logic — the part that decides success — and what is faked is only TeX.
|
|
9
9
|
* The real TeX Live half is `test/e2e/toolchain.mjs`.
|
|
10
10
|
*
|
|
@@ -78,22 +78,25 @@ check(
|
|
|
78
78
|
) === '["urw-base35"]',
|
|
79
79
|
);
|
|
80
80
|
check(
|
|
81
|
-
"cacheRoot:
|
|
82
|
-
T.cacheRoot(
|
|
83
|
-
"/ci/tl",
|
|
81
|
+
"cacheRoot: PAPERLINT_TEXLIVE_DIR wins",
|
|
82
|
+
T.cacheRoot(
|
|
83
|
+
{ PAPERLINT_TEXLIVE_DIR: "/ci/tl", XDG_CACHE_HOME: "/x" },
|
|
84
|
+
"/h",
|
|
85
|
+
) === "/ci/tl",
|
|
84
86
|
);
|
|
85
87
|
check(
|
|
86
88
|
"cacheRoot: then XDG_CACHE_HOME",
|
|
87
|
-
T.cacheRoot({ XDG_CACHE_HOME: "/x" }, "/h") === "/x/
|
|
89
|
+
T.cacheRoot({ XDG_CACHE_HOME: "/x" }, "/h") === "/x/paperlint/texlive",
|
|
88
90
|
);
|
|
89
91
|
check(
|
|
90
92
|
"cacheRoot: then ~/.cache",
|
|
91
|
-
T.cacheRoot({}, "/h") === "/h/.cache/
|
|
93
|
+
T.cacheRoot({}, "/h") === "/h/.cache/paperlint/texlive",
|
|
92
94
|
);
|
|
93
95
|
check(
|
|
94
|
-
"mirrorsFrom:
|
|
95
|
-
JSON.stringify(
|
|
96
|
-
|
|
96
|
+
"mirrorsFrom: PAPERLINT_CTAN_MIRROR is the ONLY mirror, trailing slash dropped",
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
T.mirrorsFrom({ PAPERLINT_CTAN_MIRROR: "https://m/tlnet/" }),
|
|
99
|
+
) === '["https://m/tlnet"]',
|
|
97
100
|
);
|
|
98
101
|
check(
|
|
99
102
|
"mirrorsFrom: several defaults, every one https",
|
|
@@ -146,7 +149,9 @@ check(
|
|
|
146
149
|
check("usableTree: an empty cache", T.usableTree([], () => true) === null);
|
|
147
150
|
|
|
148
151
|
// ── the fake mirror ────────────────────────────────────────────────────────────────────
|
|
149
|
-
const work = realpathSync(
|
|
152
|
+
const work = realpathSync(
|
|
153
|
+
mkdtempSync(join(tmpdir(), "paperlint-toolchain-h-")),
|
|
154
|
+
);
|
|
150
155
|
const good = join(work, "good");
|
|
151
156
|
const garbage = join(work, "garbage");
|
|
152
157
|
const staging = join(work, "staging", "install-tl-20260924");
|
|
@@ -259,14 +264,14 @@ const TEX = {
|
|
|
259
264
|
tools: { texcount: ["texcount"] },
|
|
260
265
|
};
|
|
261
266
|
const root = join(work, "cache");
|
|
262
|
-
// 🔴 banal goes into the work directory, never the developer's cache: without
|
|
263
|
-
// command installs into ~/.cache/
|
|
267
|
+
// 🔴 banal goes into the work directory, never the developer's cache: without PAPERLINT_BANAL_DIR the
|
|
268
|
+
// command installs into ~/.cache/paperlint/banal, and a harness must not leave files in a home.
|
|
264
269
|
const banalDir = join(work, "banal");
|
|
265
270
|
const env = {
|
|
266
271
|
...process.env,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
272
|
+
PAPERLINT_TEXLIVE_DIR: root,
|
|
273
|
+
PAPERLINT_CTAN_MIRROR: url(good),
|
|
274
|
+
PAPERLINT_BANAL_DIR: banalDir,
|
|
270
275
|
};
|
|
271
276
|
// A stand-in banal: a Perl script that prints what banal prints for a one-page, 10 pt probe.
|
|
272
277
|
const fakeBanal = join(work, "fake-banal");
|
|
@@ -446,7 +451,7 @@ const cmd = ({ banal = BANAL, ...over } = {}) => {
|
|
|
446
451
|
const r = cmd({
|
|
447
452
|
env: {
|
|
448
453
|
...env,
|
|
449
|
-
|
|
454
|
+
PAPERLINT_TEXLIVE_DIR: fresh,
|
|
450
455
|
FAKE_INSTALL_TL_FAIL: "1",
|
|
451
456
|
},
|
|
452
457
|
});
|
|
@@ -462,15 +467,15 @@ const cmd = ({ banal = BANAL, ...over } = {}) => {
|
|
|
462
467
|
const r = cmd({
|
|
463
468
|
env: {
|
|
464
469
|
...env,
|
|
465
|
-
|
|
466
|
-
|
|
470
|
+
PAPERLINT_TEXLIVE_DIR: join(work, "cache-nomirror"),
|
|
471
|
+
PAPERLINT_CTAN_MIRROR: MISSING,
|
|
467
472
|
},
|
|
468
473
|
});
|
|
469
474
|
check(
|
|
470
475
|
"no mirror answers: exit 1, names the mirrors and the override",
|
|
471
476
|
r.code === 1 &&
|
|
472
477
|
r.err.includes("no CTAN mirror returned install-tl") &&
|
|
473
|
-
r.err.includes("
|
|
478
|
+
r.err.includes("PAPERLINT_CTAN_MIRROR"),
|
|
474
479
|
r.err,
|
|
475
480
|
);
|
|
476
481
|
}
|
|
@@ -520,7 +525,11 @@ const withLibertine = {
|
|
|
520
525
|
};
|
|
521
526
|
const inYears = (mirror, over = {}) =>
|
|
522
527
|
cmd({
|
|
523
|
-
env: {
|
|
528
|
+
env: {
|
|
529
|
+
...env,
|
|
530
|
+
PAPERLINT_TEXLIVE_DIR: years,
|
|
531
|
+
PAPERLINT_CTAN_MIRROR: mirror,
|
|
532
|
+
},
|
|
524
533
|
...over,
|
|
525
534
|
});
|
|
526
535
|
check(
|
|
@@ -583,12 +592,12 @@ check(
|
|
|
583
592
|
{
|
|
584
593
|
// tlmgr says 2027, but the installer the mirrors hand out is still 2026.
|
|
585
594
|
const split = join(work, "cache-split");
|
|
586
|
-
cmd({ env: { ...env,
|
|
595
|
+
cmd({ env: { ...env, PAPERLINT_TEXLIVE_DIR: split } });
|
|
587
596
|
const r = cmd({
|
|
588
597
|
env: {
|
|
589
598
|
...env,
|
|
590
|
-
|
|
591
|
-
|
|
599
|
+
PAPERLINT_TEXLIVE_DIR: split,
|
|
600
|
+
PAPERLINT_CTAN_MIRROR: mirrorFor("split2027", "2027", "2026"),
|
|
592
601
|
},
|
|
593
602
|
tex: withLibertine,
|
|
594
603
|
});
|
|
@@ -606,7 +615,7 @@ check(
|
|
|
606
615
|
// ── 4. banal, the second half of the command ───────────────────────────────────────────
|
|
607
616
|
{
|
|
608
617
|
const dir = join(work, "banal-4");
|
|
609
|
-
const e = { ...env,
|
|
618
|
+
const e = { ...env, PAPERLINT_BANAL_DIR: dir };
|
|
610
619
|
const before = cmd({ env: e, check: true });
|
|
611
620
|
// Guards: --check covers banal — a complete TeX Live alone must not read as a ready toolchain.
|
|
612
621
|
check(
|
|
@@ -633,7 +642,7 @@ check(
|
|
|
633
642
|
}
|
|
634
643
|
{
|
|
635
644
|
const r = cmd({
|
|
636
|
-
env: { ...env,
|
|
645
|
+
env: { ...env, PAPERLINT_BANAL_DIR: join(work, "banal-bad") },
|
|
637
646
|
banal: { ...BANAL, sha256: "0".repeat(64) },
|
|
638
647
|
});
|
|
639
648
|
// Guards: the two halves are independent — a banal failure fails the command, and TeX Live still
|
package/src/toolchain.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `paperlint toolchain` — install upstream TeX Live, with exactly the packages the venue profiles
|
|
3
|
-
* declare, into
|
|
3
|
+
* declare, into paperlint's own cache, and banal (the page-geometry script HotCRP runs) at its pinned
|
|
4
4
|
* commit (`adapters/banal/`). `paperlint build` offers the TeX Live install on a terminal.
|
|
5
5
|
*
|
|
6
6
|
* 🔴 WHY paperlint INSTALLS TeX AT ALL (rule 11). "Install TeX Live yourself" was a manual step with a
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* once reported success having installed nothing: installers' exit codes are not evidence.)
|
|
20
20
|
* A second run with everything present does nothing and says so; `--check` only reports.
|
|
21
21
|
*
|
|
22
|
-
* Where: `$
|
|
23
|
-
* `$XDG_CACHE_HOME/
|
|
22
|
+
* Where: `$PAPERLINT_TEXLIVE_DIR` when set (CI points its cache there), else
|
|
23
|
+
* `$XDG_CACHE_HOME/paperlint/texlive`, else `~/.cache/paperlint/texlive` — one tree per TeX Live year inside.
|
|
24
24
|
* Processes run through the injected `run` (the port `build.ts` uses), so the harness drives the
|
|
25
25
|
* real download/unpack/verify logic against a fake mirror on disk, never the network.
|
|
26
26
|
*/
|
|
@@ -56,8 +56,8 @@ import {
|
|
|
56
56
|
type TexRequirements,
|
|
57
57
|
} from "./tex-requirements.ts";
|
|
58
58
|
|
|
59
|
-
export const CACHE_ENV = "
|
|
60
|
-
export const MIRROR_ENV = "
|
|
59
|
+
export const CACHE_ENV = "PAPERLINT_TEXLIVE_DIR";
|
|
60
|
+
export const MIRROR_ENV = "PAPERLINT_CTAN_MIRROR";
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* The CTAN mirrors, redirector first. 🔴 SEVERAL, AND THAT IS A MEASUREMENT: run 33650803245 died
|
|
@@ -99,7 +99,11 @@ export function cacheRoot(
|
|
|
99
99
|
home: string = homedir(),
|
|
100
100
|
): string {
|
|
101
101
|
if (env[CACHE_ENV]) return env[CACHE_ENV];
|
|
102
|
-
return join(
|
|
102
|
+
return join(
|
|
103
|
+
env["XDG_CACHE_HOME"] || join(home, ".cache"),
|
|
104
|
+
"paperlint",
|
|
105
|
+
"texlive",
|
|
106
|
+
);
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
/**
|
|
@@ -353,7 +357,9 @@ export function installBase(
|
|
|
353
357
|
mirrors: readonly string[],
|
|
354
358
|
newerThan: string | null = null,
|
|
355
359
|
): Step<{ tree: CachedTree; mirror: string }> {
|
|
356
|
-
const work = realpathSync(
|
|
360
|
+
const work = realpathSync(
|
|
361
|
+
mkdtempSync(join(tmpdir(), "paperlint-install-tl-")),
|
|
362
|
+
);
|
|
357
363
|
try {
|
|
358
364
|
io.log(` downloading install-tl…`);
|
|
359
365
|
const mirror = downloadInstaller(
|
|
@@ -409,7 +415,7 @@ function runInstallTl(
|
|
|
409
415
|
): Step<{ tree: CachedTree; mirror: string }> {
|
|
410
416
|
const dir = join(a.root, a.year);
|
|
411
417
|
mkdirSync(dir, { recursive: true });
|
|
412
|
-
const profile = join(a.work, "
|
|
418
|
+
const profile = join(a.work, "paperlint.profile");
|
|
413
419
|
writeFileSync(profile, tlProfile(dir));
|
|
414
420
|
io.log(
|
|
415
421
|
` install-tl: TeX Live ${a.year} scheme-basic from ${a.mirror} (~150 MB, about a minute)…`,
|
package/src/types.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { RuleBlock } from "./rules-config.ts";
|
|
|
5
5
|
* in someone's head: a typo in a config key read as "field not set", not as an error.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
/** The
|
|
9
|
-
export interface
|
|
8
|
+
/** The consumer's settings — the `paperlint` key of its `package.json`. */
|
|
9
|
+
export interface PaperlintConfig {
|
|
10
10
|
/** 🔴 REQUIRED. The paper director(ies), relative to the config file ITSELF. The field name is
|
|
11
11
|
* `PAPERS_DIR_FIELD` in lib/paper-config.mjs; code reads it through `papersDirOf()` in cli.ts. */
|
|
12
12
|
papersDir?: string | string[];
|
|
@@ -26,7 +26,7 @@ export interface RppConfig {
|
|
|
26
26
|
structure?: StructureConfig | false;
|
|
27
27
|
/** REMOVED — `paperlint build` compiles the paper itself. Still typed so a leftover key can be named. */
|
|
28
28
|
buildScripts?: unknown;
|
|
29
|
-
/** ESLint config blocks appended after
|
|
29
|
+
/** ESLint config blocks appended after paperlint's own — PARSED by `parseSettings` in cli.ts. */
|
|
30
30
|
rules?: readonly RuleBlock[];
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -102,5 +102,5 @@ export interface BuildResult {
|
|
|
102
102
|
|
|
103
103
|
/** The result of reading the config: either data, or the exit code the caller exits with. */
|
|
104
104
|
export type ConfigRead =
|
|
105
|
-
| { opts:
|
|
105
|
+
| { opts: PaperlintConfig; configPath: string | null; code?: undefined }
|
|
106
106
|
| { code: number; opts?: undefined; configPath?: undefined };
|