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/src/cli.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  } from "./paper-settings.ts";
57
57
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
58
58
  import { onlineReferences } from "./adapters/references/index.ts";
59
+ import type { CheckReferences } from "./ports/check-references.ts";
59
60
  import {
60
61
  narrowToOwners,
61
62
  ownedPatterns,
@@ -64,7 +65,11 @@ import {
64
65
  } from "./paper-files.ts";
65
66
  import {
66
67
  paperPreset,
67
- paperPresetProblem,
68
+ settingsProblemLine,
69
+ presetProblemText,
70
+ resolvePreset,
71
+ shippedPresets,
72
+ SHIPPED_PREFIX,
68
73
  type PaperPreset,
69
74
  } from "./presets.ts";
70
75
  import type { ToolInstaller } from "./ports/tool-installer.ts";
@@ -76,7 +81,12 @@ import {
76
81
  type TexRequirements,
77
82
  } from "./tex-requirements.ts";
78
83
  import { doctor } from "./doctor.ts";
79
- import { init, processInteractivity, askOnTerminal } from "./init.ts";
84
+ import {
85
+ askOnTerminal,
86
+ askOrDefault,
87
+ init,
88
+ processInteractivity,
89
+ } from "./init.ts";
80
90
  import {
81
91
  DEFAULT_FORMAT,
82
92
  FORMATS,
@@ -84,6 +94,7 @@ import {
84
94
  newPaper,
85
95
  reportNewPaper,
86
96
  type PaperFormat,
97
+ type VenueSetting,
87
98
  } from "./new-paper.ts";
88
99
  // The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
89
100
  // rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
@@ -123,12 +134,15 @@ import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
123
134
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
124
135
  import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
125
136
 
137
+ /** The shipped venue presets, read from the package's venues directory — the one list. */
138
+ const SHIPPED_VENUES = (): string[] => shippedPresets(packageVenuesDir());
139
+
126
140
  const USAGE = `paperlint — machine-checkable gates for a paper kept in git
127
141
 
128
142
  npx paperlint init [dir] set the project up: detect the papers directory, declare it
129
143
  in package.json, link the skills, wire the hooks into
130
144
  .claude/settings.json, offer the CI step, report what is missing
131
- npx paperlint new <name> [--format tex|md]
145
+ npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
132
146
  create <papers>/<name>/ from the template; never overwrites,
133
147
  on an existing folder adds only the missing files, then lints it
134
148
  npx paperlint lint [paths…] run every rule over your papers
@@ -156,6 +170,15 @@ init:
156
170
  --paper <name> create this paper too (without a human, the only way init creates one)
157
171
  --format tex|md the new paper's source format; default tex
158
172
 
173
+ new:
174
+ --venue <preset> the venue preset, written as "extends" into the paper's paperlint.json:
175
+ a shipped one (${SHIPPED_VENUES().join(", ")}), or a path to your
176
+ own preset starting with ./ or ../, relative to where you run the command.
177
+ On a terminal without --venue, new asks; "none" leaves it unset
178
+ --kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
179
+ preset's kinds; needs --venue
180
+ --format tex|md the paper's source format; default tex
181
+
159
182
  lint:
160
183
  npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
161
184
 
@@ -188,7 +211,7 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
188
211
 
189
212
  { "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
190
213
 
191
- "extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
214
+ "extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
192
215
  or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
193
216
 
194
217
  "rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
@@ -322,6 +345,64 @@ export const SHIPPED_RULES: ReadonlySet<string> = shippedRuleIds(
322
345
  [markdown],
323
346
  );
324
347
 
348
+ /** A plugin as `rulePlugins` gives it: its rules, and for `tex` the LaTeX language when given. */
349
+ export interface RulePlugin {
350
+ readonly rules?: Readonly<Record<string, unknown>>;
351
+ readonly languages?: Readonly<Record<string, unknown>>;
352
+ }
353
+
354
+ /**
355
+ * Every plugin paperlint's config registers, ONE object per name, read off that config. In it one
356
+ * name is bound to different objects in different blocks (`paper` beside `PIPELINE-STATUS.md` is
357
+ * not `paper` beside `paper.tex`); here their rules are merged, so a config that lints any paper
358
+ * file knows every rule id. `@eslint/markdown` is a dependency's plugin and is left out. The LaTeX
359
+ * language rides on `tex` when `texLanguage` is given, and that is how a consumer registers it: a
360
+ * second `tex` plugin of its own is refused by ESLint ("Cannot redefine plugin "tex"", measured
361
+ * with and without the language here). Without it, `tex` carries rules only — for a config that
362
+ * lints markdown papers alone.
363
+ */
364
+ export function rulePlugins(texLanguage?: unknown): Record<string, RulePlugin> {
365
+ const out: Record<string, { rules: Record<string, unknown> }> = {};
366
+ for (const block of buildConfig({}, texLanguage ?? { sentinel: "tex" }))
367
+ for (const [name, plugin] of Object.entries(
368
+ (block as { plugins?: Record<string, RulePlugin> }).plugins ?? {},
369
+ ))
370
+ if (plugin !== markdown)
371
+ out[name] = { rules: { ...out[name]?.rules, ...plugin.rules } };
372
+ if (out["tex"] && texLanguage !== undefined)
373
+ return {
374
+ ...out,
375
+ tex: { ...out["tex"], languages: { latex: texLanguage } },
376
+ };
377
+ return out;
378
+ }
379
+
380
+ /**
381
+ * A flat-config fragment for a consumer's OWN ESLint run over paper files (#104): every rule
382
+ * paperlint ships, registered and turned off. Without it a `% eslint-disable-next-line
383
+ * paper/leading-zero -- why` — the documented escape hatch — fails that run with "Definition for
384
+ * rule 'paper/leading-zero' was not found". Off, because `paperlint lint` is where they run.
385
+ *
386
+ * The second block is not optional, and it is measured: a directive naming a rule that is off
387
+ * suppresses nothing, so ESLint reports it as an unused directive (a warning by default, an error
388
+ * under `reportUnusedDisableDirectives: "error"`). On the files paperlint lints that report is
389
+ * switched off in THIS run — `paperlint lint` still reports a directive that silences nothing.
390
+ */
391
+ export function rulesOff(texLanguage?: unknown): Linter.Config[] {
392
+ return [
393
+ {
394
+ name: "paperlint/rules-off",
395
+ plugins: rulePlugins(texLanguage) as Linter.Config["plugins"],
396
+ rules: Object.fromEntries([...SHIPPED_RULES].map((id) => [id, "off"])),
397
+ },
398
+ {
399
+ name: "paperlint/rules-off/directives",
400
+ files: PAPER_FILE_PATTERNS,
401
+ linterOptions: { reportUnusedDisableDirectives: "off" },
402
+ },
403
+ ];
404
+ }
405
+
325
406
  /** Whether a rule entry (`"error"`, `2`, `["warn", {…}]`) turns the rule on. */
326
407
  const isOn = (entry: unknown): boolean => {
327
408
  const sev = Array.isArray(entry) ? entry[0] : entry;
@@ -345,12 +426,17 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
345
426
  * 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
346
427
  * nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
347
428
  * 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
429
+ * consumer turned on, some `paper.tex` must actually have it enabled; the ones none has are
349
430
  * returned, for the caller to refuse.
431
+ *
432
+ * `files` is every `paper.tex` the guard may count: the ones this run linted AND every paper of the
433
+ * project. Judged against the run alone, `paperlint lint papers/c` failed on a block written for
434
+ * papers a and b (#103) — a block that reaches a paper outside this run is not dead. A glob that
435
+ * reaches no paper of the project at all still fails, from a subset as from the whole.
350
436
  */
351
437
  export async function silentOptionalRules(
352
438
  eslint: ESLint,
353
- lintedFiles: readonly string[],
439
+ files: readonly string[],
354
440
  opts: PaperlintConfig,
355
441
  ): Promise<string[]> {
356
442
  const turnedOn = new Set(
@@ -361,11 +447,11 @@ export async function silentOptionalRules(
361
447
  ),
362
448
  );
363
449
  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);
450
+ for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
451
+ // Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
452
+ const cfg = (await eslint.calculateConfigForFile(f)) as
453
+ { rules?: Record<string, unknown> } | undefined;
454
+ for (const id of turnedOn) if (isOn(cfg?.rules?.[id])) reached.add(id);
369
455
  }
370
456
  return [...turnedOn].filter((id) => !reached.has(id));
371
457
  }
@@ -387,8 +473,8 @@ export function paperRuleBlocks(
387
473
  const out = { preset: [] as RuleBlock[], own: [] as RuleBlock[] };
388
474
  for (const dir of papers) {
389
475
  const p = paperPreset(dir, PRESET_DEPS);
390
- if (p.kind === "settings-problem" && p.problem.kind === "broken")
391
- return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
476
+ if (p.kind === "settings-problem")
477
+ return { ok: false, error: settingsProblemLine(dir, p.problem) };
392
478
  const blocks = rulesOfPaper(dir, p);
393
479
  if (!blocks.ok) return blocks;
394
480
  out.preset.push(...blocks.value.preset);
@@ -404,9 +490,9 @@ export function paperRuleBlocks(
404
490
  */
405
491
  function rulesOfPaper(
406
492
  dir: string,
407
- p: PaperPreset,
493
+ p: Exclude<PaperPreset, { kind: "settings-problem" }>,
408
494
  ): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
409
- const settings = "settings" in p ? p.settings : null;
495
+ const { settings } = p;
410
496
  const fromPreset =
411
497
  p.kind === "resolved"
412
498
  ? parseRuleEntries(
@@ -428,17 +514,17 @@ function rulesOfPaper(
428
514
  )
429
515
  : ownRules(dir, settings);
430
516
  if (!own.ok) return own;
517
+ // No `files`: `narrowToOwners` scopes the block to what paperlint lints under `dir`, exceptions
518
+ // included. A copy of the globs here is how the siblings index got parsed as JavaScript (#101).
431
519
  const scoped = (rules: Record<string, RuleEntry>): RuleBlock[] =>
432
- Object.keys(rules).length
433
- ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
434
- : [];
520
+ Object.keys(rules).length ? [{ basePath: dir, rules }] : [];
435
521
  return {
436
522
  ok: true,
437
523
  value: { preset: scoped(fromPreset.value), own: [...own.value] },
438
524
  };
439
525
  }
440
526
 
441
- /** A paper's `{ id: severity }` rules, as one block over the paper's files. */
527
+ /** A paper's `{ id: severity }` rules, as one block over the paper's files (narrowed later, #101). */
442
528
  function ownRules(
443
529
  dir: string,
444
530
  settings: PaperSettings,
@@ -447,16 +533,13 @@ function ownRules(
447
533
  if (!own.ok) return own;
448
534
  return {
449
535
  ok: true,
450
- value: own.value
451
- ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
452
- : [],
536
+ value: own.value ? [{ basePath: dir, rules: own.value }] : [],
453
537
  };
454
538
  }
455
539
 
456
540
  /**
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.
541
+ * The globs paperlint lints, for the refusal message only. Which files a block reaches is decided by
542
+ * `narrowToOwners` from `ownedScopes` (src/paper-files.ts), where each glob keeps its exceptions.
460
543
  */
461
544
  const PAPER_FILE_PATTERNS: string[] = ownedPatterns(
462
545
  buildConfig({}, { sentinel: "tex language" }),
@@ -500,6 +583,9 @@ export function parseSettings(
500
583
  const lintRoot = (home: string, paths: readonly string[]): string =>
501
584
  commonDir([home, ...paths]);
502
585
 
586
+ /** A path as the reader typed it: relative to where the command runs, or whole when it IS there. */
587
+ const shown = (cwd: string, p: string): string => relative(cwd, p) || p;
588
+
503
589
  /** The longest shared leading run of path segments. */
504
590
  const commonDir = (paths: readonly string[]): string => {
505
591
  const [first = [], ...rest] = paths.map((p) => p.split(sep));
@@ -525,29 +611,47 @@ export function parseArgs(argv: readonly string[]): Args {
525
611
  noHooks: false,
526
612
  paper: null,
527
613
  format: null,
614
+ venue: null,
615
+ kind: null,
528
616
  hooksMode: null,
529
617
  // -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
530
618
  // gate that fails on advice gets muted entirely.
531
619
  maxWarnings: -1,
532
620
  };
621
+ // Consumed from the front: a value flag takes the next argument by shifting it off.
533
622
  const rest = [...argv];
534
- if (rest[0] && !rest[0].startsWith("-")) out.cmd = rest.shift() ?? null;
623
+ const first = rest[0];
624
+ if (first && !first.startsWith("-")) {
625
+ out.cmd = first;
626
+ rest.shift();
627
+ }
535
628
 
536
629
  // 🔴 A FLAG WHOSE VALUE WAS TAKEN AWAY IS A REFUSAL, NOT A DEFAULT. The compiler found this
537
- // during the move to TypeScript: `rest[++i]` past the last argument gives `undefined`, and
630
+ // during the move to TypeScript: the value past the last argument is `undefined`, and
538
631
  // `paperlint lint --config` (the value forgotten, or eaten by a substitution in CI) silently turned
539
632
  // into "no config given" — that is, it went to auto-discovery and linted against SOMEONE ELSE'S
540
633
  // file, saying nothing. The failure is one-sided and toward silence, so it is cured by
541
634
  // behaviour, not by a type cast.
542
- const valueFor = (flag: string, i: number): string | undefined => {
543
- const v = rest[i];
635
+ const valueFor = (flag: string): string | undefined => {
636
+ const v = rest.shift();
544
637
  if (v === undefined) out.missingValue = flag;
545
638
  return v;
546
639
  };
547
640
 
548
- for (let i = 0; i < rest.length; i++) {
549
- const a = rest[i];
550
- if (a === undefined) continue;
641
+ for (let arg = rest.shift(); arg !== undefined; arg = rest.shift()) {
642
+ // `--flag=value` is `--flag value` for every flag that takes a value: the CI action writes
643
+ // `--max-warnings="$N"`, and that form used to fall through to the list of paths.
644
+ const eq = arg.indexOf("=");
645
+ const inline =
646
+ arg.startsWith("--") && eq > 0 && VALUE_FLAGS.has(arg.slice(0, eq))
647
+ ? arg.slice(eq + 1)
648
+ : undefined;
649
+ const a = inline === undefined ? arg : arg.slice(0, eq);
650
+ const take = (): string | undefined => {
651
+ if (inline === undefined) return valueFor(a);
652
+ if (inline === "") out.missingValue = a;
653
+ return inline === "" ? undefined : inline;
654
+ };
551
655
  if (a === "--json") out.json = true;
552
656
  else if (a === "--fix") out.fix = true;
553
657
  else if (a === "--all") out.all = true;
@@ -557,32 +661,47 @@ export function parseArgs(argv: readonly string[]): Args {
557
661
  else if (a === "--no-hooks") out.noHooks = true;
558
662
  else if (a.startsWith("--hooks="))
559
663
  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;
664
+ else if (a === "--paper") out.paper = take() ?? null;
665
+ else if (a === "--format") out.format = take() ?? null;
666
+ else if (a === "--venue") out.venue = take() ?? null;
667
+ else if (a === "--kind") out.kind = take() ?? null;
562
668
  // `--options` was the first spelling and is kept working. It named the wrong thing — every
563
669
  // other tool in the stack calls this file its config — but a flag in someone's CI is not
564
670
  // ours to break.
565
- else if (a === "--config" || a === "--options")
566
- out.config = valueFor(a, ++i) ?? null;
671
+ else if (a === "--config" || a === "--options") out.config = take() ?? null;
567
672
  else if (a === "--max-warnings") {
568
- const v = valueFor(a, ++i);
673
+ const v = take();
569
674
  if (v !== undefined) out.maxWarnings = Number(v);
570
675
  } else if (a === "--help" || a === "-h") out.help = true;
676
+ // An unknown flag is refused by name. Read as a path, it silently lints something else.
677
+ else if (a.startsWith("-") && a !== "-") out.unknownFlag ??= a;
571
678
  else out.paths.push(a);
572
679
  }
573
680
  return out;
574
681
  }
575
682
 
683
+ /** The flags that take a value, in either spelling: `--flag value` or `--flag=value`. */
684
+ const VALUE_FLAGS: ReadonlySet<string> = new Set([
685
+ "--paper",
686
+ "--format",
687
+ "--venue",
688
+ "--kind",
689
+ "--config",
690
+ "--options",
691
+ "--max-warnings",
692
+ ]);
693
+
576
694
  /**
577
695
  * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
578
696
  * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
579
697
  * the placeholder instead of guessing.
580
698
  */
581
- function ownVersion(): string | undefined {
699
+ export function ownVersion(
700
+ readManifest: () => string = () =>
701
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
702
+ ): string | undefined {
582
703
  try {
583
- const v = JSON.parse(
584
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
585
- )?.version;
704
+ const v = JSON.parse(readManifest())?.version;
586
705
  return typeof v === "string" ? v : undefined;
587
706
  } catch {
588
707
  return undefined;
@@ -636,12 +755,9 @@ export function readConfig(
636
755
  //
637
756
  // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
638
757
  // the array breaks any `| jq`, and it breaks it for the consumer, not for us.
639
- (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
640
- const settings = parseSettings(
641
- parsed,
642
- relative(cwd, configPath) || CONFIG_FILE,
643
- root,
644
- );
758
+ const shownPath = shown(cwd, configPath);
759
+ (a.json ? err : log)(`config: ${shownPath}`);
760
+ const settings = parseSettings(parsed, shownPath, root);
645
761
  if (!settings.ok) {
646
762
  err(settings.error);
647
763
  return { code: 2 };
@@ -769,16 +885,143 @@ export async function createPaperAt(
769
885
  log,
770
886
  err,
771
887
  cwd,
772
- }: { log: typeof console.log; err: typeof console.error; cwd: string },
888
+ venue = null,
889
+ }: {
890
+ log: typeof console.log;
891
+ err: typeof console.error;
892
+ cwd: string;
893
+ /** What `--venue` chose; null writes the template's `paperlint.json` as it is. */
894
+ venue?: VenueChoice | null;
895
+ },
773
896
  ): Promise<number> {
774
- const result = newPaper(papersRoot, name, format);
775
- const here = (p: string): string => relative(cwd, p) || p;
897
+ const result = newPaper(papersRoot, name, format, { venue });
898
+ const here = (p: string): string => shown(cwd, p);
776
899
  for (const line of reportNewPaper(result, here)) log(line);
777
900
  if (!result.ok) return 2;
901
+ const config = here(join(result.dir, CONFIG_FILE));
902
+ if (venue === null)
903
+ log(
904
+ ` venue: none yet — set "extends" in ${config}, or next time: paperlint new <name> --venue <preset> (${SHIPPED_VENUES().join(", ")})`,
905
+ );
906
+ else if (venue.kind === null && venue.kinds.length > 0)
907
+ log(
908
+ ` kind: not set — \`${venue.label}\` sets a page limit per kind (${venue.kinds.join(", ")}); add "kind" to ${config}. Until then lint reports pdf/profile`,
909
+ );
778
910
  log(``);
779
911
  return run(["lint", result.dir], { log, err, cwd });
780
912
  }
