paperlint 3.0.0 → 3.1.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.
Files changed (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
package/dist/cli.js CHANGED
@@ -45,10 +45,10 @@ import { paperRules, stringFields, } from "./paper-settings.js";
45
45
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.js";
46
46
  import { onlineReferences } from "./adapters/references/index.js";
47
47
  import { narrowToOwners, ownedPatterns, ruleOwners, scopeToOwned, } from "./paper-files.js";
48
- import { paperPreset, paperPresetProblem, } from "./presets.js";
48
+ import { paperPreset, settingsProblemLine, presetProblemText, resolvePreset, shippedPresets, SHIPPED_PREFIX, } from "./presets.js";
49
49
  import { mergeRequirements, declaredUnion, requirementsFor, NO_REQUIREMENTS, } from "./tex-requirements.js";
50
50
  import { doctor } from "./doctor.js";
51
- import { init, processInteractivity, askOnTerminal } from "./init.js";
51
+ import { askOnTerminal, askOrDefault, init, processInteractivity, } from "./init.js";
52
52
  import { DEFAULT_FORMAT, FORMATS, isFormat, newPaper, reportNewPaper, } from "./new-paper.js";
53
53
  // The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
54
54
  // rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
@@ -72,12 +72,14 @@ import reviewFrontmatter from "../eslint-rules/review-frontmatter.mjs";
72
72
  import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
73
73
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
74
74
  import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
75
+ /** The shipped venue presets, read from the package's venues directory — the one list. */
76
+ const SHIPPED_VENUES = () => shippedPresets(packageVenuesDir());
75
77
  const USAGE = `paperlint — machine-checkable gates for a paper kept in git
76
78
 
77
79
  npx paperlint init [dir] set the project up: detect the papers directory, declare it
78
80
  in package.json, link the skills, wire the hooks into
79
81
  .claude/settings.json, offer the CI step, report what is missing
80
- npx paperlint new <name> [--format tex|md]
82
+ npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
81
83
  create <papers>/<name>/ from the template; never overwrites,
82
84
  on an existing folder adds only the missing files, then lints it
83
85
  npx paperlint lint [paths…] run every rule over your papers
@@ -105,6 +107,15 @@ init:
105
107
  --paper <name> create this paper too (without a human, the only way init creates one)
106
108
  --format tex|md the new paper's source format; default tex
107
109
 
110
+ new:
111
+ --venue <preset> the venue preset, written as "extends" into the paper's paperlint.json:
112
+ a shipped one (${SHIPPED_VENUES().join(", ")}), or a path to your
113
+ own preset starting with ./ or ../, relative to where you run the command.
114
+ On a terminal without --venue, new asks; "none" leaves it unset
115
+ --kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
116
+ preset's kinds; needs --venue
117
+ --format tex|md the paper's source format; default tex
118
+
108
119
  lint:
109
120
  npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
110
121
 
@@ -137,7 +148,7 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
137
148
 
138
149
  { "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
139
150
 
140
- "extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
151
+ "extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
141
152
  or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
142
153
 
143
154
  "rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
@@ -260,6 +271,54 @@ export function buildConfig(opts = {}, texLanguage) {
260
271
  * config rather than listed again. `@eslint/markdown` is a dependency's plugin, not paperlint's.
261
272
  */
262
273
  export const SHIPPED_RULES = shippedRuleIds(buildConfig({}, { sentinel: "tex language" }), [markdown]);
274
+ /**
275
+ * Every plugin paperlint's config registers, ONE object per name, read off that config. In it one
276
+ * name is bound to different objects in different blocks (`paper` beside `PIPELINE-STATUS.md` is
277
+ * not `paper` beside `paper.tex`); here their rules are merged, so a config that lints any paper
278
+ * file knows every rule id. `@eslint/markdown` is a dependency's plugin and is left out. The LaTeX
279
+ * language rides on `tex` when `texLanguage` is given, and that is how a consumer registers it: a
280
+ * second `tex` plugin of its own is refused by ESLint ("Cannot redefine plugin "tex"", measured
281
+ * with and without the language here). Without it, `tex` carries rules only — for a config that
282
+ * lints markdown papers alone.
283
+ */
284
+ export function rulePlugins(texLanguage) {
285
+ const out = {};
286
+ for (const block of buildConfig({}, texLanguage ?? { sentinel: "tex" }))
287
+ for (const [name, plugin] of Object.entries(block.plugins ?? {}))
288
+ if (plugin !== markdown)
289
+ out[name] = { rules: { ...out[name]?.rules, ...plugin.rules } };
290
+ if (out["tex"] && texLanguage !== undefined)
291
+ return {
292
+ ...out,
293
+ tex: { ...out["tex"], languages: { latex: texLanguage } },
294
+ };
295
+ return out;
296
+ }
297
+ /**
298
+ * A flat-config fragment for a consumer's OWN ESLint run over paper files (#104): every rule
299
+ * paperlint ships, registered and turned off. Without it a `% eslint-disable-next-line
300
+ * paper/leading-zero -- why` — the documented escape hatch — fails that run with "Definition for
301
+ * rule 'paper/leading-zero' was not found". Off, because `paperlint lint` is where they run.
302
+ *
303
+ * The second block is not optional, and it is measured: a directive naming a rule that is off
304
+ * suppresses nothing, so ESLint reports it as an unused directive (a warning by default, an error
305
+ * under `reportUnusedDisableDirectives: "error"`). On the files paperlint lints that report is
306
+ * switched off in THIS run — `paperlint lint` still reports a directive that silences nothing.
307
+ */
308
+ export function rulesOff(texLanguage) {
309
+ return [
310
+ {
311
+ name: "paperlint/rules-off",
312
+ plugins: rulePlugins(texLanguage),
313
+ rules: Object.fromEntries([...SHIPPED_RULES].map((id) => [id, "off"])),
314
+ },
315
+ {
316
+ name: "paperlint/rules-off/directives",
317
+ files: PAPER_FILE_PATTERNS,
318
+ linterOptions: { reportUnusedDisableDirectives: "off" },
319
+ },
320
+ ];
321
+ }
263
322
  /** Whether a rule entry (`"error"`, `2`, `["warn", {…}]`) turns the rule on. */
264
323
  const isOn = (entry) => {
265
324
  const sev = Array.isArray(entry) ? entry[0] : entry;
@@ -274,18 +333,24 @@ export const OPTIONAL_RULES = new Set([...SHIPPED_RULES].filter((id) => !buildCo
274
333
  * 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
275
334
  * nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
276
335
  * rule that never runs reports exactly like a rule that passed. So for every optional rule the
277
- * consumer turned on, some linted `paper.tex` must actually have it enabled; the ones none has are
336
+ * consumer turned on, some `paper.tex` must actually have it enabled; the ones none has are
278
337
  * returned, for the caller to refuse.
338
+ *
339
+ * `files` is every `paper.tex` the guard may count: the ones this run linted AND every paper of the
340
+ * project. Judged against the run alone, `paperlint lint papers/c` failed on a block written for
341
+ * papers a and b (#103) — a block that reaches a paper outside this run is not dead. A glob that
342
+ * reaches no paper of the project at all still fails, from a subset as from the whole.
279
343
  */
280
- export async function silentOptionalRules(eslint, lintedFiles, opts) {
344
+ export async function silentOptionalRules(eslint, files, opts) {
281
345
  const turnedOn = new Set((opts.rules ?? []).flatMap((b) => Object.entries(b.rules)
282
346
  .filter(([id, e]) => OPTIONAL_RULES.has(id) && isOn(e))
283
347
  .map(([id]) => id)));
284
348
  const reached = new Set();
285
- for (const f of lintedFiles.filter((p) => basename(p) === MAIN)) {
349
+ for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
350
+ // Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
286
351
  const cfg = (await eslint.calculateConfigForFile(f));
287
352
  for (const id of turnedOn)
288
- if (isOn(cfg.rules?.[id]))
353
+ if (isOn(cfg?.rules?.[id]))
289
354
  reached.add(id);
290
355
  }
291
356
  return [...turnedOn].filter((id) => !reached.has(id));
@@ -303,8 +368,8 @@ export function paperRuleBlocks(paths) {
303
368
  const out = { preset: [], own: [] };
304
369
  for (const dir of papers) {
305
370
  const p = paperPreset(dir, PRESET_DEPS);
306
- if (p.kind === "settings-problem" && p.problem.kind === "broken")
307
- return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
371
+ if (p.kind === "settings-problem")
372
+ return { ok: false, error: settingsProblemLine(dir, p.problem) };
308
373
  const blocks = rulesOfPaper(dir, p);
309
374
  if (!blocks.ok)
310
375
  return blocks;
@@ -319,7 +384,7 @@ export function paperRuleBlocks(paths) {
319
384
  * contributes nothing here; `pdf/profile` reports it on the paper.
320
385
  */
321
386
  function rulesOfPaper(dir, p) {
322
- const settings = "settings" in p ? p.settings : null;
387
+ const { settings } = p;
323
388
  const fromPreset = p.kind === "resolved"
324
389
  ? parseRuleEntries(p.preset.rules, `the venue preset ${p.preset.chain.join(" → ")} → "rules"`, SHIPPED_RULES)
325
390
  : { ok: true, value: {} };
@@ -332,30 +397,27 @@ function rulesOfPaper(dir, p) {
332
397
  : ownRules(dir, settings);
333
398
  if (!own.ok)
334
399
  return own;
335
- const scoped = (rules) => Object.keys(rules).length
336
- ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
337
- : [];
400
+ // No `files`: `narrowToOwners` scopes the block to what paperlint lints under `dir`, exceptions
401
+ // included. A copy of the globs here is how the siblings index got parsed as JavaScript (#101).
402
+ const scoped = (rules) => Object.keys(rules).length ? [{ basePath: dir, rules }] : [];
338
403
  return {
339
404
  ok: true,
340
405
  value: { preset: scoped(fromPreset.value), own: [...own.value] },
341
406
  };
342
407
  }
343
- /** A paper's `{ id: severity }` rules, as one block over the paper's files. */
408
+ /** A paper's `{ id: severity }` rules, as one block over the paper's files (narrowed later, #101). */
344
409
  function ownRules(dir, settings) {
345
410
  const own = paperRules(dir, settings, SHIPPED_RULES);
346
411
  if (!own.ok)
347
412
  return own;
348
413
  return {
349
414
  ok: true,
350
- value: own.value
351
- ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
352
- : [],
415
+ value: own.value ? [{ basePath: dir, rules: own.value }] : [],
353
416
  };
354
417
  }
355
418
  /**
356
- * The files a paper's block may reach: exactly the ones paperlint's own blocks lint, read off its
357
- * config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
358
- * language — `paperlint.json` itself would be parsed as JavaScript.
419
+ * The globs paperlint lints, for the refusal message only. Which files a block reaches is decided by
420
+ * `narrowToOwners` from `ownedScopes` (src/paper-files.ts), where each glob keeps its exceptions.
359
421
  */
360
422
  const PAPER_FILE_PATTERNS = ownedPatterns(buildConfig({}, { sentinel: "tex language" }));
361
423
  /**
@@ -390,6 +452,8 @@ export function parseSettings(json, where, baseDir) {
390
452
  * papers inside the config's directory, that is the config's directory itself.
391
453
  */
392
454
  const lintRoot = (home, paths) => commonDir([home, ...paths]);
455
+ /** A path as the reader typed it: relative to where the command runs, or whole when it IS there. */
456
+ const shown = (cwd, p) => relative(cwd, p) || p;
393
457
  /** The longest shared leading run of path segments. */
394
458
  const commonDir = (paths) => {
395
459
  const [first = [], ...rest] = paths.map((p) => p.split(sep));
@@ -412,30 +476,47 @@ export function parseArgs(argv) {
412
476
  noHooks: false,
413
477
  paper: null,
414
478
  format: null,
479
+ venue: null,
480
+ kind: null,
415
481
  hooksMode: null,
416
482
  // -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
417
483
  // gate that fails on advice gets muted entirely.
418
484
  maxWarnings: -1,
419
485
  };
486
+ // Consumed from the front: a value flag takes the next argument by shifting it off.
420
487
  const rest = [...argv];
421
- if (rest[0] && !rest[0].startsWith("-"))
422
- out.cmd = rest.shift() ?? null;
488
+ const first = rest[0];
489
+ if (first && !first.startsWith("-")) {
490
+ out.cmd = first;
491
+ rest.shift();
492
+ }
423
493
  // 🔴 A FLAG WHOSE VALUE WAS TAKEN AWAY IS A REFUSAL, NOT A DEFAULT. The compiler found this
424
- // during the move to TypeScript: `rest[++i]` past the last argument gives `undefined`, and
494
+ // during the move to TypeScript: the value past the last argument is `undefined`, and
425
495
  // `paperlint lint --config` (the value forgotten, or eaten by a substitution in CI) silently turned
426
496
  // into "no config given" — that is, it went to auto-discovery and linted against SOMEONE ELSE'S
427
497
  // file, saying nothing. The failure is one-sided and toward silence, so it is cured by
428
498
  // behaviour, not by a type cast.
429
- const valueFor = (flag, i) => {
430
- const v = rest[i];
499
+ const valueFor = (flag) => {
500
+ const v = rest.shift();
431
501
  if (v === undefined)
432
502
  out.missingValue = flag;
433
503
  return v;
434
504
  };
435
- for (let i = 0; i < rest.length; i++) {
436
- const a = rest[i];
437
- if (a === undefined)
438
- continue;
505
+ for (let arg = rest.shift(); arg !== undefined; arg = rest.shift()) {
506
+ // `--flag=value` is `--flag value` for every flag that takes a value: the CI action writes
507
+ // `--max-warnings="$N"`, and that form used to fall through to the list of paths.
508
+ const eq = arg.indexOf("=");
509
+ const inline = arg.startsWith("--") && eq > 0 && VALUE_FLAGS.has(arg.slice(0, eq))
510
+ ? arg.slice(eq + 1)
511
+ : undefined;
512
+ const a = inline === undefined ? arg : arg.slice(0, eq);
513
+ const take = () => {
514
+ if (inline === undefined)
515
+ return valueFor(a);
516
+ if (inline === "")
517
+ out.missingValue = a;
518
+ return inline === "" ? undefined : inline;
519
+ };
439
520
  if (a === "--json")
440
521
  out.json = true;
441
522
  else if (a === "--fix")
@@ -453,34 +534,51 @@ export function parseArgs(argv) {
453
534
  else if (a.startsWith("--hooks="))
454
535
  out.hooksMode = a.slice("--hooks=".length);
455
536
  else if (a === "--paper")
456
- out.paper = valueFor(a, ++i) ?? null;
537
+ out.paper = take() ?? null;
457
538
  else if (a === "--format")
458
- out.format = valueFor(a, ++i) ?? null;
539
+ out.format = take() ?? null;
540
+ else if (a === "--venue")
541
+ out.venue = take() ?? null;
542
+ else if (a === "--kind")
543
+ out.kind = take() ?? null;
459
544
  // `--options` was the first spelling and is kept working. It named the wrong thing — every
460
545
  // other tool in the stack calls this file its config — but a flag in someone's CI is not
461
546
  // ours to break.
462
547
  else if (a === "--config" || a === "--options")
463
- out.config = valueFor(a, ++i) ?? null;
548
+ out.config = take() ?? null;
464
549
  else if (a === "--max-warnings") {
465
- const v = valueFor(a, ++i);
550
+ const v = take();
466
551
  if (v !== undefined)
467
552
  out.maxWarnings = Number(v);
468
553
  }
469
554
  else if (a === "--help" || a === "-h")
470
555
  out.help = true;
556
+ // An unknown flag is refused by name. Read as a path, it silently lints something else.
557
+ else if (a.startsWith("-") && a !== "-")
558
+ out.unknownFlag ??= a;
471
559
  else
472
560
  out.paths.push(a);
473
561
  }
474
562
  return out;
475
563
  }
564
+ /** The flags that take a value, in either spelling: `--flag value` or `--flag=value`. */
565
+ const VALUE_FLAGS = new Set([
566
+ "--paper",
567
+ "--format",
568
+ "--venue",
569
+ "--kind",
570
+ "--config",
571
+ "--options",
572
+ "--max-warnings",
573
+ ]);
476
574
  /**
477
575
  * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
478
576
  * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
479
577
  * the placeholder instead of guessing.
480
578
  */
481
- function ownVersion() {
579
+ export function ownVersion(readManifest = () => readFileSync(new URL("../package.json", import.meta.url), "utf8")) {
482
580
  try {
483
- const v = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))?.version;
581
+ const v = JSON.parse(readManifest())?.version;
484
582
  return typeof v === "string" ? v : undefined;
485
583
  }
486
584
  catch {
@@ -524,8 +622,9 @@ export function readConfig(a, { log = console.log, err = console.error, cwd = pr
524
622
  //
525
623
  // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
526
624
  // the array breaks any `| jq`, and it breaks it for the consumer, not for us.
527
- (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
528
- const settings = parseSettings(parsed, relative(cwd, configPath) || CONFIG_FILE, root);
625
+ const shownPath = shown(cwd, configPath);
626
+ (a.json ? err : log)(`config: ${shownPath}`);
627
+ const settings = parseSettings(parsed, shownPath, root);
529
628
  if (!settings.ok) {
530
629
  err(settings.error);
531
630
  return { code: 2 };
@@ -617,16 +716,90 @@ resolve = (spec) => createRequire(import.meta.url).resolve(spec), } = {}) {
617
716
  * become two implementations. The lint runs on THAT folder, so the first thing printed after the
618
717
  * file list is its verdict rather than the old "missing PIPELINE-STATUS.md".
619
718
  */
620
- export async function createPaperAt(papersRoot, name, format, { log, err, cwd, }) {
621
- const result = newPaper(papersRoot, name, format);
622
- const here = (p) => relative(cwd, p) || p;
719
+ export async function createPaperAt(papersRoot, name, format, { log, err, cwd, venue = null, }) {
720
+ const result = newPaper(papersRoot, name, format, { venue });
721
+ const here = (p) => shown(cwd, p);
623
722
  for (const line of reportNewPaper(result, here))
624
723
  log(line);
625
724
  if (!result.ok)
626
725
  return 2;
726
+ const config = here(join(result.dir, CONFIG_FILE));
727
+ if (venue === null)
728
+ log(` venue: none yet — set "extends" in ${config}, or next time: paperlint new <name> --venue <preset> (${SHIPPED_VENUES().join(", ")})`);
729
+ else if (venue.kind === null && venue.kinds.length > 0)
730
+ log(` kind: not set — \`${venue.label}\` sets a page limit per kind (${venue.kinds.join(", ")}); add "kind" to ${config}. Until then lint reports pdf/profile`);
627
731
  log(``);
