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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * status.mjs — the paper's status view, derived from the ledger. Every section it can print is
3
+ * driven by rows written for the purpose: fresh and stale answers, open findings with and without
4
+ * a report path, retired-vocabulary rows (an acquittal and a counted one), an orphan check, a check
5
+ * that has run and never found anything, and the all-answered footer.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { afterEach, test, vi } from "vitest";
12
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const root = useTempDir("status-");
16
+ const consumer = join(root, "consumer");
17
+ const LEDGER = join(root, "runs.jsonl");
18
+ const paper = join(consumer, "papers", "p");
19
+ writeTree(paper, { "paper.md": "# P\n\ntext\n" });
20
+ Object.assign(process.env, {
21
+ CLAUDE_PROJECT_DIR: consumer,
22
+ PIPELINE_LEDGER: LEDGER,
23
+ });
24
+ const { inputHash } = await import("./ledger.mjs");
25
+ const { EXPECTED_GATES, main, unlistedGates } = await import("./status.mjs");
26
+ afterEach(() => rmSync(LEDGER, { force: true }));
27
+
28
+ const NOW = inputHash(paper);
29
+ const TS = "2026-09-01T10:30:00.000Z";
30
+ /** A ledger row for `key` on paper p. No skill is installed here, so its current hash is null. */
31
+ const row = (key, fields) => {
32
+ const [skill, check] = key.split("/");
33
+ return {
34
+ ts: TS,
35
+ skill,
36
+ check: check ?? skill,
37
+ paper: "p",
38
+ inputSha: NOW,
39
+ skillSha: null,
40
+ ...fields,
41
+ };
42
+ };
43
+ const NO_WITNESS = { kind: "ABSTAINED", reason: "no-witness", findings: 0 };
44
+
45
+ function view(rows) {
46
+ writeFileSync(LEDGER, rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
47
+ const lines = [];
48
+ const spy = vi
49
+ .spyOn(console, "log")
50
+ .mockImplementation((...a) => lines.push(a.join(" ")));
51
+ let code;
52
+ try {
53
+ code = main(["node", "status.mjs", paper]);
54
+ } finally {
55
+ spy.mockRestore();
56
+ }
57
+ return { code, text: lines.join("\n") };
58
+ }
59
+ /** The table line for one check, with its padding collapsed. */
60
+ const tableLine = (text, key) =>
61
+ text
62
+ .split("\n")
63
+ .find((l) => l.includes(` ${key} `))
64
+ ?.trim()
65
+ .replace(/\s{2,}/g, " | ");
66
+ /** A section: from the line that starts with `head` to the next blank line. */
67
+ const section = (text, head) => {
68
+ const lines = text.split("\n");
69
+ const i = lines.findIndex((l) => l.trim().startsWith(head));
70
+ if (i < 0) return null;
71
+ const end = lines.findIndex((l, j) => j > i && l.trim() === "");
72
+ return lines.slice(i, end < 0 ? undefined : end).map((l) => l.trim());
73
+ };
74
+
75
+ const RAN = [
76
+ "research-ideate",
77
+ "find-venue",
78
+ "map-prior-work",
79
+ "sweep-design-space",
80
+ "plan-paper-timeline",
81
+ "build-benchmark",
82
+ "draft-paper",
83
+ ];
84
+
85
+ test("every kind of row, and every section it triggers", () => {
86
+ const { code, text } = view([
87
+ row("research-ideate", NO_WITNESS),
88
+ row("find-venue", {
89
+ kind: "FINDING",
90
+ findings: 3,
91
+ blocking: true,
92
+ report: "r.md",
93
+ }),
94
+ row("map-prior-work", { kind: "FINDING", findings: 2, inputSha: "older" }),
95
+ row("sweep-design-space", { ...NO_WITNESS, skillSha: "a-different-skill" }),
96
+ row("plan-paper-timeline", { verdict: "PASS" }),
97
+ row("build-benchmark", { verdict: "FINDINGS", findings: 2 }),
98
+ row("draft-paper", { kind: "FINDING", findings: 0 }),
99
+ row("mystery-check", NO_WITNESS),
100
+ row("paper-status", NO_WITNESS),
101
+ ]);
102
+ assert.deepEqual(
103
+ {
104
+ code,
105
+ rows: [
106
+ "research-ideate",
107
+ "find-venue",
108
+ "map-prior-work",
109
+ "sweep-design-space",
110
+ "plan-paper-timeline",
111
+ "build-benchmark",
112
+ "draft-paper",
113
+ "camera-ready",
114
+ ].map((k) => tableLine(text, k)),
115
+ stale: text
116
+ .split("\n")
117
+ .filter((l) => /^\s+(🟠|🟡) [\w/-]+: /.test(l))
118
+ .map((l) => l.trim()),
119
+ never: section(text, "⬜ never run:"),
120
+ mute: section(text, "🔇"),
121
+ legacy: section(text, "🏷️"),
122
+ orphans: section(text, "❗"),
123
+ open: section(text, "📌"),
124
+ quiet: section(text, "🤍"),
125
+ footer: text.trim().split("\n").pop().trim(),
126
+ },
127
+ {
128
+ code: 0,
129
+ rows: [
130
+ "🟢 research-ideate | FRESH | abstained:no-witness | 1 | 2026-09-01 10:30",
131
+ "🟢 find-venue | FRESH | 🔴 3 found | 1 | 2026-09-01 10:30",
132
+ "🟠 map-prior-work | STALE-PAPER | • 2 found | 1 | 2026-09-01 10:30",
133
+ "🟡 sweep-design-space | STALE-SKILL | abstained:no-witness | 1 | 2026-09-01 10:30",
134
+ "🟢 plan-paper-timeline | FRESH | legacy:PASS | 1 | 2026-09-01 10:30",
135
+ "🟢 build-benchmark | FRESH | • 2 found | 1 | 2026-09-01 10:30",
136
+ "🟢 draft-paper | FRESH | no row | 1 | 2026-09-01 10:30",
137
+ "⬜ camera-ready | NEVER-RUN | — | 0 | —",
138
+ ],
139
+ stale: [
140
+ // In EXPECTED_GATES order.
141
+ "🟡 sweep-design-space: the check itself changed since it ran — that answer came from a different tool",
142
+ "🟠 map-prior-work: the paper changed since it ran — its answer is about a document that is gone",
143
+ ],
144
+ never: [
145
+ `⬜ never run: ${EXPECTED_GATES.filter((k) => !RAN.includes(k)).join(", ")}`,
146
+ "nobody has asked this question about this paper — an unasked question reads exactly like a clean one in prose.",
147
+ ],
148
+ mute: [
149
+ "🔇 has run and has NEVER recorded a finding: research-ideate, sweep-design-space, plan-paper-timeline",
150
+ "Borrowed from mutation testing: a test that kills no mutant is not a test. This is",
151
+ "not proof the check is broken — it is the reason to go plant a defect and watch.",
152
+ "Precedent here: a tightening pass returned KEEP on 80 of 81 sections and a",
153
+ 'justification pass never once recommended a deletion. Both "ran".',
154
+ ],
155
+ legacy: [
156
+ "🏷️ answered in the RETIRED vocabulary: plan-paper-timeline=PASS, build-benchmark=FINDINGS",
157
+ "Rows written before 2026-08-10 are kept verbatim and are NOT translated into the",
158
+ "current constructors — a silent reinterpretation is the class of defect this",
159
+ "pipeline removed. A retired FINDINGS/FAIL row still shows its count above, because",
160
+ "it carries evidence and discarding evidence is the destructive direction.",
161
+ "plan-paper-timeline=PASS carry NOTHING forward:",
162
+ "they recorded that nothing was wrong, with no evidence attached, which is exactly",
163
+ "the value that was deleted. Re-run those checks for an answer in a live vocabulary.",
164
+ ],
165
+ // `paper-status` records too, but is the view itself, so it is never an orphan.
166
+ orphans: [
167
+ "❗ recording but NOT IN THE TABLE: mystery-check",
168
+ "These ran and said something, and this view showed you nothing. Add them to",
169
+ "EXPECTED_GATES — an invisible row is the failure this directory exists to end.",
170
+ ],
171
+ open: [
172
+ "📌 open findings: 7 across 3 check(s)",
173
+ "• map-prior-work: 2 (no report path recorded — the count is all there is) [STALE-PAPER: this count describes an older text]",
174
+ "🔴 find-venue: 3 → r.md",
175
+ "• build-benchmark: 2 (no report path recorded — the count is all there is)",
176
+ "Nothing here proves anyone acted on these. The ledger records what a check SAID,",
177
+ "never what was done about it — that lives in the session, not in a file.",
178
+ ],
179
+ quiet: [
180
+ "🤍 ran against the current bytes and recorded nothing: 1 check(s)",
181
+ "research-ideate (no-witness)",
182
+ "Read this as the ABSENCE of a finding, which is what it is. `no-witness` means the",
183
+ "check ran and has no witness for the negative answer — unlike a certifying",
184
+ "algorithm (McConnell et al. 2011), which ships one for both answers. Our discipline",
185
+ "is strictly weaker than theirs and this line is where that shows.",
186
+ "no-witness: ran to completion, produced no finding — NOT a claim that nothing is wrong",
187
+ ],
188
+ footer: "🔴 29 check(s) not answering for the current text",
189
+ },
190
+ );
191
+ });
192
+
193
+ test("every expected check answered on the current bytes: the green footer, and nothing to flag", () => {
194
+ const { code, text } = view(
195
+ EXPECTED_GATES.map((k) =>
196
+ row(k, { kind: "FINDING", findings: 1, report: "r.md" }),
197
+ ),
198
+ );
199
+ assert.deepEqual(
200
+ {
201
+ code,
202
+ sections: ["🟠", "🟡", "⬜ never", "🔇", "🏷️", "❗", "🤍"].map((h) =>
203
+ section(text, h),
204
+ ),
205
+ footer: text.trim().split("\n").pop().trim(),
206
+ },
207
+ {
208
+ code: 0,
209
+ sections: [null, null, null, null, null, null, null],
210
+ footer: "🟢 every expected check answered for the current text",
211
+ },
212
+ );
213
+ });
214
+
215
+ test("unlistedGates names checks the table does not list, except the view's own rows", () => {
216
+ assert.deepEqual(
217
+ unlistedGates([
218
+ { skill: "find-venue", check: "find-venue" },
219
+ { skill: "x", check: "y" },
220
+ { skill: "paper-status", check: "paper-status" },
221
+ ]),
222
+ ["x/y"],
223
+ );
224
+ });
225
+
226
+ test("as a process, with no argument, it reads the current directory and exits 0", () => {
227
+ const r = runNode(join(HERE, "status.mjs"), [], {
228
+ cwd: paper,
229
+ env: { CLAUDE_PROJECT_DIR: consumer, PIPELINE_LEDGER: LEDGER },
230
+ });
231
+ assert.deepEqual([r.status, r.stdout.split("\n")[1]], [0, " p"]);
232
+ });
@@ -343,8 +343,8 @@ try {
343
343
  // matters more than it looks. The first version of this guard keyed on `evt_fake_`,
344
344
  // a string the stand-in prints only on SUCCESS — so a perfectly correct schema
345
345
  // REJECTION arrived with no marker and was reported as "something else served this
346
- // tool". The guard meant to protect the verdict was hiding it, and it took a
347
- // mutation run to notice (2026-08-17). The log is written on both paths, so a
346
+ // tool". The guard meant to protect the verdict was hiding it, and that went
347
+ // unnoticed until 2026-08-17. The log is written on both paths, so a
348
348
  // non-empty log means our server handled the call, whatever it decided.
349
349
  const logged = existsSync(callLog)
350
350
  ? readFileSync(callLog, "utf8")
@@ -8,25 +8,26 @@
8
8
 
9
9
  jinja2>=3.0 # {{macro}} substitution in paper_numbers.py
10
10
 
11
- # 🔴 THIS PIN IS LOAD-BEARING, not cosmetic. aclpubcheck pulls in bibtexparser; the 2.x branch is
12
- # beta (2.0.0b9) with a different API, and the 1.4.x branch's sdist does not build under
13
- # setuptools 68+. 1.3.0 has a prebuilt WHEEL (bibtexparser-1.3.0-py3-none-any.whl), so `<2`
14
- # resolves to it and there's nothing to build. Verified 2026-09-04 in a clean venv: install exits
15
- # 0, `python -m aclpubcheck.formatchecker --help` runs.
16
- bibtexparser<2
11
+ # bibtexparser is pulled in by rebiber (below), not by aclpubcheck: aclpubcheck's own code never
12
+ # imports it. rebiber 1.4 moved to bibtexparser 2 (`bibtexparser>=2,<3` in its pyproject), and
13
+ # 2.0.1 is a stable release with a prebuilt wheel. The old pin `bibtexparser<2` existed only
14
+ # for rebiber 1.1.3; it would now make the file unsatisfiable, so it is gone.
17
15
 
18
- # 🔴 aclpubcheck IS INSTALLED SEPARATELY, WITH `--no-deps`, AND IT IS DELIBERATELY NOT LISTED HERE.
19
- # Measured 2026-09-17: it declares `tsv>=1.2,<2.0`, which has NO PREBUILT WHEEL AT ALL
20
- # (`pip download --only-binary :all: tsv` → "from versions: none"), and its source does not build
16
+ # 🔴 aclpubcheck IS INSTALLED SEPARATELY, WITH `--no-deps`, BY ensure-checkers.sh, AND IT IS
17
+ # DELIBERATELY NOT LISTED HERE (a requirements file cannot say `--no-deps` for one line).
18
+ # It comes from its GitHub source pinned to a commit, not from PyPI: upstream says to install "from
19
+ # the GitHub repository (DIFFERENT from `pypi`)", and PyPI's 0.2.0 is a snapshot of 2022-10-10. The
20
+ # pin is 237bee3 of 2026-09-11, whose own setup.py pins rebiber to the SAME commit as below —
21
+ # measured 2026-09-27: both resolve together, `pip check` names no rebiber conflict.
22
+ # Why `--no-deps`: it still declares `tsv`, which has NO PREBUILT WHEEL AT ALL
23
+ # (`pip download --only-binary :all: tsv` → "from versions: none"), and whose source does not build
21
24
  # under a modern setuptools:
22
25
  # AttributeError: install_layout. Did you mean: 'install_platlib'?
23
- # That one line alone made installing the WHOLE file fail, and that's why the paper went a month
26
+ # That one line alone once made installing the WHOLE file fail, and that's why a paper went a month
24
27
  # without an official format check.
25
- # Verified, not assumed: `tsv` is imported NOWHERE in aclpubcheck's source (grep for
26
- # `import tsv|from tsv` — 0 hits), and without it
27
- # `python3 -m aclpubcheck.formatchecker --help` prints usage and exits zero.
28
- # So what follows is aclpubcheck's OWN dependencies, taken verbatim from the package's metadata,
29
- # minus tsv. A list, rather than `aclpubcheck` on one line, so the next unrepresentable dependency
28
+ # Verified, not assumed, on the pinned commit: `tsv` is imported NOWHERE in aclpubcheck's source,
29
+ # and neither is `setuptools`, which it also declares.
30
+ # So what follows is aclpubcheck's OWN dependencies, from its setup.py, minus tsv and setuptools. A list, rather than `aclpubcheck` on one line, so the next unrepresentable dependency
30
31
  # reads as a missing line rather than an unverified assumption.
31
32
  # 🔴 THE UPPER BOUNDS ARE REMOVED, AND THAT IS A MEASUREMENT, NOT CARELESSNESS. The first edition
32
33
  # of this list copied aclpubcheck's bounds verbatim — and turned red on CI, where Python is newer:
@@ -48,8 +49,18 @@ pylatexenc>=2.10
48
49
  termcolor>=2.0.1
49
50
  tqdm>=4.64.1
50
51
 
51
- rebiber>=1.1.3 # an aclpubcheck dependency AND our own tool
52
+ # 🔴 rebiber FROM ITS GITHUB SOURCE, PINNED TO A COMMIT — NOT FROM PyPI. The upstream README says
53
+ # so itself: "Install from GitHub only. We do not publish or support PyPI (`pip install rebiber` is
54
+ # a 2021 1.1.3 release)." The `rebiber>=1.1.3` that stood here installed exactly that 2021 release
55
+ # (#114). The pin is 1.4.0, commit b917e36 of 2026-09-08 ("Support bibtexparser 2 parsing and
56
+ # writing APIs"); upstream has no 1.4 tag, so a commit is the only fixed name.
57
+ # Measured 2026-09-27 in a clean Python 3.11 venv, under this file's `--only-binary :all:`: installs
58
+ # in 36 s (pip builds it from source with hatchling, the one allowed build), `rebiber -v` runs,
59
+ # and a `--dry-run` over a three-entry .bib leaves the file untouched. It carries 409 MB of venue
60
+ # data. aclpubcheck declares `rebiber>=1.1.3,<2`, which 1.4.0 satisfies.
61
+ rebiber @ git+https://github.com/yuchenlin/rebiber@b917e36ec94084a84025eb06781b5aac96d1cedd
52
62
 
53
- # ⚠️ Call ONLY as `python3 -m aclpubcheck.formatchecker`. The package has no __main__.py, and the
54
- # form `python3 -m aclpubcheck` fails with "cannot be directly executed" — that's exactly how this
55
- # step once lived its whole life without ever running.
63
+ # Since the pinned commit the package has a `__main__.py`, so `python3 -m aclpubcheck <pdf>` runs
64
+ # the format checker (measured 2026-09-27 on its example PDF: 10 errors, 0 warnings, exit 0). The
65
+ # PyPI 0.2.0 that stood here had none, and `python3 -m aclpubcheck` failed with "cannot be directly
66
+ # executed"; `python3 -m aclpubcheck.formatchecker` works on both.
@@ -27,8 +27,10 @@ echo "== python dependencies =="
27
27
  # bibtexparser looked like the guilty one.
28
28
  pip install -q --only-binary :all: -r "$HERE/checkers-requirements.txt"
29
29
  # The checker itself — WITHOUT its dependencies: they are listed in requirements.txt by name, minus
30
- # the unbuildable `tsv`, which it does not import. The reason is in the header of requirements.txt.
31
- pip install -q --no-deps aclpubcheck
30
+ # the unbuildable `tsv`, which it does not import. From its GitHub source at a fixed commit, not
31
+ # PyPI — both reasons are in the header of requirements.txt.
32
+ ACLPUBCHECK="aclpubcheck @ git+https://github.com/acl-org/aclpubcheck@237bee3a554f2d2fcda69cd0cf1edf4168e3d339"
33
+ pip install -q --no-deps "$ACLPUBCHECK"
32
34
 
33
35
  echo "== TeXtidote (a jar, not a pip package) =="
34
36
  if [ -f "$JAR" ]; then
@@ -55,10 +57,24 @@ fail=0
55
57
  # "numpy.dtype size changed"). That is, the acceptance check that stood here let through exactly
56
58
  # the failure it was written for. `import aclpubcheck.formatchecker` pulls in numpy and pandas on
57
59
  # lines 15 and 20 of the checker itself and therefore fails honestly.
60
+ # And the entry point, which the PyPI release did not have: `python3 -m aclpubcheck` must answer.
58
61
  python3 -c "import aclpubcheck.formatchecker" >/dev/null 2>&1 \
62
+ && python3 -m aclpubcheck --help >/dev/null 2>&1 \
59
63
  && echo " ✅ aclpubcheck" || { echo " ❌ aclpubcheck is installed, but does not run"; fail=1; }
60
- python3 -c "import rebiber" >/dev/null 2>&1 \
61
- && echo " ✅ rebiber" || { echo " ❌ rebiber does not import"; fail=1; }
64
+ # 🔴 RUN IT, NOT IMPORT IT. rebiber is used as a command, and an import proves neither the entry
65
+ # point, nor that it loads its bundled venue data, nor that it parses a .bib (bibtexparser 2 since
66
+ # 1.4). So it is given the arXiv entry of a paper its data knows (NeurIPS 2017) with --dry-run
67
+ # (nothing is written) and must report that entry CONVERTED to the official record — measured
68
+ # 2026-09-27 on the pinned commit: `converted (official): 1`, no network.
69
+ reb_tmp="$(mktemp -d)"
70
+ printf '@article{vaswani2017attention,\n title = {Attention Is All You Need},\n author = {Vaswani, Ashish and Shazeer, Noam and Parmar, Niki and Uszkoreit, Jakob and Jones, Llion and Gomez, Aidan N. and Kaiser, Lukasz and Polosukhin, Illia},\n journal = {arXiv preprint arXiv:1706.03762},\n year = {2017}\n}\n' > "$reb_tmp/in.bib"
71
+ reb_out="$(rebiber -i "$reb_tmp/in.bib" --dry-run 2>&1 || true)"
72
+ if grep -q "converted (official): 1" <<<"$reb_out"; then
73
+ echo " ✅ rebiber $(rebiber -v 2>/dev/null)"
74
+ else
75
+ echo " ❌ rebiber does not convert a known entry"; fail=1
76
+ fi
77
+ rm -rf "$reb_tmp"
62
78
  python3 -c "import jinja2" >/dev/null 2>&1 \
63
79
  && echo " ✅ jinja2" || { echo " ❌ jinja2 does not import"; fail=1; }
64
80
  java -jar "$JAR" --version >/dev/null 2>&1 \
@@ -10,7 +10,6 @@
10
10
  *
11
11
  * The shim imports the compiled package (`dist/`), so `npm run build` runs before this.
12
12
  */
13
- import assert from "node:assert/strict";
14
13
  import { spawnSync } from "node:child_process";
15
14
  import {
16
15
  cpSync,
@@ -25,16 +24,13 @@ import {
25
24
  import { tmpdir } from "node:os";
26
25
  import { dirname, join, resolve } from "node:path";
27
26
  import { fileURLToPath } from "node:url";
27
+ import { createChecker } from "../../lib/check.mjs";
28
28
 
29
29
  const HERE = dirname(fileURLToPath(import.meta.url));
30
30
  const SHIM = join(HERE, "extract-pdf-facts.mjs");
31
31
  const FIX = resolve(HERE, "..", "..", "fixtures", "pdf-facts");
32
32
 
33
- let n = 0;
34
- const check = (label, cond, detail = "") => {
35
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
36
- n++;
37
- };
33
+ const check = createChecker();
38
34
 
39
35
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-extract-")));
40
36
  /**
@@ -49,6 +45,16 @@ const shim = (args, env = {}) =>
49
45
  PATH: process.env.PATH,
50
46
  HOME: root,
51
47
  CLAUDE_PROJECT_DIR: root,
48
+ // Forwarded, not chosen: under `npm run coverage` these carry the coverage directory and the
49
+ // preload that resolves dist/ to src/. Dropping them measured the shim's dist/ imports as a
50
+ // second copy of each module (the function map remapped through source maps, with shifted
51
+ // columns), so every function it did not call counted as uncovered in src/.
52
+ ...(process.env.NODE_OPTIONS
53
+ ? { NODE_OPTIONS: process.env.NODE_OPTIONS }
54
+ : {}),
55
+ ...(process.env.NODE_V8_COVERAGE
56
+ ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE }
57
+ : {}),
52
58
  ...env,
53
59
  },
54
60
  });
@@ -157,10 +163,53 @@ try {
157
163
  missing.status === 3 && /not built/.test(said(missing)),
158
164
  said(missing),
159
165
  );
166
+
167
+ // ── a PDF named directly, in a directory with no paperlint.json, no project dir set ──
168
+ const loose = join(root, "papers", "loose");
169
+ mkdirSync(loose, { recursive: true });
170
+ cpSync(join(FIX, "t3-mixed.pdf"), join(loose, "draft.pdf"));
171
+ const direct = shim([join(loose, "draft.pdf")], { CLAUDE_PROJECT_DIR: "" });
172
+ const looseFacts = join(loose, "_build", "paper.facts.json");
173
+ check(
174
+ "a PDF path: facts go to its directory's _build/, the path printed relative to the cwd",
175
+ direct.status === 0 &&
176
+ direct.stdout.startsWith(
177
+ "✅ draft.pdf → papers/loose/_build/paper.facts.json (",
178
+ ) &&
179
+ JSON.parse(readFileSync(looseFacts, "utf8")).venue === null,
180
+ said(direct),
181
+ );
182
+ // The project directory IS the facts file (nonsense, but an environment can say it): the
183
+ // relative path would be empty, so the absolute one is printed instead of nothing.
184
+ const same = shim([join(loose, "draft.pdf")], {
185
+ CLAUDE_PROJECT_DIR: looseFacts,
186
+ });
187
+ check(
188
+ "a project dir equal to the facts file prints the absolute path, never an empty one",
189
+ same.stdout.startsWith(`✅ draft.pdf → ${looseFacts} (`),
190
+ said(same),
191
+ );
192
+
193
+ // ── a file that is not a PDF: refused, strict or not, in the words for the mode ──
194
+ writeFileSync(join(loose, "broken.pdf"), "not a pdf at all\n");
195
+ const brokenStrict = shim([join(loose, "broken.pdf"), "--strict"]);
196
+ const brokenLocal = shim([join(loose, "broken.pdf")]);
197
+ check(
198
+ "an unreadable PDF: --strict exits 1 as an environment error; locally exit 0, saying THE PDF IS NOT CHECKED",
199
+ brokenStrict.status === 1 &&
200
+ /🛑 facts not taken: .* — in CI this is an environment error/.test(
201
+ brokenStrict.stderr,
202
+ ) &&
203
+ brokenLocal.status === 0 &&
204
+ /⏭️ {2}facts not taken: .*THE PDF IS NOT CHECKED/.test(
205
+ brokenLocal.stderr,
206
+ ),
207
+ `${said(brokenStrict)}\n---\n${said(brokenLocal)}`,
208
+ );
160
209
  } finally {
161
210
  rmSync(root, { recursive: true, force: true });
162
211
  }
163
212
 
164
213
  console.log(
165
- `✓ ${String(n)} assertions passed — extract-pdf-facts: the exit-code contract, schema 2, and the no-no-yes font`,
214
+ `✓ ${String(check.count)} assertions passed — extract-pdf-facts: the exit-code contract, schema 2, and the no-no-yes font`,
166
215
  );
@@ -0,0 +1,133 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`--section=1 keeps section 1 and its sub-sections, and nothing before it 1`] = `
4
+ "
5
+ === full.md — outline, in order ===
6
+ own total share section
7
+ 10 16 53.3% 1 Introduction
8
+ ↳ [7/10 KEEP] carries: the claim
9
+ 4 1.1 Detail
10
+ ↳ carries: a sub-point
11
+ 2 1.2 Bare sub
12
+ ↳ 🔴 no carries: note — nobody has said why this section is here
13
+
14
+ body total 30 words across 4 sections
15
+
16
+ === outside the page limit ===
17
+ 40 Limitations
18
+ ↳ 🔴 no carries: note — and no page limit forcing the question
19
+ 5 Scope
20
+ ↳ carries: why the scope note
21
+ 30 Appendix A
22
+ ↳ carries: the raw tables
23
+
24
+ free total 75 words = 250% of the body
25
+ 🔴 The unbudgeted half is LARGER than the paper. Nothing prices these sections, which
26
+ is exactly why prose settles here. Ask of each: would a reviewer miss it?
27
+
28
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
29
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
30
+ stranger should be able to reconstruct the argument from them alone.
31
+ "
32
+ `;
33
+
34
+ exports[`the inventory of body-only.md 1`] = `
35
+ "
36
+ === body-only.md — outline, in order ===
37
+ own total share section
38
+ 8 8 66.7% 1 Intro
39
+ ↳ 🔴 no carries: note — nobody has said why this section is here
40
+ 4 4 33.3% 2 Rest
41
+ ↳ 🔴 no carries: note — nobody has said why this section is here
42
+ 0 0 0.0% 3 Empty
43
+ ↳ 🔴 no carries: note — nobody has said why this section is here
44
+
45
+ body total 12 words across 3 sections
46
+
47
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
48
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
49
+ stranger should be able to reconstruct the argument from them alone.
50
+ "
51
+ `;
52
+
53
+ exports[`the inventory of full.md 1`] = `
54
+ "
55
+ === full.md — outline, in order ===
56
+ own total share section
57
+ 3 Orphan sub
58
+ 10 16 53.3% 1 Introduction
59
+ ↳ [7/10 KEEP] carries: the claim
60
+ 4 1.1 Detail
61
+ ↳ carries: a sub-point
62
+ 2 1.2 Bare sub
63
+ ↳ 🔴 no carries: note — nobody has said why this section is here
64
+ 6 6 20.0% 2 Method
65
+ ↳ 🔴 no carries: note — nobody has said why this section is here
66
+ 5 5 16.7% 3 Results
67
+ ↳ [?/10 CUT] carries: only a verdict
68
+ 3 3 10.0% Discussion
69
+ ↳ [4/10 NO VERDICT] carries: score only
70
+
71
+ body total 30 words across 4 sections
72
+
73
+ === outside the page limit ===
74
+ 40 Limitations
75
+ ↳ 🔴 no carries: note — and no page limit forcing the question
76
+ 5 Scope
77
+ ↳ carries: why the scope note
78
+ 30 Appendix A
79
+ ↳ carries: the raw tables
80
+
81
+ free total 75 words = 250% of the body
82
+ 🔴 The unbudgeted half is LARGER than the paper. Nothing prices these sections, which
83
+ is exactly why prose settles here. Ask of each: would a reviewer miss it?
84
+
85
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
86
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
87
+ stranger should be able to reconstruct the argument from them alone.
88
+ "
89
+ `;
90
+
91
+ exports[`the inventory of no-appendix.md 1`] = `
92
+ "
93
+ === no-appendix.md — outline, in order ===
94
+ own total share section
95
+ 8 8 100.0% 1 Intro
96
+ ↳ 🔴 no carries: note — nobody has said why this section is here
97
+
98
+ body total 8 words across 1 sections
99
+
100
+ === outside the page limit ===
101
+ 3 Limitations
102
+ ↳ 🔴 no carries: note — and no page limit forcing the question
103
+
104
+ free total 3 words = 38% of the body
105
+
106
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
107
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
108
+ stranger should be able to reconstruct the argument from them alone.
109
+ "
110
+ `;
111
+
112
+ exports[`the inventory of refs-first.md 1`] = `
113
+ "
114
+ === refs-first.md — outline, in order ===
115
+ own total share section
116
+ 8 8 100.0% 1 Intro
117
+ ↳ 🔴 no carries: note — nobody has said why this section is here
118
+
119
+ body total 8 words across 1 sections
120
+
121
+ === outside the page limit ===
122
+ 2 References
123
+ ↳ 🔴 no carries: note — and no page limit forcing the question
124
+ 2 Ethical note
125
+ ↳ 🔴 no carries: note — and no page limit forcing the question
126
+
127
+ free total 4 words = 50% of the body
128
+
129
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
130
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
131
+ stranger should be able to reconstruct the argument from them alone.
132
+ "
133
+ `;