781
913
 
914
+ /** A venue `paperlint new` will write, with what the messages need to say about it. */
915
+ export interface VenueChoice extends VenueSetting {
916
+ /** The preset's word in messages (`agenticdev`, `my-workshop`). */
917
+ readonly label: string;
918
+ /** The preset's kinds; empty when it sets no page limit. */
919
+ readonly kinds: readonly string[];
920
+ }
921
+
922
+ /**
923
+ * `--venue` / `--kind` → the `extends` and `kind` to write into `<paperDir>/paperlint.json`, or why
924
+ * not. Parsed here, at the boundary, and resolved through the same `resolvePreset` lint uses — so a
925
+ * venue `new` accepts is a venue lint resolves. On a terminal with no `--venue` it asks, with
926
+ * "none" as the default; without a terminal it chooses nothing (null), as before.
927
+ *
928
+ * A path is relative to where the command runs, because that is where it was typed; it is written
929
+ * relative to the paper's `paperlint.json`, because that is what `extends` is relative to. Written
930
+ * as typed, `./venues/x.jsonc` would name `<paper>/venues/x.jsonc`.
931
+ */
932
+ export async function chooseVenue(
933
+ flags: { readonly venue: string | null; readonly kind: string | null },
934
+ {
935
+ paperDir,
936
+ cwd,
937
+ interactive,
938
+ ask,
939
+ }: {
940
+ paperDir: string;
941
+ cwd: string;
942
+ interactive: boolean;
943
+ ask: (q: string) => Promise<string>;
944
+ },
945
+ ): Promise<Parsed<VenueChoice | null>> {
946
+ const shipped = SHIPPED_VENUES();
947
+ if (flags.kind !== null && flags.venue === null)
948
+ return bad(
949
+ `--kind needs --venue: a kind is a page limit of one venue preset — \`paperlint new <name> --venue <preset> --kind ${flags.kind}\``,
950
+ );
951
+ const asked = async (q: string): Promise<string> =>
952
+ (await askOrDefault(ask, q))?.trim() ?? "";
953
+ const venue =
954
+ flags.venue ??
955
+ (interactive
956
+ ? await asked(`venue: ${[...shipped, "none"].join(" / ")} [none] `)
957
+ : "");
958
+ if (venue === "" || (flags.venue === null && venue === "none"))
959
+ return { ok: true, value: null };
960
+ const spec = venueSpec(venue, { shipped, paperDir, cwd });
961
+ if (!spec.ok) return spec;
962
+ const r = resolvePreset(spec.value, join(paperDir, CONFIG_FILE), PRESET_DEPS);
963
+ if (!r.ok) return bad(`--venue ${venue}: ${presetProblemText(r.error)}`);
964
+ const kinds = [...r.value.format.kinds.keys()];
965
+ const label = r.value.label;
966
+ if (flags.kind !== null) {
967
+ if (kinds.length === 0)
968
+ return bad(
969
+ `--kind ${flags.kind}: \`${label}\` has no kinds — it sets no page limit, so there is no kind to choose`,
970
+ );
971
+ if (!kinds.includes(flags.kind))
972
+ return bad(
973
+ `--kind ${flags.kind}: \`${label}\` has no kind \`${flags.kind}\`; its kinds: ${kinds.join(", ")}`,
974
+ );
975
+ }
976
+ const kind =
977
+ flags.kind ??
978
+ (interactive && kinds.length > 0
979
+ ? await asked(`kind: ${[...kinds, "later"].join(" / ")} [later] `)
980
+ : null);
981
+ return {
982
+ ok: true,
983
+ value: {
984
+ extends: spec.value,
985
+ kind: kind !== null && kinds.includes(kind) ? kind : null,
986
+ label,
987
+ kinds,
988
+ },
989
+ };
990
+ }
991
+
992
+ const bad = (error: string): { ok: false; error: string } => ({
993
+ ok: false,
994
+ error,
995
+ });
996
+
997
+ /** What `--venue` names → the `extends` value, relative to `<paperDir>/paperlint.json`. */
998
+ function venueSpec(
999
+ venue: string,
1000
+ {
1001
+ shipped,
1002
+ paperDir,
1003
+ cwd,
1004
+ }: { shipped: readonly string[]; paperDir: string; cwd: string },
1005
+ ): Parsed<string> {
1006
+ if (venue.startsWith("./") || venue.startsWith("../")) {
1007
+ const file = resolve(cwd, venue);
1008
+ if (!existsSync(file))
1009
+ return bad(
1010
+ `--venue ${venue}: no such file (${file}) — a path is relative to where you run the command`,
1011
+ );
1012
+ const rel = relative(paperDir, file).split(sep).join("/");
1013
+ return { ok: true, value: rel.startsWith("../") ? rel : `./${rel}` };
1014
+ }
1015
+ const name = venue.startsWith(SHIPPED_PREFIX)
1016
+ ? venue.slice(SHIPPED_PREFIX.length)
1017
+ : venue;
1018
+ if (!shipped.includes(name))
1019
+ return bad(
1020
+ `--venue ${venue}: no such venue preset. Shipped: ${shipped.join(", ")} — or a path to your own preset, starting with ./ or ../`,
1021
+ );
1022
+ return { ok: true, value: `${SHIPPED_PREFIX}${name}` };
1023
+ }
1024
+
782
1025
  /**
783
1026
  * `paperlint new <name>` — the papers directory comes from the same declaration every other command
784
1027
  * reads; there is no second way to name it. See `new-paper.ts` for what it writes and why.
@@ -800,7 +1043,7 @@ async function runNew(
800
1043
  const [name, ...extra] = a.paths;
801
1044
  if (!name || extra.length > 0) {
802
1045
  err(
803
- `\`new\` takes exactly one paper name: \`paperlint new my-paper [--format tex|md]\``,
1046
+ `\`new\` takes exactly one paper name: \`paperlint new my-paper [--venue <preset>] [--kind <kind>] [--format tex|md]\``,
804
1047
  );
805
1048
  return 2;
806
1049
  }
@@ -814,10 +1057,10 @@ async function runNew(
814
1057
  }
815
1058
  format = a.format;
816
1059
  } else if (processInteractivity(a.yes).interactive) {
817
- const f = await ask(`format: tex / md [${DEFAULT_FORMAT}] `).catch(
818
- () => "",
819
- );
820
- if (isFormat(f.trim())) format = f.trim() as PaperFormat;
1060
+ const f = (
1061
+ await askOrDefault(ask, `format: tex / md [${DEFAULT_FORMAT}] `)
1062
+ )?.trim();
1063
+ if (isFormat(f)) format = f;
821
1064
  }
822
1065
  const cfg = readConfig({ ...a, json: false }, { log: () => {}, err, cwd });
823
1066
  if (cfg.code !== undefined) return cfg.code;
@@ -831,9 +1074,32 @@ async function runNew(
831
1074
  }
832
1075
  if (roots.length > 1)
833
1076
  log(
834
- `several papers directories are declared — using the first: ${relative(cwd, papersRoot) || papersRoot}`,
1077
+ `several papers directories are declared — using the first: ${shown(cwd, papersRoot)}`,
1078
+ );
1079
+ const paperDir = join(papersRoot, name);
1080
+ // An existing paperlint.json is never overwritten, so a venue for it is refused, not dropped.
1081
+ const hasConfig = existsSync(join(paperDir, CONFIG_FILE));
1082
+ if (hasConfig && (a.venue !== null || a.kind !== null)) {
1083
+ err(
1084
+ `${relative(cwd, join(paperDir, CONFIG_FILE))} already exists and is never overwritten — set "extends" and "kind" in it by hand`,
835
1085
  );
836
- return createPaperAt(papersRoot, name, format, { log, err, cwd });
1086
+ return 2;
1087
+ }
1088
+ const venue = hasConfig
1089
+ ? { ok: true as const, value: null }
1090
+ : await chooseVenue(a, {
1091
+ paperDir,
1092
+ cwd,
1093
+ interactive: processInteractivity(a.yes).interactive,
1094
+ ask,
1095
+ });
1096
+ if (!venue.ok) return (err(venue.error), 2);
1097
+ return createPaperAt(papersRoot, name, format, {
1098
+ log,
1099
+ err,
1100
+ cwd,
1101
+ venue: venue.value,
1102
+ });
837
1103
  }
838
1104
 
839
1105
  /**
@@ -849,7 +1115,13 @@ async function runBuild(
849
1115
  log,
850
1116
  err,
851
1117
  cwd,
852
- }: { log: typeof console.log; err: typeof console.error; cwd: string },
1118
+ checkReferences,
1119
+ }: {
1120
+ log: typeof console.log;
1121
+ err: typeof console.error;
1122
+ cwd: string;
1123
+ checkReferences: CheckReferences;
1124
+ },
853
1125
  ): Promise<number> {
854
1126
  const cfg = readConfig(a, { log, err, cwd });
855
1127
  if (cfg.code !== undefined) return cfg.code;
@@ -881,7 +1153,7 @@ async function runBuild(
881
1153
  cwd,
882
1154
  dryRun: a.dryRun,
883
1155
  log,
884
- checkReferences: onlineReferences,
1156
+ checkReferences,
885
1157
  engine: () => engineEnv(targets, a, { log, err }),
886
1158
  });
887
1159
  if (out.kind === "no-engine") return 1;
@@ -953,7 +1225,12 @@ const SIMPLE: Readonly<
953
1225
  string,
954
1226
  (
955
1227
  a: Args,
956
- io: { log: typeof console.log; err: typeof console.error; cwd: string },
1228
+ io: {
1229
+ log: typeof console.log;
1230
+ err: typeof console.error;
1231
+ cwd: string;
1232
+ checkReferences: CheckReferences;
1233
+ },
957
1234
  ) => number | Promise<number>
958
1235
  >
959
1236
  > = {
@@ -982,6 +1259,44 @@ function hostBanalInstaller(): ToolInstaller {
982
1259
  return banalInstaller({ ...ports, download }, s);
983
1260
  }
984
1261
 
1262
+ /**
1263
+ * init's TeX Live step: whether paperlint's own tree is installed, and how to install it —
1264
+ * `runToolchain` over what this project's papers need. `toolchain` is the seam a test replaces:
1265
+ * the real one downloads ~270 MB.
1266
+ */
1267
+ export function initTexLive(
1268
+ a: Args,
1269
+ {
1270
+ log,
1271
+ err,
1272
+ cwd,
1273
+ }: { log: typeof console.log; err: typeof console.error; cwd: string },
1274
+ toolchain: typeof runToolchain = runToolchain,
1275
+ ): { readonly installed: () => boolean; readonly install: () => number } {
1276
+ return {
1277
+ installed: () => cachedTree(cacheRoot(process.env)) !== null,
1278
+ install: () =>
1279
+ toolchain({
1280
+ check: false,
1281
+ log,
1282
+ err,
1283
+ banal: hostBanalInstaller(),
1284
+ tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
1285
+ }),
1286
+ };
1287
+ }
1288
+
1289
+ /**
1290
+ * The first papers directory the CLI would lint from `cwd`, or null when the config does not read
1291
+ * or names none — what `doctor` (and `init`, through it) compares the hooks' directory against.
1292
+ */
1293
+ function cliPapers(a: Args, cwd: string): string | null {
1294
+ const read = readConfig(a, { log: () => {}, err: () => {}, cwd });
1295
+ return read.code === undefined
1296
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1297
+ : null;
1298
+ }
1299
+
985
1300
  /** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
986
1301
  async function runInit(
987
1302
  a: Args,
@@ -1014,26 +1329,9 @@ async function runInit(
1014
1329
  format: isFormat(a.format) ? a.format : null,
1015
1330
  createPaper: (papersRoot, name, format) =>
1016
1331
  createPaperAt(papersRoot, name, format, { log, err, cwd }),
1017
- tex: {
1018
- installed: () => cachedTree(cacheRoot(process.env)) !== null,
1019
- install: () =>
1020
- runToolchain({
1021
- check: false,
1022
- log,
1023
- err,
1024
- banal: hostBanalInstaller(),
1025
- tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
1026
- }),
1027
- },
1028
- resolveCliPapers: (root: string): string | null => {
1029
- const read = readConfig(
1030
- { ...a, config: null },
1031
- { log: () => {}, err: () => {}, cwd: root },
1032
- );
1033
- return read.code === undefined
1034
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1035
- : null;
1036
- },
1332
+ tex: initTexLive(a, { log, err, cwd }),
1333
+ resolveCliPapers: (root: string): string | null =>
1334
+ cliPapers({ ...a, config: null }, root),
1037
1335
  });
1038
1336
  }
1039
1337
 
@@ -1043,10 +1341,16 @@ export async function run(
1043
1341
  log = console.log,
1044
1342
  err = console.error,
1045
1343
  cwd = process.cwd(),
1344
+ checkReferences = onlineReferences,
1046
1345
  }: {
1047
1346
  log?: typeof console.log;
1048
1347
  err?: typeof console.error;
1049
1348
  cwd?: string;
1349
+ /**
1350
+ * `build`'s online reference check — the real services by default. The e2e build passes a
1351
+ * fake: no test depends on Crossref, Semantic Scholar or DBLP answering.
1352
+ */
1353
+ checkReferences?: CheckReferences;
1050
1354
  } = {},
