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.
Files changed (73) hide show
  1. package/README.md +179 -180
  2. package/dist/build-engine.d.ts.map +1 -1
  3. package/dist/build-engine.js +6 -2
  4. package/dist/build-engine.js.map +1 -1
  5. package/dist/build.d.ts.map +1 -1
  6. package/dist/build.js +22 -3
  7. package/dist/build.js.map +1 -1
  8. package/dist/cli.d.ts +66 -5
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +230 -30
  11. package/dist/cli.js.map +1 -1
  12. package/dist/engine.d.ts +9 -0
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +27 -0
  15. package/dist/engine.js.map +1 -1
  16. package/dist/init.js +1 -1
  17. package/dist/init.js.map +1 -1
  18. package/dist/new-paper.d.ts +8 -1
  19. package/dist/new-paper.d.ts.map +1 -1
  20. package/dist/new-paper.js +50 -15
  21. package/dist/new-paper.js.map +1 -1
  22. package/dist/paper-files.d.ts +30 -8
  23. package/dist/paper-files.d.ts.map +1 -1
  24. package/dist/paper-files.js +56 -29
  25. package/dist/paper-files.js.map +1 -1
  26. package/dist/structure.js +1 -1
  27. package/dist/structure.js.map +1 -1
  28. package/dist/toolchain.d.ts +5 -1
  29. package/dist/toolchain.d.ts.map +1 -1
  30. package/dist/toolchain.js +22 -5
  31. package/dist/toolchain.js.map +1 -1
  32. package/dist/types.d.ts +6 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/venue-rules.js +1 -1
  35. package/dist/venue-rules.js.map +1 -1
  36. package/docs/configuration.md +48 -4
  37. package/docs/rules.md +29 -25
  38. package/docs/skills.md +80 -0
  39. package/eslint-rules/paper-typography.mjs +47 -7
  40. package/eslint-rules/paper-typography.test.mjs +40 -0
  41. package/eslint-rules/review-frontmatter.schema.json +1 -1
  42. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  43. package/fixtures/toolchain-mirror/install-tl +4 -1
  44. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  45. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  46. package/package.json +1 -1
  47. package/scripts/mutation-batteries.frozen.json +1 -1
  48. package/src/build-engine.ts +6 -1
  49. package/src/build.harness.mjs +46 -2
  50. package/src/build.ts +27 -5
  51. package/src/cli.harness.mjs +2 -2
  52. package/src/cli.ts +301 -32
  53. package/src/engine.harness.mjs +50 -0
  54. package/src/engine.ts +35 -0
  55. package/src/init.ts +1 -1
  56. package/src/linted-set.test.ts +118 -0
  57. package/src/new-paper.harness.mjs +2 -0
  58. package/src/new-paper.mutations.mjs +0 -14
  59. package/src/new-paper.test.ts +172 -1
  60. package/src/new-paper.ts +84 -17
  61. package/src/paper-files.test.ts +110 -5
  62. package/src/paper-files.ts +93 -37
  63. package/src/paper-settings-commands.test.ts +41 -5
  64. package/src/parse-args.test.ts +48 -0
  65. package/src/rules-off.test.ts +111 -0
  66. package/src/structure.harness.mjs +2 -2
  67. package/src/structure.ts +1 -1
  68. package/src/toolchain.harness.mjs +41 -6
  69. package/src/toolchain.ts +29 -5
  70. package/src/types.ts +6 -0
  71. package/src/venue-rules.test.ts +3 -0
  72. package/src/venue-rules.ts +1 -1
  73. 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: acm-sigconf, agenticdev, aisec, realm)
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 linted `paper.tex` must actually have it enabled; the ones none has are
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
- lintedFiles: readonly string[],
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 lintedFiles.filter((p) => basename(p) === MAIN)) {
365
- const cfg = (await eslint.calculateConfigForFile(f)) as {
366
- rules?: Record<string, unknown>;
367
- };
368
- for (const id of turnedOn) if (isOn(cfg.rules?.[id])) reached.add(id);
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 files a paper's block may reach: exactly the ones paperlint's own blocks lint, read off its
458
- * config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
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 a = rest[i];
550
- if (a === undefined) continue;
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 = valueFor(a, ++i) ?? null;
561
- else if (a === "--format") out.format = valueFor(a, ++i) ?? null;
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 = valueFor(a, ++i);
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
- }: { log: typeof console.log; err: typeof console.error; cwd: string },
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
- return createPaperAt(papersRoot, name, format, { log, err, cwd });
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 linted paper.tex gets it — check the block's ` +
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;
@@ -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})`,