paperlint 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/CLAUDE.md +3 -3
  3. package/CONTRIBUTING.md +21 -0
  4. package/LICENSE +1 -1
  5. package/README.md +81 -24
  6. package/action.harness.mjs +10 -10
  7. package/action.mutations.mjs +3 -3
  8. package/action.yml +11 -11
  9. package/bin/{rpp.mjs → paperlint.mjs} +2 -2
  10. package/dist/adapters/banal/invocation.js +3 -3
  11. package/dist/adapters/banal/invocation.js.map +1 -1
  12. package/dist/adapters/banal/run.d.ts +1 -1
  13. package/dist/adapters/banal/run.js +1 -1
  14. package/dist/adapters/banal/run.js.map +1 -1
  15. package/dist/adapters/banal/settings.d.ts +4 -4
  16. package/dist/adapters/banal/settings.d.ts.map +1 -1
  17. package/dist/adapters/banal/settings.js +4 -4
  18. package/dist/adapters/banal/settings.js.map +1 -1
  19. package/dist/adapters/banal/xml.js +1 -1
  20. package/dist/adapters/banal/xml.js.map +1 -1
  21. package/dist/adapters/curl/download.io.d.ts.map +1 -1
  22. package/dist/adapters/curl/download.io.js +2 -2
  23. package/dist/adapters/curl/download.io.js.map +1 -1
  24. package/dist/build-engine.d.ts +1 -1
  25. package/dist/build-engine.d.ts.map +1 -1
  26. package/dist/build-engine.js +6 -3
  27. package/dist/build-engine.js.map +1 -1
  28. package/dist/build.d.ts +1 -1
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +5 -4
  31. package/dist/build.js.map +1 -1
  32. package/dist/cli.d.ts +29 -30
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +174 -98
  35. package/dist/cli.js.map +1 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +21 -6
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/engine.d.ts +2 -2
  40. package/dist/engine.d.ts.map +1 -1
  41. package/dist/engine.js +1 -1
  42. package/dist/facts-file.d.ts +33 -9
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +117 -17
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/hooks-settings.d.ts +0 -18
  47. package/dist/hooks-settings.d.ts.map +1 -1
  48. package/dist/hooks-settings.js +70 -42
  49. package/dist/hooks-settings.js.map +1 -1
  50. package/dist/init.d.ts +13 -10
  51. package/dist/init.d.ts.map +1 -1
  52. package/dist/init.js +74 -42
  53. package/dist/init.js.map +1 -1
  54. package/dist/link-skills.js +2 -2
  55. package/dist/link-skills.js.map +1 -1
  56. package/dist/new-paper.d.ts +2 -2
  57. package/dist/new-paper.d.ts.map +1 -1
  58. package/dist/new-paper.js +17 -2
  59. package/dist/new-paper.js.map +1 -1
  60. package/dist/paper-settings.d.ts +68 -0
  61. package/dist/paper-settings.d.ts.map +1 -0
  62. package/dist/paper-settings.js +144 -0
  63. package/dist/paper-settings.js.map +1 -0
  64. package/dist/presets.d.ts +84 -0
  65. package/dist/presets.d.ts.map +1 -0
  66. package/dist/presets.js +186 -0
  67. package/dist/presets.js.map +1 -0
  68. package/dist/rules-config.d.ts +8 -1
  69. package/dist/rules-config.d.ts.map +1 -1
  70. package/dist/rules-config.js +13 -8
  71. package/dist/rules-config.js.map +1 -1
  72. package/dist/structure.d.ts.map +1 -1
  73. package/dist/structure.js +2 -1
  74. package/dist/structure.js.map +1 -1
  75. package/dist/tex-requirements.d.ts +63 -7
  76. package/dist/tex-requirements.d.ts.map +1 -1
  77. package/dist/tex-requirements.js +91 -24
  78. package/dist/tex-requirements.js.map +1 -1
  79. package/dist/toolchain.d.ts +2 -2
  80. package/dist/toolchain.d.ts.map +1 -1
  81. package/dist/toolchain.js +8 -8
  82. package/dist/toolchain.js.map +1 -1
  83. package/dist/types.d.ts +4 -4
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/venue-rules.d.ts +109 -0
  86. package/dist/venue-rules.d.ts.map +1 -0
  87. package/dist/venue-rules.js +387 -0
  88. package/dist/venue-rules.js.map +1 -0
  89. package/docs/configuration.md +73 -17
  90. package/docs/e2e.md +3 -4
  91. package/docs/install.md +11 -3
  92. package/docs/optional-rules.md +26 -25
  93. package/docs/rules.md +132 -3
  94. package/docs/toolchain.md +5 -5
  95. package/eslint-rules/paper-typography.mjs +1 -1
  96. package/eslint-rules/paper-typography.mutations.mjs +1 -1
  97. package/eslint-rules/papers.mjs +6 -2
  98. package/eslint-rules/pdf-last-page-balance.harness.mjs +5 -3
  99. package/eslint-rules/pdf-last-page-balance.mjs +23 -2
  100. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  101. package/fixtures/build-e2e/guards/paper.tex +1 -1
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.json +1 -1
  104. package/fixtures/real-markdown-paper/baseline.mjs +2 -2
  105. package/fixtures/toolchain-mirror/install-tl +2 -2
  106. package/fixtures/toolchain-mirror/stub-pdflatex +1 -1
  107. package/fixtures/toolchain-mirror/stub-tlmgr +1 -1
  108. package/hooks/hooks.harness.mjs +1 -1
  109. package/lib/paper-config.d.mts +7 -0
  110. package/lib/paper-config.harness.mjs +3 -3
  111. package/lib/paper-config.mjs +34 -3
  112. package/lib/skill-trigger-cases.harness.mjs +1 -1
  113. package/package.json +4 -4
  114. package/plugin/hooks/hooks.json +3 -3
  115. package/scripts/check.mjs +1 -1
  116. package/scripts/harness-api.frozen.json +1 -1
  117. package/scripts/harness-api.test.ts +2 -2
  118. package/scripts/layer-legacy.frozen.json +1 -1
  119. package/scripts/mutation-batteries-frozen.harness.mjs +1 -1
  120. package/scripts/mutation-batteries.frozen.json +2 -2
  121. package/scripts/release-config.test.ts +1 -1
  122. package/skills/find-venue/SKILL.md +5 -1
  123. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  124. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  125. package/skills/paper-pipeline/SKILL.md +4 -3
  126. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  127. package/skills/paper-pipeline/description-language.eval.mjs +1 -1
  128. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +1 -1
  129. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +2 -2
  130. package/skills/paper-pipeline/pipeline-firing.eval.mjs +1 -1
  131. package/skills/paper-pipeline/pipeline-language.eval.mjs +1 -1
  132. package/skills/paper-pipeline/scripts/consumer.d.mts +1 -0
  133. package/skills/paper-pipeline/scripts/consumer.harness.mjs +10 -4
  134. package/skills/paper-pipeline/scripts/consumer.mjs +8 -1
  135. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +1 -1
  136. package/skills/render-paper/SKILL.md +3 -3
  137. package/skills/render-paper/SKILL.md.spec.ts +2 -2
  138. package/skills/render-paper/extract-pdf-facts.harness.mjs +6 -6
  139. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  140. package/skills/study-accepted-papers/SKILL.md +2 -1
  141. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  142. package/skills/submit-paper/SKILL.md +9 -1
  143. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  144. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  145. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  146. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  147. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  148. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  149. package/skills/submit-paper/references/venues/venue-profile.schema.json +120 -30
  150. package/src/adapters/banal/index.test.ts +1 -1
  151. package/src/adapters/banal/invocation.test.ts +3 -3
  152. package/src/adapters/banal/invocation.ts +3 -3
  153. package/src/adapters/banal/locate.test.ts +4 -4
  154. package/src/adapters/banal/run.ts +2 -2
  155. package/src/adapters/banal/settings.test.ts +12 -6
  156. package/src/adapters/banal/settings.ts +9 -5
  157. package/src/adapters/banal/xml.test.ts +1 -1
  158. package/src/adapters/banal/xml.ts +1 -1
  159. package/src/adapters/curl/download.io.ts +4 -2
  160. package/src/adapters/curl/download.test.ts +3 -1
  161. package/src/adapters/node/files.test.ts +1 -1
  162. package/src/adapters/node/process.test.ts +2 -2
  163. package/src/adapters/node/workspace.test.ts +4 -4
  164. package/src/build-engine.harness.mjs +2 -2
  165. package/src/build-engine.ts +11 -3
  166. package/src/build.harness.mjs +27 -22
  167. package/src/build.mutations.mjs +3 -2
  168. package/src/build.ts +6 -5
  169. package/src/cli.harness.mjs +72 -131
  170. package/src/cli.mutations.mjs +7 -32
  171. package/src/cli.ts +204 -121
  172. package/src/doctor.harness.mjs +9 -25
  173. package/src/doctor.ts +28 -5
  174. package/src/engine.harness.mjs +4 -2
  175. package/src/engine.ts +2 -2
  176. package/src/facts-file.test.ts +54 -7
  177. package/src/facts-file.ts +145 -24
  178. package/src/hooks-settings.harness.mjs +24 -15
  179. package/src/hooks-settings.mutations.mjs +2 -4
  180. package/src/hooks-settings.ts +92 -49
  181. package/src/init.ts +88 -52
  182. package/src/latex-log.harness.mjs +1 -1
  183. package/src/link-skills.harness.mjs +3 -1
  184. package/src/link-skills.mutations.mjs +1 -1
  185. package/src/link-skills.ts +2 -2
  186. package/src/new-paper.harness.mjs +10 -7
  187. package/src/new-paper.test.ts +145 -0
  188. package/src/new-paper.ts +22 -2
  189. package/src/paper-settings-commands.test.ts +311 -0
  190. package/src/paper-settings.test.ts +220 -0
  191. package/src/paper-settings.ts +206 -0
  192. package/src/pdf-facts.harness.mjs +1 -1
  193. package/src/presets.test.ts +236 -0
  194. package/src/presets.ts +307 -0
  195. package/src/rules-config.ts +14 -9
  196. package/src/structure.harness.mjs +4 -4
  197. package/src/structure.ts +2 -1
  198. package/src/tex-requirements.harness.mjs +12 -18
  199. package/src/tex-requirements.ts +179 -29
  200. package/src/toolchain.harness.mjs +34 -25
  201. package/src/toolchain.ts +14 -8
  202. package/src/types.ts +4 -4
  203. package/src/venue-rules.test.ts +545 -0
  204. package/src/venue-rules.ts +573 -0
  205. package/templates/paper/paperlint.json +4 -0
  206. package/fixtures/build-e2e/acmart/venue.json +0 -1
  207. package/plugin/.claude-plugin/plugin.json +0 -8