1051
1355
  ): Promise<number> {
1052
1356
  const a = parseArgs(argv);
@@ -1061,6 +1365,12 @@ export async function run(
1061
1365
  );
1062
1366
  return 2;
1063
1367
  }
1368
+ if (a.unknownFlag !== undefined) {
1369
+ err(
1370
+ `unknown flag \`${a.unknownFlag}\` — \`paperlint --help\` lists every flag`,
1371
+ );
1372
+ return 2;
1373
+ }
1064
1374
  if (a.help || !a.cmd) {
1065
1375
  log(USAGE);
1066
1376
  return a.help ? 0 : 2;
@@ -1073,11 +1383,7 @@ export async function run(
1073
1383
  // broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
1074
1384
  // what it prints, rather than an early exit that tells the reader nothing about the hooks.
1075
1385
  if (a.cmd === "doctor") {
1076
- const read = readConfig(a, { log: () => {}, err: () => {}, cwd });
1077
- const papers =
1078
- read.code === undefined
1079
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1080
- : null;
1386
+ const papers = cliPapers(a, cwd);
1081
1387
  return doctor({
1082
1388
  log,
1083
1389
  cwd,
@@ -1086,7 +1392,7 @@ export async function run(
1086
1392
  });
1087
1393
  }
1088
1394
  const simple = SIMPLE[a.cmd];
1089
- if (simple) return await simple(a, { log, err, cwd });
1395
+ if (simple) return await simple(a, { log, err, cwd, checkReferences });
1090
1396
  if (a.cmd === "check")
1091
1397
  err(
1092
1398
  `\`check\` is now \`lint\` — running it anyway. Update the call to \`paperlint lint\`.`,
@@ -1142,13 +1448,15 @@ export async function run(
1142
1448
  rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
1143
1449
  };
1144
1450
 
1451
+ // Loaded lazily because only `lint` needs it. If the module cannot be loaded (a broken install:
1452
+ // the file missing from the package), lint still runs over the markdown papers rather than
1453
+ // failing outright — the LaTeX rules simply have no language to run in.
1145
1454
  let texLanguage: unknown = null;
1146
1455
  try {
1147
- // @ts-expect-error — the module is .mjs and has no types; a missing LaTeX parser is a normal
1148
- // case here, it is caught by the catch below.
1456
+ // @ts-expect-error — the module is .mjs and has no types
1149
1457
  ({ texLanguage } = await import("../eslint-rules/latex-language.mjs"));
1150
1458
  } catch {
1151
- /* without a LaTeX parser we work over markdown */
1459
+ texLanguage = null;
1152
1460
  }
1153
1461
 
1154
1462
  const eslint = new ESLint({
@@ -1161,7 +1469,7 @@ export async function run(
1161
1469
  const unowned = await firstUnownedFile(eslint, paths);
1162
1470
  if (unowned !== null) {
1163
1471
  err(
1164
- `${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`,
1472
+ `${shown(cwd, unowned)} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`,
1165
1473
  );
1166
1474
  return 2;
1167
1475
  }
@@ -1188,7 +1496,7 @@ export async function run(
1188
1496
  // is not invoked — and, not being invoked, it physically cannot report that.
1189
1497
  if (results.length === 0) {
1190
1498
  err(
1191
- `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.`,
1499
+ `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.`,
1192
1500
  );
1193
1501
  return 1;
1194
1502
  }
@@ -1199,6 +1507,11 @@ export async function run(
1199
1507
  err,
1200
1508
  where: relative(cwd, root) || ".",
1201
1509
  opts: withPapers,
1510
+ // Every paper of the project, not only the ones on the command line (#103).
1511
+ projectPapers: papersRoots(cfg)
1512
+ .flatMap((r) => papersIn(r))
1513
+ .map((d) => join(d, MAIN))
1514
+ .filter((f) => existsSync(f)),
1202
1515
  });
1203
1516
  }
1204
1517
 
@@ -1232,7 +1545,7 @@ const isEmptySet = (e: unknown): boolean => {
1232
1545
  return (
1233
1546
  fail?.messageTemplate === "file-not-found" ||
1234
1547
  fail?.messageTemplate === "all-matched-files-ignored" ||
1235
- /No files matching/i.test(fail?.message ?? "")
1548
+ /No files matching/i.test(String(fail?.message))
1236
1549
  );
1237
1550
  };
1238
1551
 
@@ -1251,22 +1564,24 @@ async function reportLint(
1251
1564
  err,
1252
1565
  where,
1253
1566
  opts,
1567
+ projectPapers,
1254
1568
  }: {
1255
1569
  a: Args;
1256
1570
  log: typeof console.log;
1257
1571
  err: typeof console.error;
1258
1572
  where: string;
1259
1573
  opts: PaperlintConfig;
1574
+ projectPapers: readonly string[];
1260
1575
  },
1261
1576
  ): Promise<number> {
1262
1577
  const silent = await silentOptionalRules(
1263
1578
  eslint,
1264
- results.map((r) => r.filePath),
1579
+ [...results.map((r) => r.filePath), ...projectPapers],
1265
1580
  opts,
1266
1581
  );
1267
1582
  if (silent.length > 0) {
1268
1583
  err(
1269
- `${silent.join(", ")} is turned on in "rules", but no linted paper.tex gets it — check the block's ` +
1584
+ `${silent.join(", ")} is turned on in "rules", but no paper.tex of the project gets it — check the block's ` +
1270
1585
  `"files" (relative to ${where}). A rule that never runs reports exactly like a rule that passed.`,
1271
1586
  );
1272
1587
  return 1;