paperlint 3.1.0 → 3.1.2

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 (311) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +21 -105
  4. package/CONTRIBUTING.md +23 -25
  5. package/README.md +11 -7
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/clock.io.d.ts +5 -0
  16. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  17. package/dist/adapters/references/clock.io.js +7 -0
  18. package/dist/adapters/references/clock.io.js.map +1 -0
  19. package/dist/adapters/references/index.d.ts +24 -2
  20. package/dist/adapters/references/index.d.ts.map +1 -1
  21. package/dist/adapters/references/index.js +131 -12
  22. package/dist/adapters/references/index.js.map +1 -1
  23. package/dist/adapters/references/pool.d.ts +8 -0
  24. package/dist/adapters/references/pool.d.ts.map +1 -0
  25. package/dist/adapters/references/pool.js +19 -0
  26. package/dist/adapters/references/pool.js.map +1 -0
  27. package/dist/build.d.ts +14 -2
  28. package/dist/build.d.ts.map +1 -1
  29. package/dist/build.js +17 -11
  30. package/dist/build.js.map +1 -1
  31. package/dist/cli.d.ts +27 -1
  32. package/dist/cli.d.ts.map +1 -1
  33. package/dist/cli.js +71 -55
  34. package/dist/cli.js.map +1 -1
  35. package/dist/doctor.d.ts +27 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +4 -3
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/domain/lookup-cache.d.ts +75 -0
  40. package/dist/domain/lookup-cache.d.ts.map +1 -0
  41. package/dist/domain/lookup-cache.js +137 -0
  42. package/dist/domain/lookup-cache.js.map +1 -0
  43. package/dist/domain/result.d.ts +0 -2
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +0 -4
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/hooks-settings.d.ts +8 -5
  48. package/dist/hooks-settings.d.ts.map +1 -1
  49. package/dist/hooks-settings.js +32 -32
  50. package/dist/hooks-settings.js.map +1 -1
  51. package/dist/init.d.ts +8 -0
  52. package/dist/init.d.ts.map +1 -1
  53. package/dist/init.js +3 -3
  54. package/dist/init.js.map +1 -1
  55. package/dist/latex-log.d.ts +0 -2
  56. package/dist/latex-log.d.ts.map +1 -1
  57. package/dist/latex-log.js +7 -13
  58. package/dist/latex-log.js.map +1 -1
  59. package/dist/link-skills.d.ts +15 -2
  60. package/dist/link-skills.d.ts.map +1 -1
  61. package/dist/link-skills.js +36 -20
  62. package/dist/link-skills.js.map +1 -1
  63. package/dist/paper-files.d.ts.map +1 -1
  64. package/dist/paper-files.js +6 -4
  65. package/dist/paper-files.js.map +1 -1
  66. package/dist/pdf-facts.d.ts +11 -0
  67. package/dist/pdf-facts.d.ts.map +1 -1
  68. package/dist/pdf-facts.js +16 -18
  69. package/dist/pdf-facts.js.map +1 -1
  70. package/dist/pdf-geometry.js +1 -1
  71. package/dist/pdf-geometry.js.map +1 -1
  72. package/dist/ports/check-references.d.ts +12 -2
  73. package/dist/ports/check-references.d.ts.map +1 -1
  74. package/dist/ports/check-references.js +0 -5
  75. package/dist/ports/check-references.js.map +1 -1
  76. package/dist/presets.d.ts +2 -0
  77. package/dist/presets.d.ts.map +1 -1
  78. package/dist/presets.js +3 -1
  79. package/dist/presets.js.map +1 -1
  80. package/dist/references.d.ts +11 -3
  81. package/dist/references.d.ts.map +1 -1
  82. package/dist/references.js +63 -15
  83. package/dist/references.js.map +1 -1
  84. package/dist/tex-requirements.d.ts +8 -0
  85. package/dist/tex-requirements.d.ts.map +1 -1
  86. package/dist/tex-requirements.js +6 -5
  87. package/dist/tex-requirements.js.map +1 -1
  88. package/dist/toolchain.d.ts +3 -2
  89. package/dist/toolchain.d.ts.map +1 -1
  90. package/dist/toolchain.js +4 -5
  91. package/dist/toolchain.js.map +1 -1
  92. package/dist/venue-rules.d.ts.map +1 -1
  93. package/dist/venue-rules.js +11 -9
  94. package/dist/venue-rules.js.map +1 -1
  95. package/docs/prior-art/README.md +11 -10
  96. package/docs/prior-art/citation-checkers.md +174 -0
  97. package/docs/prior-art/test-tooling.md +7 -30
  98. package/docs/references.md +126 -0
  99. package/docs/testing.md +118 -0
  100. package/docs/toolchain.md +6 -4
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +9 -2
  109. package/eslint-rules/paper-typography.test.mjs +146 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  113. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  114. package/eslint-rules/tex-build.mjs +13 -13
  115. package/eslint.config.mjs +6 -2
  116. package/hooks/hooks.harness.mjs +6 -11
  117. package/hooks/paper-edit-guard.hook.mjs +2 -2
  118. package/hooks/paper-edit-guard.test.mjs +61 -0
  119. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  120. package/hooks/paper-skills-nudge.test.mjs +38 -0
  121. package/hooks/paper-status-gates.test.mjs +42 -0
  122. package/lib/agent-cli-version.harness.mjs +7 -0
  123. package/lib/check.d.mts +17 -0
  124. package/lib/check.mjs +50 -0
  125. package/lib/check.test.ts +67 -0
  126. package/lib/markdown.mjs +16 -69
  127. package/lib/markdown.test.mjs +246 -0
  128. package/lib/paper-config.harness.mjs +28 -9
  129. package/lib/paper-config.mjs +7 -2
  130. package/lib/skill-checks.mjs +18 -46
  131. package/lib/skill-checks.test.mjs +297 -0
  132. package/lib/skill-corpus.mjs +11 -13
  133. package/lib/skill-corpus.test.mjs +54 -0
  134. package/lib/skill-trigger-cases.harness.mjs +57 -4
  135. package/package.json +6 -5
  136. package/scripts/check.harness.mjs +4 -10
  137. package/scripts/check.mjs +45 -42
  138. package/scripts/check.test.mjs +104 -0
  139. package/scripts/coverage-config.test.ts +406 -0
  140. package/scripts/eslint-report-guard.test.mjs +48 -0
  141. package/scripts/harness-api.frozen.json +0 -2
  142. package/scripts/layer-legacy-frozen.d.mts +9 -1
  143. package/scripts/layer-legacy-frozen.mjs +28 -11
  144. package/scripts/layer-legacy-frozen.test.ts +143 -1
  145. package/scripts/rules-are-content-only.harness.mjs +3 -11
  146. package/scripts/rules-are-content-only.mjs +29 -19
  147. package/scripts/rules-are-content-only.test.mjs +61 -0
  148. package/scripts/rules-see-files.mjs +11 -9
  149. package/scripts/rules-see-files.test.mjs +71 -0
  150. package/scripts/test.mjs +44 -0
  151. package/scripts/test.test.mjs +52 -0
  152. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  153. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  154. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  155. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  156. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  157. package/skills/paper-pipeline/scripts/README.md +13 -132
  158. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  159. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  160. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  161. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  162. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  163. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  164. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  165. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  166. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  167. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  168. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  169. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  170. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  171. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  172. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  173. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  174. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  175. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  176. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  177. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  178. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  179. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  180. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  181. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  182. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  183. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  184. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  185. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  186. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  187. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  188. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  189. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  190. package/skills/render-paper/checkers-requirements.txt +30 -19
  191. package/skills/render-paper/ensure-checkers.sh +20 -4
  192. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  193. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  194. package/skills/tighten-paper/structure.test.mjs +116 -0
  195. package/skills/verify-citations/SKILL.md +30 -14
  196. package/skills/verify-citations/SKILL.md.spec.ts +29 -13
  197. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
  198. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  199. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  200. package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
  201. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  202. package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
  203. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
  204. package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
  205. package/src/adapters/banal/failure.test.ts +70 -1
  206. package/src/adapters/banal/index.test.ts +16 -0
  207. package/src/adapters/banal/output.test.ts +5 -0
  208. package/src/adapters/curl/download.test.ts +43 -1
  209. package/src/adapters/memory/index.test.ts +25 -0
  210. package/src/adapters/node/files.io.ts +3 -2
  211. package/src/adapters/node/files.test.ts +13 -1
  212. package/src/adapters/node/process.test.ts +38 -0
  213. package/src/adapters/pdfjs/fill.test.ts +34 -0
  214. package/src/adapters/pdfjs/fill.ts +44 -25
  215. package/src/adapters/references/clock.io.ts +8 -0
  216. package/src/adapters/references/index.test.ts +560 -0
  217. package/src/adapters/references/index.ts +203 -19
  218. package/src/adapters/references/pool.test.ts +31 -0
  219. package/src/adapters/references/pool.ts +24 -0
  220. package/src/build-engine.harness.mjs +3 -7
  221. package/src/build-engine.test.ts +137 -0
  222. package/src/build.harness.mjs +5 -9
  223. package/src/build.test.ts +362 -0
  224. package/src/build.ts +23 -12
  225. package/src/cli.harness.mjs +10 -17
  226. package/src/cli.test.ts +584 -0
  227. package/src/cli.ts +112 -66
  228. package/src/doctor.harness.mjs +3 -8
  229. package/src/doctor.test.ts +64 -0
  230. package/src/doctor.ts +12 -4
  231. package/src/domain/lookup-cache.test.ts +178 -0
  232. package/src/domain/lookup-cache.ts +202 -0
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +27 -7
  236. package/src/facts-file.test.ts +110 -0
  237. package/src/hooks-settings.harness.mjs +43 -13
  238. package/src/hooks-settings.ts +41 -35
  239. package/src/init.test.ts +308 -5
  240. package/src/init.ts +3 -3
  241. package/src/latex-log.harness.mjs +32 -7
  242. package/src/latex-log.ts +6 -13
  243. package/src/latex-loop.harness.mjs +8 -7
  244. package/src/link-skills.harness.mjs +3 -8
  245. package/src/link-skills.test.ts +105 -0
  246. package/src/link-skills.ts +75 -21
  247. package/src/new-paper.harness.mjs +3 -7
  248. package/src/new-paper.test.ts +31 -1
  249. package/src/paper-files.test.ts +12 -0
  250. package/src/paper-files.ts +9 -5
  251. package/src/paper-settings.test.ts +20 -0
  252. package/src/pdf-facts.harness.mjs +3 -6
  253. package/src/pdf-facts.test.ts +144 -0
  254. package/src/pdf-facts.ts +17 -19
  255. package/src/pdf-geometry.harness.mjs +3 -7
  256. package/src/pdf-geometry.ts +1 -1
  257. package/src/ports/check-references.ts +16 -2
  258. package/src/presets.test.ts +2 -0
  259. package/src/presets.ts +7 -1
  260. package/src/references.test.ts +257 -10
  261. package/src/references.ts +93 -17
  262. package/src/rules-config.harness.mjs +10 -8
  263. package/src/structure.harness.mjs +3 -7
  264. package/src/tex-requirements.harness.mjs +34 -7
  265. package/src/tex-requirements.ts +12 -6
  266. package/src/toolchain.harness.mjs +3 -7
  267. package/src/toolchain.test.ts +280 -0
  268. package/src/toolchain.ts +8 -7
  269. package/src/venue-rules.test.ts +60 -0
  270. package/src/venue-rules.ts +13 -11
  271. package/action.mutations.mjs +0 -162
  272. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  273. package/eslint-rules/latex-language.mutations.mjs +0 -196
  274. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  275. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  276. package/eslint-rules/papers.mutations.mjs +0 -186
  277. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  278. package/eslint-rules/tex-build.mutations.mjs +0 -258
  279. package/hooks/hooks.mutations.mjs +0 -337
  280. package/lib/agent-cli-version.mutations.mjs +0 -109
  281. package/lib/mutation-driver.harness.mjs +0 -227
  282. package/lib/mutation-driver.mjs +0 -397
  283. package/lib/mutation-driver.mutations.mjs +0 -68
  284. package/lib/paper-config.mutations.mjs +0 -143
  285. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  286. package/scripts/check.mutations.mjs +0 -110
  287. package/scripts/exclusive.mjs +0 -138
  288. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  289. package/scripts/mutation-batteries-frozen.mjs +0 -238
  290. package/scripts/mutation-batteries.frozen.json +0 -108
  291. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  292. package/scripts/rules-see-files.mutations.mjs +0 -131
  293. package/scripts/run-mutations.mjs +0 -100
  294. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  295. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  296. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  297. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  298. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  299. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  300. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  301. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  302. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  303. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  304. package/skills/skill-contract.mutations.mjs +0 -179
  305. package/src/build.mutations.mjs +0 -196
  306. package/src/cli.mutations.mjs +0 -414
  307. package/src/doctor.mutations.mjs +0 -175
  308. package/src/hooks-settings.mutations.mjs +0 -99
  309. package/src/link-skills.mutations.mjs +0 -140
  310. package/src/new-paper.mutations.mjs +0 -65
  311. package/src/structure.mutations.mjs +0 -83
