paperlint 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
package/src/init.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * `paperlint init` — the whole install, in the terminal it was typed in.
3
3
  *
4
- * 🔴 WHAT THIS COMMAND USED TO DO, AND WHY THAT WAS A DEFECT RATHER THAN A SHORTFALL. It wrote
5
- * a separate config file with a GUESSED `"papers": "papers"` and never touched `package.json`. The three hooks
6
- * read the papers directory out of `package.json` and nothing else, so a consumer who followed the
7
- * documented install got a `paper-edit-guard` watching a directory that did not exist — and a guard
8
- * watching nothing is byte-identical, from outside, to a guard that is working (issue #33).
4
+ * 🔴 THE HOOKS, THE RULES AND THE CLI READ THE PAPERS DIRECTORY FROM ONE PLACE — the root
5
+ * `paperlint.json`, or its default `papers` when the file is absent. An install that wrote it
6
+ * anywhere else would give a `paper-edit-guard` watching a directory that does not exist — and a
7
+ * guard watching nothing is byte-identical, from outside, to a guard that is working (issue #33).
9
8
  *
10
9
  * The yardstick is how many actions happen between "I want this" and "it works": two, `npm i` and
11
10
  * this command (`docs/install.md`). Nothing is left to edit by hand.
@@ -13,9 +12,9 @@
13
12
  * ── THE DECISIONS, AND HOW EACH ONE IS MADE ─────────────────────────────────
14
13
  * papers directory MEASURED — `detectPapers` walks the repo for a directory whose CHILDREN
15
14
  * carry a paper marker. Several hits is the only case a human is asked about.
16
- * declaration WRITTEN into `package.json`, merged, never overwriting a value that is
17
- * already there. Prior art: husky's `init` edits the consumer's package.json
18
- * to add `prepare`. No second config file is created.
15
+ * declaration WRITTEN into the root `paperlint.json` only when it differs from the
16
+ * default `papers`; merged, never overwriting a value that is already there.
17
+ * A project on the defaults gets no config file at all.
19
18
  * skills LINKED — one relative symlink per shipped skill into `.claude/skills/`, the
20
19
  * only place Claude Code looks for project skills (`link-skills.ts`). An
21
20
  * entry of the same name that paperlint did not make is reported, never replaced.
@@ -27,7 +26,9 @@
27
26
  * Without a human: NO.
28
27
  * first paper OFFERED only to a human and only when the papers directory holds none;
29
28
  * without a human only `--paper <name>` creates one (`new-paper.ts`).
30
- * external toolchain REPORTED, never installed. npm's own rule, quoted in husky's write-up:
29
+ * TeX Live OFFERED to a human, with its size in the question and NO as the default;
30
+ * without a human only named as the next step (`offerTexLive`).
31
+ * other programs REPORTED, never installed. npm's own rule, quoted in husky's write-up:
31
32
  * "The only valid use of install or preinstall scripts is for compilation."
32
33
  *
33
34
  * 🔴 NOTHING IS ASKED WITHOUT A HUMAN — stdin AND stdout a terminal, `CI` unset, no `--yes`
@@ -35,8 +36,13 @@
35
36
  * an answer nobody gave. So the non-interactive path takes the stated default and SAYS which
36
37
  * default it took and why nothing was asked, rather than pretending it asked.
37
38
  */
38
- // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
39
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
39
+ import {
40
+ existsSync,
41
+ mkdirSync,
42
+ readFileSync,
43
+ writeFileSync,
44
+ // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
45
+ } from "node:fs";
40
46
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
41
47
  import { spawnSync } from "node:child_process";
42
48
  import { dirname, join, relative, resolve } from "node:path";
@@ -44,11 +50,9 @@ import { doctor, detectPapers, found, PROGRAMS } from "./doctor.ts";
44
50
  import { PAPER_MARKERS, papersIn } from "./build.ts";
45
51
  import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
46
52
  import { actionRef } from "./action-ref.ts";
47
- import { LEGACY_PACKAGE_NAME } from "../skills/paper-pipeline/scripts/consumer.mjs";
48
53
  import {
49
54
  FRESH_CLONE_NOTE,
50
55
  SETTINGS_PATH,
51
- UNINSTALL_PLUGIN,
52
56
  shippedWiring,
53
57
  wireHooks,
54
58
  type Merge,
@@ -63,12 +67,9 @@ import {
63
67
  // The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
64
68
  // rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
65
69
  import {
66
- CONFIG_KEY,
70
+ CONFIG_FILE,
67
71
  DEFAULT_PAPERS_ROOT,
68
- LEGACY_CONFIG_KEY,
69
72
  PAPERS_DIR_FIELD,
70
- declaredSettings,
71
- renamedFieldMessage,
72
73
  } from "../lib/paper-config.mjs";
73
74
 
74
75
  /** How the papers directory was arrived at. Printed, because a guess must not read as a fact. */
@@ -142,7 +143,8 @@ export interface PapersChoice {
142
143
  /**
143
144
  * One hit is used, several are asked about, none falls back to the documented default — and the
144
145
  * fallback is labelled a guess in the same breath, because the whole class of defect this command
145
- * exists to close is a guess that later reads as a measurement.
146
+ * exists to close is a guess that later reads as a measurement. Called only when no
147
+ * `paperlint.json` declares a directory yet (`declarePapers`).
146
148
  */
147
149
  export async function choosePapers(
148
150
  root: string,
@@ -175,88 +177,74 @@ export async function choosePapers(
175
177
 
176
178
  export type DeclarationResult =
177
179
  | {
180
+ /** The measured directory differs from the default and was written into `paperlint.json`. */
178
181
  readonly status: "written";
179
182
  readonly path: string;
180
183
  readonly papers: string;
181
- /** The settings were under the old key and were moved to the new one. */
182
- readonly migrated: boolean;
184
+ /** How the directory that was written was arrived at. */
185
+ readonly choice: PapersChoice;
186
+ }
187
+ | {
188
+ /** The measured directory IS the default, so nothing needs writing. */
189
+ readonly status: "default";
190
+ readonly path: string;
191
+ readonly papers: string;
192
+ readonly choice: PapersChoice;
183
193
  }
184
194
  | {
185
195
  readonly status: "kept";
186
196
  readonly path: string;
187
197
  readonly papers: unknown;
188
- readonly migrated: boolean;
189
198
  }
190
199
  | {
191
200
  readonly status: "unparsable";
192
201
  readonly path: string;
193
202
  readonly reason: string;
194
- }
195
- | { readonly status: "absent"; readonly path: string }
196
- | {
197
- readonly status: "renamed";
198
- readonly path: string;
199
- readonly message: string;
200
203
  };
201
204
 
202
205
  /**
203
- * Writes ONE declaration, into the file every channel can already name.
204
- *
205
- * 🔴 A HOOK CANNOT IMPORT CODE AND CANNOT WALK UP A TREE LOOKING FOR A CONFIG. It can read a path
206
- * it is able to spell, and the only path it can always spell is the project's own `package.json`.
207
- * That asymmetry is the whole reason the declaration lives here rather than in a file of its own:
208
- * many readers against one (`docs/install.md`, "One declaration").
206
+ * Declares the papers directory in the root `paperlint.json` — the one file the hooks, the rules
207
+ * and the CLI all read — and only when it is not the default: a project whose papers are in
208
+ * `papers/` needs no config file.
209
209
  *
210
210
  * ⚠️ Merged, not rewritten, and never over a value the consumer set — an `init` that silently
211
211
  * replaces a setting is worse than an `init` that does nothing, because the consumer keeps
212
212
  * believing the old value.
213
213
  */
214
- export function declarePapers(root: string, papers: string): DeclarationResult {
215
- const path = join(root, "package.json");
216
- if (!existsSync(path)) return { status: "absent", path };
217
- const raw = readFileSync(path, "utf8");
218
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
219
- let pkg: Record<string, any>;
220
- try {
221
- pkg = JSON.parse(raw);
222
- } catch (e) {
223
- return { status: "unparsable", path, reason: (e as Error).message };
224
- }
225
- const found = declaredSettings(pkg);
226
- if (found.conflict !== null)
227
- return { status: "renamed", path, message: found.conflict };
228
- const message = renamedFieldMessage(found.settings);
229
- if (message) return { status: "renamed", path, message };
230
- // Settings under the old key move to the new one, in the same position in the file.
231
- const migrated = found.legacy;
232
- if (migrated) pkg = renameKey(pkg, LEGACY_CONFIG_KEY, CONFIG_KEY);
233
- const write = (): void =>
234
- // Two-space indent and the file's own trailing newline: a declaration is not a licence to
235
- // reformat somebody else's file, and a one-line diff is a diff a consumer will actually read.
236
- writeFileSync(
237
- path,
238
- JSON.stringify(pkg, null, 2) + (raw.endsWith("\n") ? "\n" : ""),
239
- "utf8",
240
- );
241
- const existing = pkg?.[CONFIG_KEY]?.[PAPERS_DIR_FIELD];
242
- if (existing !== undefined) {
243
- if (migrated) write();
244
- return { status: "kept", path, papers: existing, migrated };
214
+ export async function declarePapers(
215
+ root: string,
216
+ choose: () => Promise<PapersChoice>,
217
+ ): Promise<DeclarationResult> {
218
+ const path = join(root, CONFIG_FILE);
219
+ const raw = existsSync(path) ? readFileSync(path, "utf8") : null;
220
+ let settings: Record<string, unknown> = {};
221
+ if (raw !== null) {
222
+ try {
223
+ settings = JSON.parse(raw) as Record<string, unknown>;
224
+ } catch (e) {
225
+ return { status: "unparsable", path, reason: (e as Error).message };
226
+ }
227
+ const existing = settings[PAPERS_DIR_FIELD];
228
+ if (existing !== undefined)
229
+ return { status: "kept", path, papers: existing };
245
230
  }
246
- pkg[CONFIG_KEY] = { ...(pkg[CONFIG_KEY] ?? {}), [PAPERS_DIR_FIELD]: papers };
247
- write();
248
- return { status: "written", path, papers, migrated };
249
- }
250
-
251
- /** `obj` with `from` renamed to `to`, keeping the key's position. */
252
- function renameKey(
253
- obj: Record<string, unknown>,
254
- from: string,
255
- to: string,
256
- ): Record<string, unknown> {
257
- return Object.fromEntries(
258
- Object.entries(obj).map(([k, v]) => [k === from ? to : k, v]),
231
+ // 🔴 ONLY NOW is the directory measured (or asked about). A declared one is the answer, and a
232
+ // candidate measured beside it is a decision nobody takes.
233
+ const choice = await choose();
234
+ if (choice.papers === DEFAULT_PAPERS_ROOT)
235
+ return { status: "default", path, papers: choice.papers, choice };
236
+ // Two-space indent and the file's own trailing newline: a declaration is not a licence to
237
+ // reformat somebody else's file, and a one-line diff is a diff a consumer will actually read.
238
+ writeFileSync(
239
+ path,
240
+ JSON.stringify(
241
+ { ...settings, [PAPERS_DIR_FIELD]: choice.papers },
242
+ null,
243
+ 2,
244
+ ) + (raw === null || raw.endsWith("\n") ? "\n" : ""),
245
+ "utf8",
259
246
  );
247
+ return { status: "written", path, papers: choice.papers, choice };
260
248
  }
261
249
 
262
250
  export const WORKFLOW_PATH = join(".github", "workflows", "papers.yml");
@@ -378,10 +366,18 @@ export function missingPrograms(
378
366
  * session at all. `init` now writes the same three hook commands into `.claude/settings.json`
379
367
  * itself (`hooks-settings.ts`), so there is nothing left to type anywhere but here.
380
368
  */
381
- export function nextSteps(papersDir: string = DEFAULT_PAPERS_ROOT): string {
369
+ export function nextSteps(
370
+ papersDir: string = DEFAULT_PAPERS_ROOT,
371
+ { toolchain = false }: { toolchain?: boolean } = {},
372
+ ): string {
382
373
  return [
383
374
  ``,
384
375
  `next: npx paperlint new <name> # start a paper in ${papersDir}/ from the template`,
376
+ ...(toolchain
377
+ ? [
378
+ ` npx paperlint toolchain # TeX Live for building (${TOOLCHAIN_COST})`,
379
+ ]
380
+ : []),
385
381
  ` npx paperlint lint # runs every rule over ${papersDir}`,
386
382
  ``,
387
383
  ].join("\n");
@@ -494,18 +490,8 @@ export function reportHooks(
494
490
  : ` ✓ already wired in ${here(outcome.path)} — nothing changed`,
495
491
  );
496
492
  out.push(` ${how}`);
497
- if (outcome.status === "written" && outcome.replaced > 0)
498
- out.push(
499
- ` replaced ${String(outcome.replaced)} command(s) that pointed into ${LEGACY_PACKAGE_NAME}, the package's old name`,
500
- );
501
493
  out.push(` ${FRESH_CLONE_NOTE}`);
502
494
  }
503
- if (outcome.plugin.length > 0)
504
- out.push(
505
- ` ⚠ this project also enables the plugin (${outcome.plugin.join(", ")}) — with it every hook runs twice.`,
506
- ` the plugin no longer carries the hooks: ${UNINSTALL_PLUGIN}`,
507
- ` and remove it from "enabledPlugins" in ${SETTINGS_PATH}`,
508
- );
509
495
  return out;
510
496
  }
511
497
 
@@ -530,8 +516,7 @@ export function reportSkillLinks(
530
516
  return out;
531
517
  }
532
518
  const by = (s: string) => report.links.filter((l) => l.status === s);
533
- const replaced = by("replaced");
534
- const created = [...by("created"), ...replaced];
519
+ const created = by("created");
535
520
  const present = by("present");
536
521
  const skipped = by("foreign");
537
522
  // Only a read-only call leaves anything `missing`; counted anyway, so the sum always adds up.
@@ -546,10 +531,6 @@ export function reportSkillLinks(
546
531
  out.push(
547
532
  ` ${join(here(report.home), "<name>")} → ${join(dirname(report.example), "<name>")}`,
548
533
  );
549
- if (replaced.length)
550
- out.push(
551
- ` ${String(replaced.length)} of them replaced a link into ${LEGACY_PACKAGE_NAME}, the package's old name`,
552
- );
553
534
  if (skipped.length) {
554
535
  out.push(
555
536
  ` left untouched — the name is taken by something paperlint did not make:`,
@@ -606,6 +587,11 @@ export interface InitOptions {
606
587
  * workflow is pinned to its release tag (`actionRef`); absent or unreleased, the placeholder.
607
588
  */
608
589
  version?: string;
590
+ /**
591
+ * TeX Live for `paperlint build`: whether paperlint's own tree is installed, and how to install
592
+ * it (`paperlint toolchain`). Passed in by the CLI; without them the step only names the command.
593
+ */
594
+ tex?: { readonly installed: () => boolean; readonly install?: () => number };
609
595
  }
610
596
 
611
597
  /** Reads one line from a real terminal. Kept out of `init` so the command stays testable. */
@@ -621,6 +607,92 @@ export async function askOnTerminal(question: string): Promise<string> {
621
607
  }
622
608
  }
623
609
 
610
+ /** What `paperlint toolchain` costs, said wherever it is offered. */
611
+ export const TOOLCHAIN_COST = "~270 MB, ~3 min, once";
612
+
613
+ export type TexOutcome =
614
+ "installed" | "was-installed" | "failed" | "declined" | "not-asked";
615
+
616
+ /**
617
+ * TeX Live, for `paperlint build` — `paperlint lint` needs none. OFFERED to a human with its cost
618
+ * in the question, default NO (270 MB is not a default anyone should get by pressing Enter);
619
+ * without a human only named as the next step. Nothing is ever installed unasked.
620
+ */
621
+ export async function offerTexLive(
622
+ tex: InitOptions["tex"],
623
+ {
624
+ ask,
625
+ interactive,
626
+ }: { ask?: (q: string) => Promise<string>; interactive: boolean },
627
+ ): Promise<TexOutcome> {
628
+ if (tex?.installed()) return "was-installed";
629
+ if (!interactive || !ask || !tex?.install) return "not-asked";
630
+ const answer = (
631
+ await askOrDefault(
632
+ ask,
633
+ ` install TeX Live now, for \`paperlint build\`? (${TOOLCHAIN_COST}) [y/N] `,
634
+ )
635
+ )
636
+ ?.trim()
637
+ .toLowerCase();
638
+ if (answer !== "y" && answer !== "yes") return "declined";
639
+ return tex.install() === 0 ? "installed" : "failed";
640
+ }
641
+
642
+ /** What `init` says about TeX Live. */
643
+ export function reportTexLive(t: TexOutcome, why: string): string[] {
644
+ const cmd = `npx paperlint toolchain # ${TOOLCHAIN_COST}`;
645
+ if (t === "was-installed") return [` ✓ paperlint's TeX Live is installed`];
646
+ if (t === "installed") return [` ✓ installed`];
647
+ if (t === "failed")
648
+ return [
649
+ ` ✗ the install failed — see above. Run it again:`,
650
+ ` ${cmd}`,
651
+ ];
652
+ return [
653
+ t === "declined"
654
+ ? ` · declined — nothing installed. When you want to build:`
655
+ : ` · not installed — ${why}, so nothing was asked. To build, run once:`,
656
+ ` ${cmd}`,
657
+ ];
658
+ }
659
+
660
+ /** The "papers directory" section of init's report: how the directory was arrived at. */
661
+ function papersLines(decl: DeclarationResult, why: string): string[] {
662
+ const out = [``, `papers directory`];
663
+ if (decl.status === "kept" && typeof decl.papers === "string")
664
+ return [
665
+ ...out,
666
+ ` ✓ ${decl.papers} — declared in ${CONFIG_FILE} → "${PAPERS_DIR_FIELD}"; nothing measured`,
667
+ ];
668
+ if (decl.status !== "written" && decl.status !== "default") return [];
669
+ const { choice } = decl;
670
+ if (choice.how === "detected")
671
+ return [
672
+ ...out,
673
+ ` ✓ ${choice.papers} — measured: its subdirectories carry ${PAPER_MARKERS.join(" / ")}`,
674
+ ];
675
+ if (choice.how === "chosen")
676
+ return [
677
+ ...out,
678
+ ` ✓ ${choice.papers} — you picked it out of ${String(choice.candidates.length)} candidates`,
679
+ ];
680
+ if (choice.how === "not-asked" || choice.how === "no-answer")
681
+ return [
682
+ ...out,
683
+ ` ✓ ${choice.papers} — ${String(choice.candidates.length)} candidates, ` +
684
+ (choice.how === "not-asked"
685
+ ? `${why} so nothing was asked`
686
+ : `no answer was given, so the first one was taken`),
687
+ ` the others: ${choice.candidates.slice(1).join(", ")} — set "${PAPERS_DIR_FIELD}" in ${CONFIG_FILE} if this is the wrong one`,
688
+ ];
689
+ return [
690
+ ...out,
691
+ ` · ${choice.papers} — the default. Nothing here looks like a papers directory yet;`,
692
+ ` \`npx paperlint new <name>\` creates the first paper there.`,
693
+ ];
694
+ }
695
+
624
696
  // Documented in README.md#install-and-set-up — update it when this changes.
625
697
  export async function init(
626
698
  dir: string,
@@ -659,44 +731,21 @@ export async function init(
659
731
  log(``);
660
732
  log(`paperlint init — each decision below says HOW it was decided`);
661
733
 
662
- // ── 1. where the papers are ───────────────────────────────────────────────────────────
663
- const choice = await choosePapers(root, { ask, interactive });
734
+ // ── 1. where the papers are, and 2. the declaration, in paperlint.json when not the default ─
735
+ // The declaration is read FIRST: the directory is measured only when none is declared.
736
+ const decl = await declarePapers(root, () =>
737
+ choosePapers(root, { ask, interactive }),
738
+ );
739
+ for (const line of papersLines(decl, why)) log(line);
664
740
  log(``);
665
- log(`papers directory`);
666
- if (choice.how === "detected")
667
- log(
668
- ` ✓ ${choice.papers} — measured: its subdirectories carry ${PAPER_MARKERS.join(" / ")}`,
669
- );
670
- else if (choice.how === "chosen")
671
- log(
672
- ` ✓ ${choice.papers} — you picked it out of ${String(choice.candidates.length)} candidates`,
673
- );
674
- else if (choice.how === "not-asked" || choice.how === "no-answer") {
675
- log(
676
- ` ✓ ${choice.papers} — ${String(choice.candidates.length)} candidates, ` +
677
- (choice.how === "not-asked"
678
- ? `${why} so nothing was asked`
679
- : `no answer was given, so the first one was taken`),
680
- );
681
- log(
682
- ` the others: ${choice.candidates.slice(1).join(", ")} — change it in package.json if this is the wrong one`,
683
- );
684
- } else {
685
- log(
686
- ` ⚠ ${choice.papers} — A GUESS. Nothing here looks like a papers directory yet.`,
687
- );
741
+ log(`settings`);
742
+ if (decl.status === "written")
688
743
  log(
689
- ` Nothing on disk was measured, so this is the documented default and not a finding.`,
744
+ ` ✓ ${here(decl.path)} → { "${PAPERS_DIR_FIELD}": ${JSON.stringify(decl.papers)} }`,
690
745
  );
691
- }
692
-
693
- // ── 2. one declaration, in package.json ───────────────────────────────────────────────
694
- log(``);
695
- log(`declaration`);
696
- const decl = declarePapers(root, choice.papers);
697
- if (decl.status === "written")
746
+ else if (decl.status === "default")
698
747
  log(
699
- ` ✓ ${here(decl.path)} → "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": ${JSON.stringify(decl.papers)} }`,
748
+ ` ✓ nothing to write — "${decl.papers}" is the default, so no ${CONFIG_FILE} is needed`,
700
749
  );
701
750
  else if (decl.status === "kept") {
702
751
  if (typeof decl.papers !== "string") {
@@ -708,13 +757,7 @@ export async function init(
708
757
  log(
709
758
  ` ✓ ${here(decl.path)} already declares ${PAPERS_DIR_FIELD} = ${JSON.stringify(decl.papers)} — kept, nothing overwritten`,
710
759
  );
711
- } else if (decl.status === "renamed") {
712
- err(` ✗ ${decl.message}`);
713
- err(
714
- ` nothing was written. Fix it in ${here(decl.path)}, then run init again.`,
715
- );
716
- return 2;
717
- } else if (decl.status === "unparsable") {
760
+ } else {
718
761
  err(` ✗ ${here(decl.path)} is not valid JSON: ${decl.reason}`);
719
762
  err(
720
763
  ` nothing was written. The hooks read their papers directory from this file and`,
@@ -723,30 +766,11 @@ export async function init(
723
766
  ` refuse every Bash command while it cannot be parsed — fix the JSON first.`,
724
767
  );
725
768
  return 2;
726
- } else {
727
- err(
728
- ` ✗ no package.json at ${here(root)} — there is nowhere to put the declaration.`,
729
- );
730
- err(
731
- ` The hooks can only read a path they are able to name, and that path is`,
732
- );
733
- err(
734
- ` package.json. Run \`npm init -y\` here, then \`npx paperlint init\` again.`,
735
- );
736
- return 2;
737
769
  }
738
- if (decl.migrated)
739
- log(
740
- ` ✓ moved the settings from "${LEGACY_CONFIG_KEY}" (the old key) to "${CONFIG_KEY}"`,
741
- );
742
- log(
743
- ` one declaration — the hooks, the rules and the CLI all read this one key`,
744
- );
745
770
  // Every step below uses the DECLARED directory. A kept declaration outranks what init
746
771
  // measured or guessed: otherwise the first paper and the workflow would land in the
747
772
  // guessed directory while lint and the hooks keep reading the declared one.
748
- const papersDir =
749
- decl.status === "kept" ? (decl.papers as string) : choice.papers;
773
+ const papersDir = decl.papers as string;
750
774
 
751
775
  // ── 3. the skills, linked where Claude Code looks for them ─────────────────────────────
752
776
  for (const line of reportSkillLinks(link(root), here)) log(line);
@@ -815,7 +839,13 @@ export async function init(
815
839
  ` · none yet${interactive ? "" : ` — ${why}, so nothing was asked`}. \`npx paperlint new <name>\` or \`--paper <name>\` creates one`,
816
840
  );
817
841
 
818
- // ── 7. the toolchain is reported, never installed ─────────────────────────────────────
842
+ // ── 7. TeX Live — offered to a human with its cost, never installed unasked ─────────────
843
+ log(``);
844
+ log(`TeX Live (for \`paperlint build\`; \`paperlint lint\` needs none)`);
845
+ const texOutcome = await offerTexLive(opts.tex, { ask, interactive });
846
+ for (const line of reportTexLive(texOutcome, why)) log(line);
847
+
848
+ // ── 8. the other programs are reported, never installed ────────────────────────────────
819
849
  log(``);
820
850
  log(
821
851
  `external programs (the skills shell out to these; \`paperlint lint\` needs none of them)`,
@@ -843,9 +873,13 @@ export async function init(
843
873
  log(` ${cmd}`);
844
874
  }
845
875
 
846
- log(nextSteps(papersDir));
876
+ log(
877
+ nextSteps(papersDir, {
878
+ toolchain: texOutcome !== "was-installed" && texOutcome !== "installed",
879
+ }),
880
+ );
847
881
 
848
- // ── 8. the install states its own condition ───────────────────────────────────────────
882
+ // ── 9. the install states its own condition ───────────────────────────────────────────
849
883
  log(`── paperlint doctor ${"─".repeat(56)}`);
850
884
  const cliPapers = resolveCliPapers ? resolveCliPapers(root) : papersDir;
851
885
  const code = doctor({ log, cwd: root, projectDir: root, run, cliPapers });
@@ -854,5 +888,5 @@ export async function init(
854
888
  `doctor exits ${String(code)} — the install is NOT finished. The lines marked ✗ above say what is\n` +
855
889
  `left; re-run \`npx paperlint doctor\` once you have done them.`,
856
890
  );
857
- return paperCode !== 0 ? paperCode : code;
891
+ return paperCode || code;
858
892
  }
@@ -35,11 +35,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
35
35
  const { linkSkills, locatePackage, shippedSkills } = await import(
36
36
  join(HERE, "link-skills.ts")
37
37
  );
38
- const {
39
- SHIPPED_SKILLS_DIR: SHIPS,
40
- PACKAGE_NAME: PKG,
41
- LEGACY_PACKAGE_NAME,
42
- } = await import(
38
+ const { SHIPPED_SKILLS_DIR: SHIPS, PACKAGE_NAME: PKG } = await import(
43
39
  join(HERE, "..", "skills", "paper-pipeline", "scripts", "consumer.mjs")
44
40
  );
45
41
 
@@ -222,39 +218,6 @@ try {
222
218
  }
223
219
 
224
220
  // ── VI-bis. LINKS LEFT BY AN INSTALL UNDER THE OLD NAME ARE REPLACED, NOT SKIPPED ─────
225
- // After `npm rm research-paper-pipeline && npm i -D paperlint` every old link dangles. They are
226
- // ours, spelled the way an older `init` wrote them, so the next `init` replaces them.
227
- {
228
- const dir = consumer("renamed");
229
- mkdirSync(home(dir), { recursive: true });
230
- const old = join(
231
- "..",
232
- "..",
233
- "node_modules",
234
- LEGACY_PACKAGE_NAME,
235
- SHIPS,
236
- "alpha",
237
- );
238
- symlinkSync(old, join(home(dir), "alpha"), "dir");
239
- const seen = linkSkills(dir, { write: false });
240
- check(
241
- "doctor's read names a link into the old package name, with the command that fixes it",
242
- status(seen, "alpha")?.status === "foreign" &&
243
- /old name — `npx paperlint init` replaces it/.test(
244
- status(seen, "alpha")?.reason ?? "",
245
- ),
246
- );
247
- const r = linkSkills(dir);
248
- check(
249
- "🔴 init REPLACES it: status `replaced`, and SKILL.md is reachable through the new link",
250
- status(r, "alpha")?.status === "replaced" &&
251
- existsSync(join(home(dir), "alpha", "SKILL.md")) &&
252
- readlinkSync(join(home(dir), "alpha")).includes(
253
- join("node_modules", PKG),
254
- ),
255
- );
256
- }
257
-
258
221
  // ── VII. A PACKAGE WITHOUT ITS SKILLS DIRECTORY IS AN ERROR, NOT ZERO SKILLS ─────────
259
222
  {
260
223
  const pkg = join(work, "no-skills");
@@ -84,13 +84,13 @@ process.exit(
84
84
  edits: [
85
85
  [
86
86
  SRC,
87
- ' unlinkSync,\n} from "node:fs";',
88
- ' unlinkSync,\n rmSync,\n} from "node:fs";',
87
+ ' symlinkSync,\n} from "node:fs";',
88
+ ' symlinkSync,\n rmSync,\n} from "node:fs";',
89
89
  ],
90
90
  [
91
91
  SRC,
92
- " if (!write || !fixable) {",
93
- ' if (!write || seen.status === "present") {',
92
+ ' if (!write || seen.status !== "missing")',
93
+ ' if (!write || seen.status === "present")',
94
94
  ],
95
95
  [
96
96
  SRC,
@@ -123,7 +123,11 @@ process.exit(
123
123
  "the difference between looking and changing. `paperlint doctor` would quietly repair the " +
124
124
  "state it is supposed to report, so the report can never show the gap",
125
125
  edits: [
126
- [SRC, " if (!write || !fixable) {", " if (!fixable) {"],
126
+ [
127
+ SRC,
128
+ ' if (!write || seen.status !== "missing")',
129
+ ' if (seen.status !== "missing")',
130
+ ],
127
131
  [
128
132
  SRC,
129
133
  " if (write && shipped.names.length > 0) {",