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
@@ -28,7 +28,6 @@ import { basename, dirname, join, posix } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import {
30
30
  BIN_FILE,
31
- LEGACY_PACKAGE_NAME,
32
31
  PACKAGE_NAME,
33
32
  } from "../skills/paper-pipeline/scripts/consumer.mjs";
34
33
 
@@ -47,9 +46,6 @@ export const WIRING_FILE = fileURLToPath(
47
46
  */
48
47
  export const MANAGED_BY = `node_modules/${PACKAGE_NAME}/${BIN_FILE}`;
49
48
 
50
- /** The names the package's directory under `node_modules` has had. */
51
- const PKG_DIRS: readonly string[] = [PACKAGE_NAME, LEGACY_PACKAGE_NAME];
52
-
53
49
  export interface HookCommand {
54
50
  readonly type: string;
55
51
  readonly command: string;
@@ -80,7 +76,6 @@ function bare(token: string): string {
80
76
  interface Run {
81
77
  readonly name: string;
82
78
  readonly ours: boolean;
83
- readonly legacy: boolean;
84
79
  readonly path: string;
85
80
  }
86
81
 
@@ -90,19 +85,11 @@ interface Run {
90
85
  * Recognised spellings — each is ONE lexeme of a shell command, which is what a command is:
91
86
  * `node <…>/node_modules/paperlint/bin/paperlint.mjs hook <name>`
92
87
  * ours when the path is exactly MANAGED_BY
93
- * `node node_modules/<pkg>/bin/<any other file> hook <name>`, <pkg> this name or the old one
94
- * `legacy`: the project-relative form `init` writes, pointing at a file
95
- * this version does not install — what an older `init` left behind
96
- * (1.x: `research-paper-pipeline/bin/rpp.mjs`; 2.0.0: `paperlint/bin/rpp.mjs`)
97
- * `npx paperlint hook <name>`, `node_modules/.bin/paperlint hook <name>` (or under the old package name),
98
- * an absolute path into the package's `bin/`
88
+ * `npx paperlint hook <name>`, `node_modules/.bin/paperlint hook <name>`, an absolute path into
89
+ * the package's `bin/`
99
90
  * another spelling
100
- * `vigiles … run-program <…>/node_modules/<pkg>/hooks/<name>.hook.mjs`
91
+ * `vigiles … run-program <…>/node_modules/paperlint/hooks/<name>.hook.mjs`
101
92
  * another spelling
102
- * The last is how the one real consumer wired all three by hand before `init` could.
103
- *
104
- * `legacy` is decided by the SHAPE `init` writes, not by a list of past file names: any file in
105
- * our `bin/` other than BIN_FILE is one that no longer ships, so a future rename is covered too.
106
93
  */
107
94
  function parseRun(command: string): Run | null {
108
95
  const tokens = command.trim().split(/\s+/).map(bare);
@@ -113,54 +100,37 @@ function parseRun(command: string): Run | null {
113
100
  return null;
114
101
  }
115
102
 
116
- /** The path segments after `node_modules/<pkg>/`, and whether the token starts there. */
117
- function insidePackage(parts: readonly string[]): {
118
- readonly inside: readonly string[];
119
- readonly relative: boolean;
120
- } {
103
+ /** The path segments after `node_modules/paperlint/`. */
104
+ function insidePackage(parts: readonly string[]): readonly string[] {
121
105
  const at = parts.findIndex(
122
- (p, j) => p === "node_modules" && PKG_DIRS.includes(parts[j + 1] ?? ""),
106
+ (p, j) => p === "node_modules" && parts[j + 1] === PACKAGE_NAME,
123
107
  );
124
- return {
125
- inside: at === -1 ? [] : parts.slice(at + 2),
126
- relative: at === 0,
127
- };
108
+ return at === -1 ? [] : parts.slice(at + 2);
128
109
  }
129
110
 
130
111
  /** The run token `i` starts, if it names a paperlint hook. */
131
112
  function runAt(tokens: readonly string[], i: number): Run | null {
132
113
  const t = posix.normalize(tokens[i] ?? "");
133
- const { inside, relative } = insidePackage(t.split("/"));
114
+ const inside = insidePackage(t.split("/"));
134
115
  const inBin = inside[0] === "bin" && inside.length === 2;
135
116
  const name = tokens[i + 2];
136
117
  if (
137
- (inBin || PKG_DIRS.includes(basename(t))) &&
118
+ (inBin || basename(t) === PACKAGE_NAME) &&
138
119
  tokens[i + 1] === "hook" &&
139
120
  name
140
121
  )
141
- return {
142
- name,
143
- ours: t === MANAGED_BY,
144
- legacy: inBin && relative && t !== MANAGED_BY,
145
- path: t,
146
- };
122
+ return { name, ours: t === MANAGED_BY, path: t };
147
123
  if (inside[0] === "hooks" && inside[1]?.endsWith(".hook.mjs"))
148
- return {
149
- name: basename(inside[1], ".hook.mjs"),
150
- ours: false,
151
- legacy: false,
152
- path: t,
153
- };
124
+ return { name: basename(inside[1], ".hook.mjs"), ours: false, path: t };
154
125
  return null;
155
126
  }
156
127
 
157
128
  export function hookRun(command: string): {
158
129
  readonly name: string;
159
130
  readonly ours: boolean;
160
- readonly legacy: boolean;
161
131
  } | null {
162
132
  const run = parseRun(command);
163
- return run && { name: run.name, ours: run.ours, legacy: run.legacy };
133
+ return run && { name: run.name, ours: run.ours };
164
134
  }
165
135
 
166
136
  /** Every command in a settings object, with the event it hangs off. */
@@ -205,69 +175,18 @@ export function shippedWiring(file: string = WIRING_FILE): Wiring {
205
175
  export function wiredCounts(
206
176
  settings: Settings,
207
177
  names: readonly string[],
208
- ): Map<string, { ours: number; legacy: number; other: number }> {
209
- const counts = new Map(
210
- names.map((n) => [n, { ours: 0, legacy: 0, other: 0 }]),
211
- );
178
+ ): Map<string, { ours: number; other: number }> {
179
+ const counts = new Map(names.map((n) => [n, { ours: 0, other: 0 }]));
212
180
  for (const { command } of commandsIn(settings)) {
213
181
  const run = hookRun(command);
214
182
  const slot = run ? counts.get(run.name) : undefined;
215
183
  if (run && slot)
216
184
  if (run.ours) slot.ours++;
217
- else if (run.legacy) slot.legacy++;
218
185
  else slot.other++;
219
186
  }
220
187
  return counts;
221
188
  }
222
189
 
223
- /**
224
- * The settings with every command an install under the old package name wrote removed, and the
225
- * matcher blocks and events left empty by that removal dropped. The user's own commands stay.
226
- */
227
- export function withoutLegacy(settings: Settings): {
228
- readonly settings: Settings;
229
- readonly removed: number;
230
- } {
231
- const hooks = settings["hooks"];
232
- if (!hooks || typeof hooks !== "object") return { settings, removed: 0 };
233
- let removed = 0;
234
- const next: Record<string, HookEntry[]> = {};
235
- for (const [event, entries] of Object.entries(hooks as HooksMap)) {
236
- const kept: HookEntry[] = [];
237
- for (const entry of Array.isArray(entries) ? entries : []) {
238
- const list: readonly HookCommand[] = Array.isArray(entry?.hooks)
239
- ? entry.hooks
240
- : [];
241
- const own = list.filter((h) => {
242
- const legacy =
243
- typeof h?.command === "string" && hookRun(h.command)?.legacy === true;
244
- if (legacy) removed++;
245
- return !legacy;
246
- });
247
- if (own.length > 0 || list.length === 0)
248
- kept.push({ ...entry, hooks: own });
249
- }
250
- if (kept.length > 0) next[event] = kept;
251
- }
252
- return removed === 0
253
- ? { settings, removed }
254
- : { settings: { ...settings, hooks: next }, removed };
255
- }
256
-
257
- /**
258
- * Plugin ids the PROJECT enables for this package. A user-scope plugin lives in the user's own
259
- * settings, which this cannot see — callers must say so rather than print "not enabled".
260
- */
261
- export function pluginEnabledHere(settings: Settings): string[] {
262
- const enabled = settings["enabledPlugins"];
263
- if (!enabled || typeof enabled !== "object") return [];
264
- return Object.entries(enabled as Record<string, unknown>)
265
- .filter(
266
- ([id, on]) => on === true && id.split("@")[0] === LEGACY_PACKAGE_NAME,
267
- )
268
- .map(([id]) => id);
269
- }
270
-
271
190
  export type SettingsRead =
272
191
  | { readonly status: "absent"; readonly path: string; readonly settings: {} }
273
192
  | {
@@ -301,9 +220,6 @@ export type WireResult =
301
220
  readonly status: "written" | "present";
302
221
  readonly path: string;
303
222
  readonly names: readonly string[];
304
- readonly plugin: readonly string[];
305
- /** Commands an install under the old package name wrote, removed before the merge. */
306
- readonly replaced: number;
307
223
  }
308
224
  | {
309
225
  readonly status: "foreign";
@@ -316,7 +232,6 @@ export type WireResult =
316
232
  }[];
317
233
  /** Shipped hooks that no command runs in ANY spelling — left unwired as well. */
318
234
  readonly missing: readonly string[];
319
- readonly plugin: readonly string[];
320
235
  }
321
236
  | {
322
237
  readonly status: "unparsable";
@@ -342,11 +257,7 @@ export function wireHooks(
342
257
  ): WireResult {
343
258
  const read = readSettings(root);
344
259
  if (read.status === "unparsable") return read;
345
- const { path } = read;
346
- // Commands from an install under the old package name point at a directory that is gone after
347
- // the upgrade. They are ours, so they are replaced rather than counted as someone else's.
348
- const { settings, removed } = withoutLegacy(read.settings);
349
- const plugin = pluginEnabledHere(settings);
260
+ const { path, settings } = read;
350
261
  const found = commandsIn(settings)
351
262
  .map(({ command }) => ({ command, run: hookRun(command) }))
352
263
  .filter(({ run }) => run && !run.ours && wiring.names.includes(run.name))
@@ -356,63 +267,20 @@ export function wireHooks(
356
267
  const missing = wiring.names.filter(
357
268
  (n) => (counts.get(n)?.ours ?? 0) + (counts.get(n)?.other ?? 0) === 0,
358
269
  );
359
- return {
360
- status: "foreign",
361
- path,
362
- names: wiring.names,
363
- found,
364
- missing,
365
- plugin,
366
- };
270
+ return { status: "foreign", path, names: wiring.names, found, missing };
367
271
  }
368
272
 
369
273
  const next = merge(settings, wiring.compiled, MANAGED_BY);
370
274
  if (JSON.stringify(next) === JSON.stringify(read.settings))
371
- return {
372
- status: "present",
373
- path,
374
- names: wiring.names,
375
- plugin,
376
- replaced: 0,
377
- };
275
+ return { status: "present", path, names: wiring.names };
378
276
  mkdirSync(dirname(path), { recursive: true });
379
277
  writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf8");
380
- return {
381
- status: "written",
382
- path,
383
- names: wiring.names,
384
- plugin,
385
- replaced: removed,
386
- };
278
+ return { status: "written", path, names: wiring.names };
387
279
  }
388
280
 
389
281
  /** The consequence every report of a written hook must carry, in one place. */
390
282
  export const FRESH_CLONE_NOTE = `the commands point into node_modules/${PACKAGE_NAME}/ — in a fresh clone they cannot run until \`npm install\` has run there`;
391
283
 
392
- /** The instruction for a project that also enables the plugin (it only ever existed under the old name). */
393
- export const UNINSTALL_PLUGIN = `/plugin uninstall ${LEGACY_PACKAGE_NAME}@${LEGACY_PACKAGE_NAME}`;
394
-
395
- /**
396
- * The doctor lines for hook commands an older install left behind: each names the file it runs,
397
- * which this version does not install, so the hook silently does nothing.
398
- */
399
- function legacyLines(settings: Settings): string[] {
400
- const stale = new Map<string, number>();
401
- for (const { command } of commandsIn(settings)) {
402
- const run = parseRun(command);
403
- if (run?.legacy) stale.set(run.path, (stale.get(run.path) ?? 0) + 1);
404
- }
405
- return stale.size === 0
406
- ? []
407
- : [
408
- ...[...stale].map(
409
- ([path, n]) =>
410
- ` ⚠ ${String(n)} hook command(s) run ${path}, which this version does not install — they no longer run.`,
411
- ),
412
- ` \`npx ${PACKAGE_NAME} init\` replaces them`,
413
- ];
414
- }
415
-
416
284
  /**
417
285
  * `paperlint doctor`'s section. ADVISORY — it never fails the run: the hooks are an in-editor guard,
418
286
  * `--no-hooks` is a legitimate choice, and a doctor that exits non-zero on a choice gets muted.
@@ -461,17 +329,5 @@ export function doctorHooks(
461
329
  ` ✓ wired — ${wiring.names.join(", ")}, once each`,
462
330
  ` ${FRESH_CLONE_NOTE}`,
463
331
  );
464
- out.push(...legacyLines(read.settings));
465
- const plugin = pluginEnabledHere(read.settings);
466
- if (plugin.length > 0) {
467
- out.push(
468
- ` ⚠ this project ALSO enables the plugin (${plugin.join(", ")}) — every hook would run twice.`,
469
- ` the plugin no longer carries the hooks: ${UNINSTALL_PLUGIN}`,
470
- ` and remove it from "enabledPlugins" in ${SETTINGS_PATH}`,
471
- );
472
- }
473
- out.push(
474
- ` · a plugin installed at USER scope is not visible from here — \`/plugin\` inside Claude Code shows it`,
475
- );
476
332
  return out;
477
333
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `paperlint init` over a project that ALREADY declares its papers directory: the declaration is
3
+ * the answer, and init reports it — not a directory it discovered elsewhere and then did not use.
4
+ *
5
+ * Seen migrating a consumer project: init printed "papers directory ✓ eslint-rules/fixtures — 4
6
+ * candidates", then kept the declared papersDir. The line described a decision that was not made.
7
+ */
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ mkdtempSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterEach, describe, expect, it } from "vitest";
20
+ import { choosePapers, init } from "./init.ts";
21
+
22
+ const dirs: string[] = [];
23
+ afterEach(() => {
24
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
25
+ });
26
+
27
+ /** Declares `papersDir: "papers"`, and holds two OTHER directories that look like papers roots. */
28
+ function project(): string {
29
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-init-")));
30
+ dirs.push(root);
31
+ const files: Record<string, string> = {
32
+ "package.json": JSON.stringify({ name: "c", private: true }),
33
+ "paperlint.json": JSON.stringify({ papersDir: "papers" }),
34
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
35
+ "eslint-rules/fixtures/x/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
36
+ "eslint-rules/fixtures/y/paper.md": "# y\n",
37
+ "drafts/z/paper.md": "# z\n",
38
+ };
39
+ for (const [p, text] of Object.entries(files)) {
40
+ mkdirSync(join(root, p, ".."), { recursive: true });
41
+ writeFileSync(join(root, p), text);
42
+ }
43
+ return root;
44
+ }
45
+
46
+ describe("paperlint init — a declared papers directory", () => {
47
+ it("🔴 is the one reported, and no discovered candidate is named", async () => {
48
+ const root = project();
49
+ const out: string[] = [];
50
+ const code = await init(root, {
51
+ cwd: root,
52
+ log: (s: string) => out.push(s),
53
+ err: (s: string) => out.push(s),
54
+ interactive: false,
55
+ hooks: false,
56
+ run: (() => ({ status: 0 })) as never,
57
+ link: () => ({ ok: false, error: "not linked in this test" }),
58
+ });
59
+ const text = out.join("\n");
60
+ const section = text.slice(
61
+ text.indexOf("papers directory"),
62
+ text.indexOf("settings"),
63
+ );
64
+ expect(code).toBe(0);
65
+ expect(section).toMatch(/✓ papers — declared in paperlint\.json/);
66
+ expect(section).not.toMatch(/candidates|eslint-rules|drafts/);
67
+ const cfg = JSON.parse(readFileSync(join(root, "paperlint.json"), "utf8"));
68
+ expect(cfg).toEqual({ papersDir: "papers" });
69
+ });
70
+
71
+ it("is not asked about, even with a human at the terminal", async () => {
72
+ const root = project();
73
+ const asked: string[] = [];
74
+ await init(root, {
75
+ cwd: root,
76
+ log: () => {},
77
+ err: () => {},
78
+ interactive: true,
79
+ ask: async (q) => (asked.push(q), "n"),
80
+ hooks: false,
81
+ run: (() => ({ status: 0 })) as never,
82
+ link: () => ({ ok: false, error: "not linked in this test" }),
83
+ });
84
+ expect(asked.filter((q) => /papers roots/.test(q))).toEqual([]);
85
+ });
86
+
87
+ it("without a declaration, the candidates are still measured and offered", async () => {
88
+ const choice = await choosePapers(project(), { interactive: false });
89
+ expect(choice.how).toBe("not-asked");
90
+ expect(choice.candidates.length).toBeGreaterThan(1);
91
+ });
92
+ });
93
+
94
+ describe("paperlint init — writes paperlint.json only when something differs from the default", () => {
95
+ const quiet = {
96
+ log: () => {},
97
+ err: () => {},
98
+ interactive: false,
99
+ hooks: false,
100
+ run: (() => ({ status: 0 })) as never,
101
+ link: () => ({ ok: false as const, error: "not linked in this test" }),
102
+ };
103
+ const fresh = (files: Record<string, string>): string => {
104
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-init-")));
105
+ dirs.push(root);
106
+ for (const [p, text] of Object.entries(files)) {
107
+ mkdirSync(join(root, p, ".."), { recursive: true });
108
+ writeFileSync(join(root, p), text);
109
+ }
110
+ return root;
111
+ };
112
+
113
+ it("🔴 papers/ is where the papers are: no paperlint.json is written", async () => {
114
+ const root = fresh({ "papers/a/paper.tex": "x" });
115
+ await init(root, { cwd: root, ...quiet });
116
+ expect(existsSync(join(root, "paperlint.json"))).toBe(false);
117
+ });
118
+
119
+ it("nothing looks like papers: the default, and still no file", async () => {
120
+ const root = fresh({ "README.md": "x" });
121
+ await init(root, { cwd: root, ...quiet });
122
+ expect(existsSync(join(root, "paperlint.json"))).toBe(false);
123
+ });
124
+
125
+ it("papers elsewhere: { papersDir } is written, and other keys already there are kept", async () => {
126
+ const root = fresh({
127
+ "docs/drafts/a/paper.tex": "x",
128
+ "paperlint.json": '{ "kind": "short" }\n',
129
+ });
130
+ await init(root, { cwd: root, ...quiet });
131
+ expect(
132
+ JSON.parse(readFileSync(join(root, "paperlint.json"), "utf8")),
133
+ ).toEqual({ kind: "short", papersDir: "docs/drafts" });
134
+ });
135
+ });
136
+
137
+ /** Runs init in a fresh directory with the given TeX Live port; what it printed and asked. */
138
+ const texRun = async (
139
+ tex: { installed: () => boolean; install?: () => number },
140
+ { interactive, answer = "" }: { interactive: boolean; answer?: string },
141
+ ) => {
142
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-tex-")));
143
+ dirs.push(root);
144
+ const out: string[] = [];
145
+ const asked: string[] = [];
146
+ await init(root, {
147
+ cwd: root,
148
+ log: (s: string) => out.push(s),
149
+ err: () => {},
150
+ interactive,
151
+ ask: async (q) => (asked.push(q), /TeX Live/.test(q) ? answer : ""),
152
+ hooks: false,
153
+ run: (() => ({ status: 0 })) as never,
154
+ link: () => ({ ok: false as const, error: "not linked in this test" }),
155
+ tex,
156
+ });
157
+ return { text: out.join("\n"), asked };
158
+ };
159
+
160
+ describe("paperlint init — TeX Live is offered with its cost, never installed unasked", () => {
161
+ it("🔴 no terminal: nothing is asked or installed, and the command is the next step", async () => {
162
+ let installs = 0;
163
+ const r = await texRun(
164
+ { installed: () => false, install: () => (installs++, 0) },
165
+ { interactive: false },
166
+ );
167
+ expect(installs).toBe(0);
168
+ expect(r.asked.filter((q) => /TeX Live/.test(q))).toEqual([]);
169
+ expect(r.text).toMatch(
170
+ /npx paperlint toolchain {3}# ~270 MB, ~3 min, once/,
171
+ );
172
+ expect(r.text).toMatch(/next: .*\n.*npx paperlint toolchain/);
173
+ });
174
+
175
+ it("a human is asked, with the size in the question; yes installs once", async () => {
176
+ let installs = 0;
177
+ const r = await texRun(
178
+ { installed: () => false, install: () => (installs++, 0) },
179
+ { interactive: true, answer: "y" },
180
+ );
181
+ expect(r.asked.find((q) => /TeX Live/.test(q))).toMatch(
182
+ /~270 MB, ~3 min, once\) \[y\/N\]/,
183
+ );
184
+ expect(installs).toBe(1);
185
+ expect(r.text).not.toMatch(/next: .*\n.*npx paperlint toolchain/);
186
+ });
187
+
188
+ it("Enter is no — nothing installed", async () => {
189
+ let installs = 0;
190
+ const r = await texRun(
191
+ { installed: () => false, install: () => (installs++, 0) },
192
+ { interactive: true },
193
+ );
194
+ expect(installs).toBe(0);
195
+ expect(r.text).toMatch(/declined — nothing installed/);
196
+ });
197
+
198
+ it("already installed: not asked about", async () => {
199
+ const r = await texRun(
200
+ { installed: () => true, install: () => 0 },
201
+ { interactive: true, answer: "y" },
202
+ );
203
+ expect(r.asked.filter((q) => /TeX Live/.test(q))).toEqual([]);
204
+ expect(r.text).toMatch(/✓ paperlint's TeX Live is installed/);
205
+ });
206
+ });