paperlint 7.2.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 (156) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +1 -6
  3. package/dist/build.js.map +1 -1
  4. package/dist/cli.d.ts +3 -4
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +13 -42
  7. package/dist/cli.js.map +1 -1
  8. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  9. package/dist/eslint-rules/latex-language.js +10 -13
  10. package/dist/eslint-rules/latex-language.js.map +1 -1
  11. package/dist/eslint-rules/paper-context.js +2 -2
  12. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  13. package/dist/eslint-rules/paper-typography.js +22 -91
  14. package/dist/eslint-rules/paper-typography.js.map +1 -1
  15. package/dist/eslint-rules/papers.d.ts +1 -3
  16. package/dist/eslint-rules/papers.d.ts.map +1 -1
  17. package/dist/eslint-rules/papers.js +2 -4
  18. package/dist/eslint-rules/papers.js.map +1 -1
  19. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  20. package/dist/eslint-rules/tex-build.js +8 -25
  21. package/dist/eslint-rules/tex-build.js.map +1 -1
  22. package/dist/init.d.ts +1 -4
  23. package/dist/init.d.ts.map +1 -1
  24. package/dist/init.js +3 -9
  25. package/dist/init.js.map +1 -1
  26. package/dist/lib/markdown.d.ts +5 -81
  27. package/dist/lib/markdown.d.ts.map +1 -1
  28. package/dist/lib/markdown.js +5 -116
  29. package/dist/lib/markdown.js.map +1 -1
  30. package/dist/lib/paper-config.d.ts.map +1 -1
  31. package/dist/lib/paper-config.js +0 -2
  32. package/dist/lib/paper-config.js.map +1 -1
  33. package/dist/lib/skill-eval-fixture.d.ts +6 -4
  34. package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
  35. package/dist/lib/skill-eval-fixture.js +25 -18
  36. package/dist/lib/skill-eval-fixture.js.map +1 -1
  37. package/dist/new-paper.d.ts +5 -11
  38. package/dist/new-paper.d.ts.map +1 -1
  39. package/dist/new-paper.js +12 -23
  40. package/dist/new-paper.js.map +1 -1
  41. package/dist/paper-files.d.ts +4 -4
  42. package/dist/paper-prose.d.ts +17 -0
  43. package/dist/paper-prose.d.ts.map +1 -0
  44. package/dist/paper-prose.js +46 -0
  45. package/dist/paper-prose.js.map +1 -0
  46. package/dist/structure.d.ts.map +1 -1
  47. package/dist/structure.js +4 -3
  48. package/dist/structure.js.map +1 -1
  49. package/dist/types.d.ts +0 -2
  50. package/dist/types.d.ts.map +1 -1
  51. package/docs/configuration.md +7 -9
  52. package/docs/e2e.md +4 -6
  53. package/docs/prior-art/paper-folder-scaffolding.md +24 -29
  54. package/docs/rules/tex/claim-provenance.md +0 -1
  55. package/docs/rules.md +2 -6
  56. package/eslint-rules/latex-language.harness.mjs +3 -3
  57. package/eslint-rules/latex-language.ts +10 -13
  58. package/eslint-rules/paper-context.ts +2 -2
  59. package/eslint-rules/paper-typography.test.mjs +17 -85
  60. package/eslint-rules/paper-typography.ts +22 -98
  61. package/eslint-rules/papers.harness.mjs +1 -1
  62. package/eslint-rules/papers.ts +2 -4
  63. package/eslint-rules/tex-build.harness.mjs +2 -2
  64. package/eslint-rules/tex-build.ts +8 -25
  65. package/eslint.config.mjs +0 -1
  66. package/hooks/hooks.harness.ts +15 -15
  67. package/hooks/paper-edit-guard.hook.mjs +5 -8
  68. package/hooks/paper-edit-guard.test.ts +12 -3
  69. package/hooks/paper-skills-nudge.hook.mjs +2 -3
  70. package/hooks/paper-skills-nudge.test.ts +13 -3
  71. package/hooks/paper-status-gates.hook.mjs +2 -2
  72. package/hooks/paper-status-gates.test.ts +14 -2
  73. package/lib/markdown.test.mjs +5 -60
  74. package/lib/markdown.ts +9 -136
  75. package/lib/paper-config.ts +0 -2
  76. package/lib/skill-checks.test.mjs +4 -1
  77. package/lib/skill-eval-fixture.ts +25 -18
  78. package/package.json +1 -3
  79. package/presets/realm.jsonc +1 -1
  80. package/scripts/harness-api.frozen.json +0 -5
  81. package/skills/cold-read-diff/SKILL.md +3 -3
  82. package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
  83. package/skills/grade-paper-writing/SKILL.md +14 -22
  84. package/skills/grade-paper-writing/SKILL.md.spec.ts +13 -21
  85. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
  86. package/skills/grade-paper-writing/prose-lint.mjs +108 -278
  87. package/skills/grade-paper-writing/prose-lint.test.ts +184 -81
  88. package/skills/paper-adversarial-review/SKILL.md +2 -2
  89. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  90. package/skills/paper-pipeline/SKILL.md +9 -12
  91. package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
  92. package/skills/paper-pipeline/description-language.eval.mjs +25 -15
  93. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
  94. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
  95. package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
  96. package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
  97. package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
  98. package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
  99. package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
  100. package/skills/paper-pipeline/scripts/README.md +0 -4
  101. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +8 -72
  102. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +48 -156
  103. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +81 -75
  104. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
  105. package/skills/paper-status/SKILL.md +2 -2
  106. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  107. package/skills/render-paper/SKILL.md +2 -2
  108. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  109. package/skills/render-paper/extract-pdf-facts.mjs +2 -2
  110. package/skills/tighten-paper/SKILL.md +29 -42
  111. package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
  112. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +0 -13
  113. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +23 -11
  114. package/skills/verify-citations/scripts/bib-authors.mjs +8 -93
  115. package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
  116. package/src/build.harness.mjs +2 -2
  117. package/src/build.test.ts +1 -1
  118. package/src/build.ts +1 -6
  119. package/src/cli.harness.mjs +26 -46
  120. package/src/cli.test.ts +32 -40
  121. package/src/cli.ts +14 -54
  122. package/src/folder-venue-rule.test.ts +1 -1
  123. package/src/init.test.ts +6 -7
  124. package/src/init.ts +3 -22
  125. package/src/lint-fix.test.ts +10 -4
  126. package/src/new-paper.harness.mjs +9 -28
  127. package/src/new-paper.test.ts +6 -6
  128. package/src/new-paper.ts +11 -30
  129. package/src/paper-files.test.ts +5 -4
  130. package/src/paper-files.ts +4 -4
  131. package/src/paper-prose.test.ts +49 -0
  132. package/src/paper-prose.ts +67 -0
  133. package/src/parse-args.test.ts +0 -1
  134. package/src/structure.harness.ts +5 -11
  135. package/src/structure.ts +4 -3
  136. package/src/types.ts +0 -2
  137. package/fixtures/real-markdown-paper/baseline.json +0 -22
  138. package/fixtures/real-markdown-paper/baseline.ts +0 -70
  139. package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
  140. package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
  141. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
  142. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
  143. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
  144. package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
  145. package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
  146. package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
  147. package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
  148. package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
  149. package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
  150. package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
  151. package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
  152. package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
  153. package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
  154. package/skills/tighten-paper/structure.mjs +0 -364
  155. package/skills/tighten-paper/structure.test.ts +0 -117
  156. 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
