paperlint 3.0.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 (319) 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/README.md +179 -180
  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/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  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 +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -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.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs โ†’ ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. 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
+ }