paperlint 7.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +6 -0
  4. package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
  5. package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
  6. package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
  7. package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/deadlines.io.js +26 -0
  9. package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/deadlines.js +134 -0
  11. package/dist/adapters/hotcrp/deadlines.js.map +1 -0
  12. package/dist/adapters/hotcrp/index.d.ts +2 -0
  13. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/index.js +2 -0
  15. package/dist/adapters/hotcrp/index.js.map +1 -1
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +1 -6
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +3 -4
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +53 -47
  22. package/dist/cli.js.map +1 -1
  23. package/dist/cycle-rules.d.ts +24 -0
  24. package/dist/cycle-rules.d.ts.map +1 -0
  25. package/dist/cycle-rules.js +212 -0
  26. package/dist/cycle-rules.js.map +1 -0
  27. package/dist/domain/cycle.d.ts +148 -0
  28. package/dist/domain/cycle.d.ts.map +1 -0
  29. package/dist/domain/cycle.js +281 -0
  30. package/dist/domain/cycle.js.map +1 -0
  31. package/dist/domain/deadline.d.ts +128 -0
  32. package/dist/domain/deadline.d.ts.map +1 -0
  33. package/dist/domain/deadline.js +281 -0
  34. package/dist/domain/deadline.js.map +1 -0
  35. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  36. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  37. package/dist/eslint-rules/cycle-stage.js +119 -0
  38. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  39. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  40. package/dist/eslint-rules/latex-language.js +10 -13
  41. package/dist/eslint-rules/latex-language.js.map +1 -1
  42. package/dist/eslint-rules/paper-context.js +2 -2
  43. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  44. package/dist/eslint-rules/paper-typography.js +22 -91
  45. package/dist/eslint-rules/paper-typography.js.map +1 -1
  46. package/dist/eslint-rules/papers.d.ts +1 -3
  47. package/dist/eslint-rules/papers.d.ts.map +1 -1
  48. package/dist/eslint-rules/papers.js +2 -4
  49. package/dist/eslint-rules/papers.js.map +1 -1
  50. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  51. package/dist/eslint-rules/tex-build.js +8 -25
  52. package/dist/eslint-rules/tex-build.js.map +1 -1
  53. package/dist/init.d.ts +1 -4
  54. package/dist/init.d.ts.map +1 -1
  55. package/dist/init.js +3 -9
  56. package/dist/init.js.map +1 -1
  57. package/dist/lib/markdown.d.ts +5 -81
  58. package/dist/lib/markdown.d.ts.map +1 -1
  59. package/dist/lib/markdown.js +5 -116
  60. package/dist/lib/markdown.js.map +1 -1
  61. package/dist/lib/paper-config.d.ts.map +1 -1
  62. package/dist/lib/paper-config.js +2 -2
  63. package/dist/lib/paper-config.js.map +1 -1
  64. package/dist/lib/skill-eval-fixture.d.ts +6 -4
  65. package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
  66. package/dist/lib/skill-eval-fixture.js +25 -18
  67. package/dist/lib/skill-eval-fixture.js.map +1 -1
  68. package/dist/new-paper.d.ts +5 -11
  69. package/dist/new-paper.d.ts.map +1 -1
  70. package/dist/new-paper.js +12 -23
  71. package/dist/new-paper.js.map +1 -1
  72. package/dist/paper-files.d.ts +4 -4
  73. package/dist/paper-prose.d.ts +17 -0
  74. package/dist/paper-prose.d.ts.map +1 -0
  75. package/dist/paper-prose.js +46 -0
  76. package/dist/paper-prose.js.map +1 -0
  77. package/dist/paper-settings.d.ts +7 -0
  78. package/dist/paper-settings.d.ts.map +1 -1
  79. package/dist/paper-settings.js +61 -8
  80. package/dist/paper-settings.js.map +1 -1
  81. package/dist/ports/venue-deadlines.d.ts +14 -0
  82. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  83. package/dist/ports/venue-deadlines.js +2 -0
  84. package/dist/ports/venue-deadlines.js.map +1 -0
  85. package/dist/presets.d.ts +12 -0
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -11
  88. package/dist/presets.js.map +1 -1
  89. package/dist/structure.d.ts.map +1 -1
  90. package/dist/structure.js +4 -3
  91. package/dist/structure.js.map +1 -1
  92. package/dist/submission.d.ts +8 -0
  93. package/dist/submission.d.ts.map +1 -1
  94. package/dist/submission.js +41 -10
  95. package/dist/submission.js.map +1 -1
  96. package/dist/tex-requirements.d.ts +3 -0
  97. package/dist/tex-requirements.d.ts.map +1 -1
  98. package/dist/tex-requirements.js +19 -0
  99. package/dist/tex-requirements.js.map +1 -1
  100. package/dist/tex-venue-rules.d.ts +19 -0
  101. package/dist/tex-venue-rules.d.ts.map +1 -1
  102. package/dist/tex-venue-rules.js +69 -0
  103. package/dist/tex-venue-rules.js.map +1 -1
  104. package/dist/types.d.ts +0 -2
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/venue-rules.d.ts +10 -0
  107. package/dist/venue-rules.d.ts.map +1 -1
  108. package/dist/venue-rules.js +26 -10
  109. package/dist/venue-rules.js.map +1 -1
  110. package/docs/configuration.md +146 -20
  111. package/docs/e2e.md +4 -6
  112. package/docs/prior-art/paper-folder-scaffolding.md +24 -29
  113. package/docs/rules/cycle/evidence.md +87 -0
  114. package/docs/rules/cycle/port-done.md +75 -0
  115. package/docs/rules/cycle/record.md +152 -0
  116. package/docs/rules/cycle/stage.md +85 -0
  117. package/docs/rules/tex/claim-provenance.md +0 -1
  118. package/docs/rules.md +48 -47
  119. package/docs/submission.md +5 -0
  120. package/eslint-rules/cycle-stage.test.mjs +131 -0
  121. package/eslint-rules/cycle-stage.ts +141 -0
  122. package/eslint-rules/latex-language.harness.mjs +3 -3
  123. package/eslint-rules/latex-language.ts +10 -13
  124. package/eslint-rules/paper-context.ts +2 -2
  125. package/eslint-rules/paper-typography.test.mjs +17 -85
  126. package/eslint-rules/paper-typography.ts +22 -98
  127. package/eslint-rules/papers.harness.mjs +1 -1
  128. package/eslint-rules/papers.ts +2 -4
  129. package/eslint-rules/tex-build.harness.mjs +2 -2
  130. package/eslint-rules/tex-build.ts +8 -25
  131. package/eslint.config.mjs +41 -2
  132. package/hooks/hooks.harness.ts +15 -15
  133. package/hooks/paper-edit-guard.hook.mjs +5 -8
  134. package/hooks/paper-edit-guard.test.ts +12 -3
  135. package/hooks/paper-skills-nudge.hook.mjs +2 -3
  136. package/hooks/paper-skills-nudge.test.ts +13 -3
  137. package/hooks/paper-status-gates.hook.mjs +2 -2
  138. package/hooks/paper-status-gates.test.ts +14 -2
  139. package/lib/markdown.test.mjs +5 -60
  140. package/lib/markdown.ts +9 -136
  141. package/lib/paper-config.ts +3 -2
  142. package/lib/skill-checks.test.mjs +4 -1
  143. package/lib/skill-eval-fixture.ts +25 -18
  144. package/package.json +1 -3
  145. package/presets/aidc.jsonc +24 -3
  146. package/presets/aidc.md +6 -3
  147. package/presets/msr.jsonc +25 -1
  148. package/presets/msr.md +5 -1
  149. package/presets/realm.jsonc +1 -1
  150. package/presets/venue-profile.schema.json +39 -0
  151. package/scripts/check.ts +4 -0
  152. package/scripts/harness-api.frozen.json +0 -5
  153. package/scripts/refresh-deadlines.test.ts +431 -0
  154. package/scripts/refresh-deadlines.ts +387 -0
  155. package/skills/cold-read-diff/SKILL.md +3 -3
  156. package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
  157. package/skills/grade-paper-writing/SKILL.md +14 -22
  158. package/skills/grade-paper-writing/SKILL.md.spec.ts +13 -21
  159. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
  160. package/skills/grade-paper-writing/prose-lint.mjs +108 -278
  161. package/skills/grade-paper-writing/prose-lint.test.ts +184 -81
  162. package/skills/paper-adversarial-review/SKILL.md +2 -2
  163. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  164. package/skills/paper-pipeline/SKILL.md +9 -12
  165. package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
  166. package/skills/paper-pipeline/description-language.eval.mjs +25 -15
  167. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
  168. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
  169. package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
  170. package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
  171. package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
  172. package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
  173. package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
  174. package/skills/paper-pipeline/scripts/README.md +0 -4
  175. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +8 -72
  176. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +48 -156
  177. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +81 -75
  178. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
  179. package/skills/paper-status/SKILL.md +2 -2
  180. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  181. package/skills/render-paper/SKILL.md +2 -2
  182. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  183. package/skills/render-paper/extract-pdf-facts.mjs +2 -2
  184. package/skills/tighten-paper/SKILL.md +29 -42
  185. package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
  186. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +0 -13
  187. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +23 -11
  188. package/skills/verify-citations/scripts/bib-authors.mjs +8 -93
  189. package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
  190. package/src/adapters/hotcrp/cassette/README.md +22 -0
  191. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  192. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  193. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  194. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  195. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  196. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  197. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  198. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  199. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  200. package/src/adapters/hotcrp/deadlines.ts +161 -0
  201. package/src/adapters/hotcrp/index.ts +2 -0
  202. package/src/build.harness.mjs +2 -2
  203. package/src/build.test.ts +1 -1
  204. package/src/build.ts +1 -6
  205. package/src/cli.harness.mjs +26 -46
  206. package/src/cli.test.ts +41 -40
  207. package/src/cli.ts +57 -58
  208. package/src/cycle-rules.test.ts +278 -0
  209. package/src/cycle-rules.ts +273 -0
  210. package/src/domain/cycle.test.ts +456 -0
  211. package/src/domain/cycle.ts +432 -0
  212. package/src/domain/deadline.test.ts +386 -0
  213. package/src/domain/deadline.ts +472 -0
  214. package/src/folder-venue-rule.test.ts +1 -1
  215. package/src/init.test.ts +6 -7
  216. package/src/init.ts +3 -22
  217. package/src/lint-fix.test.ts +10 -4
  218. package/src/new-paper.harness.mjs +9 -28
  219. package/src/new-paper.test.ts +7 -6
  220. package/src/new-paper.ts +11 -30
  221. package/src/paper-files.test.ts +5 -4
  222. package/src/paper-files.ts +4 -4
  223. package/src/paper-prose.test.ts +49 -0
  224. package/src/paper-prose.ts +67 -0
  225. package/src/paper-settings.test.ts +167 -0
  226. package/src/paper-settings.ts +85 -11
  227. package/src/parse-args.test.ts +0 -1
  228. package/src/ports/venue-deadlines.ts +14 -0
  229. package/src/presets.test.ts +105 -0
  230. package/src/presets.ts +43 -10
  231. package/src/rule-cases.test.ts +144 -0
  232. package/src/structure.harness.ts +5 -11
  233. package/src/structure.ts +4 -3
  234. package/src/submission.test.ts +105 -1
  235. package/src/submission.ts +73 -12
  236. package/src/tex-requirements.ts +31 -0
  237. package/src/tex-venue-rules.test.ts +76 -0
  238. package/src/tex-venue-rules.ts +85 -0
  239. package/src/types.ts +0 -2
  240. package/src/venue-rules.test.ts +82 -0
  241. package/src/venue-rules.ts +44 -12
  242. package/fixtures/real-markdown-paper/baseline.json +0 -22
  243. package/fixtures/real-markdown-paper/baseline.ts +0 -70
  244. package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
  245. package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
  246. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
  247. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
  248. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
  249. package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
  250. package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
  251. package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
  252. package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
  253. package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
  254. package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
  255. package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
  256. package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
  257. package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
  258. package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
  259. package/skills/tighten-paper/structure.mjs +0 -364
  260. package/skills/tighten-paper/structure.test.ts +0 -117
  261. package/templates/paper/paper.md +0 -4
