paperlint 4.1.0 → 5.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 (202) hide show
  1. package/CLAUDE.md +26 -2
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +15 -4
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/dist/adapters/latex/document-class.d.ts +6 -5
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -1
  8. package/dist/adapters/latex/document-class.js +18 -10
  9. package/dist/adapters/latex/document-class.js.map +1 -1
  10. package/dist/adapters/latex/includes.d.ts +16 -0
  11. package/dist/adapters/latex/includes.d.ts.map +1 -0
  12. package/dist/adapters/latex/includes.js +48 -0
  13. package/dist/adapters/latex/includes.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +9 -5
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +8 -1
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/nodes.d.ts +5 -1
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +6 -2
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +1 -0
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/adapters/latex/prose.d.ts +8 -0
  26. package/dist/adapters/latex/prose.d.ts.map +1 -0
  27. package/dist/adapters/latex/prose.js +353 -0
  28. package/dist/adapters/latex/prose.js.map +1 -0
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +7 -3
  31. package/dist/build.js.map +1 -1
  32. package/dist/claim-provenance.d.ts +34 -0
  33. package/dist/claim-provenance.d.ts.map +1 -0
  34. package/dist/claim-provenance.js +146 -0
  35. package/dist/claim-provenance.js.map +1 -0
  36. package/dist/cli.d.ts +2 -8
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +81 -31
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/paper-source.d.ts +58 -0
  41. package/dist/domain/paper-source.d.ts.map +1 -0
  42. package/dist/domain/paper-source.js +109 -0
  43. package/dist/domain/paper-source.js.map +1 -0
  44. package/dist/domain/sentences.d.ts +26 -0
  45. package/dist/domain/sentences.d.ts.map +1 -0
  46. package/dist/domain/sentences.js +48 -0
  47. package/dist/domain/sentences.js.map +1 -0
  48. package/dist/domain/tex-document.d.ts +39 -4
  49. package/dist/domain/tex-document.d.ts.map +1 -1
  50. package/dist/domain/tex-document.js.map +1 -1
  51. package/dist/eslint-rules/latex-language.d.ts +5 -1
  52. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  53. package/dist/eslint-rules/latex-language.js +6 -3
  54. package/dist/eslint-rules/latex-language.js.map +1 -1
  55. package/dist/eslint-rules/paper-context.d.ts +20 -0
  56. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  57. package/dist/eslint-rules/paper-context.js +44 -0
  58. package/dist/eslint-rules/paper-context.js.map +1 -0
  59. package/dist/eslint-rules/paper-typography.d.ts +8 -1
  60. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  61. package/dist/eslint-rules/paper-typography.js +85 -19
  62. package/dist/eslint-rules/paper-typography.js.map +1 -1
  63. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  64. package/dist/eslint-rules/tex-build.js +7 -1
  65. package/dist/eslint-rules/tex-build.js.map +1 -1
  66. package/dist/init.d.ts +4 -19
  67. package/dist/init.d.ts.map +1 -1
  68. package/dist/init.js +24 -29
  69. package/dist/init.js.map +1 -1
  70. package/dist/package-dirs.d.ts +2 -0
  71. package/dist/package-dirs.d.ts.map +1 -1
  72. package/dist/package-dirs.js +8 -0
  73. package/dist/package-dirs.js.map +1 -1
  74. package/dist/paper-files.d.ts +2 -2
  75. package/dist/paper-includes.d.ts +17 -0
  76. package/dist/paper-includes.d.ts.map +1 -0
  77. package/dist/paper-includes.js +51 -0
  78. package/dist/paper-includes.js.map +1 -0
  79. package/dist/ports/latex.d.ts +12 -1
  80. package/dist/ports/latex.d.ts.map +1 -1
  81. package/dist/register.d.ts +40 -0
  82. package/dist/register.d.ts.map +1 -0
  83. package/dist/register.js +134 -0
  84. package/dist/register.js.map +1 -0
  85. package/dist/rules-config.d.ts +1 -0
  86. package/dist/rules-config.d.ts.map +1 -1
  87. package/dist/rules-config.js.map +1 -1
  88. package/dist/structure.js +1 -1
  89. package/dist/structure.js.map +1 -1
  90. package/dist/tex-paper.d.ts +33 -0
  91. package/dist/tex-paper.d.ts.map +1 -0
  92. package/dist/tex-paper.js +103 -0
  93. package/dist/tex-paper.js.map +1 -0
  94. package/dist/tex-venue-rules.d.ts +7 -2
  95. package/dist/tex-venue-rules.d.ts.map +1 -1
  96. package/dist/tex-venue-rules.js +31 -17
  97. package/dist/tex-venue-rules.js.map +1 -1
  98. package/docs/configuration.md +5 -4
  99. package/docs/install.md +8 -8
  100. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  101. package/docs/rules/tex/claim-provenance.md +83 -0
  102. package/docs/rules/tex/register.md +77 -0
  103. package/docs/rules/tex/required-section.md +3 -1
  104. package/docs/rules/tex/template.md +12 -0
  105. package/docs/rules/tex/venue-leftover.md +4 -2
  106. package/docs/rules.md +17 -10
  107. package/eslint-rules/latex-language.ts +10 -4
  108. package/eslint-rules/paper-context.test.ts +47 -0
  109. package/eslint-rules/paper-context.ts +57 -0
  110. package/eslint-rules/paper-typography.test.mjs +48 -1
  111. package/eslint-rules/paper-typography.ts +139 -24
  112. package/eslint-rules/tex-build.ts +8 -1
  113. package/eslint.config.mjs +1 -0
  114. package/fixtures/real-markdown-paper/baseline.json +1 -2
  115. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  116. package/package.json +2 -1
  117. package/scripts/check.ts +2 -2
  118. package/scripts/eslint-report-guard.mjs +5 -2
  119. package/scripts/harness-api.frozen.json +0 -1
  120. package/skills/cold-read-diff/SKILL.md +31 -10
  121. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  122. package/skills/draft-paper/SKILL.md +2 -2
  123. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  124. package/skills/grade-paper-writing/SKILL.md +109 -44
  125. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  126. package/skills/harden-paper/SKILL.md +35 -36
  127. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  128. package/skills/paper-adversarial-review/SKILL.md +2 -2
  129. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  130. package/skills/paper-pipeline/SKILL.md +3 -3
  131. package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
  132. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  133. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  134. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  135. package/skills/pc-panel-review/SKILL.md +36 -11
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  137. package/src/adapters/latex/document-class.ts +20 -12
  138. package/src/adapters/latex/includes.ts +74 -0
  139. package/src/adapters/latex/index.test.ts +183 -1
  140. package/src/adapters/latex/index.ts +14 -4
  141. package/src/adapters/latex/nodes.ts +11 -2
  142. package/src/adapters/latex/parse.ts +1 -0
  143. package/src/adapters/latex/prose.ts +484 -0
  144. package/src/build.harness.mjs +4 -2
  145. package/src/build.test.ts +18 -1
  146. package/src/build.ts +7 -3
  147. package/src/claim-provenance.test.ts +352 -0
  148. package/src/claim-provenance.ts +217 -0
  149. package/src/cli.harness.mjs +22 -54
  150. package/src/cli.test.ts +2 -18
  151. package/src/cli.ts +92 -36
  152. package/src/domain/paper-source.test.ts +156 -0
  153. package/src/domain/paper-source.ts +233 -0
  154. package/src/domain/sentences.ts +67 -0
  155. package/src/domain/tex-document.ts +36 -3
  156. package/src/init.test.ts +33 -13
  157. package/src/init.ts +25 -48
  158. package/src/linted-set.test.ts +11 -1
  159. package/src/new-paper.harness.mjs +1 -5
  160. package/src/package-dirs.ts +9 -0
  161. package/src/paper-files.ts +2 -2
  162. package/src/paper-includes.test.ts +161 -0
  163. package/src/paper-includes.ts +77 -0
  164. package/src/ports/latex.ts +13 -0
  165. package/src/register.test.ts +165 -0
  166. package/src/register.ts +177 -0
  167. package/src/rule-cases.test.ts +43 -23
  168. package/src/rule-docs.test.ts +0 -1
  169. package/src/rules-config.ts +1 -0
  170. package/src/structure.harness.ts +1 -3
  171. package/src/structure.ts +1 -1
  172. package/src/tex-paper.test.ts +148 -0
  173. package/src/tex-paper.ts +161 -0
  174. package/src/tex-venue-rules.test.ts +87 -5
  175. package/src/tex-venue-rules.ts +62 -30
  176. package/templates/paper/PIPELINE-STATUS.md +0 -4
  177. package/dist/action-ref.d.ts +0 -12
  178. package/dist/action-ref.d.ts.map +0 -1
  179. package/dist/action-ref.js +0 -16
  180. package/dist/action-ref.js.map +0 -1
  181. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  182. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  183. package/dist/eslint-rules/paper-research-question.js +0 -188
  184. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  185. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  186. package/eslint-rules/paper-research-question.ts +0 -199
  187. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  188. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  189. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  190. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  191. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  192. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  193. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  194. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  195. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  196. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  197. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  198. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  199. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  200. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  201. package/src/action-ref.test.ts +0 -26
  202. package/src/action-ref.ts +0 -15