628
732
  return run(["lint", result.dir], { log, err, cwd });
629
733
  }
734
+ /**
735
+ * `--venue` / `--kind` → the `extends` and `kind` to write into `<paperDir>/paperlint.json`, or why
736
+ * not. Parsed here, at the boundary, and resolved through the same `resolvePreset` lint uses — so a
737
+ * venue `new` accepts is a venue lint resolves. On a terminal with no `--venue` it asks, with
738
+ * "none" as the default; without a terminal it chooses nothing (null), as before.
739
+ *
740
+ * A path is relative to where the command runs, because that is where it was typed; it is written
741
+ * relative to the paper's `paperlint.json`, because that is what `extends` is relative to. Written
742
+ * as typed, `./venues/x.jsonc` would name `<paper>/venues/x.jsonc`.
743
+ */
744
+ export async function chooseVenue(flags, { paperDir, cwd, interactive, ask, }) {
745
+ const shipped = SHIPPED_VENUES();
746
+ if (flags.kind !== null && flags.venue === null)
747
+ return bad(`--kind needs --venue: a kind is a page limit of one venue preset — \`paperlint new <name> --venue <preset> --kind ${flags.kind}\``);
748
+ const asked = async (q) => (await askOrDefault(ask, q))?.trim() ?? "";
749
+ const venue = flags.venue ??
750
+ (interactive
751
+ ? await asked(`venue: ${[...shipped, "none"].join(" / ")} [none] `)
752
+ : "");
753
+ if (venue === "" || (flags.venue === null && venue === "none"))
754
+ return { ok: true, value: null };
755
+ const spec = venueSpec(venue, { shipped, paperDir, cwd });
756
+ if (!spec.ok)
757
+ return spec;
758
+ const r = resolvePreset(spec.value, join(paperDir, CONFIG_FILE), PRESET_DEPS);
759
+ if (!r.ok)
760
+ return bad(`--venue ${venue}: ${presetProblemText(r.error)}`);
761
+ const kinds = [...r.value.format.kinds.keys()];
762
+ const label = r.value.label;
763
+ if (flags.kind !== null) {
764
+ if (kinds.length === 0)
765
+ return bad(`--kind ${flags.kind}: \`${label}\` has no kinds — it sets no page limit, so there is no kind to choose`);
766
+ if (!kinds.includes(flags.kind))
767
+ return bad(`--kind ${flags.kind}: \`${label}\` has no kind \`${flags.kind}\`; its kinds: ${kinds.join(", ")}`);
768
+ }
769
+ const kind = flags.kind ??
770
+ (interactive && kinds.length > 0
771
+ ? await asked(`kind: ${[...kinds, "later"].join(" / ")} [later] `)
772
+ : null);
773
+ return {
774
+ ok: true,
775
+ value: {
776
+ extends: spec.value,
777
+ kind: kind !== null && kinds.includes(kind) ? kind : null,
778
+ label,
779
+ kinds,
780
+ },
781
+ };
782
+ }
783
+ const bad = (error) => ({
784
+ ok: false,
785
+ error,
786
+ });
787
+ /** What `--venue` names → the `extends` value, relative to `<paperDir>/paperlint.json`. */
788
+ function venueSpec(venue, { shipped, paperDir, cwd, }) {
789
+ if (venue.startsWith("./") || venue.startsWith("../")) {
790
+ const file = resolve(cwd, venue);
791
+ if (!existsSync(file))
792
+ return bad(`--venue ${venue}: no such file (${file}) — a path is relative to where you run the command`);
793
+ const rel = relative(paperDir, file).split(sep).join("/");
794
+ return { ok: true, value: rel.startsWith("../") ? rel : `./${rel}` };
795
+ }
796
+ const name = venue.startsWith(SHIPPED_PREFIX)
797
+ ? venue.slice(SHIPPED_PREFIX.length)
798
+ : venue;
799
+ if (!shipped.includes(name))
800
+ return bad(`--venue ${venue}: no such venue preset. Shipped: ${shipped.join(", ")} — or a path to your own preset, starting with ./ or ../`);
801
+ return { ok: true, value: `${SHIPPED_PREFIX}${name}` };
802
+ }
630
803
  /**
631
804
  * `paperlint new <name>` — the papers directory comes from the same declaration every other command
632
805
  * reads; there is no second way to name it. See `new-paper.ts` for what it writes and why.
@@ -634,7 +807,7 @@ export async function createPaperAt(papersRoot, name, format, { log, err, cwd, }
634
807
  async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
635
808
  const [name, ...extra] = a.paths;
636
809
  if (!name || extra.length > 0) {
637
- err(`\`new\` takes exactly one paper name: \`paperlint new my-paper [--format tex|md]\``);
810
+ err(`\`new\` takes exactly one paper name: \`paperlint new my-paper [--venue <preset>] [--kind <kind>] [--format tex|md]\``);
638
811
  return 2;
639
812
  }
640
813
  let format = DEFAULT_FORMAT;
@@ -646,9 +819,9 @@ async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
646
819
  format = a.format;
647
820
  }
648
821
  else if (processInteractivity(a.yes).interactive) {
649
- const f = await ask(`format: tex / md [${DEFAULT_FORMAT}] `).catch(() => "");
650
- if (isFormat(f.trim()))
651
- format = f.trim();
822
+ const f = (await askOrDefault(ask, `format: tex / md [${DEFAULT_FORMAT}] `))?.trim();
823
+ if (isFormat(f))
824
+ format = f;
652
825
  }
653
826
  const cfg = readConfig({ ...a, json: false }, { log: () => { }, err, cwd });
654
827
  if (cfg.code !== undefined)
@@ -660,8 +833,30 @@ async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
660
833
  return 2;
661
834
  }
662
835
  if (roots.length > 1)
663
- log(`several papers directories are declared — using the first: ${relative(cwd, papersRoot) || papersRoot}`);
664
- return createPaperAt(papersRoot, name, format, { log, err, cwd });
836
+ log(`several papers directories are declared — using the first: ${shown(cwd, papersRoot)}`);
837
+ const paperDir = join(papersRoot, name);
838
+ // An existing paperlint.json is never overwritten, so a venue for it is refused, not dropped.
839
+ const hasConfig = existsSync(join(paperDir, CONFIG_FILE));
840
+ if (hasConfig && (a.venue !== null || a.kind !== null)) {
841
+ err(`${relative(cwd, join(paperDir, CONFIG_FILE))} already exists and is never overwritten — set "extends" and "kind" in it by hand`);
842
+ return 2;
843
+ }
844
+ const venue = hasConfig
845
+ ? { ok: true, value: null }
846
+ : await chooseVenue(a, {
847
+ paperDir,
848
+ cwd,
849
+ interactive: processInteractivity(a.yes).interactive,
850
+ ask,
851
+ });
852
+ if (!venue.ok)
853
+ return (err(venue.error), 2);
854
+ return createPaperAt(papersRoot, name, format, {
855
+ log,
856
+ err,
857
+ cwd,
858
+ venue: venue.value,
859
+ });
665
860
  }
666
861
  /**
667
862
  * 🔴 THE TARGET IS NAMED, "EVERYTHING" IS AN OPTION. That is how it is for everyone whose build is
@@ -670,7 +865,7 @@ async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
670
865
  * everything" on a corpus of five papers is twenty pdflatex runs instead of one, and almost never
671
866
  * what was wanted.
672
867
  */
