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/cli.ts
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two entry points into the tool, and both are whole now:
|
|
12
12
|
* npx paperlint lint ← here
|
|
13
|
-
* uses: zernie/
|
|
13
|
+
* uses: zernie/paperlint@<sha> ← action.yml
|
|
14
14
|
*
|
|
15
15
|
* ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
|
|
16
16
|
* The typography debt, the marker of the author-list check run, the field dictionary — all of that
|
|
17
17
|
* is about ONE corpus, and wiring it into the package would repeat the defect that put the path
|
|
18
18
|
* `.claude/skills/verify-citations/...` into a rule's message. So they live in the consumer's
|
|
19
|
-
* `
|
|
19
|
+
* `package.json`, under the `paperlint` key.
|
|
20
20
|
*
|
|
21
21
|
* 🔴 WHY THE COMMAND IS CALLED `lint` AND NOT `check`. It does exactly what everyone else calls by
|
|
22
22
|
* that word: reads files, changes nothing, prints findings, exits non-zero. `check` is taken in the
|
|
@@ -35,7 +35,7 @@ import markdown from "@eslint/markdown";
|
|
|
35
35
|
// Types come from consumer.d.mts beside it, the same arrangement as lib/paper-config.d.mts.
|
|
36
36
|
import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
|
|
37
37
|
export { isMain };
|
|
38
|
-
import type { Args,
|
|
38
|
+
import type { Args, PaperlintConfig, ConfigRead } from "./types.ts";
|
|
39
39
|
import {
|
|
40
40
|
checkStructure,
|
|
41
41
|
formatStructure,
|
|
@@ -123,7 +123,7 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
123
123
|
package the venue declares; on a terminal it offers to install
|
|
124
124
|
one, without a terminal it stops and names \`npx paperlint toolchain\`
|
|
125
125
|
npx paperlint toolchain [--check] install TeX Live with every package the venue profiles declare
|
|
126
|
-
into ~/.cache/
|
|
126
|
+
into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
|
|
127
127
|
run does nothing. --check: report what is missing, change nothing
|
|
128
128
|
npx paperlint doctor say what is actually wired — and what only LOOKS wired
|
|
129
129
|
npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
|
|
@@ -149,8 +149,8 @@ lint:
|
|
|
149
149
|
most findings here are advisory and a gate that fails on advice gets muted
|
|
150
150
|
|
|
151
151
|
settings — the \`paperlint\` key of your package.json, found by walking up from the
|
|
152
|
-
current directory, the way every other tool in the stack finds its config. \`
|
|
153
|
-
|
|
152
|
+
current directory, the way every other tool in the stack finds its config. \`--config\` names
|
|
153
|
+
another file of the same shape. \`papersDir\` is required; the rest is optional:
|
|
154
154
|
|
|
155
155
|
"paperlint": {
|
|
156
156
|
"papersDir": "papers",
|
|
@@ -164,14 +164,14 @@ read as a deprecated fallback and the run says so. \`papersDir\` is required; th
|
|
|
164
164
|
"rules": { "pdf/last-page-balance": "error" } } ]
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
"rules" takes ESLint flat-config blocks (files, ignores, rules), appended after
|
|
167
|
+
"rules" takes ESLint flat-config blocks (files, ignores, rules), appended after paperlint's own, with
|
|
168
168
|
files relative to the file holding the settings. Optional rules (off unless turned on there):
|
|
169
169
|
pdf/last-page-balance. An unknown key, anywhere in the settings, is an error.
|
|
170
170
|
`;
|
|
171
171
|
|
|
172
172
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
173
173
|
export function buildConfig(
|
|
174
|
-
opts:
|
|
174
|
+
opts: PaperlintConfig = {},
|
|
175
175
|
texLanguage: unknown,
|
|
176
176
|
): unknown[] {
|
|
177
177
|
const paperRules = { ...researchQuestion.rules, ...typography.rules };
|
|
@@ -272,8 +272,8 @@ export function buildConfig(
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
|
-
* The rule ids a consumer may name in `rules`: every rule
|
|
276
|
-
* config rather than listed again. `@eslint/markdown` is a dependency's plugin, not
|
|
275
|
+
* The rule ids a consumer may name in `rules`: every rule paperlint's own config defines, read off that
|
|
276
|
+
* config rather than listed again. `@eslint/markdown` is a dependency's plugin, not paperlint's.
|
|
277
277
|
*/
|
|
278
278
|
export const SHIPPED_RULES: ReadonlySet<string> = shippedRuleIds(
|
|
279
279
|
buildConfig({}, { sentinel: "tex language" }),
|
|
@@ -288,7 +288,7 @@ const isOn = (entry: unknown): boolean => {
|
|
|
288
288
|
|
|
289
289
|
/**
|
|
290
290
|
* Rules paperlint ships and turns on for no file itself — the ones a consumer opts into with `rules`.
|
|
291
|
-
* Derived: every shipped rule that no block of
|
|
291
|
+
* Derived: every shipped rule that no block of paperlint's own config names.
|
|
292
292
|
*/
|
|
293
293
|
export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
|
|
294
294
|
[...SHIPPED_RULES].filter(
|
|
@@ -309,7 +309,7 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
|
|
|
309
309
|
export async function silentOptionalRules(
|
|
310
310
|
eslint: ESLint,
|
|
311
311
|
lintedFiles: readonly string[],
|
|
312
|
-
opts:
|
|
312
|
+
opts: PaperlintConfig,
|
|
313
313
|
): Promise<string[]> {
|
|
314
314
|
const turnedOn = new Set(
|
|
315
315
|
(opts.rules ?? []).flatMap((b) =>
|
|
@@ -333,10 +333,10 @@ export async function silentOptionalRules(
|
|
|
333
333
|
* config blocks. Nothing after this sees the raw object.
|
|
334
334
|
*/
|
|
335
335
|
export function parseSettings(
|
|
336
|
-
opts:
|
|
336
|
+
opts: PaperlintConfig,
|
|
337
337
|
where: string,
|
|
338
338
|
baseDir: string,
|
|
339
|
-
): Parsed<
|
|
339
|
+
): Parsed<PaperlintConfig> {
|
|
340
340
|
const raw = opts as Record<string, unknown>;
|
|
341
341
|
const unknown = unknownKeys(raw);
|
|
342
342
|
if (unknown.length > 0)
|
|
@@ -431,7 +431,6 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
431
431
|
return out;
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
-
export const CONFIG_NAME = "rpp.json";
|
|
435
434
|
export const PKG_NAME = "package.json";
|
|
436
435
|
|
|
437
436
|
/**
|
|
@@ -450,36 +449,20 @@ function ownVersion(): string | undefined {
|
|
|
450
449
|
}
|
|
451
450
|
}
|
|
452
451
|
|
|
453
|
-
/** Where the consumer's settings were found, and in which of the two carriers. */
|
|
454
|
-
export interface Declaration {
|
|
455
|
-
readonly path: string;
|
|
456
|
-
readonly kind: "package.json" | "rpp.json";
|
|
457
|
-
}
|
|
458
|
-
|
|
459
452
|
/**
|
|
460
|
-
* 🔴 THE CLI
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* would find no `rpp.json`, report "nothing to lint", and the consumer would be back to
|
|
464
|
-
* declaring the same directory twice — the defect the single declaration removes (issue #33,
|
|
453
|
+
* 🔴 THE CLI READS `package.json`, THE ONE DECLARATION. `paperlint init` writes the settings under
|
|
454
|
+
* the `package.json` key that the three hooks and `eslint-rules` already read; the CLI reads the
|
|
455
|
+
* same file, so the install and the check cannot look at different files (issue #33,
|
|
465
456
|
* `docs/install.md`).
|
|
466
457
|
*
|
|
467
|
-
* `rpp.json` stays readable as a DEPRECATED fallback, and the read says so out loud. Silently
|
|
468
|
-
* dropping a file this command used to write would break working setups on upgrade.
|
|
469
|
-
*
|
|
470
458
|
* The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
|
|
471
|
-
* from inside one paper sees the same settings as a run from the repository root.
|
|
472
|
-
* `package.json` wins over `rpp.json`: it is the carrier every other reader uses, so preferring
|
|
473
|
-
* it is what keeps "one declaration" true rather than merely intended.
|
|
459
|
+
* from inside one paper sees the same settings as a run from the repository root.
|
|
474
460
|
*/
|
|
475
|
-
export function
|
|
461
|
+
export function findConfig(startDir: string): string | null {
|
|
476
462
|
let dir = resolve(startDir);
|
|
477
463
|
for (;;) {
|
|
478
464
|
const pkg = join(dir, PKG_NAME);
|
|
479
|
-
if (existsSync(pkg) && declaresSettings(pkg))
|
|
480
|
-
return { path: pkg, kind: "package.json" };
|
|
481
|
-
const rpp = join(dir, CONFIG_NAME);
|
|
482
|
-
if (existsSync(rpp)) return { path: rpp, kind: "rpp.json" };
|
|
465
|
+
if (existsSync(pkg) && declaresSettings(pkg)) return pkg;
|
|
483
466
|
const up = dirname(dir);
|
|
484
467
|
if (up === dir) return null;
|
|
485
468
|
dir = up;
|
|
@@ -500,11 +483,6 @@ const declaresSettings = (pkgPath: string): boolean => {
|
|
|
500
483
|
}
|
|
501
484
|
};
|
|
502
485
|
|
|
503
|
-
/** Kept as the one-line question "which file holds the settings" — callers that only need a path. */
|
|
504
|
-
export function findConfig(startDir: string): string | null {
|
|
505
|
-
return findDeclaration(startDir)?.path ?? null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
486
|
/**
|
|
509
487
|
* Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
|
|
510
488
|
* command needed the same config (`build`): two copies of this block would have drifted apart on
|
|
@@ -525,24 +503,17 @@ export function readConfig(
|
|
|
525
503
|
} = {},
|
|
526
504
|
): ConfigRead {
|
|
527
505
|
// 🔴 THE CONFIG FINDS ITSELF. An explicit `--config` beats the discovered one — it was named out
|
|
528
|
-
// loud, and a substitution is never silent.
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
const decl: Declaration | null = a.config
|
|
532
|
-
? {
|
|
533
|
-
path: a.config,
|
|
534
|
-
kind: basename(a.config) === PKG_NAME ? "package.json" : "rpp.json",
|
|
535
|
-
}
|
|
536
|
-
: findDeclaration(cwd);
|
|
537
|
-
const configPath = decl?.path ?? null;
|
|
506
|
+
// loud, and a substitution is never silent. Either way the file has the shape of a
|
|
507
|
+
// `package.json`: the settings sit under the key.
|
|
508
|
+
const configPath = a.config ?? findConfig(cwd);
|
|
538
509
|
if (a.config && !existsSync(a.config)) {
|
|
539
510
|
err(`config file not found: ${a.config}`);
|
|
540
511
|
return { code: 2 };
|
|
541
512
|
}
|
|
542
513
|
|
|
543
|
-
let opts:
|
|
514
|
+
let opts: PaperlintConfig = {};
|
|
544
515
|
let legacyKey = false;
|
|
545
|
-
if (
|
|
516
|
+
if (configPath) {
|
|
546
517
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
|
|
547
518
|
let parsed: any;
|
|
548
519
|
try {
|
|
@@ -551,15 +522,13 @@ export function readConfig(
|
|
|
551
522
|
err(`${configPath} is not valid JSON: ${(e as Error).message}`);
|
|
552
523
|
return { code: 2 };
|
|
553
524
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
legacyKey = d.legacy;
|
|
562
|
-
} else opts = parsed;
|
|
525
|
+
const d = declaredSettings(parsed);
|
|
526
|
+
if (d.conflict !== null) {
|
|
527
|
+
err(d.conflict);
|
|
528
|
+
return { code: 2 };
|
|
529
|
+
}
|
|
530
|
+
opts = (d.settings ?? {}) as PaperlintConfig;
|
|
531
|
+
legacyKey = d.legacy;
|
|
563
532
|
// The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
|
|
564
533
|
// up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
|
|
565
534
|
//
|
|
@@ -568,30 +537,19 @@ export function readConfig(
|
|
|
568
537
|
// test but by an attempt to wire our own action to this output; in the harness I first WORKED
|
|
569
538
|
// AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
|
|
570
539
|
// the defect exactly where it should have been shouting.
|
|
571
|
-
(a.json ? err : log)(`config: ${relative(cwd, configPath) ||
|
|
572
|
-
// 🔴 THE DEPRECATED CARRIER IS NAMED OUT LOUD, IT DOES NOT STOP BEING READ. The hooks read ONLY
|
|
573
|
-
// package.json, so a consumer whose settings stayed in rpp.json lints one directory and guards
|
|
574
|
-
// another — and both states look equally green.
|
|
575
|
-
if (decl.kind === "rpp.json")
|
|
576
|
-
(a.json ? err : log)(
|
|
577
|
-
` ⚠ ${CONFIG_NAME} is deprecated — move these keys under "${CONFIG_KEY}" in ${PKG_NAME}; ` +
|
|
578
|
-
`the hooks read only that file. \`npx paperlint init\` does it for you.`,
|
|
579
|
-
);
|
|
540
|
+
(a.json ? err : log)(`config: ${relative(cwd, configPath) || PKG_NAME}`);
|
|
580
541
|
if (legacyKey) (a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
|
|
581
542
|
}
|
|
582
543
|
|
|
583
544
|
// The old field name is refused before anything else is read from the settings: falling back
|
|
584
545
|
// to it would keep it working forever, and this package has no released users to migrate.
|
|
585
|
-
const where =
|
|
586
|
-
|
|
587
|
-
? `${PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`
|
|
588
|
-
: (decl?.path ?? CONFIG_NAME);
|
|
589
|
-
const renamed = decl ? renamedFieldMessage(opts, where) : null;
|
|
546
|
+
const where = `${configPath ? basename(configPath) : PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`;
|
|
547
|
+
const renamed = configPath ? renamedFieldMessage(opts, where) : null;
|
|
590
548
|
if (renamed) {
|
|
591
549
|
err(renamed);
|
|
592
550
|
return { code: 2 };
|
|
593
551
|
}
|
|
594
|
-
if (
|
|
552
|
+
if (configPath) {
|
|
595
553
|
const parsed = parseSettings(
|
|
596
554
|
opts,
|
|
597
555
|
where,
|
|
@@ -607,15 +565,11 @@ export function readConfig(
|
|
|
607
565
|
// 🔴 THE PAPERS DIRECTORY IS A REQUIRED FIELD. The papers directory is the one thing without which the tool
|
|
608
566
|
// does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
|
|
609
567
|
// the rules over the whole checkout and exits green over a scope nobody chose.
|
|
610
|
-
if (
|
|
568
|
+
if (configPath && !hasPapers(opts)) {
|
|
611
569
|
err(
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
`It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`
|
|
616
|
-
: `${decl.path} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
|
|
617
|
-
` { "${PAPERS_DIR_FIELD}": "papers" }\n` +
|
|
618
|
-
`It is the one thing this tool cannot guess.`,
|
|
570
|
+
`${configPath} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
|
|
571
|
+
` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
|
|
572
|
+
`It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`,
|
|
619
573
|
);
|
|
620
574
|
return { code: 2 };
|
|
621
575
|
}
|
|
@@ -623,7 +577,7 @@ export function readConfig(
|
|
|
623
577
|
}
|
|
624
578
|
|
|
625
579
|
/** The papers directory field of the settings, read by its one declared name. */
|
|
626
|
-
export function papersDirOf(opts:
|
|
580
|
+
export function papersDirOf(opts: PaperlintConfig): unknown {
|
|
627
581
|
return (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
|
|
628
582
|
}
|
|
629
583
|
|
|
@@ -635,7 +589,7 @@ export function toPaths(papers: unknown): string[] {
|
|
|
635
589
|
return [];
|
|
636
590
|
}
|
|
637
591
|
|
|
638
|
-
const hasPapers = (opts:
|
|
592
|
+
const hasPapers = (opts: PaperlintConfig): boolean =>
|
|
639
593
|
toPaths(papersDirOf(opts)).length > 0;
|
|
640
594
|
|
|
641
595
|
/**
|
|
@@ -703,7 +657,7 @@ export function runHook(
|
|
|
703
657
|
throw new Error(`resolved vigiles, but no cli.js beside it: ${runtime}`);
|
|
704
658
|
} catch {
|
|
705
659
|
err(
|
|
706
|
-
`
|
|
660
|
+
`paperlint: the hook runtime (vigiles) is not resolvable from ${fileURLToPath(new URL(".", import.meta.url))}.\n` +
|
|
707
661
|
`The \`${name}\` hook is NOT running. Everything else — \`paperlint lint\`, CI — is unaffected.\n` +
|
|
708
662
|
`Reinstall this package so its dependencies are present.`,
|
|
709
663
|
);
|
|
@@ -1048,7 +1002,7 @@ export async function run(
|
|
|
1048
1002
|
);
|
|
1049
1003
|
if (paths.length === 0) {
|
|
1050
1004
|
err(
|
|
1051
|
-
`nothing to lint: no path was given and no ${
|
|
1005
|
+
`nothing to lint: no path was given and no "${CONFIG_KEY}" key was found in a ${PKG_NAME}.\n` +
|
|
1052
1006
|
`Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`,
|
|
1053
1007
|
);
|
|
1054
1008
|
return 2;
|
|
@@ -1133,7 +1087,7 @@ async function reportLint(
|
|
|
1133
1087
|
log: typeof console.log;
|
|
1134
1088
|
err: typeof console.error;
|
|
1135
1089
|
where: string;
|
|
1136
|
-
opts:
|
|
1090
|
+
opts: PaperlintConfig;
|
|
1137
1091
|
},
|
|
1138
1092
|
): Promise<number> {
|
|
1139
1093
|
const silent = await silentOptionalRules(
|
|
@@ -1180,7 +1134,7 @@ async function reportLint(
|
|
|
1180
1134
|
|
|
1181
1135
|
// 🔴 `isMain`, NOT A STRING COMPARISON. The first version wrote
|
|
1182
1136
|
// if (import.meta.url === `file://${process.argv[1]}`)
|
|
1183
|
-
// and the utility, launched via `node_modules/.bin/
|
|
1137
|
+
// and the utility, launched via `node_modules/.bin/paperlint`, SILENTLY EXITED WITH ZERO: npm puts a
|
|
1184
1138
|
// SYMLINK there, `process.argv[1]` stays the symlink's path while `import.meta.url` is the real
|
|
1185
1139
|
// path, and the condition is false. That is, the only way a real consumer launches the utility did
|
|
1186
1140
|
// not work at all — and it looked like a clean run.
|
package/src/doctor.harness.mjs
CHANGED
|
@@ -41,9 +41,9 @@ const check = (label, cond) => {
|
|
|
41
41
|
assert.ok(cond, label);
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
/** A consumer on disk: a papers directory,
|
|
45
|
-
function consumer({ papersDir, pkgKey,
|
|
46
|
-
const dir = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
44
|
+
/** A consumer on disk: a papers directory, and maybe a declaration in package.json. */
|
|
45
|
+
function consumer({ papersDir, pkgKey, makeDir = true }) {
|
|
46
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-doctor-")));
|
|
47
47
|
if (makeDir && papersDir) {
|
|
48
48
|
mkdirSync(join(dir, papersDir, "some-paper"), { recursive: true });
|
|
49
49
|
writeFileSync(
|
|
@@ -54,11 +54,6 @@ function consumer({ papersDir, pkgKey, rppJson, makeDir = true }) {
|
|
|
54
54
|
const pkg = { name: "consumer", version: "1.0.0" };
|
|
55
55
|
if (pkgKey !== undefined) pkg["paperlint"] = { [PAPERS_DIR_FIELD]: pkgKey };
|
|
56
56
|
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
|
|
57
|
-
if (rppJson !== undefined)
|
|
58
|
-
writeFileSync(
|
|
59
|
-
join(dir, "rpp.json"),
|
|
60
|
-
JSON.stringify({ [PAPERS_DIR_FIELD]: rppJson }, null, 2),
|
|
61
|
-
);
|
|
62
57
|
return dir;
|
|
63
58
|
}
|
|
64
59
|
|
|
@@ -113,13 +108,10 @@ const runDoctor = (
|
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
// ── II. THE VERY DEFECT: AN INSTALL FOLLOWING THE DOCS ─────────────────────────────────────
|
|
116
|
-
// `
|
|
111
|
+
// The old `init` wrote its own config file and did not touch package.json; the hook reads package.json.
|
|
117
112
|
// Measured 09-18.
|
|
118
113
|
{
|
|
119
|
-
const dir = consumer({
|
|
120
|
-
papersDir: "writing/drafts",
|
|
121
|
-
rppJson: "writing/drafts",
|
|
122
|
-
});
|
|
114
|
+
const dir = consumer({ papersDir: "writing/drafts" });
|
|
123
115
|
const r = runDoctor(dir, { cliPapers: "writing/drafts" });
|
|
124
116
|
check(
|
|
125
117
|
"an install that follows the docs — a FAILURE, not a cheerful report",
|
|
@@ -150,7 +142,7 @@ const runDoctor = (
|
|
|
150
142
|
// It must not fail here (an error-level false positive costs more than a miss), but it must
|
|
151
143
|
// not stay silent either.
|
|
152
144
|
{
|
|
153
|
-
const dir = consumer({ papersDir: "papers"
|
|
145
|
+
const dir = consumer({ papersDir: "papers" });
|
|
154
146
|
const r = runDoctor(dir, { cliPapers: "papers" });
|
|
155
147
|
check("an install that works by coincidence does NOT crash", r.code === 0);
|
|
156
148
|
check(
|
|
@@ -220,18 +212,10 @@ const runDoctor = (
|
|
|
220
212
|
|
|
221
213
|
// ── III. TWO DECLARATIONS HAVE DRIFTED APART ────────────────────────────────────────────────
|
|
222
214
|
{
|
|
223
|
-
const dir = consumer({
|
|
224
|
-
papersDir: "writing/drafts",
|
|
225
|
-
pkgKey: "papers",
|
|
226
|
-
rppJson: "writing/drafts",
|
|
227
|
-
});
|
|
215
|
+
const dir = consumer({ papersDir: "writing/drafts", pkgKey: "papers" });
|
|
228
216
|
mkdirSync(join(dir, "papers"), { recursive: true });
|
|
229
217
|
const r = runDoctor(dir, { cliPapers: "writing/drafts" });
|
|
230
218
|
check("both declarations exist, but differ — a FAILURE", r.code === 2);
|
|
231
|
-
check(
|
|
232
|
-
"the stale rpp.json is called out ⚠",
|
|
233
|
-
/rpp\.json is present/.test(r.out),
|
|
234
|
-
);
|
|
235
219
|
rmSync(dir, { recursive: true, force: true });
|
|
236
220
|
}
|
|
237
221
|
|
|
@@ -307,7 +291,7 @@ const runDoctor = (
|
|
|
307
291
|
|
|
308
292
|
// ── VII. DETECTING THE PAPERS DIRECTORY ─────────────────────────────────────────────────────
|
|
309
293
|
{
|
|
310
|
-
const dir = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
294
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-detect-")));
|
|
311
295
|
mkdirSync(join(dir, "writing", "drafts", "p1"), { recursive: true });
|
|
312
296
|
writeFileSync(join(dir, "writing", "drafts", "p1", "paper.tex"), "x");
|
|
313
297
|
mkdirSync(join(dir, "node_modules", "pkg", "papers", "p"), {
|
|
@@ -387,7 +371,7 @@ const runDoctor = (
|
|
|
387
371
|
check("a program that really exists is found", found("node") === true);
|
|
388
372
|
check(
|
|
389
373
|
"a made-up one is not (otherwise the check answers the form, not the subject)",
|
|
390
|
-
found("
|
|
374
|
+
found("paperlint-definitely-not-a-real-binary-xyz") === false,
|
|
391
375
|
);
|
|
392
376
|
}
|
|
393
377
|
|
package/src/doctor.ts
CHANGED
|
@@ -282,8 +282,6 @@ export function doctor({
|
|
|
282
282
|
out.push(...declaration.lines);
|
|
283
283
|
bad += declaration.bad;
|
|
284
284
|
}
|
|
285
|
-
if (existsSync(join(root, "rpp.json")))
|
|
286
|
-
out.push(` ⚠ rpp.json is present — deprecated; the hooks never read it`);
|
|
287
285
|
|
|
288
286
|
out.push("", "papers directory");
|
|
289
287
|
const hookRoot = rawPkg ? papersRoot(rawPkg) : null;
|
package/src/engine.harness.mjs
CHANGED
|
@@ -53,7 +53,7 @@ const facts = (o) => ({
|
|
|
53
53
|
...o,
|
|
54
54
|
});
|
|
55
55
|
const ROWS = [
|
|
56
|
-
// Guards: the order —
|
|
56
|
+
// Guards: the order — paperlint's own verified cache first, so a build does not depend on whatever TeX
|
|
57
57
|
// Live the machine happens to carry.
|
|
58
58
|
[
|
|
59
59
|
"a complete cache wins, even over a complete system TeX",
|
|
@@ -198,7 +198,9 @@ check(
|
|
|
198
198
|
// to start, while `paperlint toolchain --check` reported the tree verified. Checked on disk, not
|
|
199
199
|
// through an injected predicate, because the predicate is exactly what was wrong.
|
|
200
200
|
{
|
|
201
|
-
const bin = realpathSync(
|
|
201
|
+
const bin = realpathSync(
|
|
202
|
+
mkdtempSync(join(tmpdir(), "paperlint-engine-bin-")),
|
|
203
|
+
);
|
|
202
204
|
try {
|
|
203
205
|
mkdirSync(join(bin, "texcount"));
|
|
204
206
|
writeFileSync(join(bin, "checkcites"), "#!/bin/sh\n");
|
package/src/engine.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* TeX Live qualifies only when `kpsewhich` finds every file the venue declares (issue #26).
|
|
9
9
|
*
|
|
10
10
|
* The order (the Playwright pattern, decided 2026-09-24):
|
|
11
|
-
* 1.
|
|
11
|
+
* 1. paperlint's own cache (`paperlint toolchain` installs it) — when it has everything;
|
|
12
12
|
* 2. a TeX Live already on PATH — when IT has everything;
|
|
13
13
|
* 3. neither: on a terminal, ask once and install; without one (CI, an agent), refuse in one line
|
|
14
14
|
* that names `npx paperlint toolchain` and the missing packages. Never a silent install without a
|
|
@@ -41,7 +41,7 @@ export interface TreeProbe {
|
|
|
41
41
|
export interface EngineFacts {
|
|
42
42
|
/** install-tl-unx runs here (Linux, macOS). */
|
|
43
43
|
readonly supported: boolean;
|
|
44
|
-
/**
|
|
44
|
+
/** paperlint's cached TeX Live, probed — or null when there is none. */
|
|
45
45
|
readonly cache: TreeProbe | null;
|
|
46
46
|
/** The `pdflatex` on PATH, probed — or null when there is none. */
|
|
47
47
|
readonly system: TreeProbe | null;
|
|
@@ -40,7 +40,6 @@ const {
|
|
|
40
40
|
doctorHooks,
|
|
41
41
|
readSettings,
|
|
42
42
|
MANAGED_BY,
|
|
43
|
-
LEGACY_MANAGED_BY,
|
|
44
43
|
SETTINGS_PATH,
|
|
45
44
|
} = await import(join(HERE, "hooks-settings.ts"));
|
|
46
45
|
const { claudeCodeHookProtocol } = await import("vigiles/claude-code");
|
|
@@ -66,7 +65,7 @@ check(
|
|
|
66
65
|
wiring.names.join() ===
|
|
67
66
|
"paper-edit-guard,paper-skills-nudge,paper-status-gates",
|
|
68
67
|
);
|
|
69
|
-
const work = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
68
|
+
const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-hooks-")));
|
|
70
69
|
try {
|
|
71
70
|
const empty = join(work, "empty-hooks.json");
|
|
72
71
|
writeFileSync(empty, '{"hooks":{}}');
|
|
@@ -89,11 +88,16 @@ try {
|
|
|
89
88
|
`node "$CLAUDE_PROJECT_DIR/${MANAGED_BY}" hook paper-status-gates`,
|
|
90
89
|
{ name: "paper-status-gates", ours: true, legacy: false },
|
|
91
90
|
],
|
|
92
|
-
// Guards: what an install
|
|
91
|
+
// Guards: what an older install wrote is recognised as ours-but-stale. Spelled literally, not
|
|
92
|
+
// built from a constant: these are what 1.x and 2.0.0 actually put in users' settings files.
|
|
93
93
|
[
|
|
94
|
-
`node "$CLAUDE_PROJECT_DIR
|
|
94
|
+
`node "$CLAUDE_PROJECT_DIR/node_modules/research-paper-pipeline/bin/rpp.mjs" hook paper-edit-guard`,
|
|
95
95
|
{ name: "paper-edit-guard", ours: false, legacy: true },
|
|
96
96
|
],
|
|
97
|
+
[
|
|
98
|
+
`node "\${CLAUDE_PROJECT_DIR}/node_modules/paperlint/bin/rpp.mjs" hook paper-status-gates`,
|
|
99
|
+
{ name: "paper-status-gates", ours: false, legacy: true },
|
|
100
|
+
],
|
|
97
101
|
[
|
|
98
102
|
`node "$CLAUDE_PROJECT_DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program "$CLAUDE_PROJECT_DIR/node_modules/paperlint/hooks/paper-edit-guard.hook.mjs"`,
|
|
99
103
|
{ name: "paper-edit-guard", ours: false, legacy: false },
|
|
@@ -107,11 +111,11 @@ try {
|
|
|
107
111
|
{ name: "paper-skills-nudge", ours: false, legacy: false },
|
|
108
112
|
],
|
|
109
113
|
[
|
|
110
|
-
`node /abs/proj/node_modules/paperlint/bin/
|
|
114
|
+
`node /abs/proj/node_modules/paperlint/bin/paperlint.mjs hook paper-edit-guard`,
|
|
111
115
|
{ name: "paper-edit-guard", ours: false, legacy: false },
|
|
112
116
|
],
|
|
113
117
|
[`node my-own-lint.mjs`, null],
|
|
114
|
-
[`node node_modules/paperlint/bin/
|
|
118
|
+
[`node node_modules/paperlint/bin/paperlint.mjs lint`, null],
|
|
115
119
|
];
|
|
116
120
|
for (const [cmd, want] of cases)
|
|
117
121
|
check(
|
|
@@ -154,37 +158,42 @@ try {
|
|
|
154
158
|
);
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
// ── an install
|
|
158
|
-
|
|
161
|
+
// ── what an older install wrote is migrated, the user's own command kept ─────────────────
|
|
162
|
+
// Two real histories, spelled literally: 1.x wrote the old package directory, 2.0.0 wrote the
|
|
163
|
+
// new directory with the old entry file name. Neither file exists after an upgrade.
|
|
164
|
+
for (const [release, stale] of [
|
|
165
|
+
["1.x", "node_modules/research-paper-pipeline/bin/rpp.mjs"],
|
|
166
|
+
["2.0.0", "node_modules/paperlint/bin/rpp.mjs"],
|
|
167
|
+
]) {
|
|
159
168
|
const legacyWired = JSON.parse(
|
|
160
169
|
JSON.stringify(merge({}, wiring.compiled, MANAGED_BY)).replaceAll(
|
|
161
170
|
MANAGED_BY,
|
|
162
|
-
|
|
171
|
+
stale,
|
|
163
172
|
),
|
|
164
173
|
);
|
|
165
174
|
legacyWired.hooks.PostToolUse[0].hooks.push({
|
|
166
175
|
type: "command",
|
|
167
176
|
command: "node my-own-lint.mjs",
|
|
168
177
|
});
|
|
169
|
-
const dir = project(
|
|
178
|
+
const dir = project(`legacy-${release}`, legacyWired);
|
|
170
179
|
const before = doctorHooks(dir, wiring).join("\n");
|
|
171
180
|
check(
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
`🔴 doctor names hook commands ${release} left behind — the missing file, and the fix`,
|
|
182
|
+
before.includes(`run ${stale}, which this version does not install`) &&
|
|
174
183
|
/npx paperlint init` replaces them/.test(before),
|
|
175
184
|
);
|
|
176
185
|
const r = wireHooks(dir, merge, wiring);
|
|
177
186
|
const s = JSON.parse(text(dir));
|
|
178
187
|
const counts = [...wiredCounts(s, wiring.names).values()];
|
|
179
188
|
check(
|
|
180
|
-
|
|
189
|
+
`🔴 init replaces what ${release} wrote: every hook wired once, none left at the old path`,
|
|
181
190
|
r.status === "written" &&
|
|
182
191
|
r.replaced === wiring.names.length &&
|
|
183
192
|
counts.every((c) => c.ours === 1 && c.legacy === 0 && c.other === 0) &&
|
|
184
|
-
!text(dir).includes(
|
|
193
|
+
!text(dir).includes(stale),
|
|
185
194
|
);
|
|
186
195
|
check(
|
|
187
|
-
|
|
196
|
+
`and the user's own command in the same matcher survives the ${release} migration`,
|
|
188
197
|
text(dir).includes("node my-own-lint.mjs"),
|
|
189
198
|
);
|
|
190
199
|
}
|
|
@@ -27,11 +27,9 @@ process.exit(
|
|
|
27
27
|
expect: '→ {"name":"paper-edit-guard","ours":false,"legacy":true}',
|
|
28
28
|
disables:
|
|
29
29
|
"the duplicate guard for every bin spelling but ours. A hook wired by hand as " +
|
|
30
|
-
"`npx paperlint hook …` or through an absolute
|
|
30
|
+
"`npx paperlint hook …` or through an absolute path to the bin reads as ours, init merges its own " +
|
|
31
31
|
"copy beside it, and the hook runs twice per event",
|
|
32
|
-
edits: [
|
|
33
|
-
[SRC, " ours: t === MANAGED_BY,", " ours: true,"],
|
|
34
|
-
],
|
|
32
|
+
edits: [[SRC, " ours: t === MANAGED_BY,", " ours: true,"]],
|
|
35
33
|
},
|
|
36
34
|
{
|
|
37
35
|
name: "another spelling no longer stops the write",
|