@@ -27,6 +27,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
27
  import { basename, dirname, join, posix } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import {
30
+ BIN_FILE,
30
31
  LEGACY_PACKAGE_NAME,
31
32
  PACKAGE_NAME,
32
33
  } from "../skills/paper-pipeline/scripts/consumer.mjs";
@@ -44,13 +45,7 @@ export const WIRING_FILE = fileURLToPath(
44
45
  * and `${CLAUDE_PROJECT_DIR}/` before comparing, so this is the same file as the spelling in
45
46
  * `hooks.json`, and nothing machine-specific lands in a committed file.
46
47
  */
47
- export const MANAGED_BY = `node_modules/${PACKAGE_NAME}/bin/rpp.mjs`;
48
-
49
- /**
50
- * The same token as an install under the package's old name wrote it. A command spelled this way
51
- * is ours from before the rename: `init` removes it and writes the current one, `doctor` names it.
52
- */
53
- export const LEGACY_MANAGED_BY = `node_modules/${LEGACY_PACKAGE_NAME}/bin/rpp.mjs`;
48
+ export const MANAGED_BY = `node_modules/${PACKAGE_NAME}/${BIN_FILE}`;
54
49
 
55
50
  /** The names the package's directory under `node_modules` has had. */
56
51
  const PKG_DIRS: readonly string[] = [PACKAGE_NAME, LEGACY_PACKAGE_NAME];
@@ -81,50 +76,91 @@ function bare(token: string): string {
81
76
  return unquoted.replace(/^\$\{?CLAUDE_PROJECT_DIR\}?[/\\]/, "");
82
77
  }
83
78
 
79
+ /** A hook command reduced to what it runs: the hook, and the path token that runs it. */
80
+ interface Run {
81
+ readonly name: string;
82
+ readonly ours: boolean;
83
+ readonly legacy: boolean;
84
+ readonly path: string;
85
+ }
86
+
84
87
  /**
85
88
  * Which paperlint hook a command runs, if any, and whether it is spelled the way `init` writes it.
86
89
  *
87
90
  * Recognised spellings — each is ONE lexeme of a shell command, which is what a command is:
88
- * `node <…>/node_modules/paperlint/bin/rpp.mjs hook <name>` ours when the path is exactly
89
- * MANAGED_BY
90
- * the same under `node_modules/research-paper-pipeline/` `legacy`: ours before the rename
91
- * `npx paperlint hook <name>`, `node_modules/.bin/paperlint hook <name>` (or the old bin names)
92
- * another spelling
91
+ * `node <…>/node_modules/paperlint/bin/paperlint.mjs hook <name>`
92
+ * 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/`
99
+ * another spelling
93
100
  * `vigiles … run-program <…>/node_modules/<pkg>/hooks/<name>.hook.mjs`
94
- * another spelling
101
+ * another spelling
95
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.
96
106
  */
107
+ function parseRun(command: string): Run | null {
108
+ const tokens = command.trim().split(/\s+/).map(bare);
109
+ for (let i = 0; i < tokens.length; i++) {
110
+ const run = runAt(tokens, i);
111
+ if (run) return run;
112
+ }
113
+ return null;
114
+ }
115
+
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
+ } {
121
+ const at = parts.findIndex(
122
+ (p, j) => p === "node_modules" && PKG_DIRS.includes(parts[j + 1] ?? ""),
123
+ );
124
+ return {
125
+ inside: at === -1 ? [] : parts.slice(at + 2),
126
+ relative: at === 0,
127
+ };
128
+ }
129
+
130
+ /** The run token `i` starts, if it names a paperlint hook. */
131
+ function runAt(tokens: readonly string[], i: number): Run | null {
132
+ const t = posix.normalize(tokens[i] ?? "");
133
+ const { inside, relative } = insidePackage(t.split("/"));
134
+ const inBin = inside[0] === "bin" && inside.length === 2;
135
+ const name = tokens[i + 2];
136
+ if (
137
+ (inBin || PKG_DIRS.includes(basename(t))) &&
138
+ tokens[i + 1] === "hook" &&
139
+ name
140
+ )
141
+ return {
142
+ name,
143
+ ours: t === MANAGED_BY,
144
+ legacy: inBin && relative && t !== MANAGED_BY,
145
+ path: t,
146
+ };
147
+ 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
+ };
154
+ return null;
155
+ }
156
+
97
157
  export function hookRun(command: string): {
98
158
  readonly name: string;
99
159
  readonly ours: boolean;
100
160
  readonly legacy: boolean;
101
161
  } | null {
102
- const tokens = command.trim().split(/\s+/).map(bare);
103
- for (let i = 0; i < tokens.length; i++) {
104
- const t = posix.normalize(tokens[i] ?? "");
105
- const parts = t.split("/");
106
- const at = parts.findIndex(
107
- (p, j) => p === "node_modules" && PKG_DIRS.includes(parts[j + 1] ?? ""),
108
- );
109
- const inside = at === -1 ? [] : parts.slice(at + 2);
110
- const isBin =
111
- (inside[0] === "bin" && inside[1] === "rpp.mjs") ||
112
- ["rpp", ...PKG_DIRS].includes(basename(t));
113
- const name = tokens[i + 2];
114
- if (isBin && tokens[i + 1] === "hook" && name)
115
- return {
116
- name,
117
- ours: t === MANAGED_BY,
118
- legacy: t === LEGACY_MANAGED_BY,
119
- };
120
- if (inside[0] === "hooks" && inside[1]?.endsWith(".hook.mjs"))
121
- return {
122
- name: basename(inside[1], ".hook.mjs"),
123
- ours: false,
124
- legacy: false,
125
- };
126
- }
127
- return null;
162
+ const run = parseRun(command);
163
+ return run && { name: run.name, ours: run.ours, legacy: run.legacy };
128
164
  }
