paperlint 3.1.0 โ†’ 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/bin/paperlint.test.mjs +45 -0
  6. package/dist/adapters/node/files.io.d.ts +2 -0
  7. package/dist/adapters/node/files.io.d.ts.map +1 -1
  8. package/dist/adapters/node/files.io.js +3 -2
  9. package/dist/adapters/node/files.io.js.map +1 -1
  10. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  11. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  12. package/dist/adapters/pdfjs/fill.js +31 -21
  13. package/dist/adapters/pdfjs/fill.js.map +1 -1
  14. package/dist/adapters/references/index.d.ts.map +1 -1
  15. package/dist/adapters/references/index.js +14 -5
  16. package/dist/adapters/references/index.js.map +1 -1
  17. package/dist/adapters/references/pool.d.ts +8 -0
  18. package/dist/adapters/references/pool.d.ts.map +1 -0
  19. package/dist/adapters/references/pool.js +19 -0
  20. package/dist/adapters/references/pool.js.map +1 -0
  21. package/dist/build.d.ts +10 -2
  22. package/dist/build.d.ts.map +1 -1
  23. package/dist/build.js +7 -8
  24. package/dist/build.js.map +1 -1
  25. package/dist/cli.d.ts +27 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +71 -55
  28. package/dist/cli.js.map +1 -1
  29. package/dist/doctor.d.ts +27 -1
  30. package/dist/doctor.d.ts.map +1 -1
  31. package/dist/doctor.js +4 -3
  32. package/dist/doctor.js.map +1 -1
  33. package/dist/domain/result.d.ts +0 -2
  34. package/dist/domain/result.d.ts.map +1 -1
  35. package/dist/domain/result.js +0 -4
  36. package/dist/domain/result.js.map +1 -1
  37. package/dist/hooks-settings.d.ts +8 -5
  38. package/dist/hooks-settings.d.ts.map +1 -1
  39. package/dist/hooks-settings.js +32 -32
  40. package/dist/hooks-settings.js.map +1 -1
  41. package/dist/init.d.ts +8 -0
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +3 -3
  44. package/dist/init.js.map +1 -1
  45. package/dist/latex-log.d.ts +0 -2
  46. package/dist/latex-log.d.ts.map +1 -1
  47. package/dist/latex-log.js +7 -13
  48. package/dist/latex-log.js.map +1 -1
  49. package/dist/link-skills.d.ts +15 -2
  50. package/dist/link-skills.d.ts.map +1 -1
  51. package/dist/link-skills.js +36 -20
  52. package/dist/link-skills.js.map +1 -1
  53. package/dist/paper-files.d.ts.map +1 -1
  54. package/dist/paper-files.js +6 -4
  55. package/dist/paper-files.js.map +1 -1
  56. package/dist/pdf-facts.d.ts +11 -0
  57. package/dist/pdf-facts.d.ts.map +1 -1
  58. package/dist/pdf-facts.js +16 -18
  59. package/dist/pdf-facts.js.map +1 -1
  60. package/dist/pdf-geometry.js +1 -1
  61. package/dist/pdf-geometry.js.map +1 -1
  62. package/dist/presets.d.ts +2 -0
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +3 -1
  65. package/dist/presets.js.map +1 -1
  66. package/dist/references.js +2 -2
  67. package/dist/references.js.map +1 -1
  68. package/dist/tex-requirements.d.ts +8 -0
  69. package/dist/tex-requirements.d.ts.map +1 -1
  70. package/dist/tex-requirements.js +6 -5
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/toolchain.d.ts +3 -2
  73. package/dist/toolchain.d.ts.map +1 -1
  74. package/dist/toolchain.js +4 -5
  75. package/dist/toolchain.js.map +1 -1
  76. package/dist/venue-rules.d.ts.map +1 -1
  77. package/dist/venue-rules.js +11 -9
  78. package/dist/venue-rules.js.map +1 -1
  79. package/docs/prior-art/README.md +1 -1
  80. package/docs/prior-art/test-tooling.md +7 -30
  81. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  82. package/eslint-rules/latex-language.harness.mjs +3 -3
  83. package/eslint-rules/latex-language.mjs +12 -9
  84. package/eslint-rules/latex-language.test.mjs +200 -0
  85. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  86. package/eslint-rules/paper-stages.harness.mjs +9 -14
  87. package/eslint-rules/paper-stages.test.mjs +135 -0
  88. package/eslint-rules/paper-typography.mjs +9 -2
  89. package/eslint-rules/paper-typography.test.mjs +146 -1
  90. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  91. package/eslint-rules/review-frontmatter.mjs +2 -1
  92. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  93. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  94. package/eslint-rules/tex-build.mjs +13 -13
  95. package/eslint.config.mjs +6 -2
  96. package/hooks/hooks.harness.mjs +6 -11
  97. package/hooks/paper-edit-guard.hook.mjs +2 -2
  98. package/hooks/paper-edit-guard.test.mjs +61 -0
  99. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  100. package/hooks/paper-skills-nudge.test.mjs +38 -0
  101. package/hooks/paper-status-gates.test.mjs +42 -0
  102. package/lib/agent-cli-version.harness.mjs +7 -0
  103. package/lib/check.d.mts +17 -0
  104. package/lib/check.mjs +50 -0
  105. package/lib/check.test.ts +67 -0
  106. package/lib/markdown.mjs +16 -69
  107. package/lib/markdown.test.mjs +246 -0
  108. package/lib/paper-config.harness.mjs +28 -9
  109. package/lib/paper-config.mjs +7 -2
  110. package/lib/skill-checks.mjs +18 -46
  111. package/lib/skill-checks.test.mjs +297 -0
  112. package/lib/skill-corpus.mjs +11 -13
  113. package/lib/skill-corpus.test.mjs +54 -0
  114. package/lib/skill-trigger-cases.harness.mjs +57 -4
  115. package/package.json +6 -5
  116. package/scripts/check.harness.mjs +4 -10
  117. package/scripts/check.mjs +45 -42
  118. package/scripts/check.test.mjs +104 -0
  119. package/scripts/coverage-config.test.ts +406 -0
  120. package/scripts/eslint-report-guard.test.mjs +48 -0
  121. package/scripts/harness-api.frozen.json +0 -2
  122. package/scripts/layer-legacy-frozen.d.mts +9 -1
  123. package/scripts/layer-legacy-frozen.mjs +28 -11
  124. package/scripts/layer-legacy-frozen.test.ts +143 -1
  125. package/scripts/rules-are-content-only.harness.mjs +3 -11
  126. package/scripts/rules-are-content-only.mjs +29 -19
  127. package/scripts/rules-are-content-only.test.mjs +61 -0
  128. package/scripts/rules-see-files.mjs +11 -9
  129. package/scripts/rules-see-files.test.mjs +71 -0
  130. package/scripts/test.mjs +44 -0
  131. package/scripts/test.test.mjs +52 -0
  132. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  133. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  134. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  135. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  136. package/skills/paper-pipeline/scripts/README.md +13 -132
  137. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  138. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  139. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  140. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  141. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  142. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  145. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  148. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  149. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  150. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  151. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs โ†’ ledger.test.mjs} +44 -76
  152. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  153. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  154. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  155. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  156. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  157. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  158. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  159. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  160. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  161. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  162. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  163. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  164. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  165. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  166. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  167. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  168. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  169. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  170. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  171. package/skills/tighten-paper/structure.test.mjs +116 -0
  172. package/skills/verify-citations/SKILL.md +5 -5
  173. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  174. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  175. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  176. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  177. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  178. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  179. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  180. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  181. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  182. package/src/adapters/banal/failure.test.ts +70 -1
  183. package/src/adapters/banal/index.test.ts +16 -0
  184. package/src/adapters/banal/output.test.ts +5 -0
  185. package/src/adapters/curl/download.test.ts +43 -1
  186. package/src/adapters/memory/index.test.ts +25 -0
  187. package/src/adapters/node/files.io.ts +3 -2
  188. package/src/adapters/node/files.test.ts +13 -1
  189. package/src/adapters/node/process.test.ts +38 -0
  190. package/src/adapters/pdfjs/fill.test.ts +34 -0
  191. package/src/adapters/pdfjs/fill.ts +44 -25
  192. package/src/adapters/references/index.test.ts +228 -0
  193. package/src/adapters/references/index.ts +21 -6
  194. package/src/adapters/references/pool.test.ts +31 -0
  195. package/src/adapters/references/pool.ts +24 -0
  196. package/src/build-engine.harness.mjs +3 -7
  197. package/src/build-engine.test.ts +137 -0
  198. package/src/build.harness.mjs +5 -9
  199. package/src/build.test.ts +362 -0
  200. package/src/build.ts +13 -9
  201. package/src/cli.harness.mjs +10 -17
  202. package/src/cli.test.ts +584 -0
  203. package/src/cli.ts +112 -66
  204. package/src/doctor.harness.mjs +3 -8
  205. package/src/doctor.test.ts +64 -0
  206. package/src/doctor.ts +12 -4
  207. package/src/domain/result.test.ts +4 -0
  208. package/src/domain/result.ts +0 -5
  209. package/src/engine.harness.mjs +27 -7
  210. package/src/facts-file.test.ts +110 -0
  211. package/src/hooks-settings.harness.mjs +43 -13
  212. package/src/hooks-settings.ts +41 -35
  213. package/src/init.test.ts +308 -5
  214. package/src/init.ts +3 -3
  215. package/src/latex-log.harness.mjs +32 -7
  216. package/src/latex-log.ts +6 -13
  217. package/src/latex-loop.harness.mjs +8 -7
  218. package/src/link-skills.harness.mjs +3 -8
  219. package/src/link-skills.test.ts +105 -0
  220. package/src/link-skills.ts +75 -21
  221. package/src/new-paper.harness.mjs +3 -7
  222. package/src/new-paper.test.ts +31 -1
  223. package/src/paper-files.test.ts +12 -0
  224. package/src/paper-files.ts +9 -5
  225. package/src/paper-settings.test.ts +20 -0
  226. package/src/pdf-facts.harness.mjs +3 -6
  227. package/src/pdf-facts.test.ts +144 -0
  228. package/src/pdf-facts.ts +17 -19
  229. package/src/pdf-geometry.harness.mjs +3 -7
  230. package/src/pdf-geometry.ts +1 -1
  231. package/src/presets.test.ts +2 -0
  232. package/src/presets.ts +7 -1
  233. package/src/references.test.ts +97 -0
  234. package/src/references.ts +2 -2
  235. package/src/rules-config.harness.mjs +10 -8
  236. package/src/structure.harness.mjs +3 -7
  237. package/src/tex-requirements.harness.mjs +34 -7
  238. package/src/tex-requirements.ts +12 -6
  239. package/src/toolchain.harness.mjs +3 -7
  240. package/src/toolchain.test.ts +280 -0
  241. package/src/toolchain.ts +8 -7
  242. package/src/venue-rules.test.ts +60 -0
  243. package/src/venue-rules.ts +13 -11
  244. package/action.mutations.mjs +0 -162
  245. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  246. package/eslint-rules/latex-language.mutations.mjs +0 -196
  247. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  248. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  249. package/eslint-rules/papers.mutations.mjs +0 -186
  250. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  251. package/eslint-rules/tex-build.mutations.mjs +0 -258
  252. package/hooks/hooks.mutations.mjs +0 -337
  253. package/lib/agent-cli-version.mutations.mjs +0 -109
  254. package/lib/mutation-driver.harness.mjs +0 -227
  255. package/lib/mutation-driver.mjs +0 -397
  256. package/lib/mutation-driver.mutations.mjs +0 -68
  257. package/lib/paper-config.mutations.mjs +0 -143
  258. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  259. package/scripts/check.mutations.mjs +0 -110
  260. package/scripts/exclusive.mjs +0 -138
  261. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  262. package/scripts/mutation-batteries-frozen.mjs +0 -238
  263. package/scripts/mutation-batteries.frozen.json +0 -108
  264. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  265. package/scripts/rules-see-files.mutations.mjs +0 -131
  266. package/scripts/run-mutations.mjs +0 -100
  267. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  268. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  269. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  270. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  271. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  272. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  273. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  274. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  275. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  276. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  277. package/skills/skill-contract.mutations.mjs +0 -179
  278. package/src/build.mutations.mjs +0 -196
  279. package/src/cli.mutations.mjs +0 -414
  280. package/src/doctor.mutations.mjs +0 -175
  281. package/src/hooks-settings.mutations.mjs +0 -99
  282. package/src/link-skills.mutations.mjs +0 -140
  283. package/src/new-paper.mutations.mjs +0 -65
  284. package/src/structure.mutations.mjs +0 -83