673
- async function runBuild(a, { log, err, cwd, }) {
868
+ async function runBuild(a, { log, err, cwd, checkReferences, }) {
674
869
  const cfg = readConfig(a, { log, err, cwd });
675
870
  if (cfg.code !== undefined)
676
871
  return cfg.code;
@@ -698,7 +893,7 @@ async function runBuild(a, { log, err, cwd, }) {
698
893
  cwd,
699
894
  dryRun: a.dryRun,
700
895
  log,
701
- checkReferences: onlineReferences,
896
+ checkReferences,
702
897
  engine: () => engineEnv(targets, a, { log, err }),
703
898
  });
704
899
  if (out.kind === "no-engine")
@@ -782,6 +977,33 @@ function hostBanalInstaller() {
782
977
  });
783
978
  return banalInstaller({ ...ports, download }, s);
784
979
  }
980
+ /**
981
+ * init's TeX Live step: whether paperlint's own tree is installed, and how to install it —
982
+ * `runToolchain` over what this project's papers need. `toolchain` is the seam a test replaces:
983
+ * the real one downloads ~270 MB.
984
+ */
985
+ export function initTexLive(a, { log, err, cwd, }, toolchain = runToolchain) {
986
+ return {
987
+ installed: () => cachedTree(cacheRoot(process.env)) !== null,
988
+ install: () => toolchain({
989
+ check: false,
990
+ log,
991
+ err,
992
+ banal: hostBanalInstaller(),
993
+ tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
994
+ }),
995
+ };
996
+ }
997
+ /**
998
+ * The first papers directory the CLI would lint from `cwd`, or null when the config does not read
999
+ * or names none — what `doctor` (and `init`, through it) compares the hooks' directory against.
1000
+ */
1001
+ function cliPapers(a, cwd) {
1002
+ const read = readConfig(a, { log: () => { }, err: () => { }, cwd });
1003
+ return read.code === undefined
1004
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1005
+ : null;
1006
+ }
785
1007
  /** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
786
1008
  async function runInit(a, { log, err, cwd, }) {
787
1009
  // Deferred, not implemented: named and refused, rather than read as the directory argument.
@@ -804,25 +1026,11 @@ async function runInit(a, { log, err, cwd, }) {
804
1026
  paper: a.paper,
805
1027
  format: isFormat(a.format) ? a.format : null,
806
1028
  createPaper: (papersRoot, name, format) => createPaperAt(papersRoot, name, format, { log, err, cwd }),
807
- tex: {
808
- installed: () => cachedTree(cacheRoot(process.env)) !== null,
809
- install: () => runToolchain({
810
- check: false,
811
- log,
812
- err,
813
- banal: hostBanalInstaller(),
814
- tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
815
- }),
816
- },
817
- resolveCliPapers: (root) => {
818
- const read = readConfig({ ...a, config: null }, { log: () => { }, err: () => { }, cwd: root });
819
- return read.code === undefined
820
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
821
- : null;
822
- },
1029
+ tex: initTexLive(a, { log, err, cwd }),
1030
+ resolveCliPapers: (root) => cliPapers({ ...a, config: null }, root),
823
1031
  });
824
1032
  }
825
- export async function run(argv, { log = console.log, err = console.error, cwd = process.cwd(), } = {}) {
1033
+ export async function run(argv, { log = console.log, err = console.error, cwd = process.cwd(), checkReferences = onlineReferences, } = {}) {
826
1034
  const a = parseArgs(argv);
827
1035
  // The refusal must come FIRST: behind a flag without a value there is usually a typo, or a
828
1036
  // substitution in CI that collapsed into nothing, and any continuation works on something other
@@ -833,6 +1041,10 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
833
1041
  `not what the command line said.`);
834
1042
  return 2;
835
1043
  }
1044
+ if (a.unknownFlag !== undefined) {
1045
+ err(`unknown flag \`${a.unknownFlag}\` — \`paperlint --help\` lists every flag`);
1046
+ return 2;
1047
+ }
836
1048
  if (a.help || !a.cmd) {
837
1049
  log(USAGE);
838
1050
  return a.help ? 0 : 2;
@@ -846,10 +1058,7 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
846
1058
  // broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
847
1059
  // what it prints, rather than an early exit that tells the reader nothing about the hooks.
848
1060
  if (a.cmd === "doctor") {
849
- const read = readConfig(a, { log: () => { }, err: () => { }, cwd });
850
- const papers = read.code === undefined
851
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
852
- : null;
1061
+ const papers = cliPapers(a, cwd);
853
1062
  return doctor({
854
1063
  log,
855
1064
  cwd,
@@ -859,7 +1068,7 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
859
1068
  }
860
1069
  const simple = SIMPLE[a.cmd];
861
1070
  if (simple)
862
- return await simple(a, { log, err, cwd });
1071
+ return await simple(a, { log, err, cwd, checkReferences });
863
1072
  if (a.cmd === "check")
864
1073
  err(`\`check\` is now \`lint\` — running it anyway. Update the call to \`paperlint lint\`.`);
865
1074
  if (a.cmd !== "lint" && a.cmd !== "check") {
@@ -908,14 +1117,16 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
908
1117
  ...opts,
909
1118
  rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
910
1119
  };
1120
+ // Loaded lazily because only `lint` needs it. If the module cannot be loaded (a broken install:
1121
+ // the file missing from the package), lint still runs over the markdown papers rather than
1122
+ // failing outright — the LaTeX rules simply have no language to run in.
911
1123
  let texLanguage = null;
912
1124
  try {
913
- // @ts-expect-error — the module is .mjs and has no types; a missing LaTeX parser is a normal
914
- // case here, it is caught by the catch below.
1125
+ // @ts-expect-error — the module is .mjs and has no types
915
1126
  ({ texLanguage } = await import("../eslint-rules/latex-language.mjs"));
916
1127
  }
917
1128
  catch {
918
- /* without a LaTeX parser we work over markdown */
1129
+ texLanguage = null;
919
1130
  }