@@ -1,7 +1,7 @@
1
1
  /**
2
- * AN ESLint LANGUAGE FOR `.tex` — so that the ~30 prose rules of a paper pipeline read papers
3
- * written in LaTeX, and not only papers written in markdown. The spike it grew out of lives in
4
- * the source corpus as a coverage measurement plus an executable prototype kept as history.
2
+ * AN ESLint LANGUAGE FOR `.tex` — so that prose rules read a paper's LaTeX source. The spike it
3
+ * grew out of lives in the source corpus as a coverage measurement plus an executable prototype
4
+ * kept as history.
5
5
  *
6
6
  * ── CONSTRUCTION: two legs ──────────────────────────────────────────────────
7
7
  * 1. AST — a flat mdast-like root (heading / code / html / strong) with REAL positions taken
@@ -9,9 +9,8 @@
9
9
  * 2. TEXT — a projection of `.tex` into a markdown-like view THAT PRESERVES LENGTHS AND
10
10
  * OFFSETS: comments, the preamble, math, service macros and code environments are blanked
11
11
  * with spaces; `\section{X}` projects to `## X `, `\texttt{X}` to `` `X` ``.
12
- * The point: a rule receives what it used to see in markdown and KNOWS NOTHING about
13
- * LaTeX, while the position of a finding points into the real `.tex`. Not one rule was
14
- * rewritten for LaTeX.
12
+ * The point: a rule reads a plain, heading-marked text and KNOWS NOTHING about LaTeX, while
13
+ * the position of a finding points into the real `.tex`.
15
14
  *
16
15
  * ── HEADING SYNTHESIS: not decoration, but the repair of seven silent rules ──
17
16
  * Measured 2026-08-26 across three real papers: NONE of them has a `Limitations` /
@@ -47,10 +46,8 @@
47
46
  * `95 of it, costing 5 and A B`. This matters: a word splitter counts `%` as part of
48
47
  * a word, and a plain-language rule counts NUMBERS — so the percent sign detaches from
49
48
  * its number. Not fixed: three exceptions would have to be written and pinned.
50
- * 3. THRESHOLDS WERE CALIBRATED ON MARKDOWN. 60 / 350 / 700 words were taken from a markdown
51
- * paper. The projection counts differently: the same place gave 1239 words before macro
52
- * blanking and 1208 after — a 2.5% discrepancy. The discrepancy is ACCEPTED deliberately;
53
- * the thresholds were not recalibrated.
49
+ * 3. MACRO BLANKING SHIFTS WORD COUNTS. The same place gave 1239 words before macro blanking
50
+ * and 1208 after — a 2.5% discrepancy, ACCEPTED deliberately.
54
51
  * 4. ONE FILE AT A TIME. `\input` / `\include` are in OPAQUE: the projection of `paper.tex`
55
52
  * does not hold what they pull in. `paperlint lint` hands ESLint each file the paper includes
56
53
  * from its body as a file of its own (src/paper-includes.ts), and a file with no `document`
@@ -546,9 +543,9 @@ export function texToMdast(
546
543
  // 🔴 AND SYMMETRICALLY ON THE LEFT (found by a run on 2026-08-27, not by reading). A
547
544
  // comment that follows a line of text directly, rather than a blank line, comes back from
548
545
  // unified-latex with a position starting at the PREVIOUS newline instead of at `%`.
549
- // Consumers recognise a comment by its first character (`<!--` in markdown, `%` in
550
- // LaTeX), so such a node was not a comment to them: notes above bold appendix lead-ins
551
- // never bound, and the block-note / block-verdict rules could NEVER fire. Measured:
546
+ // Consumers recognise a comment by its first character (`%`), so such a node was not a
547
+ // comment to them: notes above bold appendix lead-ins never bound, and the block-note /
548
+ // block-verdict rules could NEVER fire. Measured:
552
549
  // 2 out of 2 blocks were reported as "no note AT ALL" while the notes were right there.
553
550
  let cs = pos.start.offset;
554
551
  while (cs < ce && src[cs] !== "%") cs++;
@@ -8,8 +8,8 @@
8
8
  * them under `settings.paperlint.paper`
9
9
  * for `paper.tex` and for every file it includes. A rule reads the other files from disk.
10
10
  *
11
- * No setting — a file linted outside `paperlint lint`, or a markdown paper — means a paper of one
12
- * file: every rule decides on the file it was handed, as it did before.
11
+ * No setting — a file linted outside `paperlint lint` — means a paper of one file: every rule
12
+ * decides on the file it was handed, as it did before.
13
13
  */