129
165
 
130
166
  /** Every command in a settings object, with the event it hangs off. */
@@ -356,16 +392,23 @@ export const FRESH_CLONE_NOTE = `the commands point into node_modules/${PACKAGE_
356
392
  /** The instruction for a project that also enables the plugin (it only ever existed under the old name). */
357
393
  export const UNINSTALL_PLUGIN = `/plugin uninstall ${LEGACY_PACKAGE_NAME}@${LEGACY_PACKAGE_NAME}`;
358
394
 
359
- /** The doctor lines for hook commands an install under the old package name left behind. */
360
- function legacyLines(
361
- counts: ReadonlyMap<string, { readonly legacy: number }>,
362
- ): string[] {
363
- let legacy = 0;
364
- for (const c of counts.values()) legacy += c.legacy;
365
- return legacy === 0
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
366
406
  ? []
367
407
  : [
368
- ` ⚠ ${String(legacy)} hook command(s) still point into node_modules/${LEGACY_PACKAGE_NAME}/, the package's old name — they no longer run.`,
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
+ ),
369
412
  ` \`npx ${PACKAGE_NAME} init\` replaces them`,
370
413
  ];
371
414
  }
@@ -400,7 +443,7 @@ export function doctorHooks(
400
443
  out.push(` ⚠ wired TWICE — each of these runs more than once per event:`);
401
444
  for (const n of twice) out.push(` ${n} ×${String(total(n))}`);
402
445
  out.push(
403
- ` keep one command per hook; \`npx paperlint init\` writes the rpp.mjs form`,
446
+ ` keep one command per hook; \`npx paperlint init\` writes the ${MANAGED_BY} form`,
404
447
  );
405
448
  }
