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
package/src/cli.ts
CHANGED
|
@@ -65,6 +65,10 @@ import {
|
|
|
65
65
|
import {
|
|
66
66
|
paperPreset,
|
|
67
67
|
paperPresetProblem,
|
|
68
|
+
presetProblemText,
|
|
69
|
+
resolvePreset,
|
|
70
|
+
shippedPresets,
|
|
71
|
+
SHIPPED_PREFIX,
|
|
68
72
|
type PaperPreset,
|
|
69
73
|
} from "./presets.ts";
|
|
70
74
|
import type { ToolInstaller } from "./ports/tool-installer.ts";
|
|
@@ -84,6 +88,7 @@ import {
|
|
|
84
88
|
newPaper,
|
|
85
89
|
reportNewPaper,
|
|
86
90
|
type PaperFormat,
|
|
91
|
+
type VenueSetting,
|
|
87
92
|
} from "./new-paper.ts";
|
|
88
93
|
// The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
|
|
89
94
|
// rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
|
|
@@ -123,12 +128,15 @@ import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
|
|
|
123
128
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
124
129
|
import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
|
|
125
130
|
|
|
131
|
+
/** The shipped venue presets, read from the package's venues directory — the one list. */
|
|
132
|
+
const SHIPPED_VENUES = (): string[] => shippedPresets(packageVenuesDir());
|
|
133
|
+
|
|
126
134
|
const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
127
135
|
|
|
128
136
|
npx paperlint init [dir] set the project up: detect the papers directory, declare it
|
|
129
137
|
in package.json, link the skills, wire the hooks into
|
|
130
138
|
.claude/settings.json, offer the CI step, report what is missing
|
|
131
|
-
npx paperlint new <name> [--format tex|md]
|
|
139
|
+
npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
|
|
132
140
|
create <papers>/<name>/ from the template; never overwrites,
|
|
133
141
|
on an existing folder adds only the missing files, then lints it
|
|
134
142
|
npx paperlint lint [paths…] run every rule over your papers
|
|
@@ -156,6 +164,15 @@ init:
|
|
|
156
164
|
--paper <name> create this paper too (without a human, the only way init creates one)
|
|
157
165
|
--format tex|md the new paper's source format; default tex
|
|
158
166
|
|
|
167
|
+
new:
|
|
168
|
+
--venue <preset> the venue preset, written as "extends" into the paper's paperlint.json:
|
|
169
|
+
a shipped one (${SHIPPED_VENUES().join(", ")}), or a path to your
|
|
170
|
+
own preset starting with ./ or ../, relative to where you run the command.
|
|
171
|
+
On a terminal without --venue, new asks; "none" leaves it unset
|
|
172
|
+
--kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
|
|
173
|
+
preset's kinds; needs --venue
|
|
174
|
+
--format tex|md the paper's source format; default tex
|
|
175
|
+
|
|
159
176
|
lint:
|
|
160
177
|
npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
|
|
161
178
|
|
|
@@ -188,7 +205,7 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
|
188
205
|
|
|
189
206
|
{ "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
|
|
190
207
|
|
|
191
|
-
"extends" names a venue preset: paperlint:<name> (shipped:
|
|
208
|
+
"extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
|
|
192
209
|
or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
|
|
193
210
|
|
|
194
211
|
"rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
|
|
@@ -322,6 +339,64 @@ export const SHIPPED_RULES: ReadonlySet<string> = shippedRuleIds(
|
|
|
322
339
|
[markdown],
|
|
323
340
|
);
|
|
324
341
|
|
|
342
|
+
/** A plugin as `rulePlugins` gives it: its rules, and for `tex` the LaTeX language when given. */
|
|
343
|
+
export interface RulePlugin {
|
|
344
|
+
readonly rules?: Readonly<Record<string, unknown>>;
|
|
345
|
+
readonly languages?: Readonly<Record<string, unknown>>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Every plugin paperlint's config registers, ONE object per name, read off that config. In it one
|
|
350
|
+
* name is bound to different objects in different blocks (`paper` beside `PIPELINE-STATUS.md` is
|
|
351
|
+
* not `paper` beside `paper.tex`); here their rules are merged, so a config that lints any paper
|
|
352
|
+
* file knows every rule id. `@eslint/markdown` is a dependency's plugin and is left out. The LaTeX
|
|
353
|
+
* language rides on `tex` when `texLanguage` is given, and that is how a consumer registers it: a
|
|
354
|
+
* second `tex` plugin of its own is refused by ESLint ("Cannot redefine plugin "tex"", measured
|
|
355
|
+
* with and without the language here). Without it, `tex` carries rules only — for a config that
|
|
356
|
+
* lints markdown papers alone.
|
|
357
|
+
*/
|
|
358
|
+
export function rulePlugins(texLanguage?: unknown): Record<string, RulePlugin> {
|
|
359
|
+
const out: Record<string, { rules: Record<string, unknown> }> = {};
|
|
360
|
+
for (const block of buildConfig({}, texLanguage ?? { sentinel: "tex" }))
|
|
361
|
+
for (const [name, plugin] of Object.entries(
|
|
362
|
+
(block as { plugins?: Record<string, RulePlugin> }).plugins ?? {},
|
|
363
|
+
))
|
|
364
|
+
if (plugin !== markdown)
|
|
365
|
+
out[name] = { rules: { ...out[name]?.rules, ...plugin.rules } };
|
|
366
|
+
if (out["tex"] && texLanguage !== undefined)
|
|
367
|
+
return {
|
|
368
|
+
...out,
|
|
369
|
+
tex: { ...out["tex"], languages: { latex: texLanguage } },
|
|
370
|
+
};
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* A flat-config fragment for a consumer's OWN ESLint run over paper files (#104): every rule
|
|
376
|
+
* paperlint ships, registered and turned off. Without it a `% eslint-disable-next-line
|
|
377
|
+
* paper/leading-zero -- why` — the documented escape hatch — fails that run with "Definition for
|
|
378
|
+
* rule 'paper/leading-zero' was not found". Off, because `paperlint lint` is where they run.
|
|
379
|
+
*
|
|
380
|
+
* The second block is not optional, and it is measured: a directive naming a rule that is off
|
|
381
|
+
* suppresses nothing, so ESLint reports it as an unused directive (a warning by default, an error
|
|
382
|
+
* under `reportUnusedDisableDirectives: "error"`). On the files paperlint lints that report is
|
|
383
|
+
* switched off in THIS run — `paperlint lint` still reports a directive that silences nothing.
|
|
384
|
+
*/
|
|
385
|
+
export function rulesOff(texLanguage?: unknown): Linter.Config[] {
|
|
386
|
+
return [
|
|
387
|
+
{
|
|
388
|
+
name: "paperlint/rules-off",
|
|
389
|
+
plugins: rulePlugins(texLanguage) as Linter.Config["plugins"],
|
|
390
|
+
rules: Object.fromEntries([...SHIPPED_RULES].map((id) => [id, "off"])),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "paperlint/rules-off/directives",
|
|
394
|
+
files: PAPER_FILE_PATTERNS,
|
|
395
|
+
linterOptions: { reportUnusedDisableDirectives: "off" },
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
|
|
325
400
|
/** Whether a rule entry (`"error"`, `2`, `["warn", {…}]`) turns the rule on. */
|
|
326
401
|
const isOn = (entry: unknown): boolean => {
|
|
327
402
|
const sev = Array.isArray(entry) ? entry[0] : entry;
|
|
@@ -345,12 +420,17 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
|
|
|
345
420
|
* 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
|
|
346
421
|
* nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
|
|
347
422
|
* rule that never runs reports exactly like a rule that passed. So for every optional rule the
|
|
348
|
-
* consumer turned on, some
|
|
423
|
+
* consumer turned on, some `paper.tex` must actually have it enabled; the ones none has are
|
|
349
424
|
* returned, for the caller to refuse.
|
|
425
|
+
*
|
|
426
|
+
* `files` is every `paper.tex` the guard may count: the ones this run linted AND every paper of the
|
|
427
|
+
* project. Judged against the run alone, `paperlint lint papers/c` failed on a block written for
|
|
428
|
+
* papers a and b (#103) — a block that reaches a paper outside this run is not dead. A glob that
|
|
429
|
+
* reaches no paper of the project at all still fails, from a subset as from the whole.
|
|
350
430
|
*/
|
|
351
431
|
export async function silentOptionalRules(
|
|
352
432
|
eslint: ESLint,
|
|
353
|
-
|
|
433
|
+
files: readonly string[],
|
|
354
434
|
opts: PaperlintConfig,
|
|
355
435
|
): Promise<string[]> {
|
|
356
436
|
const turnedOn = new Set(
|
|
@@ -361,11 +441,11 @@ export async function silentOptionalRules(
|
|
|
361
441
|
),
|
|
362
442
|
);
|
|
363
443
|
const reached = new Set<string>();
|
|
364
|
-
for (const f of
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
for (const id of turnedOn) if (isOn(cfg
|
|
444
|
+
for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
|
|
445
|
+
// Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
|
|
446
|
+
const cfg = (await eslint.calculateConfigForFile(f)) as
|
|
447
|
+
{ rules?: Record<string, unknown> } | undefined;
|
|
448
|
+
for (const id of turnedOn) if (isOn(cfg?.rules?.[id])) reached.add(id);
|
|
369
449
|
}
|
|
370
450
|
return [...turnedOn].filter((id) => !reached.has(id));
|
|
371
451
|
}
|
|
@@ -428,17 +508,17 @@ function rulesOfPaper(
|
|
|
428
508
|
)
|
|
429
509
|
: ownRules(dir, settings);
|
|
430
510
|
if (!own.ok) return own;
|
|
511
|
+
// No `files`: `narrowToOwners` scopes the block to what paperlint lints under `dir`, exceptions
|
|
512
|
+
// included. A copy of the globs here is how the siblings index got parsed as JavaScript (#101).
|
|
431
513
|
const scoped = (rules: Record<string, RuleEntry>): RuleBlock[] =>
|
|
432
|
-
Object.keys(rules).length
|
|
433
|
-
? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
|
|
434
|
-
: [];
|
|
514
|
+
Object.keys(rules).length ? [{ basePath: dir, rules }] : [];
|
|
435
515
|
return {
|
|
436
516
|
ok: true,
|
|
437
517
|
value: { preset: scoped(fromPreset.value), own: [...own.value] },
|
|
438
518
|
};
|
|
439
519
|
}
|
|
440
520
|
|
|
441
|
-
/** A paper's `{ id: severity }` rules, as one block over the paper's files. */
|
|
521
|
+
/** A paper's `{ id: severity }` rules, as one block over the paper's files (narrowed later, #101). */
|
|
442
522
|
function ownRules(
|
|
443
523
|
dir: string,
|
|
444
524
|
settings: PaperSettings,
|
|
@@ -447,16 +527,13 @@ function ownRules(
|
|
|
447
527
|
if (!own.ok) return own;
|
|
448
528
|
return {
|
|
449
529
|
ok: true,
|
|
450
|
-
value: own.value
|
|
451
|
-
? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
|
|
452
|
-
: [],
|
|
530
|
+
value: own.value ? [{ basePath: dir, rules: own.value }] : [],
|
|
453
531
|
};
|
|
454
532
|
}
|
|
455
533
|
|
|
456
534
|
/**
|
|
457
|
-
* The
|
|
458
|
-
*
|
|
459
|
-
* language — `paperlint.json` itself would be parsed as JavaScript.
|
|
535
|
+
* The globs paperlint lints, for the refusal message only. Which files a block reaches is decided by
|
|
536
|
+
* `narrowToOwners` from `ownedScopes` (src/paper-files.ts), where each glob keeps its exceptions.
|
|
460
537
|
*/
|
|
461
538
|
const PAPER_FILE_PATTERNS: string[] = ownedPatterns(
|
|
462
539
|
buildConfig({}, { sentinel: "tex language" }),
|
|
@@ -525,6 +602,8 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
525
602
|
noHooks: false,
|
|
526
603
|
paper: null,
|
|
527
604
|
format: null,
|
|
605
|
+
venue: null,
|
|
606
|
+
kind: null,
|
|
528
607
|
hooksMode: null,
|
|
529
608
|
// -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
|
|
530
609
|
// gate that fails on advice gets muted entirely.
|
|
@@ -546,8 +625,21 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
546
625
|
};
|
|
547
626
|
|
|
548
627
|
for (let i = 0; i < rest.length; i++) {
|
|
549
|
-
const
|
|
550
|
-
if (
|
|
628
|
+
const arg = rest[i];
|
|
629
|
+
if (arg === undefined) continue;
|
|
630
|
+
// `--flag=value` is `--flag value` for every flag that takes a value: the CI action writes
|
|
631
|
+
// `--max-warnings="$N"`, and that form used to fall through to the list of paths.
|
|
632
|
+
const eq = arg.indexOf("=");
|
|
633
|
+
const inline =
|
|
634
|
+
arg.startsWith("--") && eq > 0 && VALUE_FLAGS.has(arg.slice(0, eq))
|
|
635
|
+
? arg.slice(eq + 1)
|
|
636
|
+
: undefined;
|
|
637
|
+
const a = inline === undefined ? arg : arg.slice(0, eq);
|
|
638
|
+
const take = (): string | undefined => {
|
|
639
|
+
if (inline === undefined) return valueFor(a, ++i);
|
|
640
|
+
if (inline === "") out.missingValue = a;
|
|
641
|
+
return inline === "" ? undefined : inline;
|
|
642
|
+
};
|
|
551
643
|
if (a === "--json") out.json = true;
|
|
552
644
|
else if (a === "--fix") out.fix = true;
|
|
553
645
|
else if (a === "--all") out.all = true;
|
|
@@ -557,22 +649,36 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
557
649
|
else if (a === "--no-hooks") out.noHooks = true;
|
|
558
650
|
else if (a.startsWith("--hooks="))
|
|
559
651
|
out.hooksMode = a.slice("--hooks=".length);
|
|
560
|
-
else if (a === "--paper") out.paper =
|
|
561
|
-
else if (a === "--format") out.format =
|
|
652
|
+
else if (a === "--paper") out.paper = take() ?? null;
|
|
653
|
+
else if (a === "--format") out.format = take() ?? null;
|
|
654
|
+
else if (a === "--venue") out.venue = take() ?? null;
|
|
655
|
+
else if (a === "--kind") out.kind = take() ?? null;
|
|
562
656
|
// `--options` was the first spelling and is kept working. It named the wrong thing — every
|
|
563
657
|
// other tool in the stack calls this file its config — but a flag in someone's CI is not
|
|
564
658
|
// ours to break.
|
|
565
|
-
else if (a === "--config" || a === "--options")
|
|
566
|
-
out.config = valueFor(a, ++i) ?? null;
|
|
659
|
+
else if (a === "--config" || a === "--options") out.config = take() ?? null;
|
|
567
660
|
else if (a === "--max-warnings") {
|
|
568
|
-
const v =
|
|
661
|
+
const v = take();
|
|
569
662
|
if (v !== undefined) out.maxWarnings = Number(v);
|
|
570
663
|
} else if (a === "--help" || a === "-h") out.help = true;
|
|
664
|
+
// An unknown flag is refused by name. Read as a path, it silently lints something else.
|
|
665
|
+
else if (a.startsWith("-") && a !== "-") out.unknownFlag ??= a;
|
|
571
666
|
else out.paths.push(a);
|
|
572
667
|
}
|
|
573
668
|
return out;
|
|
574
669
|
}
|
|
575
670
|
|
|
671
|
+
/** The flags that take a value, in either spelling: `--flag value` or `--flag=value`. */
|
|
672
|
+
const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
673
|
+
"--paper",
|
|
674
|
+
"--format",
|
|
675
|
+
"--venue",
|
|
676
|
+
"--kind",
|
|
677
|
+
"--config",
|
|
678
|
+
"--options",
|
|
679
|
+
"--max-warnings",
|
|
680
|
+
]);
|
|
681
|
+
|
|
576
682
|
/**
|
|
577
683
|
* This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
|
|
578
684
|
* the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
|
|
@@ -769,16 +875,143 @@ export async function createPaperAt(
|
|
|
769
875
|
log,
|
|
770
876
|
err,
|
|
771
877
|
cwd,
|
|
772
|
-
|
|
878
|
+
venue = null,
|
|
879
|
+
}: {
|
|
880
|
+
log: typeof console.log;
|
|
881
|
+
err: typeof console.error;
|
|
882
|
+
cwd: string;
|
|
883
|
+
/** What `--venue` chose; null writes the template's `paperlint.json` as it is. */
|
|
884
|
+
venue?: VenueChoice | null;
|
|
885
|
+
},
|
|
773
886
|
): Promise<number> {
|
|
774
|
-
const result = newPaper(papersRoot, name, format);
|
|
887
|
+
const result = newPaper(papersRoot, name, format, { venue });
|
|
775
888
|
const here = (p: string): string => relative(cwd, p) || p;
|
|
776
889
|
for (const line of reportNewPaper(result, here)) log(line);
|
|
777
890
|
if (!result.ok) return 2;
|
|
891
|
+
const config = here(join(result.dir, CONFIG_FILE));
|
|
892
|
+
if (venue === null)
|
|
893
|
+
log(
|
|
894
|
+
` venue: none yet — set "extends" in ${config}, or next time: paperlint new <name> --venue <preset> (${SHIPPED_VENUES().join(", ")})`,
|
|
895
|
+
);
|
|
896
|
+
else if (venue.kind === null && venue.kinds.length > 0)
|
|
897
|
+
log(
|
|
898
|
+
` kind: not set — \`${venue.label}\` sets a page limit per kind (${venue.kinds.join(", ")}); add "kind" to ${config}. Until then lint reports pdf/profile`,
|
|
899
|
+
);
|
|
778
900
|
log(``);
|
|
779
901
|
return run(["lint", result.dir], { log, err, cwd });
|
|
780
902
|
}
|
|
781
903
|
|
|
904
|
+
/** A venue `paperlint new` will write, with what the messages need to say about it. */
|
|
905
|
+
export interface VenueChoice extends VenueSetting {
|
|
906
|
+
/** The preset's word in messages (`agenticdev`, `my-workshop`). */
|
|
907
|
+
readonly label: string;
|
|
908
|
+
/** The preset's kinds; empty when it sets no page limit. */
|
|
909
|
+
readonly kinds: readonly string[];
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* `--venue` / `--kind` → the `extends` and `kind` to write into `<paperDir>/paperlint.json`, or why
|
|
914
|
+
* not. Parsed here, at the boundary, and resolved through the same `resolvePreset` lint uses — so a
|
|
915
|
+
* venue `new` accepts is a venue lint resolves. On a terminal with no `--venue` it asks, with
|
|
916
|
+
* "none" as the default; without a terminal it chooses nothing (null), as before.
|
|
917
|
+
*
|
|
918
|
+
* A path is relative to where the command runs, because that is where it was typed; it is written
|
|
919
|
+
* relative to the paper's `paperlint.json`, because that is what `extends` is relative to. Written
|
|
920
|
+
* as typed, `./venues/x.jsonc` would name `<paper>/venues/x.jsonc`.
|
|
921
|
+
*/
|
|
922
|
+
export async function chooseVenue(
|
|
923
|
+
flags: { readonly venue: string | null; readonly kind: string | null },
|
|
924
|
+
{
|
|
925
|
+
paperDir,
|
|
926
|
+
cwd,
|
|
927
|
+
interactive,
|
|
928
|
+
ask,
|
|
929
|
+
}: {
|
|
930
|
+
paperDir: string;
|
|
931
|
+
cwd: string;
|
|
932
|
+
interactive: boolean;
|
|
933
|
+
ask: (q: string) => Promise<string>;
|
|
934
|
+
},
|
|
935
|
+
): Promise<Parsed<VenueChoice | null>> {
|
|
936
|
+
const shipped = SHIPPED_VENUES();
|
|
937
|
+
if (flags.kind !== null && flags.venue === null)
|
|
938
|
+
return bad(
|
|
939
|
+
`--kind needs --venue: a kind is a page limit of one venue preset — \`paperlint new <name> --venue <preset> --kind ${flags.kind}\``,
|
|
940
|
+
);
|
|
941
|
+
const asked = async (q: string): Promise<string> =>
|
|
942
|
+
(await ask(q).catch(() => "")).trim();
|
|
943
|
+
const venue =
|
|
944
|
+
flags.venue ??
|
|
945
|
+
(interactive
|
|
946
|
+
? await asked(`venue: ${[...shipped, "none"].join(" / ")} [none] `)
|
|
947
|
+
: "");
|
|
948
|
+
if (venue === "" || (flags.venue === null && venue === "none"))
|
|
949
|
+
return { ok: true, value: null };
|
|
950
|
+
const spec = venueSpec(venue, { shipped, paperDir, cwd });
|
|
951
|
+
if (!spec.ok) return spec;
|
|
952
|
+
const r = resolvePreset(spec.value, join(paperDir, CONFIG_FILE), PRESET_DEPS);
|
|
953
|
+
if (!r.ok) return bad(`--venue ${venue}: ${presetProblemText(r.error)}`);
|
|
954
|
+
const kinds = [...r.value.format.kinds.keys()];
|
|
955
|
+
const label = r.value.label;
|
|
956
|
+
if (flags.kind !== null) {
|
|
957
|
+
if (kinds.length === 0)
|
|
958
|
+
return bad(
|
|
959
|
+
`--kind ${flags.kind}: \`${label}\` has no kinds — it sets no page limit, so there is no kind to choose`,
|
|
960
|
+
);
|
|
961
|
+
if (!kinds.includes(flags.kind))
|
|
962
|
+
return bad(
|
|
963
|
+
`--kind ${flags.kind}: \`${label}\` has no kind \`${flags.kind}\`; its kinds: ${kinds.join(", ")}`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
const kind =
|
|
967
|
+
flags.kind ??
|
|
968
|
+
(interactive && kinds.length > 0
|
|
969
|
+
? await asked(`kind: ${[...kinds, "later"].join(" / ")} [later] `)
|
|
970
|
+
: null);
|
|
971
|
+
return {
|
|
972
|
+
ok: true,
|
|
973
|
+
value: {
|
|
974
|
+
extends: spec.value,
|
|
975
|
+
kind: kind !== null && kinds.includes(kind) ? kind : null,
|
|
976
|
+
label,
|
|
977
|
+
kinds,
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const bad = (error: string): { ok: false; error: string } => ({
|
|
983
|
+
ok: false,
|
|
984
|
+
error,
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
/** What `--venue` names → the `extends` value, relative to `<paperDir>/paperlint.json`. */
|
|
988
|
+
function venueSpec(
|
|
989
|
+
venue: string,
|
|
990
|
+
{
|
|
991
|
+
shipped,
|
|
992
|
+
paperDir,
|
|
993
|
+
cwd,
|
|
994
|
+
}: { shipped: readonly string[]; paperDir: string; cwd: string },
|
|
995
|
+
): Parsed<string> {
|
|
996
|
+
if (venue.startsWith("./") || venue.startsWith("../")) {
|
|
997
|
+
const file = resolve(cwd, venue);
|
|
998
|
+
if (!existsSync(file))
|
|
999
|
+
return bad(
|
|
1000
|
+
`--venue ${venue}: no such file (${file}) — a path is relative to where you run the command`,
|
|
1001
|
+
);
|
|
1002
|
+
const rel = relative(paperDir, file).split(sep).join("/");
|
|
1003
|
+
return { ok: true, value: rel.startsWith("../") ? rel : `./${rel}` };
|
|
1004
|
+
}
|
|
1005
|
+
const name = venue.startsWith(SHIPPED_PREFIX)
|
|
1006
|
+
? venue.slice(SHIPPED_PREFIX.length)
|
|
1007
|
+
: venue;
|
|
1008
|
+
if (!shipped.includes(name))
|
|
1009
|
+
return bad(
|
|
1010
|
+
`--venue ${venue}: no such venue preset. Shipped: ${shipped.join(", ")} — or a path to your own preset, starting with ./ or ../`,
|
|
1011
|
+
);
|
|
1012
|
+
return { ok: true, value: `${SHIPPED_PREFIX}${name}` };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
782
1015
|
/**
|
|
783
1016
|
* `paperlint new <name>` — the papers directory comes from the same declaration every other command
|
|
784
1017
|
* reads; there is no second way to name it. See `new-paper.ts` for what it writes and why.
|
|
@@ -800,7 +1033,7 @@ async function runNew(
|
|
|
800
1033
|
const [name, ...extra] = a.paths;
|
|
801
1034
|
if (!name || extra.length > 0) {
|
|
802
1035
|
err(
|
|
803
|
-
`\`new\` takes exactly one paper name: \`paperlint new my-paper [--format tex|md]\``,
|
|
1036
|
+
`\`new\` takes exactly one paper name: \`paperlint new my-paper [--venue <preset>] [--kind <kind>] [--format tex|md]\``,
|
|
804
1037
|
);
|
|
805
1038
|
return 2;
|
|
806
1039
|
}
|
|
@@ -833,7 +1066,30 @@ async function runNew(
|
|
|
833
1066
|
log(
|
|
834
1067
|
`several papers directories are declared — using the first: ${relative(cwd, papersRoot) || papersRoot}`,
|
|
835
1068
|
);
|
|
836
|
-
|
|
1069
|
+
const paperDir = join(papersRoot, name);
|
|
1070
|
+
// An existing paperlint.json is never overwritten, so a venue for it is refused, not dropped.
|
|
1071
|
+
const hasConfig = existsSync(join(paperDir, CONFIG_FILE));
|
|
1072
|
+
if (hasConfig && (a.venue !== null || a.kind !== null)) {
|
|
1073
|
+
err(
|
|
1074
|
+
`${relative(cwd, join(paperDir, CONFIG_FILE))} already exists and is never overwritten — set "extends" and "kind" in it by hand`,
|
|
1075
|
+
);
|
|
1076
|
+
return 2;
|
|
1077
|
+
}
|
|
1078
|
+
const venue = hasConfig
|
|
1079
|
+
? { ok: true as const, value: null }
|
|
1080
|
+
: await chooseVenue(a, {
|
|
1081
|
+
paperDir,
|
|
1082
|
+
cwd,
|
|
1083
|
+
interactive: processInteractivity(a.yes).interactive,
|
|
1084
|
+
ask,
|
|
1085
|
+
});
|
|
1086
|
+
if (!venue.ok) return (err(venue.error), 2);
|
|
1087
|
+
return createPaperAt(papersRoot, name, format, {
|
|
1088
|
+
log,
|
|
1089
|
+
err,
|
|
1090
|
+
cwd,
|
|
1091
|
+
venue: venue.value,
|
|
1092
|
+
});
|
|
837
1093
|
}
|
|
838
1094
|
|
|
839
1095
|
/**
|
|
@@ -1061,6 +1317,12 @@ export async function run(
|
|
|
1061
1317
|
);
|
|
1062
1318
|
return 2;
|
|
1063
1319
|
}
|
|
1320
|
+
if (a.unknownFlag !== undefined) {
|
|
1321
|
+
err(
|
|
1322
|
+
`unknown flag \`${a.unknownFlag}\` — \`paperlint --help\` lists every flag`,
|
|
1323
|
+
);
|
|
1324
|
+
return 2;
|
|
1325
|
+
}
|
|
1064
1326
|
if (a.help || !a.cmd) {
|
|
1065
1327
|
log(USAGE);
|
|
1066
1328
|
return a.help ? 0 : 2;
|
|
@@ -1199,6 +1461,11 @@ export async function run(
|
|
|
1199
1461
|
err,
|
|
1200
1462
|
where: relative(cwd, root) || ".",
|
|
1201
1463
|
opts: withPapers,
|
|
1464
|
+
// Every paper of the project, not only the ones on the command line (#103).
|
|
1465
|
+
projectPapers: papersRoots(cfg)
|
|
1466
|
+
.flatMap((r) => papersIn(r))
|
|
1467
|
+
.map((d) => join(d, MAIN))
|
|
1468
|
+
.filter((f) => existsSync(f)),
|
|
1202
1469
|
});
|
|
1203
1470
|
}
|
|
1204
1471
|
|
|
@@ -1251,22 +1518,24 @@ async function reportLint(
|
|
|
1251
1518
|
err,
|
|
1252
1519
|
where,
|
|
1253
1520
|
opts,
|
|
1521
|
+
projectPapers,
|
|
1254
1522
|
}: {
|
|
1255
1523
|
a: Args;
|
|
1256
1524
|
log: typeof console.log;
|
|
1257
1525
|
err: typeof console.error;
|
|
1258
1526
|
where: string;
|
|
1259
1527
|
opts: PaperlintConfig;
|
|
1528
|
+
projectPapers: readonly string[];
|
|
1260
1529
|
},
|
|
1261
1530
|
): Promise<number> {
|
|
1262
1531
|
const silent = await silentOptionalRules(
|
|
1263
1532
|
eslint,
|
|
1264
|
-
results.map((r) => r.filePath),
|
|
1533
|
+
[...results.map((r) => r.filePath), ...projectPapers],
|
|
1265
1534
|
opts,
|
|
1266
1535
|
);
|
|
1267
1536
|
if (silent.length > 0) {
|
|
1268
1537
|
err(
|
|
1269
|
-
`${silent.join(", ")} is turned on in "rules", but no
|
|
1538
|
+
`${silent.join(", ")} is turned on in "rules", but no paper.tex of the project gets it — check the block's ` +
|
|
1270
1539
|
`"files" (relative to ${where}). A rule that never runs reports exactly like a rule that passed.`,
|
|
1271
1540
|
);
|
|
1272
1541
|
return 1;
|
package/src/engine.harness.mjs
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
whichOnPath,
|
|
29
29
|
isExecutable,
|
|
30
30
|
probeTree,
|
|
31
|
+
missingDependencies,
|
|
31
32
|
supportedPlatform,
|
|
32
33
|
} = await import(join(HERE, "engine.ts"));
|
|
33
34
|
|
|
@@ -251,4 +252,53 @@ check(
|
|
|
251
252
|
}
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
// ── missingDependencies: what an install that "continued anyway" left out ──────────────────
|
|
256
|
+
// Real stdout of `tlmgr check depends` on a tree whose install-tl lost unicode-data to a mirror
|
|
257
|
+
// (2026-09-26): the pdflatex format could not be built, yet every venue .sty was present.
|
|
258
|
+
// Byte for byte: tlmgr opens every section header with a FORM FEED, then a space.
|
|
259
|
+
const BROKEN_DEPENDS =
|
|
260
|
+
"\f DEPENDS WITHOUT PACKAGES:\n" +
|
|
261
|
+
"unicode-data in: collection-basic latex-bin luahbtex luatex\n" +
|
|
262
|
+
"\f PACKAGES NOT IN ANY COLLECTION: acmart booktabs caption\n";
|
|
263
|
+
{
|
|
264
|
+
const calls = [];
|
|
265
|
+
const run = (cmd, args) => {
|
|
266
|
+
calls.push([cmd, args]);
|
|
267
|
+
return {
|
|
268
|
+
stdout: BROKEN_DEPENDS,
|
|
269
|
+
stderr: "tlmgr: An error has occurred.",
|
|
270
|
+
status: 2,
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
const missing = missingDependencies("/tl/bin", run);
|
|
274
|
+
check(
|
|
275
|
+
"missingDependencies: asks THAT tree's tlmgr `check depends`",
|
|
276
|
+
calls.length === 1 &&
|
|
277
|
+
calls[0][0] === "/tl/bin/tlmgr" &&
|
|
278
|
+
JSON.stringify(calls[0][1]) === '["check","depends"]',
|
|
279
|
+
JSON.stringify(calls),
|
|
280
|
+
);
|
|
281
|
+
// Guards: a package the tree's own database requires but never installed is a gap.
|
|
282
|
+
check(
|
|
283
|
+
"🔴 missingDependencies: names the dependency the install dropped",
|
|
284
|
+
JSON.stringify(missing) === '["unicode-data"]',
|
|
285
|
+
JSON.stringify(missing),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
check(
|
|
289
|
+
"missingDependencies: packages outside any collection are not gaps",
|
|
290
|
+
JSON.stringify(
|
|
291
|
+
missingDependencies("/tl/bin", () => ({
|
|
292
|
+
stdout: "\f PACKAGES NOT IN ANY COLLECTION: acmart booktabs\n",
|
|
293
|
+
status: 2,
|
|
294
|
+
})),
|
|
295
|
+
) === "[]",
|
|
296
|
+
);
|
|
297
|
+
check(
|
|
298
|
+
"missingDependencies: a clean tree has none",
|
|
299
|
+
JSON.stringify(
|
|
300
|
+
missingDependencies("/tl/bin", () => ({ stdout: "", status: 0 })),
|
|
301
|
+
) === "[]",
|
|
302
|
+
);
|
|
303
|
+
|
|
254
304
|
console.log(`engine: ${n} checks passed`);
|
package/src/engine.ts
CHANGED
|
@@ -164,6 +164,41 @@ export function probeTree(
|
|
|
164
164
|
return missingPackages(packages, String(r.stdout ?? ""));
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Packages the tree's OWN database lists as dependencies but never installed, by its `tlmgr check
|
|
169
|
+
* depends`. 🔴 The venue's `.sty` files are not enough evidence of a working tree: install-tl that
|
|
170
|
+
* cannot download a package prints "continuing anyway", and a missing `unicode-data` leaves every
|
|
171
|
+
* `.sty` in place while the pdflatex format cannot be built — pdflatex then dies before writing a
|
|
172
|
+
* log. Only paperlint's cache trees are asked: their tlmgr writes to a directory we own. A tlmgr
|
|
173
|
+
* that cannot start returns nothing here; the tree is then judged by `probeTree` alone.
|
|
174
|
+
*/
|
|
175
|
+
export function missingDependencies(
|
|
176
|
+
bin: string,
|
|
177
|
+
run: Runner = spawnSync,
|
|
178
|
+
): string[] {
|
|
179
|
+
const r = run(join(bin, "tlmgr"), ["check", "depends"], {
|
|
180
|
+
encoding: "utf8",
|
|
181
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
182
|
+
});
|
|
183
|
+
if (r.error) return [];
|
|
184
|
+
const lines = String(r.stdout ?? "").split("\n");
|
|
185
|
+
const start = lines.findIndex(
|
|
186
|
+
(l) => l.trim() === "DEPENDS WITHOUT PACKAGES:",
|
|
187
|
+
);
|
|
188
|
+
if (start < 0) return [];
|
|
189
|
+
// The section runs until tlmgr's next header. Headers open with a form feed and a space, entries
|
|
190
|
+
// with the package name, so a header is any line with leading whitespace.
|
|
191
|
+
const section = lines.slice(start + 1);
|
|
192
|
+
const end = section.findIndex((l) => l !== l.trimStart() || !l.trim());
|
|
193
|
+
return [
|
|
194
|
+
...new Set(
|
|
195
|
+
(end < 0 ? section : section.slice(0, end)).map((l) =>
|
|
196
|
+
l.split(" in: ")[0]!.trim(),
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
].sort();
|
|
200
|
+
}
|
|
201
|
+
|
|
167
202
|
/** The first directory on `path` holding a runnable `name` — what a shell would run. */
|
|
168
203
|
export function whichOnPath(
|
|
169
204
|
name: string,
|
package/src/init.ts
CHANGED
|
@@ -372,7 +372,7 @@ export function nextSteps(
|
|
|
372
372
|
): string {
|
|
373
373
|
return [
|
|
374
374
|
``,
|
|
375
|
-
`next: npx paperlint new <name> # start a paper in ${papersDir}/ from the template`,
|
|
375
|
+
`next: npx paperlint new <name> --venue <preset> # start a paper in ${papersDir}/ from the template`,
|
|
376
376
|
...(toolchain
|
|
377
377
|
? [
|
|
378
378
|
` npx paperlint toolchain # TeX Live for building (${TOOLCHAIN_COST})`,
|