14
14
  import { readFileSync } from "node:fs";
15
15
  import { z } from "zod";
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import { describe, expect, it } from "vitest";
13
13
  import { ESLint } from "eslint";
14
- import markdown from "@eslint/markdown";
15
14
  import { readFileSync } from "node:fs";
16
15
  import { dirname, join } from "node:path";
17
16
  import { fileURLToPath } from "node:url";
@@ -49,16 +48,6 @@ const eslint = (fix = false) =>
49
48
  linterOptions: { reportUnusedDisableDirectives: "error" },
50
49
  rules: RULES,
51
50
  },
52
- {
53
- files: ["**/*.md"],
54
- plugins: { markdown, paper: typography },
55
- language: "markdown/gfm",
56
- languageOptions: { frontmatter: "yaml" },
57
- rules: {
58
- "paper/section-word": "warn",
59
- "paper/leading-zero": "warn",
60
- },
61
- },
62
51
  ],
63
52
  });
64
53
 
@@ -164,14 +153,6 @@ describe("paper/section-word", () => {
164
153
  "\\documentclass{article}\n\\crefname{section}{§}{§§}\n\\begin{document}\nSee §5.\n\\end{document}\n";
165
154
  expect(ids(await lint(src), "paper/section-word")).toHaveLength(1);
166
155
  });