406
449
  if (missing.length === wiring.names.length)
@@ -418,7 +461,7 @@ export function doctorHooks(
418
461
  ` ✓ wired — ${wiring.names.join(", ")}, once each`,
419
462
  ` ${FRESH_CLONE_NOTE}`,
420
463
  );
421
- out.push(...legacyLines(counts));
464
+ out.push(...legacyLines(read.settings));
422
465
  const plugin = pluginEnabledHere(read.settings);
423
466
  if (plugin.length > 0) {
424
467
  out.push(
package/src/init.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * `paperlint init` — the whole install, in the terminal it was typed in.
3
3
  *
4
4
  * 🔴 WHAT THIS COMMAND USED TO DO, AND WHY THAT WAS A DEFECT RATHER THAN A SHORTFALL. It wrote
5
- * `rpp.json` with a GUESSED `"papers": "papers"` and never touched `package.json`. The three hooks
5
+ * a separate config file with a GUESSED `"papers": "papers"` and never touched `package.json`. The three hooks
6
6
  * read the papers directory out of `package.json` and nothing else, so a consumer who followed the
7
7
  * documented install got a `paper-edit-guard` watching a directory that did not exist — and a guard
8
8
  * watching nothing is byte-identical, from outside, to a guard that is working (issue #33).
@@ -15,7 +15,7 @@
15
15
  * carry a paper marker. Several hits is the only case a human is asked about.
16
16
  * declaration WRITTEN into `package.json`, merged, never overwriting a value that is
17
17
  * already there. Prior art: husky's `init` edits the consumer's package.json
18
- * to add `prepare`. `rpp.json` is no longer created at all.
18
+ * to add `prepare`. No second config file is created.
19
19
  * skills LINKED — one relative symlink per shipped skill into `.claude/skills/`, the
20
20
  * only place Claude Code looks for project skills (`link-skills.ts`). An
21
21
  * entry of the same name that paperlint did not make is reported, never replaced.
@@ -35,8 +35,14 @@
35
35
  * an answer nobody gave. So the non-interactive path takes the stated default and SAYS which
36
36
  * default it took and why nothing was asked, rather than pretending it asked.
37
37
  */
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";
38
+ import {
39
+ existsSync,
40
+ mkdirSync,
41
+ readFileSync,
42
+ unlinkSync,
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";
@@ -66,10 +72,13 @@ import {
66
72
  CONFIG_KEY,
67
73
  DEFAULT_PAPERS_ROOT,
68
74
  LEGACY_CONFIG_KEY,
75
+ LEGACY_PAPER_SETTINGS_FILE,
69
76
  PAPERS_DIR_FIELD,
77
+ PAPER_SETTINGS_FILE,
70
78
  declaredSettings,
71
79
  renamedFieldMessage,
72
80
  } from "../lib/paper-config.mjs";
81
+ import { migrationOf } from "./paper-settings.ts";
73
82
 
74
83
  /** How the papers directory was arrived at. Printed, because a guess must not read as a fact. */
75
84
  export type PapersHow =
@@ -204,8 +213,8 @@ export type DeclarationResult =
204
213
  *
205
214
  * 🔴 A HOOK CANNOT IMPORT CODE AND CANNOT WALK UP A TREE LOOKING FOR A CONFIG. It can read a path
206
215
  * 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 moved here rather than the readers moving to
208
- * `rpp.json`: many readers against one (`docs/install.md`, "One declaration").
216
+ * That asymmetry is the whole reason the declaration lives here rather than in a file of its own:
217
+ * many readers against one (`docs/install.md`, "One declaration").
209
218
  *
210
219
  * ⚠️ Merged, not rewritten, and never over a value the consumer set — an `init` that silently
211
220
  * replaces a setting is worse than an `init` that does nothing, because the consumer keeps
@@ -259,35 +268,6 @@ function renameKey(
259
268
  );
260
269
  }
261
270
 
262
- export type RppJsonResult = "absent" | "kept" | "filled" | "unparsable";
263
-
264
- /**
265
- * `rpp.json` is no longer CREATED — but a consumer who already has one keeps it working, and it
266
- * gets the same `papers` value rather than being left to disagree with `package.json` in silence.
267
- * Two declarations that disagree is the defect `paperlint doctor` was written to catch; writing the
268
- * second one on purpose would be handing it new work.
269
- */
270
- export function syncRppJson(root: string, papers: string): RppJsonResult {
271
- const path = join(root, "rpp.json");
272
- if (!existsSync(path)) return "absent";
273
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
274
- let cfg: Record<string, any>;
275
- const raw = readFileSync(path, "utf8");
276
- try {
277
- cfg = JSON.parse(raw);
278
- } catch {
279
- return "unparsable";
280
- }
281
- if (cfg?.[PAPERS_DIR_FIELD] !== undefined) return "kept";
282
- cfg[PAPERS_DIR_FIELD] = papers;
283
- writeFileSync(
284
- path,
285
- JSON.stringify(cfg, null, 2) + (raw.endsWith("\n") ? "\n" : ""),
286
- "utf8",
287
- );
288
- return "filled";
289
- }
290
-
291
271
  export const WORKFLOW_PATH = join(".github", "workflows", "papers.yml");
292
272
 
293
273
  /** Where the action is pinned when no release tag is known — obviously a placeholder. */
@@ -310,7 +290,7 @@ export function workflowYaml(papers: string, ref: string | null): string {
310
290
  ` runs-on: ubuntu-latest`,
311
291
  ` steps:`,
312
292
  ` - uses: actions/checkout@v4`,
313
- ` - uses: zernie/research-paper-pipeline@${ref ?? UNPINNED_REF}`,
293
+ ` - uses: zernie/paperlint@${ref ?? UNPINNED_REF}`,
314
294
  ` with:`,
315
295
  ` paths: ${papers}`,
316
296
  ``,
@@ -380,7 +360,7 @@ export function reportWorkflow(
380
360
  if (wf !== "written" && wf !== "kept")
381
361
  out.push(
382
362
  ` to run the same checks in CI, add this step to a workflow:`,
383
- ` - uses: zernie/research-paper-pipeline@${ref ?? UNPINNED_REF}`,
363
+ ` - uses: zernie/paperlint@${ref ?? UNPINNED_REF}`,
384
364
  ` with:`,
385
365
  ` paths: ${papersDir}`,
386
366
  );
@@ -638,6 +618,73 @@ export interface InitOptions {
638
618
  }
639
619
 
640
620
  /** Reads one line from a real terminal. Kept out of `init` so the command stays testable. */
621
+ /**
622
+ * Move every paper's pre-2.1.0 `venue.json` to `paperlint.json`, the way the old package.json key
623
+ * is moved, and in the same step `"venue": "aisec"` becomes `"extends": "paperlint:aisec"`: written
624
+ * as `paperlint.json` when it is alone, removed when `paperlint.json` already says the same, and
625
+ * REFUSED — both files left as they are — when they differ, since there is no way to know which
626
+ * one the author means. `code` is 2 when anything was refused.
627
+ */
628
+ export function migratePaperSettings(papersAbs: string): {
629
+ readonly code: number;
630
+ readonly lines: readonly string[];
631
+ } {
632
+ const lines: string[] = [];
633
+ let code = 0;
634
+ for (const dir of papersIn(papersAbs, [
635
+ ...PAPER_MARKERS,
636
+ LEGACY_PAPER_SETTINGS_FILE,
637
+ ])) {
638
+ const r = migrateOne(dir, (p) => relative(papersAbs, p));
639
+ if (r === null) continue;
640
+ lines.push(r.line);
641
+ if (r.refused) code = 2;
642
+ }
643
+ return {
644
+ code,
645
+ lines: lines.length ? ["", "paper settings", ...lines] : [],
646
+ };
647
+ }
648
+
649
+ /** One paper's move: done, and the line that says so — or null when there is nothing to move. */
650
+ function migrateOne(
651
+ dir: string,
652
+ shown: (p: string) => string,
653
+ ): { readonly line: string; readonly refused: boolean } | null {
654
+ const [from, to] = [LEGACY_PAPER_SETTINGS_FILE, PAPER_SETTINGS_FILE].map(
655
+ (f) => join(dir, f),
656
+ ) as [string, string];
657
+ const read = (p: string) => (existsSync(p) ? readFileSync(p) : null);
658
+ const plan = migrationOf(read(from), read(to));
659
+ switch (plan.kind) {
660
+ case "none":
661
+ return null;
662
+ case "move":
663
+ writeFileSync(to, plan.text);
664
+ unlinkSync(from);
665
+ return {
666
+ line: ` ✓ ${shown(from)} → ${shown(to)} ("venue" is now "extends": "paperlint:<name>"; renamed in paperlint 2.1.0)`,
667
+ refused: false,
668
+ };
669
+ case "drop-legacy":
670
+ unlinkSync(from);
671
+ return {
672
+ line: ` ✓ ${shown(from)} removed — ${shown(to)} already says the same`,
673
+ refused: false,
674
+ };
675
+ case "conflict":
676
+ return {
677
+ line: ` ✗ ${shown(from)} and ${shown(to)} both exist and differ — nothing was moved. Keep ${PAPER_SETTINGS_FILE}, copy what you need from ${LEGACY_PAPER_SETTINGS_FILE} into it (its "venue": "x" is "extends": "paperlint:x"), delete ${LEGACY_PAPER_SETTINGS_FILE}, then run init again`,
678
+ refused: true,
679
+ };
680
+ case "broken":
681
+ return {
682
+ line: ` ✗ ${shown(from)} was not moved: ${plan.why}`,
683
+ refused: true,
684
+ };
685
+ }
686
+ }
687
+
641
688
  export async function askOnTerminal(question: string): Promise<string> {
642
689
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
643
690
  const { createInterface } = await import("node:readline/promises");
@@ -772,23 +819,12 @@ export async function init(
772
819
  ` one declaration — the hooks, the rules and the CLI all read this one key`,
773
820
  );
774
821
  // Every step below uses the DECLARED directory. A kept declaration outranks what init
775
- // measured or guessed: otherwise the first paper, the workflow and rpp.json would land in the
822
+ // measured or guessed: otherwise the first paper and the workflow would land in the
776
823
  // guessed directory while lint and the hooks keep reading the declared one.
777
824
  const papersDir =
778
825
  decl.status === "kept" ? (decl.papers as string) : choice.papers;
779
- const rpp = syncRppJson(root, papersDir);
780
- if (rpp === "filled")
781
- log(
782
- ` ⚠ rpp.json was already here — gave it the same ${PAPERS_DIR_FIELD} value; it is deprecated`,
783
- );
784
- else if (rpp === "kept")
785
- log(
786
- ` ⚠ rpp.json was already here and already declares ${PAPERS_DIR_FIELD} — left untouched; it is deprecated`,
787
- );
788
- else if (rpp === "unparsable")
789
- log(
790
- ` ⚠ rpp.json is here and does not parse — left untouched; it is deprecated, delete it`,
791
- );
826
+ const moved = migratePaperSettings(resolve(root, papersDir));
827
+ for (const line of moved.lines) (moved.code ? err : log)(line);
792
828
 
793
829
  // ── 3. the skills, linked where Claude Code looks for them ─────────────────────────────
794
830
  for (const line of reportSkillLinks(link(root), here)) log(line);
@@ -896,5 +932,5 @@ export async function init(
896
932
  `doctor exits ${String(code)} — the install is NOT finished. The lines marked ✗ above say what is\n` +
897
933
  `left; re-run \`npx paperlint doctor\` once you have done them.`,
898
934
  );
899
- return paperCode !== 0 ? paperCode : code;
935
+ return paperCode || moved.code || code;
900
936
  }
@@ -112,7 +112,7 @@ const undefinedCs = [
112
112
  "Here is how much of TeX's memory you used:",
113
113
  ];
114
114
  const ex = errorExcerpt(undefinedCs);
115
- // Guards: the spelling rpp's own flags produce for most errors — with only the `!` form recognised,
115
+ // Guards: the spelling paperlint's own flags produce for most errors — with only the `!` form recognised,
116
116
  // an undefined control sequence fails with no error line quoted.
117
117
  check(
118
118
  "-file-line-error form: the error line, the l.NNN line and the rest of the source line",
@@ -50,7 +50,9 @@ const check = (label, cond) => {
50
50
  };
51
51
 
52
52
  const SKILLS = ["alpha", "beta", "gamma"];
53
- const work = realpathSync(mkdtempSync(join(tmpdir(), "rpp-link-skills-")));
53
+ const work = realpathSync(
54
+ mkdtempSync(join(tmpdir(), "paperlint-link-skills-")),
55
+ );
54
56
 
55
57
  /** A package as a manager would unpack it: manifest, skills, one non-skill. */
56
58
  function writePackage(dir) {
@@ -27,7 +27,7 @@ process.exit(
27
27
  runner: "node",
28
28
  cases: [
29
29
  {
30
- // The definition lives in the port since rpp#62 (`installedSkills` in consumer.mjs), so
30
+ // The definition lives in the port since paperlint#62 (`installedSkills` in consumer.mjs), so
31
31
  // the defect is planted THERE and must still die in THIS harness — the evidence that the
32
32
  // linker reads it through the shared function rather than through a copy of its own.
33
33
  name: "any directory counts as a skill",
@@ -66,7 +66,7 @@ export function locatePackage(project: string): Located {
66
66
  let manifest: string;
67
67
  try {
68
68
  const req = createRequire(
69
- pathToFileURL(join(project, "__rpp_locate__.js")).href,
69
+ pathToFileURL(join(project, "__paperlint_locate__.js")).href,
70
70
  );
71
71
  manifest = req.resolve(`${PACKAGE_NAME}/package.json`);
72
72
  } catch (e) {
@@ -100,7 +100,7 @@ export function shippedSkills(
100
100
  // clean run.
101
101
  if (!existsSync(skillsDir))
102
102
  return { error: `the package's skills directory is missing: ${skillsDir}` };
103
- // The same function every reader of an installed skills directory calls (rpp#62): the writer
103
+ // The same function every reader of an installed skills directory calls (paperlint#62): the writer
104
104
  // and the readers of this fact share one definition of "a skill is here", links included.
105
105
  try {
106
106
  return { skillsDir, names: installedSkills(skillsDir) };
@@ -52,7 +52,7 @@ const check = (label, cond) => {
52
52
  assert.ok(cond, label);
53
53
  };
54
54
 
55
- const work = realpathSync(mkdtempSync(join(tmpdir(), "rpp-new-")));
55
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
56
56
  const read = (...p) => readFileSync(join(...p), "utf8");
57
57
  const front = (text) => load(frontmatterBlock(text) ?? "") ?? {};
58
58
 
@@ -98,11 +98,11 @@ try {
98
98
  const papers = join(work, "papers");
99
99
  const r = newPaper(papers, "demo", "tex");
100
100
  check(
101
- "a fresh folder gets the scorecard and a .tex source, both from the package",
101
+ "a fresh folder gets the scorecard, a .tex source and paperlint.json, all from the package",
102
102
  r.ok &&
103
103
  r.fresh &&
104
104
  r.files.map((f) => `${f.file}:${f.status}:${f.from}`).join(" ") ===
105
- "PIPELINE-STATUS.md:created:package paper.tex:created:package",
105
+ "PIPELINE-STATUS.md:created:package paper.tex:created:package paperlint.json:created:package",
106
106
  );
107
107
  const status = read(papers, "demo", STATUS_FILE);
108
108
  const fm = front(status);
@@ -122,8 +122,10 @@ try {
122
122
  );
123
123
  const l = await lint(join(papers, "demo"));
124
124
  check(
125
- "🔴 `paperlint lint` passes the scaffold clean — the first run is green, not a missing-file error",
126
- l.code === 0 && /no findings/.test(l.text),
125
+ "🔴 `paperlint lint` passes the scaffold — exit 0, not a missing-file error — with the one warning that no venue is chosen yet",
126
+ l.code === 0 &&
127
+ /names no venue preset yet/.test(l.text) &&
128
+ /1 problem \(0 errors, 1 warning\)/.test(l.text),
127
129
  );
128
130
 
129
131
  // The format contract of the skills' checker: four sections, parsed.
@@ -171,9 +173,10 @@ try {
171
173
  );
172
174
  check(
173
175
  "wantedFiles asks for a source only when neither format is there",
174
- wantedFiles(join(papers, "old"), "tex").join() === STATUS_FILE &&
176
+ wantedFiles(join(papers, "old"), "tex").join() ===
177
+ `${STATUS_FILE},paperlint.json` &&
175
178
  wantedFiles(join(papers, "nowhere"), "md").join() ===
176
- `${STATUS_FILE},paper.md`,
179
+ `${STATUS_FILE},paper.md,paperlint.json`,
177
180
  );
178
181
  writeFileSync(join(papers, "afile"), "x");
179
182
  check(
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `paperlint new` writes `<paper>/paperlint.json` — from the package's template, or from the
3
+ * project's `<papers>/.template/` when it has one — with no venue chosen yet, which lint then names
4
+ * in one warning instead of staying silent.
5
+ */
6
+ import {
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { afterEach, describe, expect, it } from "vitest";
17
+ import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
18
+ import { parsePaperSettings } from "./paper-settings.ts";
19
+ import { run } from "./cli.ts";
20
+
21
+ const dirs: string[] = [];
22
+ afterEach(() => {
23
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
24
+ });
25
+ const tmp = () => {
26
+ const d = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
27
+ dirs.push(d);
28
+ return d;
29
+ };
30
+ const settingsOf = (dir: string) =>
31
+ JSON.parse(readFileSync(join(dir, "paperlint.json"), "utf8")) as Record<
32
+ string,
33
+ unknown
34
+ >;
35
+
36
+ describe("paperlint new — paperlint.json", () => {
37
+ it("is always written, from the package template: no venue chosen yet, and valid", () => {
38
+ const papers = join(tmp(), "papers");
39
+ const r = newPaper(papers, "demo", "tex");
40
+ expect(r.ok && r.files.find((f) => f.file === "paperlint.json")).toEqual({
41
+ file: "paperlint.json",
42
+ status: "created",
43
+ from: "package",
44
+ });
45
+ const s = settingsOf(join(papers, "demo"));
46
+ expect(s["extends"]).toBeNull();
47
+ expect(String(s["$comment"])).toMatch(/paperlint:agenticdev/);
48
+ expect(parsePaperSettings(s)).toEqual({
49
+ ok: true,
50
+ value: { extends: null, kind: null, pdf: null, rules: null },
51
+ });
52
+ });
53
+
54
+ it("comes from the project's <papers>/.template/ when it has one", () => {
55
+ const papers = join(tmp(), "papers");
56
+ mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
57
+ writeFileSync(
58
+ join(papers, OVERRIDE_DIR, "paperlint.json"),
59
+ '{ "extends": "paperlint:aisec", "kind": "research" }\n',
60
+ );
61
+ const r = newPaper(papers, "house", "tex");
62
+ expect(r.ok && r.files.find((f) => f.file === "paperlint.json")?.from).toBe(
63
+ "project",
64
+ );
65
+ expect(settingsOf(join(papers, "house"))).toEqual({
66
+ extends: "paperlint:aisec",
67
+ kind: "research",
68
+ });
69
+ });
70
+
71
+ it("is never overwritten", () => {
72
+ const papers = join(tmp(), "papers");
73
+ mkdirSync(join(papers, "p"), { recursive: true });
74
+ writeFileSync(
75
+ join(papers, "p", "paperlint.json"),
76
+ '{"extends":"paperlint:aisec"}',
77
+ );
78
+ const r = newPaper(papers, "p", "tex");
79
+ expect(
80
+ r.ok && r.files.find((f) => f.file === "paperlint.json")?.status,
81
+ ).toBe("kept");
82
+ expect(settingsOf(join(papers, "p"))).toEqual({
83
+ extends: "paperlint:aisec",
84
+ });
85
+ });
86
+
87
+ it("is not added beside a pre-2.1.0 venue.json — `paperlint init` moves that one", () => {
88
+ const papers = join(tmp(), "papers");
89
+ mkdirSync(join(papers, "old"), { recursive: true });
90
+ writeFileSync(join(papers, "old", "venue.json"), '{"venue":"aisec"}');
91
+ const r = newPaper(papers, "old", "tex");
92
+ expect(r.ok && r.files.map((f) => f.file)).not.toContain("paperlint.json");
93
+ });
94
+ });
95
+
96
+ describe("paperlint lint — a paper with no venue preset chosen", () => {
97
+ async function lintNew(extendsValue: string | null) {
98
+ const root = tmp();
99
+ writeFileSync(
100
+ join(root, "package.json"),
101
+ JSON.stringify({
102
+ name: "c",
103
+ private: true,
104
+ paperlint: { papersDir: "papers" },
105
+ }),
106
+ );
107
+ newPaper(join(root, "papers"), "demo", "tex");
108
+ if (extendsValue !== null)
109
+ writeFileSync(
110
+ join(root, "papers", "demo", "paperlint.json"),
111
+ JSON.stringify({ extends: extendsValue, kind: "short" }),
112
+ );
113
+ const out: string[] = [];
114
+ const code = await run(["lint", "--json"], {
115
+ cwd: root,
116
+ log: (s: string) => out.push(s),
117
+ err: () => {},
118
+ });
119
+ const messages = (
120
+ JSON.parse(out.join("\n")) as {
121
+ messages: { ruleId: string; severity: number; message: string }[];
122
+ }[]
123
+ ).flatMap((r) => r.messages);
124
+ return { code, messages };
125
+ }
126
+
127
+ it("gets exactly one warning naming the file to set, and exits 0", async () => {
128
+ const { code, messages } = await lintNew(null);
129
+ expect(code).toBe(0);
130
+ expect(messages).toHaveLength(1);
131
+ expect(messages[0]?.ruleId).toBe("pdf/measured");
132
+ expect(messages[0]?.severity).toBe(1);
133
+ expect(messages[0]?.message).toMatch(/names no venue preset yet/);
134
+ expect(messages[0]?.message).toMatch(
135
+ /set "extends" in .*papers\/demo\/paperlint\.json/,
136
+ );
137
+ });
138
+
139
+ it("a paper with a real extends does not get it", async () => {
140
+ const { messages } = await lintNew("paperlint:agenticdev");
141
+ expect(messages.map((m) => m.message).join("\n")).not.toMatch(
142
+ /names no venue preset yet/,
143
+ );
144
+ });
145
+ });