@@ -132,8 +132,8 @@ check(
132
132
  Array.isArray(cfg) && ruleBlocks(cfg).length === 4,
133
133
  );
134
134
  check(
135
- "with a LaTeX language a fifth block is added",
136
- ruleBlocks(buildConfig({}, {})).length === 5,
135
+ "with a LaTeX language two blocks are added: paper.tex, and the files it includes",
136
+ ruleBlocks(buildConfig({}, {})).length === 6,
137
137
  );
138
138
  check(
139
139
  "🔴 the project's paper TEMPLATE directory is ignored — flat config does not skip dot-directories",
@@ -181,7 +181,7 @@ check(
181
181
  writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
182
182
  writeFileSync(
183
183
  join(paper, "PIPELINE-STATUS.md"),
184
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
184
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
185
185
  );
186
186
  writeFileSync(
187
187
  join(root, "package.json"),
@@ -441,32 +441,14 @@ check(
441
441
  !existsSync(join(dir, WORKFLOW_PATH)),
442
442
  );
443
443
  check(
444
- "and the CI step is still printed — it just gets pasted in by hand",
445
- /uses: zernie\/paperlint@/.test(out.text()) &&
446
- /paths: writing/.test(out.text()),
447
- );
448
- }
449
- {
450
- // Guards: the printed step carries the tag of the running release, not a placeholder.
451
- const dir = project("ci-printed-pin", { papers: ["writing"] });
452
- const out = say();
453
- await init(dir, {
454
- log: out.log,
455
- err: out.log,
456
- interactive: false,
457
- run: haveAll,
458
- version: "1.2.3",
459
- });
460
- check(
461
- "🔴 a released version — the printed step is pinned to its tag, no <commit-sha>",
462
- /uses: zernie\/paperlint@v1\.2\.3\b/.test(out.text()) &&
463
- !/<commit-sha>/.test(out.text()),
444
+ "and the CI steps are still printed — they just get pasted in by hand",
445
+ out.text().includes("- uses: ./node_modules/paperlint"),
464
446
  );
465
447
  }
466
448
  {
467
449
  const dir = project("ci-yes", { papers: ["writing"] });
468
450
  const asked = [];
469
- const wf = await offerWorkflow(dir, "writing", {
451
+ const wf = await offerWorkflow(dir, {
470
452
  interactive: true,
471
453
  ask: async (q) => {
472
454
  asked.push(q);
@@ -477,16 +459,22 @@ check(
477
459
  "saying yes WRITES the workflow",
478
460
  wf === "written" && existsSync(join(dir, WORKFLOW_PATH)),
479
461
  );
462
+ const steps =
463
+ yamlLoad(readFileSync(join(dir, WORKFLOW_PATH), "utf8"))?.jobs?.papers
464
+ ?.steps ?? [];
480
465
  check(
481
- "and the workflow carries THE directory that was actually discussed",
482
- /paths: writing/.test(readFileSync(join(dir, WORKFLOW_PATH), "utf8")),
466
+ "🔴 the workflow runs the action from the installed package — one revision, set by the lockfile",
467
+ steps.some((st) => st.uses === "./node_modules/paperlint") &&
468
+ !steps.some((st) =>
469
+ String(st.uses ?? "").startsWith("zernie/paperlint"),
470
+ ),
483
471
  );
484
472
  check(
485
- "without a known release the action keeps the obvious placeholder, never a guessed tag",
486
- /@<commit-sha>/.test(readFileSync(join(dir, WORKFLOW_PATH), "utf8")),
473
+ "and it repeats no papers directory — the action reads papersDir from paperlint.json",
474
+ steps.every((st) => st.with?.paths === undefined),
487
475
  );
488
476
  check("the question is asked exactly once", asked.length === 1);
489
- const again = await offerWorkflow(dir, "writing", {
477
+ const again = await offerWorkflow(dir, {
490
478
  interactive: true,
491
479
  ask: async () => "y",
492
480
  });
@@ -495,29 +483,9 @@ check(
495
483
  again === "kept",
496
484
  );
497
485
  }
498
- {
499
- const dir = project("ci-pinned", { papers: ["writing"] });
500
- const wf = await offerWorkflow(dir, "writing", {
501
- interactive: true,
502
- ask: async () => "y",
503
- version: "1.2.3",
504
- });
505
- const yaml = existsSync(join(dir, WORKFLOW_PATH))
506
- ? readFileSync(join(dir, WORKFLOW_PATH), "utf8")
507
- : "";
508
- const uses = (yamlLoad(yaml)?.jobs?.papers?.steps ?? []).map(
509
- (s) => s.uses,
510
- );
511
- check(
512
- "🔴 a released version — the written workflow is pinned to its tag, no <commit-sha> left",
513
- wf === "written" &&
514
- uses.includes("zernie/paperlint@v1.2.3") &&
515
- !yaml.includes("<commit-sha>"),
516
- );
517
- }
518
486
  {
519
487
  const dir = project("ci-no", { papers: ["writing"] });
520
- const no = await offerWorkflow(dir, "writing", {
488
+ const no = await offerWorkflow(dir, {
521
489
  interactive: true,
522
490
  ask: async () => "",
523
491
  });
@@ -532,7 +500,7 @@ check(
532
500
  // 🔴 THE `.catch` HERE IS LOAD-BEARING, NOT CAUTION. A leaked exception is a PROPERTY that
533
501
  // gets asserted; without it, the harness crashes instead of failing a named assertion,
534
502
  // i.e. the real defect would look like a hole in the test rather than a caught one.
535
- const aborted = await offerWorkflow(dir, "writing", {
503
+ const aborted = await offerWorkflow(dir, {
536
504
  interactive: true,
537
505
  ask: async () => {
538
506
  throw new Error("Aborted with Ctrl+D");
@@ -733,7 +701,7 @@ check(
733
701
  "x".repeat(100),
734
702
  );
735
703
  const status = (bytes) =>
736
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
704
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
737
705
  writeFileSync(join(paper, "versions", "s.tex"), "abcd");
738
706
  writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
739
707
 
@@ -822,7 +790,7 @@ check(
822
790
  );
823
791
  writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
824
792
  const status = (bytes) =>
825
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
793
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
826
794
  writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(100));
827
795
 
828
796
  // findProjectRoot — kept separate from the run so the failure is distinguishable
@@ -934,7 +902,7 @@ check(
934
902
  );
935
903
  writeFileSync(
936
904
  join(paper, "PIPELINE-STATUS.md"),
937
- `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\nresearchQuestion: "does it hold?"\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
905
+ `---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
938
906
  );
939
907
  writeFileSync(
940
908
  join(root, "paperlint.json"),
package/src/cli.test.ts CHANGED
@@ -14,7 +14,6 @@ import type { runToolchain } from "./toolchain.ts";
14
14
  import {
15
15
  chooseVenue,
16
16
  initTexLive,
17
- ownVersion,
18
17
  paperRuleBlocks,
19
18
  parseArgs,
20
19
  parseSettings,
@@ -259,7 +258,7 @@ test("build: a markdown-only paper is refused", async () => {
259
258
  code: 1,
260
259
  out: [
261
260
  "papers/md",
262
- ` inputs: TEXINPUTS += ${venues}`,
261
+ ` inputs: TEXINPUTS += the paper's directory, then ${venues}`,
263
262
  " compile: refused — no paper.tex; paperlint compiles LaTeX, and this paper has none",
264
263
  " measure: skipped — nothing is compiled",
265
264
  " references: skipped — nothing is compiled",
@@ -288,7 +287,7 @@ test("build: with no TeX Live a dry run says where a real run stops, and a real
288
287
  out: [
289
288
  `engine: none — a real run would stop here: paperlint build: no TeX Live with every package these papers need — run \`npx paperlint toolchain\` (${missing})`,
290
289
  "papers/tex",
291
- ` inputs: TEXINPUTS += ${venues}`,
290
+ ` inputs: TEXINPUTS += the paper's directory, then ${venues}`,
292
291
  " compile: paper.tex (\\documentclass{article})",
293
292
  " measure: pdf.js → _build/paper.facts.json (facts for the lint rules; nothing is judged here)",
294
293
  " references: online: citations exist, titles and authors match → _build/references.json (never fails the build)",
@@ -529,21 +528,6 @@ test("chooseVenue: a question that fails (the stream ended) takes the default, n
529
528
  assert.deepEqual(r, { ok: true, value: null });
530
529
  });
531
530
 
532
- test("ownVersion: the manifest's version; an unreadable manifest or a non-string version is undefined", () => {
533
- assert.deepEqual(
534
- [
535
- ownVersion(() => '{"version": "1.2.3"}'),
536
- ownVersion(() => "{ nope"),
537
- ownVersion(() => '{"version": 3}'),
538
- ownVersion(() => {
539
- throw new Error("ENOENT");
540
- }),
541
- typeof ownVersion(),
542
- ],
543
- ["1.2.3", undefined, undefined, undefined, "string"],
544
- );
545
- });
546
-
547
531
  test("init's TeX Live install runs the toolchain over the project's papers, never a check", () => {
548
532
  const dir = writeTree(join(root, "init-tex"), { "package.json": "{}" });
549
533
  const calls: Parameters<typeof runToolchain>[0][] = [];
package/src/cli.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * but that is INTERNAL machinery, and you no longer need to know it in order to run them.
10
10
  *
11
11
  * Two entry points into the tool, and both are whole now:
12
- * npx paperlint lint ← here
13
- * uses: zernie/paperlint@<sha> ← action.yml
12
+ * npx paperlint lint ← here
13
+ * uses: ./node_modules/paperlint ← action.yml, after `npm ci`
14
14
  *
15
15
  * ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
16
16
  * Where the papers are and the project's own rule blocks — that is about ONE corpus, so it lives in
@@ -42,6 +42,7 @@ import {
42
42
  asEslintResults,
43
43
  } from "./structure.ts";
44
44
  import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
45
+ import { includeBlocks, paperBodies, unreadLines } from "./paper-includes.ts";
45
46
  import { prepareEngine } from "./build-engine.ts";
46
47
  import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
47
48
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
@@ -50,6 +51,8 @@ import { latexReader } from "./adapters/latex/index.ts";
50
51
  import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
51
52
  import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
52
53
  import { TEX_VENUE_RULE_LEVELS, texVenueRules } from "./tex-venue-rules.ts";
54
+ import { claimProvenanceRule } from "./claim-provenance.ts";
55
+ import { registerRule } from "./register.ts";
53
56
  import {
54
57
  paperRules,
55
58
  stringFields,
@@ -122,7 +125,6 @@ export { init };
122
125
  export { nextSteps } from "./init.ts";
123
126
 
124
127
  import paperStages from "#eslint-rules/paper-stages";
125
- import researchQuestion from "#eslint-rules/paper-research-question";
126
128
  import typography from "#eslint-rules/paper-typography";
127
129
  import texBuild from "#eslint-rules/tex-build";
128
130
  import bibReachable from "#eslint-rules/bib-reachable-entry";
@@ -225,7 +227,7 @@ export function buildConfig(
225
227
  opts: PaperlintConfig = {},
226
228
  texLanguage: unknown,
227
229
  ): ConfigBlock[] {
228
- const paperRules = { ...researchQuestion.rules, ...typography.rules };
230
+ const paperRules = { ...typography.rules };
229
231
  // The reference rules judge `_build/references.json`, and only on `paper.tex`.
230
232
  const texPaperRules = {
231
233
  ...paperRules,
@@ -233,7 +235,6 @@ export function buildConfig(
233
235
  };
234
236
  // Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
235
237
  const prose = {
236
- "paper/research-question": "warn",
237
238
  "paper/section-word": "warn",
238
239
  "paper/leading-zero": "warn",
239
240
  };
@@ -312,6 +313,14 @@ export function buildConfig(
312
313
  venuesDir: presetsDir(),
313
314
  latex: latexReader,
314
315
  }),
316
+ "claim-provenance": claimProvenanceRule({
317
+ files: nodeFiles,
318
+ latex: latexReader,
319
+ }),
320
+ register: registerRule({
321
+ files: nodeFiles,
322
+ latex: latexReader,
323
+ }),
315
324
  },
316
325
  },
317
326
  paper: { rules: texPaperRules },
@@ -324,23 +333,64 @@ export function buildConfig(
324
333
  "paper/figure-ref-style": "warn",
325
334
  "bib/reachable-entry": "warn",
326
335
  "tex/future-promise": "warn",
336
+ // A number with no owner: a prose rule, on beside the others above.
337
+ "tex/claim-provenance": "warn",
338
+ // Sentence-initial conjunctions over the whole body: register, not vocabulary.
339
+ "tex/register": "warn",
327
340
  "tex/acm-frontmatter-override": "error",
328
341
  // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
329
342
  ...VENUE_RULE_LEVELS,
330
343
  ...TEX_VENUE_RULE_LEVELS,
331
344
  },
332
345
  });
346
+ // THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
347
+ // (#144). The block claims every `.tex` but `paper.tex` — yet it is left out of the scope below, so
348
+ // it reaches only the files `paperlint lint` un-ignores by name for each paper (src/paper-includes.ts):
349
+ // a frozen version or a macro file is never enumerated. The rules are the ones that read a file as
350
+ // a fragment (no `document` environment: all of it is body); the whole-paper rules stay on
351
+ // `paper.tex`, which reads its includes itself (src/tex-paper.ts).
352
+ const fragments: readonly ConfigBlock[] = texLanguage
353
+ ? [
354
+ {
355
+ files: ["**/*.tex"],
356
+ ignores: ["**/paper.tex"],
357
+ plugins: {
358
+ tex: {
359
+ languages: { latex: texLanguage },
360
+ rules: { "future-promise": texBuild["future-promise"] },
361
+ },
362
+ paper: {
363
+ rules: {
364
+ "section-word": typography.rules["section-word"],
365
+ "leading-zero": typography.rules["leading-zero"],
366
+ "figure-ref-style": typography.rules["figure-ref-style"],
367
+ },
368
+ },
369
+ },
370
+ language: "tex/latex",
371
+ rules: {
372
+ "paper/section-word": "warn",
373
+ "paper/leading-zero": "warn",
374
+ "paper/figure-ref-style": "warn",
375
+ "tex/future-promise": "warn",
376
+ },
377
+ },
378
+ ]
379
+ : [];
333
380
  // 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
334
381
  // built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
335
382
  // `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
336
383
  // FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
337
- const owners = ruleOwners(cfg);
384
+ const owners = ruleOwners([...cfg, ...fragments]);
338
385
  cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
339
386
  // The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
340
387
  // `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
341
388
  // rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
342
389
  // PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
343
- cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
390
+ cfg.push(
391
+ ...fragments,
392
+ ...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)),
393
+ );
344
394
  return cfg;
345
395
  }
346
396
 
@@ -481,6 +531,17 @@ export async function silentOptionalRules(
481
531
  return [...turnedOn].filter((id) => !reached.has(id));
482
532
  }
483
533
 
534
+ /**
535
+ * The paper directories `paths` reach: each directory and the papers under it. A FILE named on the
536
+ * command line belongs to the paper it sits in.
537
+ */
538
+ function paperDirs(paths: readonly string[]): readonly string[] {
539
+ const dirs = paths.map((p) =>
540
+ existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
541
+ );
542
+ return [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
543
+ }
544
+
484
545
  /**
485
546
  * Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
486
547
  * preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
@@ -490,11 +551,7 @@ export async function silentOptionalRules(
490
551
  export function paperRuleBlocks(
491
552
  paths: readonly string[],
492
553
  ): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
493
- // A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
494
- const dirs = paths.map((p) =>
495
- existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
496
- );
497
- const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
554
+ const papers = paperDirs(paths);
498
555
  const out: { preset: RuleBlock[]; own: RuleBlock[] } = {
499
556
  preset: [],
500
557
  own: [],
@@ -745,28 +802,6 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
745
802
  "--max-warnings",
746
803
  ]);
747
804
 
748
- /**
749
- * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
750
- * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
751
- * the placeholder instead of guessing.
752
- */
753
- export function ownVersion(
754
- readManifest: () => string = () =>
755
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
756
- ): string | undefined {
757
- try {
758
- const manifest: unknown = JSON.parse(readManifest());
759
- return typeof manifest === "object" &&
760
- manifest !== null &&
761
- "version" in manifest &&
762
- typeof manifest.version === "string"
763
- ? manifest.version
764
- : undefined;
765
- } catch {
766
- return undefined;
767
- }
768
- }
769
-
770
805
  /**
771
806
  * Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
772
807
  * command needed the same config (`build`): two copies of this block would have drifted apart on
@@ -1401,7 +1436,6 @@ async function runInit(
1401
1436
  log,
1402
1437
  err,
1403
1438
  cwd,
1404
- version: ownVersion(),
1405
1439
  yes: a.yes,
1406
1440
  hooks: !a.noHooks,
1407
1441
  paper: a.paper,
@@ -1537,10 +1571,32 @@ export async function run(
1537
1571
  texLanguage = null;
1538
1572
  }
1539
1573
 
1574
+ // Each paper's body files, resolved the way the build resolves them — only when the LaTeX language
1575
+ // loaded, since only its rules read them. The project's papers too: `paperlint lint .` reaches
1576
+ // papers two levels down, which a directory's immediate papers do not.
1577
+ const bodies = texLanguage
1578
+ ? paperBodies(paperDirs([...paths, ...papersRoots(cfg)]), {
1579
+ files: nodeFiles,
1580
+ latex: latexReader,
1581
+ })
1582
+ : [];
1583
+ // Named only for the papers this run lints: a paper elsewhere in the project is not this run's.
1584
+ const inScope = (dir: string) =>
1585
+ paths.some((p) => dir === p || dir.startsWith(`${p}${sep}`));
1586
+ unreadLines(
1587
+ bodies.filter((b) => inScope(b.dir)),
1588
+ (p) => shown(cwd, p),
1589
+ ).forEach((l) => {
1590
+ err(l);
1591
+ });
1592
+
1540
1593
  const eslint = new ESLint({
1541
1594
  cwd: lintRoot(root, paths),
1542
1595
  overrideConfigFile: true,
1543
- overrideConfig: eslintConfig(buildConfig(withPapers, texLanguage)),
1596
+ overrideConfig: eslintConfig([
1597
+ ...buildConfig(withPapers, texLanguage),
1598
+ ...includeBlocks(bodies),
1599
+ ]),
1544
1600
  fix: a.fix,
1545
1601
  });
1546
1602
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The paper as one text: includes spliced where they stand, every character mapped back to its
3
+ * file, a missing file reported, a subfile's body only, and a file that includes itself entered once.
4
+ * The reader is a stand-in that finds `\input{…}` by position, so these tests are about assembling,
5
+ * not about parsing LaTeX (the LaTeX adapter's own tests cover `includes`).
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ assemblePaper,
10
+ originOf,
11
+ type AssembleDeps,
12
+ type Include,
13
+ } from "./paper-source.ts";
14
+
15
+ /** `\input{x}` / `\include{x}` / `\subfile{x}` in `src`, by a scan good enough for these inputs. */
16
+ const includes = (src: string): readonly Include[] =>
17
+ [...src.matchAll(/\\(input|include|subfile)\{([^}]*)\}/gu)].map((m) => ({
18
+ macro:
19
+ m[1] === "subfile" ? "subfile" : m[1] === "include" ? "include" : "input",
20
+ target: m[2] ?? "",
21
+ span: { start: m.index, end: m.index + m[0].length },
22
+ }));
23
+
24
+ const deps = (files: Record<string, string>): AssembleDeps => ({
25
+ includes,
26
+ documentBody: (src) => {
27
+ const a = src.indexOf("\\begin{document}");
28
+ const b = src.indexOf("\\end{document}");
29
+ return a < 0 || b < 0
30
+ ? null
31
+ : { start: a + "\\begin{document}".length, end: b };
32
+ },
33
+ read: (p) => files[p] ?? null,
34
+ });
35
+
36
+ describe("assemblePaper — the text TeX reads", () => {
37
+ it("splices each file where its include stands; `.tex` is tried first", () => {
38
+ const main = "A\\input{s/one}B\\include{two.tex}C";
39
+ const p = assemblePaper(
40
+ "paper.tex",
41
+ main,
42
+ deps({ "s/one.tex": "one", "two.tex": "two" }),
43
+ );
44
+ expect(p.text).toBe("AoneBtwoC");
45
+ expect(p.missing).toEqual([]);
46
+ expect([...new Set(p.segments.map((g) => g.file))]).toEqual([
47
+ "paper.tex",
48
+ "s/one.tex",
49
+ "two.tex",
50
+ ]);
51
+ });
52
+
53
+ it("nested includes resolve from the paper directory, and map back through the outermost include", () => {
54
+ const main = "x\\input{a}y";
55
+ const p = assemblePaper(
56
+ "paper.tex",
57
+ main,
58
+ deps({ "a.tex": "[\\input{s/b}]", "s/b.tex": "BB" }),
59
+ );
60
+ expect(p.text).toBe("x[BB]y");
61
+ expect(originOf(p, { start: 2, end: 4 })).toEqual({
62
+ file: "s/b.tex",
63
+ source: "BB",
64
+ span: { start: 0, end: 2 },
65
+ via: { start: 1, end: 10 },
66
+ });
67
+ });
68
+
69
+ it("🔴 a missing file is reported where it stands, and left out", () => {
70
+ const main = "a\\input{gone}b";
71
+ const p = assemblePaper("paper.tex", main, deps({}));
72
+ expect(p.text).toBe("ab");
73
+ expect(p.missing).toEqual([
74
+ {
75
+ file: "paper.tex",
76
+ target: "gone",
77
+ span: { start: 1, end: 13 },
78
+ via: { start: 1, end: 13 },
79
+ },
80
+ ]);
81
+ });
82
+ });
83
+
84
+ describe("assemblePaper — subfiles and a file that includes itself", () => {
85
+ it("a subfile contributes its document body, not its preamble", () => {
86
+ const sub =
87
+ "\\documentclass[paper]{subfiles}\\begin{document}BODY\\end{document}";
88
+ const p = assemblePaper(
89
+ "paper.tex",
90
+ "<\\subfile{s}>",
91
+ deps({ "s.tex": sub }),
92
+ );
93
+ expect(p.text).toBe("<BODY>");
94
+ });
95
+
96
+ it("a subfile with no document environment is read whole", () => {
97
+ const p = assemblePaper(
98
+ "paper.tex",
99
+ "<\\subfile{s}>",
100
+ deps({ "s.tex": "ALL" }),
101
+ );
102
+ expect(p.text).toBe("<ALL>");
103
+ });
104
+
105
+ it("a file that includes itself is entered once", () => {
106
+ const p = assemblePaper(
107
+ "paper.tex",
108
+ "m\\input{a}",
109
+ deps({ "a.tex": "a\\input{a}\\input{paper.tex}" }),
110
+ );
111
+ expect(p.text).toBe("ma");
112
+ });
113
+ });
114
+
115
+ describe("originOf — where a span of the assembled text comes from", () => {
116
+ const p = assemblePaper(
117
+ "paper.tex",
118
+ "ab\\input{x}cd",
119
+ deps({ "x.tex": "XYZ" }),
120
+ );
121
+
122
+ it("in the main file: its own offsets, via nothing", () => {
123
+ expect(originOf(p, { start: 0, end: 2 })).toEqual({
124
+ file: "paper.tex",
125
+ source: "ab\\input{x}cd",
126
+ span: { start: 0, end: 2 },
127
+ via: null,
128
+ });
129
+ });
130
+
131
+ it("after the include: offsets in the main file past the macro", () => {
132
+ expect(originOf(p, { start: 5, end: 7 })).toEqual({
133
+ file: "paper.tex",
134
+ source: "ab\\input{x}cd",
135
+ span: { start: 11, end: 13 },
136
+ via: null,
137
+ });
138
+ });
139
+
140
+ it("a span running out of an included file is cut at its end", () => {
141
+ expect(originOf(p, { start: 3, end: 7 })).toEqual({
142
+ file: "x.tex",
143
+ source: "XYZ",
144
+ span: { start: 1, end: 3 },
145
+ via: { start: 2, end: 11 },
146
+ });
147
+ });
148
+
149
+ it("an empty span at the very end belongs to the last stretch; outside the text there is none", () => {
150
+ expect(originOf(p, { start: 7, end: 7 })?.span).toEqual({
151
+ start: 13,
152
+ end: 13,
153
+ });
154
+ expect(originOf(p, { start: 9, end: 9 })).toBe(null);
155
+ });
156
+ });