167
-
168
- it("markdown: `§5` → `Section 5`, code untouched", async () => {
169
- const src = "See §5 and `§3` here.\n";
170
- expect(ids(await lint(src, "paper.md"), "paper/section-word")).toHaveLength(
171
- 1,
172
- );
173
- expect(await fixed(src, "paper.md")).toBe("See Section 5 and `§3` here.\n");
174
- });
175
156
  });
176
157
 
177
158
  describe("paper/leading-zero", () => {
@@ -237,17 +218,6 @@ describe("paper/leading-zero", () => {
237
218
  doc("At $p < 0.05$ and $d=0.21$; cell & 0.037 \\\\ and .5 or 0.25."),
238
219
  );
239
220
  });
240
-
241
- it("markdown: prose and a table cell are fixed, code is not", async () => {
242
- const src =
243
- "Significant at p<.05.\n\n| task | p |\n| --- | --- |\n| bugfix | .002 |\n\nRun `.25`.\n";
244
- expect(ids(await lint(src, "paper.md"), "paper/leading-zero")).toHaveLength(
245
- 2,
246
- );
247
- expect(await fixed(src, "paper.md")).toBe(
248
- "Significant at p<0.05.\n\n| task | p |\n| --- | --- |\n| bugfix | 0.002 |\n\nRun `.25`.\n",
249
- );
250
- });
251
221
  });
252
222
 
253
223
  describe("a finding after a heading written over several lines", () => {
@@ -290,15 +260,11 @@ describe("paper/leading-zero — a number after ¶ or § is not a decimal (#102)
290
260
  expect(await fixed(doc(CITE))).toBe(doc(CITE));
291
261
  });
292
262
 
293
- it("…while a real decimal still fixes, in markdown too, even beside a cited paragraph", async () => {
263
+ it("…while a real decimal still fixes, even beside a cited paragraph", async () => {
294
264
  const body = `${CITE}\n\nWe use a threshold of .05 throughout.`;
295
265
  expect(await fixed(doc(body))).toBe(
296
266
  doc(`${CITE}\n\nWe use a threshold of 0.05 throughout.`),
297
267
  );
298
- const md = "See ¶¶.42 and .44.\n\nSignificant at p<.05.\n";
299
- expect(await fixed(md, "paper.md")).toBe(
300
- "See ¶¶.42 and .44.\n\nSignificant at p<0.05.\n",
301
- );
302
268
  });
303
269
  });
304
270
 
@@ -467,48 +433,6 @@ describe("where visible text comes from — the walk's other paths", () => {
467
433
  });
468
434
  });
469
435
 
