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
package/src/init.ts CHANGED
@@ -63,12 +63,7 @@ import {
63
63
  type Merge,
64
64
  type WireResult,
65
65
  } from "./hooks-settings.ts";
66
- import {
67
- DEFAULT_FORMAT,
68
- isFormat,
69
- nameProblem,
70
- type PaperFormat,
71
- } from "./new-paper.ts";
66
+ import { nameProblem } from "./new-paper.ts";
72
67
  // The one source for the consumer's config key lives in lib/ (the ESLint rules and the skill scripts
73
68
  // import it too). It is imported compiled, from dist/, the one path that resolves the same from src/
74
69
  // and from dist/ — see CONTRIBUTING.md.
@@ -572,17 +567,11 @@ export interface InitOptions {
572
567
  merge?: Merge | undefined;
573
568
  /** `--paper <name>`: create this paper, even without a terminal. */
574
569
  paper?: string | null;
575
- /** `--format tex|md` for that paper. */
576
- format?: PaperFormat | null;
577
570
  /**
578
571
  * Creates one paper and lints it — `paperlint new`'s own routine, passed in by the CLI so `init` and
579
572
  * `new` cannot drift into two implementations.
580
573
  */
581
- createPaper?: (
582
- papersRoot: string,
583
- name: string,
584
- format: PaperFormat,
585
- ) => Promise<number>;
574
+ createPaper?: (papersRoot: string, name: string) => Promise<number>;
586
575
  /**
587
576
  * The venues a paper name may not name (`shippedVenueNames`), so `--paper aisec-2026` is refused
588
577
  * before anything is asked. `createPaper` refuses it again whatever this holds.
@@ -717,7 +706,6 @@ export async function init(
717
706
  hooks = true,
718
707
  merge,
719
708
  paper = null,
720
- format = null,
721
709
  createPaper,
722
710
  venues = [],
723
711
  run = spawnSync,
@@ -824,18 +812,11 @@ export async function init(
824
812
  }
825
813
  if (wanted !== null && createPaper) {
826
814
  const problem = nameProblem(wanted, venues);
827
- let fmt: PaperFormat = format ?? DEFAULT_FORMAT;
828
- if (!problem && format === null && interactive) {
829
- const f = (
830
- await askOrDefault(ask, ` format: tex / md [${DEFAULT_FORMAT}] `)
831
- )?.trim();
832
- if (isFormat(f)) fmt = f;
833
- }
834
815
  if (problem) {
835
816
  log(` ✗ no paper created — ${problem}`);
836
817
  paperCode = 2;
837
818
  } else {
838
- const code = await createPaper(papersAbs, wanted, fmt);
819
+ const code = await createPaper(papersAbs, wanted);
839
820
  if (code !== 0) {
840
821
  log(` ⚠ the new paper's lint exited ${String(code)} — see above`);
841
822
  paperCode = code;
@@ -20,6 +20,10 @@ afterEach(() => {
20
20
  for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
21
21
  });
22
22
 
23
+ /** A paper.tex whose whole body is `body`. */
24
+ const PAPER = (body: string): string =>
25
+ `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
26
+
23
27
  function project(
24
28
  settings: Record<string, unknown>,
25
29
  files: Record<string, string> = {},
@@ -30,7 +34,7 @@ function project(
30
34
  "package.json": JSON.stringify({ name: "c", private: true }),
31
35
  "paperlint.json": JSON.stringify({ papersDir: "papers", ...settings }),
32
36
  "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
33
- "papers/a/paper.md": "# Intro\n\nSee §5, §6 and §7; p < .05.\n",
37
+ "papers/a/paper.tex": PAPER("See §5, §6 and §7; $p < .05$."),
34
38
  ...files,
35
39
  };
36
40
  for (const [p, text] of Object.entries(all)) {
@@ -64,15 +68,17 @@ describe("paperlint lint --fix", () => {
64
68
  const fixed = await lint(root, ["--fix"]);
65
69
  expect(ruleIds(fixed.out)).not.toContain("paper/section-word");
66
70
  expect(ruleIds(fixed.out)).not.toContain("paper/leading-zero");
67
- expect(readFileSync(join(root, "papers/a/paper.md"), "utf8")).toBe(
68
- "# Intro\n\nSee Section 5, Section 6 and Section 7; p < 0.05.\n",
71
+ expect(readFileSync(join(root, "papers/a/paper.tex"), "utf8")).toBe(
72
+ PAPER("See Section 5, Section 6 and Section 7; $p < 0.05$."),
69
73
  );
70
74
  });
71
75
 
72
76
  it("without --fix nothing is written", async () => {
73
77
  const root = project({});
74
78
  await lint(root);
75
- expect(readFileSync(join(root, "papers/a/paper.md"), "utf8")).toMatch(/§5/);
79
+ expect(readFileSync(join(root, "papers/a/paper.tex"), "utf8")).toMatch(
80
+ /§5/,
81
+ );
76
82
  });
77
83
  });
78
84
 
@@ -36,7 +36,6 @@ const ROOT = dirname(HERE);
36
36
  const {
37
37
  newPaper,
38
38
  nameProblem,
39
- wantedFiles,
40
39
  PACKAGE_TEMPLATES,
41
40
  OVERRIDE_DIR,
42
41
  STATUS_FILE,
@@ -63,7 +62,7 @@ async function lint(dir) {
63
62
 
64
63
  try {
65
64
  // ── the templates are FILES in the package, one per required file ──────────────────────
66
- for (const f of [STATUS_FILE, SOURCE_FILE.tex, SOURCE_FILE.md])
65
+ for (const f of [STATUS_FILE, SOURCE_FILE])
67
66
  check(
68
67
  `the package ships templates/paper/${f}`,
69
68
  existsSync(join(PACKAGE_TEMPLATES, f)),
@@ -85,13 +84,13 @@ try {
85
84
  );
86
85
  check(
87
86
  "a refused name creates nothing",
88
- newPaper(work, "Bad", "tex", { venues: [] }).ok === false &&
87
+ newPaper(work, "Bad", { venues: [] }).ok === false &&
89
88
  !existsSync(join(work, "Bad")),
90
89
  );
91
90
 
92
91
  // ── a fresh LaTeX paper ─────────────────────────────────────────────────────────────
93
92
  const papers = join(work, "papers");
94
- const r = newPaper(papers, "demo", "tex", { venues: [] });
93
+ const r = newPaper(papers, "demo", { venues: [] });
95
94
  check(
96
95
  "a fresh folder gets the scorecard, a .tex source and paperlint.json, all from the package",
97
96
  r.ok &&
@@ -129,36 +128,18 @@ try {
129
128
 
130
129
  // ── never overwrite; on an existing folder only the missing files ─────────────────────
131
130
  writeFileSync(join(papers, "demo", STATUS_FILE), "mine\n");
132
- const again = newPaper(papers, "demo", "md", { venues: [] });
131
+ const again = newPaper(papers, "demo", { venues: [] });
133
132
  check(
134
- "🔴 a second run overwrites nothing and adds no second source",
133
+ "🔴 a second run overwrites nothing",
135
134
  again.ok &&
136
135
  !again.fresh &&
137
136
  read(papers, "demo", STATUS_FILE) === "mine\n" &&
138
- !existsSync(join(papers, "demo", "paper.md")) &&
139
137
  again.files.every((f) => f.status === "kept"),
140
138
  );
141
- mkdirSync(join(papers, "old"), { recursive: true });
142
- writeFileSync(join(papers, "old", "paper.md"), "# Old\n");
143
- const migrated = newPaper(papers, "old", "tex", { venues: [] });
144
- check(
145
- "an old folder with only paper.md gets ONLY the scorecard — that is the migration",
146
- migrated.ok &&
147
- existsSync(join(papers, "old", STATUS_FILE)) &&
148
- !existsSync(join(papers, "old", "paper.tex")) &&
149
- read(papers, "old", "paper.md") === "# Old\n",
150
- );
151
- check(
152
- "wantedFiles asks for a source only when neither format is there",
153
- wantedFiles(join(papers, "old"), "tex").join() ===
154
- `${STATUS_FILE},paperlint.json` &&
155
- wantedFiles(join(papers, "nowhere"), "md").join() ===
156
- `${STATUS_FILE},paper.md,paperlint.json`,
157
- );
158
139
  writeFileSync(join(papers, "afile"), "x");
159
140
  check(
160
141
  "a name taken by a FILE is refused, not written through",
161
- newPaper(papers, "afile", "tex", { venues: [] }).ok === false,
142
+ newPaper(papers, "afile", { venues: [] }).ok === false,
162
143
  );
163
144
 
164
145
  // ── the project's template wins, file by file ─────────────────────────────────────────
@@ -167,7 +148,7 @@ try {
167
148
  join(papers, OVERRIDE_DIR, STATUS_FILE),
168
149
  "---\n---\n# House scorecard for {{name}}\n",
169
150
  );
170
- const own = newPaper(papers, "house", "md", { venues: [] });
151
+ const own = newPaper(papers, "house", { venues: [] });
171
152
  // Guards: the override slot — `fromTemplate` in new-paper.ts reads `<papers>/.template/<file>`
172
153
  // before the package's copy. Replace that lookup with the package's alone and this goes red.
173
154
  check(
@@ -176,11 +157,11 @@ try {
176
157
  read(papers, "house", STATUS_FILE) ===
177
158
  "---\n---\n# House scorecard for house\n" &&
178
159
  own.files.find((f) => f.file === STATUS_FILE)?.from === "project" &&
179
- own.files.find((f) => f.file === "paper.md")?.from === "package",
160
+ own.files.find((f) => f.file === SOURCE_FILE)?.from === "package",
180
161
  );
181
162
 
182
163
  // ── a missing template is a broken install, not an empty file ─────────────────────────
183
- const broken = newPaper(join(work, "b"), "x", "tex", {
164
+ const broken = newPaper(join(work, "b"), "x", {
184
165
  venues: [],
185
166
  packageTemplates: join(work, "no-such-dir"),
186
167
  });
@@ -41,7 +41,7 @@ const settingsOf = (dir: string) =>
41
41
  describe("paperlint new — paperlint.json", () => {
42
42
  it("is always written, from the package template: no venue chosen yet, and valid", () => {
43
43
  const papers = join(tmp(), "papers");
44
- const r = newPaper(papers, "demo", "tex", { venues: [] });
44
+ const r = newPaper(papers, "demo", { venues: [] });
45
45
  expect(r.ok && r.files.find((f) => f.file === "paperlint.json")).toEqual({
46
46
  file: "paperlint.json",
47
47
  status: "created",
@@ -72,7 +72,7 @@ describe("paperlint new — paperlint.json", () => {
72
72
  join(papers, OVERRIDE_DIR, "paperlint.json"),
73
73
  '{ "extends": "paperlint:aisec", "kind": "research" }\n',
74
74
  );
75
- const r = newPaper(papers, "house", "tex", { venues: [] });
75
+ const r = newPaper(papers, "house", { venues: [] });
76
76
  expect(r.ok && r.files.find((f) => f.file === "paperlint.json")?.from).toBe(
77
77
  "project",
78
78
  );
@@ -89,7 +89,7 @@ describe("paperlint new — paperlint.json", () => {
89
89
  join(papers, "p", "paperlint.json"),
90
90
  '{"extends":"paperlint:aisec"}',
91
91
  );
92
- const r = newPaper(papers, "p", "tex", { venues: [] });
92
+ const r = newPaper(papers, "p", { venues: [] });
93
93
  expect(
94
94
  r.ok && r.files.find((f) => f.file === "paperlint.json")?.status,
95
95
  ).toBe("kept");
@@ -106,7 +106,7 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
106
106
  join(root, "package.json"),
107
107
  JSON.stringify({ name: "c", private: true }),
108
108
  );
109
- newPaper(join(root, "papers"), "demo", "tex", { venues: [] });
109
+ newPaper(join(root, "papers"), "demo", { venues: [] });
110
110
  if (extendsValue !== null)
111
111
  writeFileSync(
112
112
  join(root, "papers", "demo", "paperlint.json"),
@@ -488,7 +488,7 @@ describe("a project's own template", () => {
488
488
  mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
489
489
  const src = join(papers, OVERRIDE_DIR, "paperlint.json");
490
490
  writeFileSync(src, '{\n // our house venue\n "extends": null\n}\n');
491
- const r = newPaper(papers, "p", "tex", {
491
+ const r = newPaper(papers, "p", {
492
492
  venues: [],
493
493
  venue: { extends: "paperlint:aisec", kind: null },
494
494
  });
@@ -504,7 +504,7 @@ describe("a project's own template", () => {
504
504
  mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
505
505
  writeFileSync(join(papers, OVERRIDE_DIR, "paperlint.json"), "{}\n");
506
506
  const lines = reportNewPaper(
507
- newPaper(papers, "p", "tex", { venues: [] }),
507
+ newPaper(papers, "p", { venues: [] }),
508
508
  (p) => p,
509
509
  );
510
510
  expect(lines).toContain(
package/src/new-paper.ts CHANGED
@@ -8,8 +8,7 @@
8
8
  * Every rule below is taken from a tool that already solved it (docs/prior-art/paper-folder-scaffolding.md):
9
9
  * - the template is a FILE with a lookup order — the project's `<papers>/.template/<file>` first,
10
10
  * the package's `templates/paper/<file>` second (Hugo's archetypes);
11
- * - it NEVER overwrites: on an existing folder it adds only the missing required files, which is
12
- * also the migration for an old folder that has only `paper.md` (`cargo init`);
11
+ * - it NEVER overwrites: on an existing folder it adds only the missing required files (`cargo init`);
13
12
  * - the name is validated, because it becomes a path and, through the hooks, a shell argument.
14
13
  *
15
14
  * The only substitution is `{{name}}` → the folder name. Nothing else in a template is touched.
@@ -33,10 +32,6 @@ import {
33
32
  type NamedVenue,
34
33
  } from "./domain/venue-name.ts";
35
34
 
36
- export type PaperFormat = "tex" | "md";
37
- export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
38
- export const DEFAULT_FORMAT: PaperFormat = "tex";
39
-
40
35
  /** The package's default templates. Read from disk, so they ship in the tarball (`files`). */
41
36
  export const PACKAGE_TEMPLATES = fileURLToPath(
42
37
  new URL("../templates/paper/", import.meta.url),
@@ -50,17 +45,12 @@ export const PACKAGE_TEMPLATES = fileURLToPath(
50
45
  export const OVERRIDE_DIR = ".template";
51
46
 
52
47
  export const STATUS_FILE = "PIPELINE-STATUS.md";
53
- export const SOURCE_FILE: Readonly<Record<PaperFormat, string>> = {
54
- tex: "paper.tex",
55
- md: "paper.md",
56
- };
48
+ /** A paper's source: LaTeX, one file at the paper's root. */
49
+ export const SOURCE_FILE = "paper.tex";
57
50
 
58
51
  /** The flag that lets `paperlint new` create a folder whose name names a venue. */
59
52
  export const ALLOW_VENUE_NAME = "--allow-venue-name";
60
53
 
61
- export const isFormat = (v: unknown): v is PaperFormat =>
62
- FORMATS.some((f) => f === v);
63
-
64
54
  /**
65
55
  * Why a name is refused, or null. `[a-z0-9._-]+` is the charset; a LEADING dot is refused on top
66
56
  * of it, because discovery skips dot-directories — a paper named `.x` would be created and then
@@ -109,20 +99,12 @@ export type NewPaperResult =
109
99
  }
110
100
  | { readonly ok: false; readonly reason: string };
111
101
 
112
- /**
113
- * The files a paper folder must end up with: the scorecard, a source in `format` — unless it
114
- * already has a source in EITHER format, in which case that one stands — and `paperlint.json`.
115
- */
116
- export function wantedFiles(dir: string, format: PaperFormat): string[] {
117
- return [...scorecardAndSource(dir, format), CONFIG_FILE];
118
- }
119
-
120
- function scorecardAndSource(dir: string, format: PaperFormat): string[] {
121
- const hasSource = Object.values(SOURCE_FILE).some((f) =>
122
- existsSync(join(dir, f)),
123
- );
124
- return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
125
- }
102
+ /** The files a paper folder must end up with: the scorecard, the source and `paperlint.json`. */
103
+ export const WANTED_FILES: readonly string[] = [
104
+ STATUS_FILE,
105
+ SOURCE_FILE,
106
+ CONFIG_FILE,
107
+ ];
126
108
 
127
109
  /** The venue `paperlint new --venue` chose: what goes into the new paper's `paperlint.json`. */
128
110
  export interface VenueSetting {
@@ -195,7 +177,7 @@ function fromTemplate(
195
177
  return { ok: false, reason: `no template for ${file}: ${src} is missing` };
196
178
  const text = readFileSync(src, "utf8").split("{{name}}").join(name);
197
179
  const setClass = venue?.setClass ?? null;
198
- if (file === SOURCE_FILE.tex && setClass !== null)
180
+ if (file === SOURCE_FILE && setClass !== null)
199
181
  return { ok: true, value: { file, text: setClass(text), from } };
200
182
  if (file !== CONFIG_FILE || venue === null)
201
183
  return { ok: true, value: { file, text, from } };
@@ -217,7 +199,6 @@ export interface NewPaperOptions {
217
199
  export function newPaper(
218
200
  papersRoot: string,
219
201
  name: string,
220
- format: PaperFormat,
221
202
  opts: NewPaperOptions,
222
203
  ): NewPaperResult {
223
204
  const { packageTemplates = PACKAGE_TEMPLATES, venue = null } = opts;
@@ -230,7 +211,7 @@ export function newPaper(
230
211
 
231
212
  const files: FileOutcome[] = [];
232
213
  const toWrite: Planned[] = [];
233
- for (const file of wantedFiles(dir, format)) {
214
+ for (const file of WANTED_FILES) {
234
215
  if (existsSync(join(dir, file))) {
235
216
  files.push({ file, status: "kept" });
236
217
  continue;
@@ -20,6 +20,7 @@ import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { afterEach, describe, expect, it } from "vitest";
22
22
  import { buildConfig, eslintConfig, run } from "./cli.ts";
23
+ import { texLanguage } from "#eslint-rules/latex-language";
23
24
  import {
24
25
  narrowToOwners,
25
26
  ownedPatterns,
@@ -247,7 +248,7 @@ describe("`rules` blocks — where the rule lands", () => {
247
248
  },
248
249
  ],
249
250
  },
250
- null,
251
+ texLanguage,
251
252
  ),
252
253
  ),
253
254
  });
@@ -257,9 +258,9 @@ describe("`rules` blocks — where the rule lands", () => {
257
258
  const status = await on("papers/a/PIPELINE-STATUS.md");
258
259
  expect(status["paper/source"]).toEqual([1]);
259
260
  expect(status["paper/section-word"]).toBeUndefined();
260
- const draft = await on("papers/a/paper.md");
261
- expect(draft["paper/section-word"]).toEqual([0]);
262
- expect(draft["paper/source"]).toBeUndefined();
261
+ const source = await on("papers/a/paper.tex");
262
+ expect(source["paper/section-word"]).toEqual([0]);
263
+ expect(source["paper/source"]).toBeUndefined();
263
264
  });
264
265
  });
265
266
 
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * THE FILES PAPERLINT OWNS — read off its own config, and made the only thing ESLint may reach.
3
3
  *
4
- * paperlint's blocks name the files they lint (`PIPELINE-STATUS.md`, `paper.md`, `paper.tex`,
5
- * `reviews/*.md`, …). That list is the whole scope of `paperlint lint`, and this module turns it
4
+ * paperlint's blocks name the files they lint (`PIPELINE-STATUS.md`, `paper.tex`, `reviews/*.md`,
5
+ * …). That list is the whole scope of `paperlint lint`, and this module turns it
6
6
  * into two things ESLint understands:
7
7
  *
8
8
  * 1. **A global ignore of everything else** (`scopeToOwned`). Handing ESLint a directory is not
@@ -17,7 +17,7 @@
17
17
  * 2. **Each rule scoped to the files it was written for** (`ruleOwners` + `narrowToOwners`).
18
18
  * One plugin NAME is bound to different plugin objects in different blocks: `paper` holds
19
19
  * `stages`/`source`/`author-list` beside `PIPELINE-STATUS.md` and `typography` beside
20
- * `paper.md`/`paper.tex`. So a consumer block that named `paper/source`
20
+ * `paper.tex`. So a consumer block that named `paper/source`
21
21
  * for every file under the papers directory reached `paper.tex`, where `paper` has no `source`
22
22
  * — and ESLint threw `Could not find "source" in plugin "paper"` from inside `lintFiles`,
23
23
  * after paperlint's own validation had (correctly) accepted the id. A block is therefore split
@@ -36,7 +36,7 @@
36
36
  * - a global-ignore pattern that matches a DIRECTORY hides everything inside it, so directories
37
37
  * must be un-ignored (`!` + a pattern ending in a slash) before owned files can be;
38
38
  * - that un-ignores `node_modules/` and `.git/` too — ESLint's own defaults — so they are
39
- * re-ignored after, or a `paper.md` inside a dependency would be linted;
39
+ * re-ignored after, or a `paper.tex` inside a dependency would be linted;
40
40
  * - later global-ignore patterns win, across blocks, in config order;
41
41
  * - `files: [["a", "b"]]` matches a file only when BOTH patterns match.
42
42
  */
@@ -0,0 +1,49 @@
1
+ import { expect, it } from "vitest";
2
+ import { latexReader } from "./adapters/latex/index.ts";
3
+ import { memoryFiles } from "./adapters/memory/index.ts";
4
+ import { paperProse } from "./paper-prose.ts";
5
+
6
+ const SRC = [
7
+ "\\documentclass{article}",
8
+ "\\usepackage{amsmath} % a preamble word count of its own",
9
+ "\\title{A Title}",
10
+ "\\begin{document}",
11
+ "\\maketitle",
12
+ "\\section{Introduction}",
13
+ "We measure it~\\cite{a}. % a comment",
14
+ "",
15
+ "A second paragraph \\input{body}",
16
+ "\\begin{filecontents*}{refs.bib}",
17
+ "@misc{a, author = {Ada Lovelace}, title = {Never Prose}}",
18
+ "\\end{filecontents*}",
19
+ "\\end{document}",
20
+ ].join("\n");
21
+
22
+ it("the body's prose, a blank line between passages, includes spliced; the preamble, comments, the inline .bib and the title stay out", () => {
23
+ const files = memoryFiles({
24
+ "/p/paper.tex": SRC,
25
+ "/p/body.tex": "\\subsection{Method}\nends here.",
26
+ });
27
+ expect(
28
+ paperProse("/p/paper.tex", SRC, { files, latex: latexReader }),
29
+ ).toEqual({
30
+ // A citation stands as `[1]`, where it is set; a heading — here an included one — ends a passage.
31
+ body: "We measure it [1].\n\nA second paragraph\n\nends here.",
32
+ headings: ["Introduction", "Method"],
33
+ files: [
34
+ { file: "paper.tex", text: SRC },
35
+ { file: "body.tex", text: "\\subsection{Method}\nends here." },
36
+ ],
37
+ });
38
+ });
39
+
40
+ it("a citation stands as `[1]` and a cross-reference as `1`, so the sentence still ends on them", () => {
41
+ const src =
42
+ "\\documentclass{article}\n\\begin{document}\nIt holds~\\cite{a}. It is shown (Section~\\ref{s}).\n\\end{document}\n";
43
+ expect(
44
+ paperProse("/p/paper.tex", src, {
45
+ files: memoryFiles({ "/p/paper.tex": src }),
46
+ latex: latexReader,
47
+ }).body,
48
+ ).toBe("It holds [1]. It is shown (Section 1).");
49
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * THE PROSE OF A paper.tex AS PLAIN TEXT — what `grade-paper-writing/prose-lint.mjs` measures.
3
+ *
4
+ * prose-lint counts sentences and words, and every metric it prints is a fraction of the word
5
+ * count. Read as raw text, a `.tex` puts the preamble, the `%` comments and an inline `filecontents`
6
+ * bibliography into that count (8127 "words" against about 4636 real ones on one paper), so every
7
+ * rate comes out roughly halved. This reads the paper the way the `tex/register` rule does: the
8
+ * includes spliced in (`readPaper`), then the body's prose as passages (`bodyProse` — the abstract
9
+ * to the back matter, without headings, captions, floats or code), each passage's sentences
10
+ * (`bodySentences`). A blank line separates two passages, so no sentence runs across them.
11
+ *
12
+ * A citation stands as `[1]` and a cross-reference as `1`, where they are set — not left out:
13
+ * prose-lint judges a sentence that ENDS on one (a wasted stress position) and counts references
14
+ * to other parts of the paper (`(Section 1)`), and both are invisible in a sentence the marks
15
+ * were cut out of. Math is left out; prose-lint counts no formula.
16
+ */
17
+ import { bodySentences } from "./domain/register.ts";
18
+ import { CITATION, MATH, REFERENCE } from "./domain/sentences.ts";
19
+ import { runText } from "./domain/tex-document.ts";
20
+ import { readPaper, type PaperDeps } from "./tex-paper.ts";
21
+
22
+ /** A sentence's text with each mark as a reader would see it set, and math left out. */
23
+ const rendered = (raw: string): string =>
24
+ raw
25
+ .replaceAll(CITATION, "[1]")
26
+ .replaceAll(REFERENCE, "1")
27
+ .replaceAll(MATH, "")
28
+ .replace(/\s+/gu, " ")
29
+ .trim();
30
+
31
+ /**
32
+ * A paper's body prose, one passage per paragraph, its section titles in document order, and the
33
+ * text of each file it is made of — `paper.tex` and every file it includes, once, named as the
34
+ * include names it — for what the body leaves out, such as captions.
35
+ */
36
+ export interface PaperProse {
37
+ readonly body: string;
38
+ readonly headings: readonly string[];
39
+ readonly files: readonly { readonly file: string; readonly text: string }[];
40
+ }
41
+
42
+ /** The prose and the headings of the paper whose main file is `filename` with the text `src`. */
43
+ export function paperProse(
44
+ filename: string,
45
+ src: string,
46
+ deps: PaperDeps,
47
+ ): PaperProse {
48
+ const { text, segments } = readPaper(filename, src, deps);
49
+ const passages = deps.latex
50
+ .bodyProse(text)
51
+ .map((p) =>
52
+ bodySentences([p])
53
+ .map((s) => rendered(s.raw))
54
+ .join(" "),
55
+ )
56
+ .filter((p) => p !== "");
57
+ return {
58
+ body: passages.join("\n\n"),
59
+ headings: deps.latex
60
+ .headings(text)
61
+ .filter((h) => h.command.kind === "heading" && h.argument === "title")
62
+ .map((h) => runText(h.title).replace(/\s+/g, " ").trim()),
63
+ files: [...new Map(segments.map((s) => [s.file, s.source])).entries()].map(
64
+ ([file, text]) => ({ file, text }),
65
+ ),
66
+ };
67
+ }
@@ -22,7 +22,6 @@ describe("parseArgs — `--flag=value` is the same as `--flag value`", () => {
22
22
  ["--config=o.json", "config", "o.json"],
23
23
  ["--venue=agenticdev", "venue", "agenticdev"],
24
24
  ["--kind=short", "kind", "short"],
25
- ["--format=md", "format", "md"],
26
25
  ["--paper=p", "paper", "p"],
27
26
  ] as const)("%s", (flag, key, value) => {
28
27
  const a = parseArgs(["new", "x", flag]);
@@ -47,7 +47,6 @@ const paper = (name: string, files: readonly string[]) => {
47
47
 
48
48
  try {
49
49
  paper("complete", ["PIPELINE-STATUS.md", "paper.tex", "refs.bib"]);
50
- paper("complete-md", ["PIPELINE-STATUS.md", "paper.md"]);
51
50
  // the marker is present (paper.tex), the scorecard is not — the directory is linted by ZERO
52
51
  // rules and reports clean
53
52
  paper("no-scorecard", ["paper.tex", "refs.bib"]);
@@ -65,10 +64,6 @@ try {
65
64
  "a complete paper directory — NOT A SINGLE finding",
66
65
  at("complete").length === 0,
67
66
  );
68
- check(
69
- "and `paper.md` counts on equal footing with `paper.tex` — the corpus holds both forms",
70
- at("complete-md").length === 0,
71
- );
72
67
  check(
73
68
  "a missing scorecard — a finding",
74
69
  at("no-scorecard").length === 1 &&
@@ -86,10 +81,11 @@ try {
86
81
  /no-scorecard/.test(at("no-scorecard")[0]?.message ?? ""),
87
82
  );
88
83
  check(
89
- "a directory with no source — a finding, and BOTH accepted forms are listed",
84
+ "a directory with no source — a finding naming `paper.tex`",
90
85
  at("no-source").length === 1 &&
91
- /`paper\.tex`/.test(at("no-source")[0]?.message ?? "") &&
92
- /`paper\.md`/.test(at("no-source")[0]?.message ?? ""),
86
+ /`paper\.tex` — a paper directory with no source/.test(
87
+ at("no-source")[0]?.message ?? "",
88
+ ),
93
89
  );
94
90
 
95
91
  // 🔴 THE PAIRED HALF: detection is GENEROUS. Without this the check would scream about every
@@ -124,9 +120,7 @@ try {
124
120
  {
125
121
  cwd: root,
126
122
  },
127
- ).some(
128
- (x) => x.file.endsWith("complete-md") && /refs\.bib/.test(x.message),
129
- ),
123
+ ).some((x) => x.file.endsWith("no-source") && /refs\.bib/.test(x.message)),
130
124
  );
131
125
  check(
132
126
  "a nonexistent root does not crash it — that's the empty-set guard talking",
package/src/structure.ts CHANGED
@@ -26,6 +26,7 @@ import { readdirSync, existsSync } from "node:fs";
26
26
  import { join, relative, basename, isAbsolute, sep } from "node:path";
27
27
  import type { StructureConfig, StructureFinding } from "./types.ts";
28
28
  import { CONFIG_FILE } from "#lib/paper-config";
29
+ import { SOURCE_FILE } from "./new-paper.ts";
29
30
 
30
31
  /** The requirements after the consumer's config is laid over the defaults. */
31
32
  type Rules = Required<StructureConfig>;
@@ -38,9 +39,9 @@ type Rules = Required<StructureConfig>;
38
39
  * byte-compared by the `paper/stages` rule anyway.
39
40
  */
40
41
  export const STRUCTURE_DEFAULTS = {
41
- markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md", CONFIG_FILE],
42
+ markers: ["PIPELINE-STATUS.md", SOURCE_FILE, CONFIG_FILE],
42
43
  require: ["PIPELINE-STATUS.md"],
43
- requireOneOf: [["paper.tex", "paper.md"]],
44
+ requireOneOf: [[SOURCE_FILE]],
44
45
  ignore: [],
45
46
  };
46
47
 
@@ -118,7 +119,7 @@ export function checkStructure(
118
119
  * The message NAMES THE CONSEQUENCE rather than restating the condition. "missing
119
120
  * PIPELINE-STATUS.md" without the second half reads as nitpicking about formatting; with it you
120
121
  * can see which checks the directory silently loses — the rules that read the scorecard; the rules
121
- * over `paper.md` / `paper.tex` still run.
122
+ * over `paper.tex` still run.
122
123
  */
123
124
  function whyMissingMatters(file: string, dirName: string): string {
124
125
  if (file === "PIPELINE-STATUS.md")
package/src/types.ts CHANGED
@@ -62,8 +62,6 @@ export interface Args {
62
62
  noHooks: boolean;
63
63
  /** `--paper <name>`: `init` creates this paper. */
64
64
  paper: string | null;
65
- /** `--format tex|md` for `new` and `init --paper`. Validated by the command, not here. */
66
- format: string | null;
67
65
  /** `--venue <preset>` for `new`: a shipped preset name, or a path to the project's own. */
68
66
  venue: string | null;
69
67
  /** `--kind <kind>` for `new`: one of the venue preset's kinds. */
@@ -1,22 +0,0 @@
1
- {
2
- "$comment": [
3
- "What the rules say about this article TODAY, as data rather than as a sentence in a docblock.",
4
- "It is not a target and not a clean bill of health — the fixture's README says findings here",
5
- "are measurements. Its job is the SILENCE half: a rule that starts reporting something new on",
6
- "225 lines of real prose has almost certainly gained a false positive, and the harness beside",
7
- "this file names the rule. That is not expressible on an eleven-line stub, where every line was",
8
- "written by someone who already knew which rule would read it.",
9
- "",
10
- "Recorded 2026-09-19, re-recorded 2026-09-26 for 3.0.0. Two of the three rules are about the",
11
- "scorecard being deliberately thin (no pdf, no frozen source) rather than about the prose.",
12
- "paper/leading-zero reports each of the 18 p-values of paperlint#44 where it is (one finding per",
13
- "occurrence since 3.0.0; before, paper/typography reported them as one count). They are real",
14
- "findings against IEEE / ISO 80000-1 style, in a blog post written for a general audience.",
15
- "The count is expected to stay. A drop is never a failure here, only growth is."
16
- ],
17
- "findings": {
18
- "paper/stages": 2,
19
- "paper/source": 1,
20
- "paper/leading-zero": 18
21
- }
22
- }