@@ -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
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A PIPELINE-STATUS.md fixture shared by the pipeline-check harness and its CLI test: the four
3
+ * template sections, every row green and dated `today`, so a test plants exactly one defect.
4
+ */
5
+ export const FAR = "2027-12-31";
6
+
7
+ /**
8
+ * A scorecard with the four sections the template requires, all green. `over` replaces individual
9
+ * cells: `{ cites: { date: "2020-01-01" } }`, `{ verdict: "โ€ฆ" }`, `{ deadline: "2026-08-18" }`,
10
+ * `{ tail: "โ€ฆ" }` for anything appended after the last section, `{ preamble: "โ€ฆ" }` for anything
11
+ * inserted BEFORE the first heading (the only way to plant a row under no heading at all).
12
+ */
13
+ export function scorecard(
14
+ over = {},
15
+ today = new Date().toISOString().slice(0, 10),
16
+ ) {
17
+ const r = (id, status = "โ˜‘", date = today) => ({
18
+ id,
19
+ status,
20
+ date,
21
+ ...(over[id] ?? {}),
22
+ });
23
+ const row = (o, ...mid) =>
24
+ `| ${o.id} | work | ${o.skill ?? "skill"} | ${mid.length ? mid.join(" | ") + " | " : ""}${o.status} | ${o.date} | ${o.result ?? "result"} | โ€” |`;
25
+ // The GATES `Requires` cell, canonical by default so the baseline is silent โ€” `{ structure: {
26
+ // requires: "render" } }` plants a dropped edge. `harden` deliberately declares four of its five
27
+ // canonical inputs: the fifth (`priordelta`) has no row in this fixture, and the clean case asserts that the checker stays
28
+ // quiet about an edge there is nowhere to point at.
29
+ const req = (id, dflt) => over[id]?.requires ?? dflt;
30
+ const verdict =
31
+ over.verdict ??
32
+ "Submit-ready โ€” every gate green, artifact reproduces clean, nothing blocking.";
33
+ return [
34
+ "# PIPELINE-STATUS โ€” harness fixture",
35
+ `Venue: Fixture Workshop ยท Deadline: ${over.deadline ?? FAR} ยท Blind: double ยท State: drafting`,
36
+ "",
37
+ `**Readiness verdict:** ${verdict}`,
38
+ "",
39
+ over.preamble ?? "",
40
+ "",
41
+ "### SETUP",
42
+ "| id | Work | Skill | Status | Date | Result | Open |",
43
+ "|----|------|-------|--------|------|--------|------|",
44
+ row(r("idea")),
45
+ row(r("access")),
46
+ row(r("frame")),
47
+ "",
48
+ "### LOOP",
49
+ "| id | Work | Skill | Status | Date | Result | Open |",
50
+ "|----|------|-------|--------|------|--------|------|",
51
+ row(r("study")),
52
+ row(r("draft")),
53
+ row(r("arc")),
54
+ "",
55
+ "### CONTINUOUS",
56
+ "| id | Trigger | Skill | Status | Date | Result | Open |",
57
+ "|----|---------|-------|--------|------|--------|------|",
58
+ row(r("cites")),
59
+ row(r("render")),
60
+ "",
61
+ "### GATES",
62
+ "| id | Gate | Skill | Requires | Status | Date | Result | Open |",
63
+ "|----|------|-------|----------|--------|------|--------|------|",
64
+ row(r("structure"), req("structure", "render, arc")),
65
+ row(r("writing"), req("writing", "draft, arc, structure")),
66
+ row(r("panel"), req("panel", "structure, writing")),
67
+ row(r("harden"), req("harden", "panel, structure, writing, cites")),
68
+ "",
69
+ over.tail ?? "",
70
+ ].join("\n");
71
+ }