470
- describe("markdown: offsets that cannot be followed, and rules that are LaTeX-only", () => {
471
- const mdLint = async (text, rules) => {
472
- const [res] = await new ESLint({
473
- cwd: FIX,
474
- overrideConfigFile: true,
475
- overrideConfig: [
476
- {
477
- files: ["**/*.md"],
478
- plugins: { markdown, paper: typography },
479
- language: "markdown/gfm",
480
- rules,
481
- },
482
- ],
483
- }).lintText(text, { filePath: join(FIX, "inline", "paper.md") });
484
- expect(res.messages.filter((m) => m.fatal)).toEqual([]);
485
- return res;
486
- };
487
-
488
- it("`§` before prose is reported without a fix", async () => {
489
- const src = "As § above.\n";
490
- const res = await mdLint(src, { "paper/section-word": "warn" });
491
- expect(res.messages.map((m) => [m.ruleId, m.column, m.fix])).toEqual([
492
- ["paper/section-word", 4, undefined],
493
- ]);
494
- });
495
-
496
- it("a sign or a dot written as a character reference has no source offset: nothing is reported", async () => {
497
- const res = await mdLint("See &#167; 5 and &#46;25.\n", {
498
- "paper/section-word": "warn",
499
- "paper/leading-zero": "warn",
500
- });
501
- expect(res.messages).toEqual([]);
502
- });
503
-
504
- it("figure-ref-style is LaTeX-only: silent on markdown", async () => {
505
- const res = await mdLint("Figure~\\ref{a} and Fig.~\\ref{b}.\n", {
506
- "paper/figure-ref-style": "warn",
507
- });
508
- expect(res.messages).toEqual([]);
509
- });
510
- });
511
-
512
436
  describe("a source code the rules did not expect — the defaults hold", () => {
513
437
  const reports = (ruleId, sourceCode) => {
514
438
  const out = [];
@@ -530,12 +454,20 @@ describe("a source code the rules did not expect — the defaults hold", () => {
530
454
  ]);
531
455
  });
532
456
 
533
- it("markdown text nodes without positions: seen, never reported at a guessed place", () => {
534
- const ast = {
535
- type: "root",
536
- children: [{ type: "text", value: "§5 and .25" }],
537
- };
538
- expect(reports("section-word", { text: "§5 and .25", ast })).toEqual([]);
539
- expect(reports("leading-zero", { text: "§5 and .25", ast })).toEqual([]);
540
- });
457
+ it.each(["section-word", "leading-zero", "figure-ref-style"])(
458
+ "%s reads LaTeX only: a source without `raw` (any other language) is not reported",
459
+ (ruleId) => {
460
+ const ast = {
461
+ type: "root",
462
+ children: [
463
+ {
464
+ type: "text",
465
+ value: "§5 and .25",
466
+ position: { start: { offset: 0 }, end: { offset: 10 } },
467
+ },
468
+ ],
469
+ };
470
+ expect(reports(ruleId, { text: "§5 and .25", ast })).toEqual([]);
471
+ },
472
+ );
541
473
  });
@@ -41,11 +41,9 @@
41
41
  * IEEE Editorial Style Manual ("0.25, not .25") require the zero. A regex over the raw source
42
42
  * found 22 decimals on a real corpus and none was a defect: every one sat in markup (an option,
43
43
  * a column spec, a comment, a listing, a tikz coordinate). So this walks the TREE and keeps only
44
- * what the reader sees as a number:
45
- * LaTeX prose, inline and display math, table cells, and the text arguments of a known
46
- * set of text macros. Not: any other macro's arguments, environment arguments,
47
- * comments, verbatim/listings/`\lstinline`, tikz, the preamble, the bibliography.
48
- * Markdown text nodes, table cells included. Not: code, inline code, html, front matter.
44
+ * what the reader sees as a number: prose, inline and display math, table cells, and the text
45
+ * arguments of a known set of text macros. Not: any other macro's arguments, environment
46
+ * arguments, comments, verbatim/listings/`\lstinline`, tikz, the preamble, the bibliography.
49
47
  * Each character of a run keeps its source offset, so the finding points at the dot and the fix
50
48
  * inserts the zero there. The arXiv lookbehind stays: in `2310.05736` the dot follows a digit.
51
49
  * A text macro outside the known set (`\hl{.05}`) is read as a parameter and NOT reported — a
@@ -62,7 +60,6 @@
62
60
  import assert from "node:assert/strict";
63
61
  import { getParser } from "@unified-latex/unified-latex-util-parse";
64
62
  import { takeWhile } from "remeda";