920
1131
  const eslint = new ESLint({
921
1132
  cwd: lintRoot(root, paths),
@@ -925,7 +1136,7 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
925
1136
  });
926
1137
  const unowned = await firstUnownedFile(eslint, paths);
927
1138
  if (unowned !== null) {
928
- err(`${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`);
1139
+ err(`${shown(cwd, unowned)} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`);
929
1140
  return 2;
930
1141
  }
931
1142
  // 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
@@ -951,7 +1162,7 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
951
1162
  // indistinguishable from "not a single rule got a single file". A rule whose glob did not match
952
1163
  // is not invoked — and, not being invoked, it physically cannot report that.
953
1164
  if (results.length === 0) {
954
- err(`nothing was linted under ${paths.map((x) => relative(cwd, x) || x).join(", ")} — no PIPELINE-STATUS.md, paper.md/tex or reviews/ found there. A clean report over zero files is not a clean report.`);
1165
+ err(`nothing was linted under ${paths.map((x) => shown(cwd, x)).join(", ")} — no PIPELINE-STATUS.md, paper.md/tex or reviews/ found there. A clean report over zero files is not a clean report.`);
955
1166
  return 1;
956
1167
  }
957
1168
  return await reportLint(eslint, results, structure, {
@@ -960,6 +1171,11 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
960
1171
  err,
961
1172
  where: relative(cwd, root) || ".",
962
1173
  opts: withPapers,
1174
+ // Every paper of the project, not only the ones on the command line (#103).
1175
+ projectPapers: papersRoots(cfg)
1176
+ .flatMap((r) => papersIn(r))
1177
+ .map((d) => join(d, MAIN))
1178
+ .filter((f) => existsSync(f)),
963
1179
  });