@@ -298,9 +298,9 @@ assert.equal(
298
298
 
299
299
  // 🔴 RELATIVE, AND `/`-SEPARATED, ASSERTED BEFORE THE EQUALITIES BELOW — and the order is a
300
300
  // finding, not a preference. `assert` aborts at the first failure, so with this line placed
301
- // after them a resolver that returned an absolute path died on "must be returned as written":
302
- // the mutation run reported RED-but-a-DIFFERENT-case, i.e. this assertion was never reached and
303
- // nothing showed it can fail. The narrow property goes first; the equalities then cover the rest.
301
+ // after them a resolver that returned an absolute path died on "must be returned as written" —
302
+ // a RED result, but for a different case than this one, i.e. this assertion was never reached
303
+ // and nothing showed it can fail. The narrow property goes first; the equalities then cover the rest.
304
304
  // The value is compared against text a human typed inside a SKILL.md, so an absolute path makes
305
305
  // every `startsWith` false — not an error, an empty loop body.
306
306
  assert.equal(
@@ -192,27 +192,32 @@ export function consumerSkillsDir(opts) {
192
192
  * in the port and the TypeScript writer imports it, as `src/cli.ts` already does for `isMain`.
193
193
  *
194
194
  * @param dir the directory to list. A missing directory throws `ENOENT` from `readdirSync`.
195
+ * @param fs the four calls it makes; a test passes a fake to stage what a real disk only does
196
+ * in a race (an entry gone between the listing and the stat).
195
197
  * @returns the skill names, sorted.
196
198
  */
197
- export function installedSkills(dir) {
199
+ export function installedSkills(
200
+ dir,
201
+ fs = { readdirSync, statSync, existsSync, readlinkSync },
202
+ ) {
198
203
  const names = [];
199
204
  const dangling = [];
200
- for (const name of readdirSync(dir)) {
205
+ for (const name of fs.readdirSync(dir)) {
201
206
  const entry = join(dir, name);
202
207
  let st;
203
208
  try {
204
- st = statSync(entry); // FOLLOWS the link — the question is where the entry LEADS
209
+ st = fs.statSync(entry); // FOLLOWS the link — the question is where the entry LEADS
205
210
  } catch (e) {
206
211
  // The entry was listed a moment ago, so a failed stat is a link that leads nowhere
207
212
  // (ENOENT) or in a circle (ELOOP) — not an absence.
208
213
  dangling.push({
209
214
  name,
210
- target: readlinkOr(entry),
215
+ target: readlinkOr(entry, fs.readlinkSync),
211
216
  cause: e.code ?? "error",
212
217
  });
213
218
  continue;
214
219
  }
215
- if (st.isDirectory() && existsSync(join(entry, "SKILL.md")))
220
+ if (st.isDirectory() && fs.existsSync(join(entry, "SKILL.md")))
216
221
  names.push(name);
217
222
  }
218
223
  if (dangling.length) {
@@ -233,11 +238,11 @@ export function installedSkills(dir) {
233
238
  }
234
239
 
235
240
  /** Where a link points, for the message — or a placeholder when it is not a link at all. */
236
- function readlinkOr(entry) {
241
+ function readlinkOr(entry, readlink) {
237
242
  try {
238
- return readlinkSync(entry);
243
+ return readlink(entry);
239
244
  } catch {
240
- return "(not a link)";
245
+ return "(not a link)"; // a plain entry gone between the listing and the stat
241
246
  }
242
247
  }
243
248
 
@@ -295,23 +300,6 @@ export function ledgerPath(
295
300
  return join(hereDir, "runs.jsonl");
296
301
  }
297
302
 
298
- /**
299
- * `ledgerPath()` plus the assurance that the directory holding it exists.
300
- *
301
- * Split out rather than folded in, because the two failures are different: an undeclared ledger
302
- * is a setup mistake the message above can fix, while a declared-but-missing directory may be
303
- * the very thing the caller is about to create.
304
- */
305
- export function ledgerPathExisting(hereDir, opts) {
306
- const p = ledgerPath(hereDir, opts);
307
- if (!existsSync(p))
308
- throw new Error(
309
- `${CONFIG_KEY}: the ledger "${p}" does not exist. Create it (an empty file is a valid ` +
310
- `empty ledger) or fix the "ledger" declaration in ${CONFIG_FILE}.`,
311
- );
312
- return p;
313
- }
314
-
315
303
  // ═════════════════════════════════════════════════════════════════════════════════════════════
316
304
  // THE FOURTH CARRIER — WHERE A SKILL'S PROSE NAMES THESE SCRIPTS
317
305
  //
@@ -0,0 +1,113 @@
1
+ /**
2
+ * consumer.mjs carriers the harness does not reach: isMain without a real entry point, a dangling
3
+ * skill link, the ledger that must exist, the declared time zone, and the contact address.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterEach, test } from "vitest";
9
+ import { useTempDir, writeTree } from "../../../test/support.mjs";
10
+ import {
11
+ consumerContactEmail,
12
+ consumerTimezone,
13
+ installedSkills,
14
+ isMain,
15
+ } from "./consumer.mjs";
16
+
17
+ const root = useTempDir("consumer-");
18
+ const argv1 = process.argv[1];
19
+ afterEach(() => {
20
+ process.argv[1] = argv1;
21
+ });
22
+ /** A consumer root whose paperlint.json holds `settings`. */
23
+ const consumer = (name, settings) =>
24
+ writeTree(join(root, name), {
25
+ "paperlint.json": JSON.stringify(settings),
26
+ });
27
+ const at = (dir) => ({ env: {}, cwd: dir });
28
+
29
+ test("isMain: no entry point is never main; an entry that is not on disk is compared as written", () => {
30
+ process.argv[1] = undefined;
31
+ assert.equal(isMain("file:///x.mjs"), false);
32
+ process.argv[1] = "/no/such/entry.mjs";
33
+ assert.equal(isMain("file:///no/such/entry.mjs"), true);
34
+ assert.equal(isMain("file:///other.mjs"), false);
35
+ });
36
+
37
+ test("installedSkills: a link that leads nowhere is refused by name, with the errno", () => {
38
+ const dir = join(root, "skills");
39
+ mkdirSync(join(dir, "ok"), { recursive: true });
40
+ writeFileSync(join(dir, "ok", "SKILL.md"), "# ok\n");
41
+ symlinkSync(join(root, "gone"), join(dir, "dead"));
42
+ assert.throws(() => installedSkills(dir), {
43
+ code: "DANGLING_SKILL_LINK",
44
+ dangling: [{ name: "dead", target: join(root, "gone"), cause: "ENOENT" }],
45
+ });
46
+ });
47
+
48
+ test("installedSkills: an entry gone between the listing and the stat is refused as '(not a link)'; an error without a code says 'error'", () => {
49
+ const fake = (statError) => ({
50
+ readdirSync: () => ["gone"],
51
+ statSync: () => {
52
+ throw statError;
53
+ },
54
+ existsSync: () => false,
55
+ readlinkSync: () => {
56
+ throw Object.assign(new Error("EINVAL"), { code: "EINVAL" });
57
+ },
58
+ });
59
+ assert.throws(
60
+ () =>
61
+ installedSkills(
62
+ "/s",
63
+ fake(Object.assign(new Error("ENOENT"), { code: "ENOENT" })),
64
+ ),
65
+ { dangling: [{ name: "gone", target: "(not a link)", cause: "ENOENT" }] },
66
+ );
67
+ assert.throws(() => installedSkills("/s", fake(new Error("odd"))), {
68
+ dangling: [{ name: "gone", target: "(not a link)", cause: "error" }],
69
+ });
70
+ });
71
+
72
+ test("consumerTimezone: UTC by default, the declared zone, and loud refusals of null and a typo", () => {
73
+ assert.equal(consumerTimezone(at(join(root, "nothing-declared"))), "UTC");
74
+ assert.equal(
75
+ consumerTimezone(at(consumer("tz", { timezone: "Europe/Berlin" }))),
76
+ "Europe/Berlin",
77
+ );
78
+ assert.throws(
79
+ () => consumerTimezone(at(consumer("tz-null", { timezone: null }))),
80
+ new TypeError(
81
+ 'paperlint: "timezone" must be a non-empty IANA zone string, got null',
82
+ ),
83
+ );
84
+ assert.throws(
85
+ () =>
86
+ consumerTimezone(at(consumer("tz-typo", { timezone: "Europe/Berlinn" }))),
87
+ {
88
+ name: "RangeError",
89
+ message:
90
+ /^paperlint: "timezone" is "Europe\/Berlinn", which is not an IANA time zone/,
91
+ },
92
+ );
93
+ });
94
+
95
+ test("consumerContactEmail: absent or null is the public pool; a non-address is refused", () => {
96
+ assert.equal(
97
+ consumerContactEmail(at(consumer("mail-null", { contactEmail: null }))),
98
+ null,
99
+ );
100
+ assert.equal(
101
+ consumerContactEmail(
102
+ at(consumer("mail", { contactEmail: "me@example.org" })),
103
+ ),
104
+ "me@example.org",
105
+ );
106
+ assert.throws(
107
+ () =>
108
+ consumerContactEmail(at(consumer("mail-bad", { contactEmail: "me" }))),
109
+ new TypeError(
110
+ 'paperlint: "contactEmail" must be an email address, got "me". Remove the key entirely to use the public pool.',
111
+ ),
112
+ );
113
+ });
@@ -212,7 +212,7 @@ function dropEtAl(authors) {
212
212
  return { authors: out.filter(Boolean), truncated: true };
213
213
  }
214
214
 
215
- const yearIn = (s) => (/\b(19|20)\d{2}\b/.exec(s ?? "") ?? [null])[0];
215
+ const yearIn = (s) => (/\b(19|20)\d{2}\b/.exec(s) ?? [null])[0];
216
216
 
217
217
  // ── parsing: .bib WITH A REAL PARSER ─────────────────────────────────────────
218
218
 
@@ -254,10 +254,13 @@ const joinName = (a) =>
254
254
  * the same, and "the bibliography was not checked" reads as "the bibliography is fine". That is why
255
255
  * the message names the command, not the fact.
256
256
  */
257
- export async function parseBib(text) {
257
+ export async function parseBib(
258
+ text,
259
+ { load = () => import("@retorquere/bibtex-parser") } = {},
260
+ ) {
258
261
  let parse;
259
262
  try {
260
- ({ parse } = await import("@retorquere/bibtex-parser"));
263
+ ({ parse } = await load());
261
264
  } catch (e) {
262
265
  if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e;
263
266
  throw new Error(
@@ -289,6 +292,9 @@ export async function parseBib(text) {
289
292
  .filter((a) => !isOthers(a))
290
293
  .map(joinName)
291
294
  .filter(Boolean);
295
+ // A field is a string in today's parser output (list fields such as `keywords` are arrays,
296
+ // and none of those is read); an array or a number from another parser version is joined or
297
+ // printed rather than dropped.
292
298
  const str = (v) =>
293
299
  Array.isArray(v)
294
300
  ? v.join(" ")
@@ -477,7 +483,6 @@ export function loadCache(path) {
477
483
  }
478
484
 
479
485
  function saveCache(path, cache) {
480
- if (!path) return;
481
486
  mkdirSync(dirname(path), { recursive: true });
482
487
  writeFileSync(path, `${JSON.stringify(cache, null, 1)}\n`);
483
488
  }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * extract-ref-facts.mjs beyond its harness: the edges of each reader (markdown list, .bib, CrossRef,
3
+ * arXiv, cache) and the command itself — sources, flags, the network loop (with `fetch` stubbed in
4
+ * the child process), and the refusals.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { test } from "vitest";
11
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
12
+ import {
13
+ buildFacts,
14
+ extractIds,
15
+ loadCache,
16
+ loadEntries,
17
+ parseBib,
18
+ parseMarkdownRefs,
19
+ primaryOf,
20
+ readArxiv,
21
+ readCrossref,
22
+ recordFrom,
23
+ resolveSource,
24
+ } from "./extract-ref-facts.mjs";
25
+
26
+ const SCRIPT = join(
27
+ dirname(fileURLToPath(import.meta.url)),
28
+ "extract-ref-facts.mjs",
29
+ );
30
+ const root = useTempDir("extract-ref-facts-");
31
+
32
+ test("markdown: the list ends at the file's end or a second References heading; lines continue an entry", () => {
33
+ assert.deepEqual(parseMarkdownRefs("# P\n\n## References"), []);
34
+ const refs = parseMarkdownRefs(
35
+ [
36
+ "## References",
37
+ "",
38
+ "1. A. Author, B. Author. *A Title.* Venue,",
39
+ " continued 2021.",
40
+ "2. *Untitled Authors.* 2019 note",
41
+ "3. C. Writer 2018. *Late Year.* Venue",
42
+ "4. No title here at all",
43
+ "",
44
+ "## References",
45
+ "5. Never read. *X.* 2000",
46
+ ].join("\n"),
47
+ );
48
+ assert.deepEqual(
49
+ refs.map(({ n, line, authors, title, year, venue_text }) => ({
50
+ n,
51
+ line,
52
+ authors,
53
+ title,
54
+ year,
55
+ venue_text,
56
+ })),
57
+ [
58
+ {
59
+ n: 1,
60
+ line: 3,
61
+ authors: ["A. Author", "B. Author"],
62
+ title: "A Title",
63
+ year: "2021",
64
+ venue_text: "Venue, continued 2021.",
65
+ },
66
+ {
67
+ n: 2,
68
+ line: 5,
69
+ authors: [],
70
+ title: "Untitled Authors",
71
+ year: "2019",
72
+ venue_text: "2019 note",
73
+ },
74
+ {
75
+ n: 3,
76
+ line: 6,
77
+ authors: ["C. Writer 2018"],
78
+ title: "Late Year",
79
+ year: "2018",
80
+ venue_text: "Venue",
81
+ },
82
+ {
83
+ n: 4,
84
+ line: 7,
85
+ authors: [],
86
+ title: null,
87
+ year: null,
88
+ venue_text: "No title here at all",
89
+ },
90
+ ],
91
+ );
92
+ });
93
+
94
+ test(".bib: a key the text spells with spaces has no line; a missing title or year is null, not empty", async () => {
95
+ const [entry] = await parseBib(
96
+ "@misc{ spaced ,\n note={see arXiv:2101.00001}\n}\n",
97
+ );
98
+ assert.deepEqual(
99
+ {
100
+ line: entry.line,
101
+ title: entry.title,
102
+ year: entry.year,
103
+ authors: entry.authors,
104
+ raw: entry.raw,
105
+ },
106
+ {
107
+ line: 0,
108
+ title: null,
109
+ year: null,
110
+ authors: [],
111
+ raw: "see arXiv:2101.00001",
112
+ },
113
+ );
114
+ });
115
+
116
+ test(".bib from a parser that returns no fields, or list and number fields: joined and printed, not dropped", async () => {
117
+ const load = async () => ({
118
+ parse: () => ({
119
+ entries: [
120
+ { key: "bare" },
121
+ {
122
+ key: "odd",
123
+ fields: {
124
+ title: ["Split", "Title"],
125
+ year: 2021,
126
+ note: null,
127
+ author: [{ firstName: "Ada", lastName: "Lovelace" }],
128
+ },
129
+ },
130
+ ],
131
+ }),
132
+ });
133
+ const [bare, odd] = await parseBib("@misc{bare,}\n@misc{odd,}\n", { load });
134
+ assert.deepEqual(
135
+ [
136
+ bare,
137
+ { title: odd.title, year: odd.year, authors: odd.authors, raw: odd.raw },
138
+ ],
139
+ [
140
+ {
141
+ n: 1,
142
+ line: 1,
143
+ key: "bare",
144
+ raw: "",
145
+ authors: [],
146
+ truncated: false,
147
+ title: null,
148
+ year: null,
149
+ venue_text: "",
150
+ doi_field: null,
151
+ },
152
+ {
153
+ title: "Split Title",
154
+ year: "2021",
155
+ authors: ["Ada Lovelace"],
156
+ raw: "Ada Lovelace Split Title 2021",
157
+ },
158
+ ],
159
+ );
160
+ });
161
+
162
+ test("identifiers: a declared DOI field comes first; an entry without text fields has none", () => {
163
+ const ids = extractIds({
164
+ doi_field: "10.1111/field",
165
+ raw: "doi:10.2222/text.",
166
+ });
167
+ assert.deepEqual(ids, { arxiv: [], doi: ["10.1111/field", "10.2222/text"] });
168
+ assert.equal(primaryOf(ids), "doi:10.1111/field");
169
+ const none = extractIds({});
170
+ assert.deepEqual(none, { arxiv: [], doi: [] });
171
+ assert.equal(primaryOf(none), null);
172
+ });
173
+
174
+ test("CrossRef: every fallback — no message, the date sources, scalar title/subtitle/venue, nameless authors", () => {
175
+ assert.equal(readCrossref("{}"), null);
176
+ const rec = (message) => readCrossref(JSON.stringify({ message }));
177
+ assert.deepEqual(
178
+ rec({
179
+ title: "Scalar",
180
+ subtitle: "Sub",
181
+ author: [{ family: "F" }, { name: "Org" }, {}],
182
+ "container-title": "Journal",
183
+ "published-print": { "date-parts": [[2019]] },
184
+ }),
185
+ {
186
+ title: "Scalar: Sub",
187
+ authors: ["F", "Org", ""],
188
+ year: "2019",
189
+ venue: "Journal",
190
+ journalRef: null,
191
+ doi: null,
192
+ error: false,
193
+ },
194
+ );
195
+ assert.equal(
196
+ rec({ title: ["T"], "published-online": { "date-parts": [[2018]] } }).year,
197
+ "2018",
198
+ );
199
+ assert.deepEqual(rec({ title: ["T"] }), {
200
+ title: "T",
201
+ authors: [],
202
+ year: null,
203
+ venue: undefined,
204
+ journalRef: null,
205
+ doi: null,
206
+ error: false,
207
+ });
208
+ });
209
+
210
+ test("arXiv: an entry with no published date and no title", () => {
211
+ assert.deepEqual(readArxiv("<feed><entry><id>x</id></entry></feed>"), {
212
+ title: null,
213
+ authors: [],
214
+ year: null,
215
+ venue: null,
216
+ journalRef: null,
217
+ doi: null,
218
+ error: false,
219
+ });
220
+ });
221
+
222
+ test("a record: a hand-written cache entry with no status, an undated response, and a 200 naming no work", () => {
223
+ assert.deepEqual(recordFrom("arxiv:2101.00001", {}), {
224
+ cached: true,
225
+ registry: "arxiv",
226
+ httpStatus: null,
227
+ fetched: null,
228
+ });
229
+ assert.deepEqual(
230
+ recordFrom("doi:10.1234/x", { httpStatus: 200, body: "{}" }),
231
+ {
232
+ cached: true,
233
+ registry: "crossref",
234
+ httpStatus: 200,
235
+ fetched: null,
236
+ found: false,
237
+ },
238
+ );
239
+ });
240
+
241
+ test("cache: no path and a corrupt file both read as empty", () => {
242
+ const corrupt = join(root, "corrupt.json");
243
+ writeFileSync(corrupt, "{ nope");
244
+ assert.deepEqual([loadCache(undefined), loadCache(corrupt)], [{}, {}]);
245
+ });
246
+
247
+ test("sources: a file names itself; a .bib is read as BibTeX; facts without a cache say so", async () => {
248
+ const bib = join(root, "one.bib");
249
+ writeFileSync(bib, "@misc{k, title={T}, year={2020}}\n");
250
+ assert.equal(resolveSource(bib), bib);
251
+ const entries = await loadEntries(bib);
252
+ const facts = buildFacts({
253
+ source: bib,
254
+ text: "",
255
+ entries,
256
+ cache: {},
257
+ cachePath: null,
258
+ });
259
+ assert.deepEqual([facts.source_kind, facts.cache], ["bibtex", null]);
260
+ });
261
+
262
+ // ── the command ─────────────────────────────────────────────────────────────
263
+
264
+ const CROSSREF_OK = JSON.stringify({
265
+ message: {
266
+ title: ["Found"],
267
+ author: [{ family: "A" }],
268
+ issued: { "date-parts": [[2020]] },
269
+ },
270
+ });
271
+ writeTree(root, {
272
+ "paper/paper.md":
273
+ "## References\n\n1. A. A. *Found.* doi:10.1234/ok\n2. B. B. *Lost.* doi:10.1234/down\n",
274
+ "empty/paper.md": "# Nothing cited\n",
275
+ // `fetch` in the child: 10.1234/down fails at the transport, anything else is a CrossRef hit.
276
+ "fetch.mjs":
277
+ `globalThis.fetch = async (url) => {\n` +
278
+ ` if (url.includes("down")) throw new Error("network down");\n` +
279
+ ` return { status: 200, text: async () => ${JSON.stringify(CROSSREF_OK)} };\n` +
280
+ `};\n`,
281
+ // The optional .bib parser, uninstalled — and broken in some other way.
282
+ "no-parser.mjs":
283
+ "import { register } from 'node:module';\n" +
284
+ "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('gone'), { code: 'ERR_MODULE_NOT_FOUND' }); return next(s, c); }\"));\n",
285
+ "bad-parser.mjs":
286
+ "import { register } from 'node:module';\n" +
287
+ "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('broken install'), { code: 'EACCES' }); return next(s, c); }\"));\n",
288
+ "bib/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
289
+ });
290
+ const cli = (args, { cwd = root, nodeArgs = [] } = {}) =>
291
+ runNode(SCRIPT, args, {
292
+ cwd,
293
+ nodeArgs,
294
+ env: { CLAUDE_PROJECT_DIR: root },
295
+ });
296
+ const FETCH = ["--import", join(root, "fetch.mjs")];
297
+
298
+ test("online: hits are fetched and cached, a transport failure is named and stays missing", () => {
299
+ const r = cli(["paper"], { nodeArgs: FETCH });
300
+ assert.deepEqual(
301
+ {
302
+ status: r.status,
303
+ stdout: r.stdout,
304
+ stderr: r.stderr,
305
+ cached: Object.keys(
306
+ JSON.parse(readFileSync(join(root, "paper/repro/refs-cache.json"))),
307
+ ),
308
+ },
309
+ {
310
+ status: 0,
311
+ stdout:
312
+ "📚 paper.md → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, +1 fetched)\n",
313
+ stderr: " … doi:10.1234/down: network down\n",
314
+ cached: ["doi:10.1234/ok"],
315
+ },
316
+ );
317
+ });
318
+
319
+ test("online again: cached keys are not refetched; --quiet hides the failure; --refresh refetches", () => {
320
+ const again = cli(["paper", "--quiet"], { nodeArgs: FETCH });
321
+ assert.deepEqual(again, {
322
+ status: 0,
323
+ stdout:
324
+ "📚 paper.md → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, all from cache)\n",
325
+ stderr: "",
326
+ });
327
+ const refresh = cli(["paper", "--refresh", "--quiet"], { nodeArgs: FETCH });
328
+ assert.match(refresh.stdout, /1 resolved, \+1 fetched\)\n$/);
329
+ });
330
+
331
+ test("the paper file itself as the target, with --cache, --out and --offline", () => {
332
+ const r = cli([
333
+ join(root, "paper/paper.md"),
334
+ "--offline",
335
+ `--cache=${join(root, "paper/repro/refs-cache.json")}`,
336
+ `--out=${join(root, "out/facts.json")}`,
337
+ ]);
338
+ assert.deepEqual(r, {
339
+ status: 0,
340
+ stdout:
341
+ "📚 paper.md → out/facts.json (2 entries, 2 identifiers, 1 resolved, offline)\n",
342
+ stderr: "",
343
+ });
344
+ });
345
+
346
+ test("no target: the current directory", () => {
347
+ const r = cli(["--offline"], { cwd: join(root, "paper") });
348
+ assert.equal(r.status, 0, r.stderr);
349
+ });
350
+
351
+ test("refusals: nowhere to read from, and zero entries (not written as a clean bibliography)", () => {
352
+ assert.deepEqual(cli([join(root, "nope"), "--offline"]), {
353
+ status: 1,
354
+ stdout: "",
355
+ stderr: `🛑 no paper.md, draft.md, refs.bib, build/custom.bib under ${join(root, "nope")} — nowhere to take a bibliography from.\n`,
356
+ });
357
+ assert.deepEqual(cli(["empty", "--offline"]), {
358
+ status: 1,
359
+ stdout: "",
360
+ stderr:
361
+ "🛑 parsed 0 entries out of empty/paper.md. Silence here would look like a clean bibliography.\n",
362
+ });
363
+ });
364
+
365
+ test(".bib without its optional parser: the error names the install; any other failure is not masked", () => {
366
+ const missing = cli(["bib", "--offline"], {
367
+ nodeArgs: ["--import", join(root, "no-parser.mjs")],
368
+ });
369
+ assert.equal(missing.status, 1);
370
+ assert.match(
371
+ missing.stderr,
372
+ /parsing \.bib requires @retorquere\/bibtex-parser[\s\S]*Install: {2}npm i -D @retorquere\/bibtex-parser/,
373
+ );
374
+ const broken = cli(["bib", "--offline"], {
375
+ nodeArgs: ["--import", join(root, "bad-parser.mjs")],
376
+ });
377
+ assert.equal(broken.status, 1);
378
+ assert.match(broken.stderr, /broken install/);
379
+ assert.doesNotMatch(broken.stderr, /npm i -D/);
380
+ });