65
- import type { Nodes } from "mdast";
66
63
  import {
67
64
  texToMdast,
68
65
  type TexArg,
@@ -108,7 +105,7 @@ export function bibRange(text: string): BibRange | null {
108
105
  return { start: m.index, end: m.index + whole.length, bodyStart, body };
109
106
  }
110
107
 
111
- /** A node of a file's projection, as far as its place goes: mdast's, or the `.tex` language's. */
108
+ /** A node of the `.tex` language's projection, as far as its place goes. */
112
109
  interface ProjectedNode {
113
110
  readonly type: string;
114
111
  readonly position?:
@@ -372,66 +369,6 @@ export function texVisibleRuns(
372
369
  return texRuns(document ? document.content : root, false, raw, []);
373
370
  }
374
371
 
375
- // Markdown node types that are not prose.
376
- const MD_HIDDEN = new Set([
377
- "code",
378
- "inlineCode",
379
- "html",
380
- "yaml",
381
- "toml",
382
- "math",
383
- "inlineMath",
384
- ]);
385
- /**
386
- * The source offset of each character of a text node's value. The value is the source with
387
- * markdown's escapes (`\*`) and character references (`&lt;`) resolved, so the two are walked
388
- * side by side; a character whose source cannot be followed gets null (reported, not fixed).
389
- */
390
- function mdOffsets(value: string, src: string, start: number) {
391
- const offs: (number | null)[] = [];
392
- let j = start;
393
- for (const ch of value) {
394
- if (src[j] === ch) offs.push(j++);
395
- else if (src[j] === "\\" && src[j + 1] === ch) {
396
- offs.push(j + 1);
397
- j += 2;
398
- } else if (src[j] === "&" && /^&#?\w{1,8};/.test(src.slice(j, j + 10))) {
399
- offs.push(null);
400
- j = src.indexOf(";", j) + 1;
401
- } else {
402
- offs.push(null);
403
- j++;
404
- }
405
- }
406
- return offs;
407
- }
408
-
409
- function mdVisibleRuns(
410
- node: Nodes | undefined,
411
- src: string,
412
- out: Run[] = [],
413
- ): Run[] {
414
- if (!node || MD_HIDDEN.has(node.type)) return out;
415
- if (node.type === "text") {
416
- const start = node.position?.start.offset;
417
- out.push({
418
- text: node.value,
419
- offs:
420
- typeof start === "number"
421
- ? mdOffsets(node.value, src, start)
422
- : Array.from(node.value, () => null),
423
- });
424
- }
425
- for (const c of "children" in node ? node.children : [])
426
- mdVisibleRuns(c, src, out);
427
- return out;
428
- }
429
-
430
- const visibleRuns = (sourceCode: RuleSourceCode): Run[] =>
431
- isTex(sourceCode)
432
- ? texVisibleRuns(sourceCode.raw)
433
- : mdVisibleRuns(sourceCode.ast, sourceCode.text);
434
-
435
372
  /** A report at `[from, to)` of the file. */
436
373
  const at = (
437
374
  context: RuleContext,
@@ -455,36 +392,22 @@ const SECTION_GLYPH = /§/g;
455
392
 
456
393
  function sectionWord(context: RuleContext) {
457
394
  const sc = context.sourceCode;
458
- if (isTex(sc)) {
459
- const seen = new Set();
460
- for (const m of markupMatches(sc, SECTION_TEX)) {
461
- seen.add(m.index);
462
- const word = m[3] ? "Section~" : "Section ";
463
- const range: [number, number] = [m.index, m.index + m[0].length];
464
- at(context, range[0], range[1], {
465
- messageId: "sign",
466
- fix: (f) => f.replaceTextRange(range, word),
467
- });
468
- }
469
- // A glyph before neither a reference nor a number: reported, not rewritten — there is no
470
- // single right word for a doubled sign or a sign before prose.
471
- for (const m of markupMatches(sc, SECTION_GLYPH))
472
- if (!seen.has(m.index))
473
- at(context, m.index, m.index + 1, { messageId: "sign" });
474
- return;
395
+ if (!isTex(sc)) return;
396
+ const seen = new Set();
397
+ for (const m of markupMatches(sc, SECTION_TEX)) {
398
+ seen.add(m.index);
399
+ const word = m[3] ? "Section~" : "Section ";
400
+ const range: [number, number] = [m.index, m.index + m[0].length];
401
+ at(context, range[0], range[1], {
402
+ messageId: "sign",
403
+ fix: (f) => f.replaceTextRange(range, word),
404
+ });
475
405
  }
476
- for (const r of visibleRuns(sc))
477
- for (const m of r.text.matchAll(/§([ \t]*)(\d)?/g)) {
478
- const from = r.offs[m.index];
479
- if (from === null || from === undefined) continue;
480
- // The sign and the space after it; the number stays.
481
- const [, gap = ""] = m;
482
- const range: [number, number] = [from, from + 1 + gap.length];
483
- at(context, range[0], range[1], {
484
- messageId: "sign",
485
- ...(m[2] ? { fix: (f) => f.replaceTextRange(range, "Section ") } : {}),
486
- });
487
- }
406
+ // A glyph before neither a reference nor a number: reported, not rewritten — there is no
407
+ // single right word for a doubled sign or a sign before prose.
408
+ for (const m of markupMatches(sc, SECTION_GLYPH))
409
+ if (!seen.has(m.index))
410
+ at(context, m.index, m.index + 1, { messageId: "sign" });
488
411
  }
489
412
 
490
413
  // ── paper/leading-zero ─────────────────────────────────────────────────────────────────
@@ -508,8 +431,9 @@ const paragraphBefore = (src: string, i: number) => {
508
431
 
509
432
  function leadingZero(context: RuleContext) {
510
433
  const sc = context.sourceCode;
511
- const src = isTex(sc) ? sc.raw : sc.text;
512
- for (const r of visibleRuns(sc))
434
+ if (!isTex(sc)) return;
435
+ const src = sc.raw;
436
+ for (const r of texVisibleRuns(src))
513
437
  for (const m of r.text.matchAll(BARE_DECIMAL)) {
514
438
  const dot = r.offs[m.index];
515
439
  if (dot === null || dot === undefined) continue;
@@ -170,7 +170,7 @@ for (const bad of ["", null, 0, false, [], {}, 42])
170
170
  const names = Object.keys(sets).sort();
171
171
  assert.deepEqual(
172
172
  names,
173
- ["all", "md", "pdfFacts", "refFacts", "status", "tex", "venue"],
173
+ ["all", "pdfFacts", "refFacts", "status", "tex", "venue"],
174
174
  "the glob set changed — a consumer's config enumerates these by name, and a renamed or " +
175
175
  "dropped key is a block that silently lints nothing",
176
176
  );
@@ -41,7 +41,7 @@
41
41
  *
42
42
  * A FILENAME MARKER, no root at all (`**\/paper.tex`).
43
43
  * Measured on the first consumer: `paper.tex` ×4 with one outside the corpus,
44
- * `PIPELINE-STATUS.md` ×6 with two of them test fixtures, `paper.md` ×3 with two outside.
44
+ * `PIPELINE-STATUS.md` ×6 with two of them test fixtures.
45
45
  * The guard that would consume this is a `deny` at `error` severity; a denial on writing
46
46
  * a fixture gets the hook switched off the same day.
47
47
  *
@@ -134,15 +134,13 @@ export function papersRoot(
134
134
  * The glob sets a consumer declares its blocks on, derived from one root.
135
135
  *
136
136
  * 🔴 THE SHAPES LIVE HERE, NOT IN THE CONSUMER, and that is the half that keeps a rule from
137
- * going blind later. `paper.md` + `draft.md`, `_build/*.facts.json`, `PIPELINE-STATUS.md` are
137
+ * going blind later. `paper.tex`, `_build/*.facts.json`, `PIPELINE-STATUS.md` are
138
138
  * this pipeline's CONVENTION; if a rule starts reading a new file, the glob for it must arrive
139
139
  * with the rule, in the same package, rather than as a line every consumer has to add by hand
140
140
  * to keep up. A hand-maintained list of paths is the thing that rots, and rots silently.
141
141
  */
142
142
  export function paperFiles(root: string) {
143
143
  return {
144
- /** Markdown drafts — the two names a paper's prose is allowed to have. */
145
- md: [`${root}/*/paper.md`, `${root}/*/draft.md`],
146
144
  /** The LaTeX source of a paper (not its figures, not its vendored drafts). */
147
145
  tex: [`${root}/*/paper.tex`],
148
146
  /** The per-paper stage ledger. */
@@ -35,8 +35,8 @@
35
35
  * (`284:will be released`, `284:Upon acceptance` + `285:will be made publicly available`).
36
36
  *
37
37
  * 🔴 AND ONE DISAGREEMENT, WHICH IS NOT IN THE LOGIC BUT IN THE INPUT — and it is the whole
38
- * gain. The old implementation picked its file by looking for `paper.md` / `draft.md` and
39
- * otherwise taking the FIRST `.tex` in `readdirSync` order. Measured 2026-09-07:
38
+ * gain. The old implementation picked its file by taking the FIRST `.tex` in `readdirSync`
39
+ * order. Measured 2026-09-07:
40
40
  * paper A → a 536-BYTE file of nine `\def`s with venue numbers. The check, added
41
41
  * specifically BECAUSE of paper A, was not reading paper A;
42
42
  * paper B → a four-month-old draft, one of seven `.tex` files in that directory;
@@ -3,27 +3,11 @@
3
3
  * how the prose is written.
4
4
  *
5
5
  * ── WHY ITS OWN NAMESPACE, AND NOT `paper/*` ────────────────────────────────
6
- * In the source corpus the `paper/*` family is declared on BOTH `paper.md` and `paper.tex`,
7
- * and that parity is pinned by an assertion: "a rule added for markdown must not silently
8
- * fail to reach LaTeX (or the other way round)". The subject here is the BUILD MODE
9
- * (`\documentclass[review]`, `printacmref=false`), and in markdown it does not exist: a draft
10
- * has no mechanical sign saying "this build is the final one". Writing
11
- * `paper/future-promise: "off"` on markdown would be an exemption justified by "there is no
12
- * subject", and that same parity assertion requires the rule to be ALIVE on the other side —
13
- * i.e. the machinery exists but is built for the opposite direction (off on `.tex`, alive on
14
- * `.md`).
15
- *
16
- * Hence a namespace of its own, the same way an external word-counter gets one: it has a word
17
- * count markdown does not have, we have a preamble markdown does not have. Where the config
18
- * enumerates non-`paper/*` rules on `.tex`, the list is CLOSED and `tex/future-promise` is
19
- * named explicitly, so that a new namespace arrives by decision rather than as a side effect
20
- * of editing a config.
21
- *
22
- * ⚠️ THE MEASUREMENT THIS DECISION RESTS ON (source corpus, 2026-09-06 → 2026-09-07): had the
23
- * rule been declared on `paper.md` it would produce ZERO there TODAY — not one match of
24
- * `FUTURE_PROMISE_RE` in the markdown draft of the one paper that has one. So the choice is
25
- * about semantics, not noise: markdown has no input distinguishing a draft from a shipped
26
- * build, and the rule would fire on a draft's honest promise.
6
+ * The subject here is the BUILD MODE (`\documentclass[review]`, `printacmref=false`), not the
7
+ * prose the `paper/*` rules read. Hence a namespace of its own, the same way an external
8
+ * word-counter gets one. Where the config enumerates non-`paper/*` rules on `.tex`, the list is
9
+ * CLOSED and `tex/future-promise` is named explicitly, so that a new namespace arrives by
10
+ * decision rather than as a side effect of editing a config.
27
11
  *
28
12
  * ── THE RULE READS THE FILE FROM DISK, NOT THE PROJECTION. This is load-bearing ──
29
13
  * The LaTeX language in `latex-language.mjs` hands rules a PROJECTION of `.tex` into a
@@ -37,9 +21,8 @@
37
21
  * run (and in CI) there is no such difference.
38
22
  *
39
23
  * ── WHAT THE MOVE TO A LINT RULE FIXED BESIDES THE FORM (measured 2026-09-07) ──
40
- * 🔴 The previous implementation, a script, picked its input like this: look for `paper.md` /
41
- * `draft.md`, and failing that take `readdirSync(dir).find((f) => f.endsWith(".tex"))` — the
42
- * FIRST `.tex` in directory order. Measured across the three papers of the source corpus:
24
+ * 🔴 The previous implementation, a script, picked its input like this:
25
+ * `readdirSync(dir).find((f) => f.endsWith(".tex"))` — the FIRST `.tex` in directory order. Measured across the three papers of the source corpus:
43
26
  * paper A → a 536-BYTE file holding nine `\def`s with venue numbers. The check, added
44
27
  * specifically BECAUSE of paper A, was not reading paper A;
45
28
  * paper B → a four-month-old draft, one of SEVEN `.tex` files in that directory (the real
@@ -121,7 +104,7 @@ const FRONTMATTER_OVERRIDES = [
121
104
  // is looking.
122
105
  //
123
106
  // The scope is DELIBERATELY NARROW, because a noisy gate is a silenced gate:
124
- // - `.tex` only (see the file header: markdown has no sign of "this build is final");
107
+ // - `.tex` only: the build mode is read from the preamble;
125
108
  // - a review-mode build is EXEMPT, and that is the whole point: during peer review
126
109
  // "at camera-ready" is a true and ordinary promise. The defect is only a promise that
127
110
  // outlived its own delivery;
package/eslint.config.mjs CHANGED
@@ -413,6 +413,40 @@ export const IO_GLOBALS = {
413
413
  },
414
414
  };
415
415
 
416
+ /**
417
+ * `paperlint lint` NEVER READS THE CLOCK. Its findings are a function of the files and the installed
418
+ * package: the same checkout lints the same tomorrow, and a deadline's distance belongs to a status
419
+ * view or a calendar, not to a gate (a lint that turns red on an untouched tree gets muted). The
420
+ * clock is an effect like the disk, so it is read where effects are — an adapter's `*.io.ts` and the
421
+ * root, `src/cli.ts` — and passed in. Matched on the AST: `Date.now` (called or passed along),
422
+ * `new Date()` and `Date()` without an argument. `new Date(ms)` converts a given time and passes.
423
+ * `toolchain.ts` and `build-engine.ts` keep `Date.now` as a default for their own timing (build and
424
+ * install only, neither runs under `lint`); that legacy I/O moves behind a port in #76.
425
+ */
426
+ const READS_THE_CLOCK = [
427
+ "MemberExpression[object.name='Date'][property.name='now']",
428
+ "NewExpression[callee.name='Date'][arguments.length=0]",
429
+ "CallExpression[callee.name='Date']",
430
+ ].map((selector) => ({
431
+ selector,
432
+ message:
433
+ "Lint never reads the clock: its findings are a function of the files. Take the time as an argument; read it in an adapter's *.io.ts or src/cli.ts.",
434
+ }));
435
+
436
+ /** Every source file the checks are made of, but the places effects are allowed. Same scope as axis B. */
437
+ export const NO_CLOCK = {
438
+ files: ["src/**/*.ts"],
439
+ ignores: [
440
+ ...IO_GLOBALS.ignores,
441
+ // Legacy I/O, moves behind a port in #76 (see READS_THE_CLOCK).
442
+ "src/toolchain.ts",
443
+ "src/build-engine.ts",
444
+ ],
445
+ rules: {
446
+ "no-restricted-syntax": ["error", AS_UNKNOWN_AS, ...READS_THE_CLOCK],
447
+ },
448
+ };
449
+
416
450
  /**
417
451
  * EVERY PRESET HAS A CARD, AND EVERY CARD'S RULES TABLE IS THE PRESET'S. Two checks, one block each:
418
452
  *
@@ -486,7 +520,6 @@ const TYPESCRIPT_ONLY = [
486
520
  "test/e2e/**/*.{js,mjs,cjs}",
487
521
  "test/fixtures/layers/src/**/*.{js,mjs,cjs}",
488
522
  "fixtures/accepted-papers/**/*.{js,mjs,cjs}",
489
- "fixtures/real-markdown-paper/**/*.{js,mjs,cjs}",
490
523
  "skills/paper-pipeline/scripts/fixtures/**/*.{js,mjs,cjs}",
491
524
  "skills/plan-paper-timeline/fixtures/**/*.{js,mjs,cjs}",
492
525
  ];
@@ -608,6 +641,7 @@ export default [
608
641
  },
609
642
  // Hexagonal layers, both axes (src/CLAUDE.md): knowledge by element, purity by file category.
610
643
  IO_GLOBALS,
644
+ NO_CLOCK,
611
645
  layerBoundaries(dirname(fileURLToPath(import.meta.url))),
612
646
  // The ratchet: per-file ceilings for the files written before the limits existed. See RATCHET.
613
647
  ...Object.entries(RATCHET).map(([file, max]) => ({
@@ -805,7 +839,12 @@ export default [
805
839
  files: ["eslint-rules/*.ts", "eslint-rules/*.mjs"],
806
840
  ignores: ["eslint-rules/*.test.*", "eslint-rules/*.harness.*"],
807
841
  rules: {
808
- "no-restricted-syntax": ["error", AS_UNKNOWN_AS, ...GIT_IN_A_RULE],
842
+ "no-restricted-syntax": [
843
+ "error",
844
+ AS_UNKNOWN_AS,
845
+ ...GIT_IN_A_RULE,
846
+ ...READS_THE_CLOCK,
847
+ ],
809
848
  },
810
849
  },
811
850
  // LAST, so no block above can replace its `no-restricted-syntax` options for these files (none