964
1180
  }
965
1181
  /**
@@ -985,7 +1201,7 @@ const isEmptySet = (e) => {
985
1201
  const fail = e;
986
1202
  return (fail?.messageTemplate === "file-not-found" ||
987
1203
  fail?.messageTemplate === "all-matched-files-ignored" ||
988
- /No files matching/i.test(fail?.message ?? ""));
1204
+ /No files matching/i.test(String(fail?.message)));
989
1205
  };
990
1206
  /**
991
1207
  * The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
@@ -993,10 +1209,10 @@ const isEmptySet = (e) => {
993
1209
  */
994
1210
  async function reportLint(eslint,
995
1211
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
996
- results, structure, { a, log, err, where, opts, }) {
997
- const silent = await silentOptionalRules(eslint, results.map((r) => r.filePath), opts);
1212
+ results, structure, { a, log, err, where, opts, projectPapers, }) {
1213
+ const silent = await silentOptionalRules(eslint, [...results.map((r) => r.filePath), ...projectPapers], opts);
998
1214
  if (silent.length > 0) {
999
- err(`${silent.join(", ")} is turned on in "rules", but no linted paper.tex gets it — check the block's ` +
1215
+ err(`${silent.join(", ")} is turned on in "rules", but no paper.tex of the project gets it — check the block's ` +
1000
1216
  `"files" (relative to ${where}). A rule that never runs reports exactly like a rule that passed.`);
1001
1217
  return 1;
1002
1218
  }