@@ -520,7 +520,6 @@ const TYPESCRIPT_ONLY = [
520
520
  "test/e2e/**/*.{js,mjs,cjs}",
521
521
  "test/fixtures/layers/src/**/*.{js,mjs,cjs}",
522
522
  "fixtures/accepted-papers/**/*.{js,mjs,cjs}",
523
- "fixtures/real-markdown-paper/**/*.{js,mjs,cjs}",
524
523
  "skills/paper-pipeline/scripts/fixtures/**/*.{js,mjs,cjs}",
525
524
  "skills/plan-paper-timeline/fixtures/**/*.{js,mjs,cjs}",
526
525
  ];
@@ -206,7 +206,7 @@ try {
206
206
  // ═══════════════════════════════════════════════════════════════════════════
207
207
  {
208
208
  const dir = fixture({ [PAPERS_DIR_FIELD]: "docs/papers" });
209
- const P = "docs/papers/alpha/paper.md";
209
+ const P = "docs/papers/alpha/paper.tex";
210
210
  const T = "docs/papers/alpha/paper.tex";
211
211
  const deny = (label: string, cmd: string) => {
212
212
  const r = at(dir, "paper-edit-guard", onBash(cmd));
@@ -228,7 +228,7 @@ try {
228
228
  };
229
229
 
230
230
  // The two cases `touches` alone MISSES: a redirection target is not an argv token.
231
- deny("a redirect write to paper.md", `echo x ${GT} ${P}`);
231
+ deny("a redirect write to paper.tex", `echo x ${GT} ${P}`);
232
232
  deny("an append write to a .tex", `cat /tmp/a ${GT}${GT} ${T}`);
233
233
  deny("sed -i on a paper", `sed -i s/a/b/ ${P}`);
234
234
  deny("cp onto a paper", `cp /tmp/a ${P}`);
@@ -358,8 +358,8 @@ try {
358
358
  // for a gate that had never read the declaration. Caught by its own negative control,
359
359
  // 2026-09-12. A declared root only demonstrates anything when the default would MISS it.
360
360
  const DECLARED = "writing/drafts";
361
- const declaredWrite = onBash(`echo x ${GT} ${DECLARED}/alpha/paper.md`);
362
- const defaultWrite = onBash(`echo x ${GT} papers/alpha/paper.md`);
361
+ const declaredWrite = onBash(`echo x ${GT} ${DECLARED}/alpha/paper.tex`);
362
+ const defaultWrite = onBash(`echo x ${GT} papers/alpha/paper.tex`);
363
363
 
364
364
  // (a) a declared root is used AS DECLARED — and the default is NOT.
365
365
  {
@@ -479,11 +479,11 @@ try {
479
479
  r.stdout.length === 0 && r.stderr.length === 0 && r.exitCode === 0,
480
480
  );
481
481
  };
482
- lands("a relative paper.md", "docs/papers/alpha/paper.md");
482
+ lands("a relative paper.tex", "docs/papers/alpha/paper.tex");
483
483
  lands("a .tex", "docs/papers/alpha/paper.tex");
484
484
  // 🔴 THE ABSOLUTE SPELLING. The live harness sends absolute paths; a helper that only builds
485
485
  // relative ones let an anchored predecessor pass every test while being dead in production.
486
- lands("an ABSOLUTE paper.md", join(dir, "docs/papers/alpha/paper.md"));
486
+ lands("an ABSOLUTE paper.tex", join(dir, "docs/papers/alpha/paper.tex"));
487
487
  silent("a README under the papers root", "docs/papers/README.md");
488
488
  silent("a file outside the papers root", "README.md");
489
489
  // The advisory's own asymmetry: it goes quiet where the gate refuses.
@@ -495,7 +495,7 @@ try {
495
495
  const r = at(
496
496
  broken,
497
497
  "paper-skills-nudge",
498
- onEdit("docs/papers/alpha/paper.md"),
498
+ onEdit("docs/papers/alpha/paper.tex"),
499
499
  );
500
500
  check(
501
501
  "nudge is SILENT (not blocking) on an unusable declaration",
@@ -508,7 +508,7 @@ try {
508
508
  const d = at(
509
509
  broken,
510
510
  "paper-skills-nudge",
511
- onEdit("papers/alpha/paper.md"),
511
+ onEdit("papers/alpha/paper.tex"),
512
512
  );
513
513
  check(
514
514
  "nudge: an explicit null is NOT read as an absence (no fallback to the default root)",
@@ -537,15 +537,15 @@ try {
537
537
  r.stdout.length === 0 && r.stderr.length === 0 && r.exitCode === 0,
538
538
  );
539
539
  };
540
- fires("a relative paper.md", "docs/papers/alpha/paper.md");
541
- fires("an ABSOLUTE paper.md", join(dir, "docs/papers/alpha/paper.md"));
540
+ fires("a relative paper.tex", "docs/papers/alpha/paper.tex");
541
+ fires("an ABSOLUTE paper.tex", join(dir, "docs/papers/alpha/paper.tex"));
542
542
  silent("a README under the papers root", "docs/papers/README.md");
543
543
  silent("a file outside the papers root", "README.md");
544
544
  // 🔴 THE BOUNDARY IN `(?:^|/)`: a directory merely ENDING in the root's last segment must
545
545
  // not smuggle a match. Without the boundary this is a hit.
546
546
  silent(
547
547
  "a look-alike root (`notdocs/papers/…`)",
548
- "notdocs/papers/alpha/paper.md",
548
+ "notdocs/papers/alpha/paper.tex",
549
549
  );
550
550
  // A `.tex` whose directory name carries a shell metacharacter cannot reach a command.
551
551
  silent(
@@ -560,8 +560,8 @@ try {
560
560
  );
561
561
  check(
562
562
  "gates: an explicit null is NOT read as an absence (no fallback to the default root)",
563
- at(broken, "paper-status-gates", onEdit("papers/alpha/paper.md")).stderr
564
- .length === 0,
563
+ at(broken, "paper-status-gates", onEdit("papers/alpha/paper.tex"))
564
+ .stderr.length === 0,
565
565
  );
566
566
  }
567
567
  // The declared root is regex-ESCAPED: `.` in a root must not act as a wildcard.
@@ -576,7 +576,7 @@ try {
576
576
  at(
577
577
  dotted,
578
578
  "paper-status-gates",
579
- onEdit("docs.v2/papers/alpha/paper.md"),
579
+ onEdit("docs.v2/papers/alpha/paper.tex"),
580
580
  ).stderr,
581
581
  ),
582
582
  );
@@ -585,7 +585,7 @@ try {
585
585
  at(
586
586
  dotted,
587
587
  "paper-status-gates",
588
- onEdit("docsXv2/papers/alpha/paper.md"),
588
+ onEdit("docsXv2/papers/alpha/paper.tex"),
589
589
  ).stderr.length === 0,
590
590
  );
591
591
  }
@@ -186,7 +186,7 @@ export const papersRoot = (rawConfig) => {
186
186
  /**
187
187
  * Redirection targets (`> f`, `>> f`, `1> f`, `&> f`), which `touches` cannot see.
188
188
  *
189
- * MEASURED, not assumed: `leafCommandsNormalized("echo x > p/x/paper.md")` returns
189
+ * MEASURED, not assumed: `leafCommandsNormalized("echo x > p/x/paper.tex")` returns
190
190
  * `[{head:"echo", argv:["echo","x"], args:["x"], …}]` — the redirection and its target are
191
191
  * DROPPED by the parser, so they appear in no field of any leaf. The closed vocabulary
192
192
  * (`runs` / `touches` / `pipesToShell` / `isSideEffecting`) has no way to ask what a command
@@ -196,9 +196,9 @@ export const papersRoot = (rawConfig) => {
196
196
  * 🔴 THE SCAN IS QUOTE-AWARE, and the version before it was not. It stripped quoted spans
197
197
  * wholesale, so a quoted redirection target vanished with them. MEASURED on this guard:
198
198
  *
199
- * echo x > <root>/x/paper.md → blocked
200
- * echo x > "<root>/x/paper.md" → ALLOWED
201
- * echo x > '<root>/x/paper.md' → ALLOWED
199
+ * echo x > <root>/x/paper.tex → blocked
200
+ * echo x > "<root>/x/paper.tex" → ALLOWED
201
+ * echo x > '<root>/x/paper.tex' → ALLOWED
202
202
  *
203
203
  * Stripping was not merely careless: a `>` INSIDE quotes is not a redirection at all, so the
204
204
  * guard has to know about quoting or it fires on any command that merely QUOTES a paper write —
@@ -376,10 +376,7 @@ const runsMutator = (cmd) => MUTATORS.some((m) => cmd.runs(m));
376
376
  */
377
377
  const isPaperSource = (p) => !isFrozenSnapshot(p) && hasSourceExtension(p);
378
378
 
379
- const hasSourceExtension = (p) =>
380
- p.endsWith(".tex") ||
381
- /\/(paper|draft)\.md$/.test(p) ||
382
- /^(paper|draft)\.md$/.test(p);
379
+ const hasSourceExtension = (p) => p.endsWith(".tex");
383
380
 
384
381
  /**
385
382
  * A FROZEN SNAPSHOT — a file under a paper's `versions/`, which is the same carve-out as the