paperlint 10.3.0 → 10.4.1

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 (187) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/git/index.d.ts +10 -0
  7. package/dist/adapters/git/index.d.ts.map +1 -0
  8. package/dist/adapters/git/index.js +151 -0
  9. package/dist/adapters/git/index.js.map +1 -0
  10. package/dist/adapters/latex/captions.d.ts +9 -0
  11. package/dist/adapters/latex/captions.d.ts.map +1 -0
  12. package/dist/adapters/latex/captions.js +40 -0
  13. package/dist/adapters/latex/captions.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +1 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +1 -0
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/parse.d.ts.map +1 -1
  19. package/dist/adapters/latex/parse.js +6 -0
  20. package/dist/adapters/latex/parse.js.map +1 -1
  21. package/dist/adapters/memory/index.d.ts +6 -0
  22. package/dist/adapters/memory/index.d.ts.map +1 -1
  23. package/dist/adapters/memory/index.js +8 -0
  24. package/dist/adapters/memory/index.js.map +1 -1
  25. package/dist/adapters/node/index.d.ts +1 -0
  26. package/dist/adapters/node/index.d.ts.map +1 -1
  27. package/dist/adapters/node/index.js +1 -0
  28. package/dist/adapters/node/index.js.map +1 -1
  29. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  30. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  31. package/dist/adapters/node/list-entries.io.js +26 -0
  32. package/dist/adapters/node/list-entries.io.js.map +1 -0
  33. package/dist/adapters/study-registry/index.d.ts +70 -10
  34. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  35. package/dist/adapters/study-registry/index.js +84 -10
  36. package/dist/adapters/study-registry/index.js.map +1 -1
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +33 -3
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/decide-first.d.ts +110 -0
  41. package/dist/domain/decide-first.d.ts.map +1 -0
  42. package/dist/domain/decide-first.js +108 -0
  43. package/dist/domain/decide-first.js.map +1 -0
  44. package/dist/domain/index-columns.d.ts +45 -0
  45. package/dist/domain/index-columns.d.ts.map +1 -0
  46. package/dist/domain/index-columns.js +64 -0
  47. package/dist/domain/index-columns.js.map +1 -0
  48. package/dist/domain/study-mode.d.ts +37 -0
  49. package/dist/domain/study-mode.d.ts.map +1 -0
  50. package/dist/domain/study-mode.js +45 -0
  51. package/dist/domain/study-mode.js.map +1 -0
  52. package/dist/domain/study-registry.d.ts +116 -12
  53. package/dist/domain/study-registry.d.ts.map +1 -1
  54. package/dist/domain/study-registry.js +164 -44
  55. package/dist/domain/study-registry.js.map +1 -1
  56. package/dist/outputs-before-decision-rule.d.ts +17 -0
  57. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  58. package/dist/outputs-before-decision-rule.js +94 -0
  59. package/dist/outputs-before-decision-rule.js.map +1 -0
  60. package/dist/ports/git-history.d.ts +44 -0
  61. package/dist/ports/git-history.d.ts.map +1 -0
  62. package/dist/ports/git-history.js +2 -0
  63. package/dist/ports/git-history.js.map +1 -0
  64. package/dist/ports/list-entries.d.ts +20 -0
  65. package/dist/ports/list-entries.d.ts.map +1 -0
  66. package/dist/ports/list-entries.js +2 -0
  67. package/dist/ports/list-entries.js.map +1 -0
  68. package/dist/ports/study-deps.d.ts +14 -0
  69. package/dist/ports/study-deps.d.ts.map +1 -0
  70. package/dist/ports/study-deps.js +2 -0
  71. package/dist/ports/study-deps.js.map +1 -0
  72. package/dist/ports/study-folder.d.ts +43 -0
  73. package/dist/ports/study-folder.d.ts.map +1 -0
  74. package/dist/ports/study-folder.js +2 -0
  75. package/dist/ports/study-folder.js.map +1 -0
  76. package/dist/ports/study-registry-codec.d.ts +6 -3
  77. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  78. package/dist/presets.d.ts +1 -2
  79. package/dist/presets.d.ts.map +1 -1
  80. package/dist/presets.js +1 -2
  81. package/dist/presets.js.map +1 -1
  82. package/dist/studies.d.ts +4 -0
  83. package/dist/studies.d.ts.map +1 -1
  84. package/dist/studies.js +52 -21
  85. package/dist/studies.js.map +1 -1
  86. package/dist/study-folder.d.ts +14 -0
  87. package/dist/study-folder.d.ts.map +1 -0
  88. package/dist/study-folder.js +123 -0
  89. package/dist/study-folder.js.map +1 -0
  90. package/dist/study-gate.d.ts +14 -0
  91. package/dist/study-gate.d.ts.map +1 -0
  92. package/dist/study-gate.js +132 -0
  93. package/dist/study-gate.js.map +1 -0
  94. package/dist/study-index-input.d.ts +5 -0
  95. package/dist/study-index-input.d.ts.map +1 -0
  96. package/dist/study-index-input.js +18 -0
  97. package/dist/study-index-input.js.map +1 -0
  98. package/dist/study-modes.d.ts +56 -0
  99. package/dist/study-modes.d.ts.map +1 -0
  100. package/dist/study-modes.js +59 -0
  101. package/dist/study-modes.js.map +1 -0
  102. package/dist/study-registry-rule.d.ts +13 -1
  103. package/dist/study-registry-rule.d.ts.map +1 -1
  104. package/dist/study-registry-rule.js +33 -21
  105. package/dist/study-registry-rule.js.map +1 -1
  106. package/dist/study-run.d.ts +19 -0
  107. package/dist/study-run.d.ts.map +1 -0
  108. package/dist/study-run.js +9 -0
  109. package/dist/study-run.js.map +1 -0
  110. package/docs/design/study-modes.md +265 -0
  111. package/docs/design/study-registry.md +6 -0
  112. package/docs/e2e.md +8 -4
  113. package/docs/layout.md +22 -19
  114. package/docs/rules/repro/outputs-before-decision.md +122 -0
  115. package/docs/rules/repro/study-registered.md +49 -15
  116. package/docs/rules/tex/heading-case.md +1 -1
  117. package/docs/rules.md +54 -53
  118. package/docs/testing.md +1 -0
  119. package/eslint.config.mjs +36 -24
  120. package/package.json +2 -1
  121. package/scripts/check.ts +6 -0
  122. package/skills/camera-ready/SKILL.md +6 -1
  123. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  124. package/skills/draft-paper/SKILL.md +11 -3
  125. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  126. package/skills/grade-paper-writing/SKILL.md +4 -2
  127. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  128. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  129. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  130. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  131. package/skills/harden-paper/SKILL.md +12 -2
  132. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  133. package/skills/osf-artifact-upload/SKILL.md +7 -1
  134. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  135. package/skills/pc-panel-review/SKILL.md +10 -3
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  137. package/skills/tighten-paper/SKILL.md +5 -1
  138. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  139. package/src/CLAUDE.md +7 -4
  140. package/src/CLAUDE.md.spec.ts +6 -3
  141. package/src/adapters/git/history.test.ts +121 -0
  142. package/src/adapters/git/index.test.ts +268 -0
  143. package/src/adapters/git/index.ts +227 -0
  144. package/src/adapters/latex/captions.test.ts +57 -0
  145. package/src/adapters/latex/captions.ts +56 -0
  146. package/src/adapters/latex/index.ts +1 -0
  147. package/src/adapters/latex/parse.ts +7 -0
  148. package/src/adapters/memory/index.test.ts +15 -1
  149. package/src/adapters/memory/index.ts +13 -0
  150. package/src/adapters/node/index.ts +1 -0
  151. package/src/adapters/node/list-entries.io.ts +27 -0
  152. package/src/adapters/node/list-entries.test.ts +61 -0
  153. package/src/adapters/study-registry/index.test.ts +12 -9
  154. package/src/adapters/study-registry/index.ts +130 -24
  155. package/src/adapters/study-registry/mode.test.ts +162 -0
  156. package/src/cli.ts +47 -3
  157. package/src/domain/decide-first.test.ts +291 -0
  158. package/src/domain/decide-first.ts +258 -0
  159. package/src/domain/index-columns.test.ts +261 -0
  160. package/src/domain/index-columns.ts +107 -0
  161. package/src/domain/study-measurements.test.ts +16 -8
  162. package/src/domain/study-mode.test.ts +55 -0
  163. package/src/domain/study-mode.ts +78 -0
  164. package/src/domain/study-registry.test.ts +72 -37
  165. package/src/domain/study-registry.ts +281 -50
  166. package/src/new-paper.test.ts +5 -1
  167. package/src/outputs-before-decision-rule.test.ts +251 -0
  168. package/src/outputs-before-decision-rule.ts +133 -0
  169. package/src/ports/git-history.ts +44 -0
  170. package/src/ports/list-entries.ts +21 -0
  171. package/src/ports/study-deps.ts +14 -0
  172. package/src/ports/study-folder.ts +38 -0
  173. package/src/ports/study-registry-codec.ts +10 -3
  174. package/src/presets.ts +1 -2
  175. package/src/rule-cases.test.ts +78 -3
  176. package/src/studies-index-modes.test.ts +129 -0
  177. package/src/studies.test.ts +35 -11
  178. package/src/studies.ts +96 -26
  179. package/src/study-folder.test.ts +237 -0
  180. package/src/study-folder.ts +176 -0
  181. package/src/study-gate.test.ts +467 -0
  182. package/src/study-gate.ts +234 -0
  183. package/src/study-index-input.ts +31 -0
  184. package/src/study-modes.ts +107 -0
  185. package/src/study-registry-rule.test.ts +285 -11
  186. package/src/study-registry-rule.ts +64 -35
  187. package/src/study-run.ts +21 -0
package/eslint.config.mjs CHANGED
@@ -212,27 +212,33 @@ export const IO_MODULES = [
212
212
  "readline/promises",
213
213
  ].flatMap((m) => [m, `node:${m}`]);
214
214
 
215
- /** What the inside may import from outside the repository: types, pure path arithmetic, hashing. */
216
- export const INSIDE_EXTERNALS = [
217
- "ts-essentials",
218
- "path",
219
- "node:path",
220
- "crypto",
221
- "node:crypto",
222
- ];
223
- /** What the flat app files may import besides: Node's pure helpers, measured on the first v7 run. */
224
- export const APP_EXTERNALS = [
225
- ...INSIDE_EXTERNALS,
226
- "util",
227
- "node:util",
228
- "url",
229
- "node:url",
230
- "module",
231
- "node:module",
232
- ];
215
+ /**
216
+ * The npm packages that exist because of ONE external program or format, from `dependencies` in
217
+ * package.json: only that tool's adapter (or `src/cli.ts`, the root) imports them; the domain, a
218
+ * port and the app take a port. A package that exists because of one external tool goes here in the
219
+ * same PR that adds its adapter. A pure utility library (remeda, zod, ts-essentials) needs nothing:
220
+ * the inside may import every package and Node built-in that is not listed here and is not an effect
221
+ * (`IO_MODULES`).
222
+ */
223
+ export const TOOL_PACKAGES = [
224
+ "@eslint/markdown", // ESLint's Markdown language
225
+ "@eslint/plugin-kit",
226
+ "@retorquere/bibtex-parser", // BibTeX
227
+ "@unified-latex/unified-latex-types", // LaTeX
228
+ "@unified-latex/unified-latex-util-parse",
229
+ "@unified-latex/unified-latex-util-visit",
230
+ "ajv", // JSON Schema
231
+ "eslint",
232
+ "js-yaml", // YAML
233
+ "markdown-it", // Markdown
234
+ "mp4box", // MP4
235
+ "srt-parser-2", // SRT subtitles
236
+ "typescript", // the compiler API
237
+ "unpdf", // PDF
238
+ "vigiles", // the agent harness
239
+ ].flatMap((m) => [m, `${m}/**`]);
233
240
 
234
241
  const ORIGINS = ["external", "core"];
235
- const modules = (source) => ({ to: { module: { origin: ORIGINS, source } } });
236
242
  const elements = (...types) => ({ element: { types: { anyOf: types } } });
237
243
  const APP = { file: { categories: "app" } };
238
244
  const ROOT = { file: { categories: "root" } };
@@ -337,14 +343,20 @@ export const layerBoundaries = (root) => ({
337
343
  allow: { to: elements("js-module") },
338
344
  },
339
345
  // ── libraries and built-ins (the plugin calls Node built-ins origin "core") ──
346
+ // Everyone may import any package and built-in …
340
347
  {
341
- from: elements("domain", "port"),
342
- allow: modules(INSIDE_EXTERNALS),
348
+ from: [elements("domain", "port", "adapter"), APP, ROOT],
349
+ allow: { to: { module: { origin: ORIGINS } } },
343
350
  },
344
- { from: APP, allow: modules(APP_EXTERNALS) },
351
+ // … but the inside — not an adapter, not the root — not a package that exists because of
352
+ // one external tool (TOOL_PACKAGES). Effects are axis B's, below.
345
353
  {
346
- from: [elements("adapter"), ROOT],
347
- allow: { to: { module: { origin: ORIGINS } } },
354
+ from: [elements("domain", "port"), APP],
355
+ disallow: {
356
+ to: { module: { origin: "external", source: TOOL_PACKAGES } },
357
+ },
358
+ message:
359
+ "{{ dependency.source }} exists because of one external tool or format: only that tool's adapter (src/adapters/<tool>/) and src/cli.ts import it; this file takes a port (src/CLAUDE.md, layers). A new package of that kind is listed in TOOL_PACKAGES in the PR that adds its adapter.",
348
360
  },
349
361
  // Tests may import anything, fakes included …
350
362
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "10.3.0",
3
+ "version": "10.4.1",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "test:e2e": "vitest run --project 'e2e-*'",
12
12
  "test:e2e:install": "vitest run --project e2e-install",
13
13
  "test:e2e:tex": "vitest run --project e2e-tex",
14
+ "test:e2e:studies": "vitest run --project e2e-studies",
14
15
  "coverage": "NODE_OPTIONS=\"--import=./test/coverage-src.ts $NODE_OPTIONS\" c8 --check-coverage npm test --",
15
16
  "lint": "eslint .",
16
17
  "check": "node scripts/check.ts",
package/scripts/check.ts CHANGED
@@ -173,6 +173,12 @@ export const GATES: readonly Gate[] = [
173
173
  script: "test:e2e:install",
174
174
  vitest: true,
175
175
  },
176
+ {
177
+ name: "studies e2e — a study decided before its run, in a real git repository",
178
+ job: "gates",
179
+ script: "test:e2e:studies",
180
+ vitest: true,
181
+ },
176
182
  {
177
183
  name: "TeX e2e — real TeX Live: paperlint toolchain, a real pdflatex build of every fixture, the real banal",
178
184
  job: "build-e2e",
@@ -4,7 +4,7 @@ description: On acceptance, turn the anonymized-for-review paper into the de-ano
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:1c9ad89e55c3e89a compiled from skills/camera-ready/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:66444fd7ce0e56a0 compiled from skills/camera-ready/SKILL.md.spec.ts -->
8
8
 
9
9
  # camera-ready — de-anonymize an accepted paper into the final proceedings version
10
10
 
@@ -73,6 +73,11 @@ The review artifact was an OSF anonymized view-only link. The camera-ready needs
73
73
  or `\doi{}`.
74
74
  - Keep the artifact **self-checking** (it still recomputes every headline number and exits non-zero on
75
75
  drift) — de-anonymizing it doesn't excuse it from reproducing the paper.
76
+ - **Ship each study's evidence of its mode**: its `plan.json`, its decision file, and
77
+ `repro/README.md` with the index regenerated (`npx paperlint studies index <paper-dir>`) — each study's mode,
78
+ decision commit and first output. The public repo keeps the **whole git history** — the order of
79
+ decision and outputs is read from it (`repro/outputs-before-decision`), and a squashed or shallow
80
+ copy loses it.
76
81
 
77
82
  ## 3. Responsible disclosure (security papers — BEFORE public release)
78
83
  For the AISec "Safety Theater" paper the studied hooks are named-but-anonymized, and disclosure to the
@@ -88,6 +88,11 @@ The review artifact was an OSF anonymized view-only link. The camera-ready needs
88
88
  or \`\\doi{}\`.
89
89
  - Keep the artifact **self-checking** (it still recomputes every headline number and exits non-zero on
90
90
  drift) — de-anonymizing it doesn't excuse it from reproducing the paper.
91
+ - **Ship each study's evidence of its mode**: its \`plan.json\`, its decision file, and
92
+ \`repro/README.md\` with the index regenerated (\`npx paperlint studies index <paper-dir>\`) — each study's mode,
93
+ decision commit and first output. The public repo keeps the **whole git history** — the order of
94
+ decision and outputs is read from it (\`repro/outputs-before-decision\`), and a squashed or shallow
95
+ copy loses it.
91
96
 
92
97
  ## 3. Responsible disclosure (security papers — BEFORE public release)
93
98
  For the AISec "Safety Theater" paper the studied hooks are named-but-anonymized, and disclosure to the
@@ -4,7 +4,7 @@ description: Use when the numbers exist and it's time to WRITE the paper — "dr
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:c8d25c21dc01a742 compiled from skills/draft-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:a68aebea0917e4a6 compiled from skills/draft-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # draft-paper — prose that survives review and gets cited
10
10
 
@@ -33,9 +33,17 @@ number in the body stays in the body**.
33
33
  study, and — for security — the explicit threat model (attacker goals/capabilities/knowledge). A
34
34
  fuzzy threat model is the fastest reject at a security venue.
35
35
  4. **Method** — the reusable core. How the measurement/benchmark is *constructed* so someone else can
36
- run it on a different target. This is the part that gets cited; give it the most care.
36
+ run it on a different target. This is the part that gets cited; give it the most care. **It states
37
+ each study's mode from the index, never from memory:** run `npx paperlint studies index <paper-dir>`, then read
38
+ each reported study's `mode`, `decision` and `first output` columns in `repro/README.md` and
39
+ write one sentence per study — decided before its run, with the decision's commit and date (and
40
+ its first output's, which came after); an exploratory study named exploratory, with its reason.
41
+ A study whose `decision` reads `not committed` was not decided before its run: say so.
37
42
  5. **Results** — the numbers from `build-benchmark`, each tied to a claim. Report ranges/CIs, not
38
- rounded maxes. One figure or table carries the paper — make it legible standalone.
43
+ rounded maxes. One figure or table carries the paper — make it legible standalone. A number from
44
+ an exploratory study says "exploratory" where it is reported; "pre-registered" or "decided before"
45
+ only for a study the index shows as `decide_before_run` with its decision committed before its
46
+ first output.
39
47
  6. **Threats to validity** — construct / internal / external, honestly (see below). This section is a
40
48
  trust signal, not a confession.
41
49
  7. **Related work** — position against the *nearest* neighbor explicitly ("X measures A; we measure B
@@ -39,9 +39,17 @@ number in the body stays in the body**.
39
39
  study, and — for security — the explicit threat model (attacker goals/capabilities/knowledge). A
40
40
  fuzzy threat model is the fastest reject at a security venue.
41
41
  4. **Method** — the reusable core. How the measurement/benchmark is *constructed* so someone else can
42
- run it on a different target. This is the part that gets cited; give it the most care.
42
+ run it on a different target. This is the part that gets cited; give it the most care. **It states
43
+ each study's mode from the index, never from memory:** run \`npx paperlint studies index <paper-dir>\`, then read
44
+ each reported study's \`mode\`, \`decision\` and \`first output\` columns in \`repro/README.md\` and
45
+ write one sentence per study — decided before its run, with the decision's commit and date (and
46
+ its first output's, which came after); an exploratory study named exploratory, with its reason.
47
+ A study whose \`decision\` reads \`not committed\` was not decided before its run: say so.
43
48
  5. **Results** — the numbers from \`build-benchmark\`, each tied to a claim. Report ranges/CIs, not
44
- rounded maxes. One figure or table carries the paper — make it legible standalone.
49
+ rounded maxes. One figure or table carries the paper — make it legible standalone. A number from
50
+ an exploratory study says "exploratory" where it is reported; "pre-registered" or "decided before"
51
+ only for a study the index shows as \`decide_before_run\` with its decision committed before its
52
+ first output.
45
53
  6. **Threats to validity** — construct / internal / external, honestly (see below). This section is a
46
54
  trust signal, not a confession.
47
55
  7. **Related work** — position against the *nearest* neighbor explicitly ("X measures A; we measure B
@@ -4,7 +4,7 @@ description: Grade how a paper reads and fix it sentence by sentence. Use when a
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:1897016b69f3f9b8 compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:2b7193c739a374e6 compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
8
8
 
9
9
  # grade-paper-writing — grade how the paper READS, then fix it sentence by sentence
10
10
 
@@ -223,7 +223,9 @@ is what happened.
223
223
  ```
224
224
 
225
225
  It measures the prose a reader sees — the files the last build read spliced in, no preamble,
226
- comments, floats or inline `.bib` — so its rates are the paper's, not the source file's. Run
226
+ comments, floats or inline `.bib` — so its rates are the paper's, not the source file's — and, on
227
+ its own line, the figure captions that float outside that prose, flagging a caption sentence over
228
+ 40 words or a caption over 100. Run
227
229
  `npx paperlint build` first: with no current build record it reads `paper.tex` alone and says so. The skill is what must ACT on it.
228
230
  Its thresholds are sourced; a FLAG means the text sits above a published human or machine
229
231
  baseline, which is a defect with a number attached, not an opinion.
@@ -229,7 +229,9 @@ is what happened.
229
229
  \`\`\`
230
230
 
231
231
  It measures the prose a reader sees — the files the last build read spliced in, no preamble,
232
- comments, floats or inline \`.bib\` — so its rates are the paper's, not the source file's. Run
232
+ comments, floats or inline \`.bib\` — so its rates are the paper's, not the source file's — and, on
233
+ its own line, the figure captions that float outside that prose, flagging a caption sentence over
234
+ 40 words or a caption over 100. Run
233
235
  \`npx paperlint build\` first: with no current build record it reads \`paper.tex\` alone and says so. The skill is what must ACT on it.
234
236
  Its thresholds are sourced; a FLAG means the text sits above a published human or machine
235
237
  baseline, which is a defect with a number attached, not an opinion.
@@ -1,10 +1,19 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
+ exports[`--flags-only: a metric over its threshold (not a caption) is a finding too, and the report flags the same line 1`] = `
4
+ "✍️ prose-lint — paper.tex — 1 finding(s):
5
+ sentences whose main claim is a negation: 75%
6
+ run prose-lint.mjs on the file without --flags-only for every metric
7
+ "
8
+ `;
9
+
3
10
  exports[`--flags-only: an over-long caption flags and exits 1 1`] = `
4
11
  "✍️ prose-lint — paper.tex — 2 finding(s):
5
- figures/fig1.tex: caption sentence of 46 words (warn >40) — "Big. word0 word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 wor…"
6
- figures/fig1.tex: caption is 106 words (warn >100) — a caption this long is a section under a picture
7
- run prose-lint.mjs on the file without --flags-only for the sentences
12
+ all metadiscourse/1000w 363.6 (Hyland RA baseline 64.9, warn >90)
13
+ figure captions over the limit: 2
14
+ > figures/fig1.tex: caption sentence of 46 words (warn >40) — "Big. word0 word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 wor…"
15
+ > figures/fig1.tex: caption is 106 words (warn >100) — a caption this long is a section under a picture
16
+ run prose-lint.mjs on the file without --flags-only for every metric
8
17
  "
9
18
  `;
10
19
 
@@ -37,6 +46,7 @@ ok all metadiscourse/1000w 0 (Hyland RA baseline 64.9, warn >90)
37
46
  ok "rather than"/instead-of/10,000w 0 (warn >25; not epanorthosis, no published baseline)
38
47
  ok sentences ending on a cross-ref/citation/hedge (wasted stress position): 0
39
48
  ok sentences whose main claim is a negation: 0%
49
+ ok figure captions over the limit: 0
40
50
  topic positions (whose story is this?): "a rendered page." | "it was hyphenated across a" | "next page text here."
41
51
 
42
52
  (thresholds and their sources are in THRESHOLDS at the top of this file)
@@ -64,6 +74,9 @@ FLAG sentences ending on a cross-ref/citation/hedge (wasted stress position): 12
64
74
  > …], and that results from a handful of runs need honest statistics [1].
65
75
  > …g code [1], and models that memorize answers instead of reasoning [1].
66
76
  ok sentences whose main claim is a negation: 37%
77
+ FLAG figure captions over the limit: 2
78
+ > paper.tex: caption sentence of 52 words (warn >40) — "Two things make it different from how efficiency tools are usually measured: the tool is installed the way a u…"
79
+ > paper.tex: caption sentence of 44 words (warn >40) — "The advertised 65% is an output-token reduction; because output is only math of the dollar cost, even a perfec…"
67
80
  topic positions (whose story is this?): "a fast-growing ecosystem of plugins," | "the most popular single artifact---a" | "we show that such claims," | "efficiency tools are validated on" | "we introduce a reproducible, cost-aware," | "across 140 runs on two" | "output falls far short of" | "the ceiling is also structural:" | "we argue that trustworthy evaluation" | "ai coding agents and their" | "built on the reasoning-and-acting loops" | "assistants of this kind are" | "an unverified performance claim about" | "while the community has invested" | "we focus on the most" | "the flagship example is a" | "naming the commits matters more" | "two separate claims are bundled" | "the first is that the" | "that one has already been" | "the second claim is the" | "nobody had measured it." | "it fails for a reason" | "this paper is about the" | "rq: when a token-efficiency tool" | "the first half is measured," | "our contribution is not that" | "we identify why the headline" | "we build a cost-aware, correctness-gated" | "we measure two widely used" | "output falls far short of" | "the dollar bill does not" | "the broader point is about" | "a modern agent bills tokens" | "a multi-turn agentic session [1]" | "reading the upstream benchmark code" | "the reported number measures something" | "the fix is to measure" | "the compression literature these tools" | "the harness --- the ecosystem" | "we implement the evaluation as" | "figure 1 shows the whole" | "each tool is delivered as" | "a skill that ships as" | "this step is load-bearing: a" | "we use seven self-contained coding" | "the suite deliberately skews short" | "each task is run with" | "the runs date from july" | "the two tools are the" | "for every run we record" | "on a subscription the \`\`bill''" | "we report dollars---the weighting most" | "each task carries a deterministic" | "these are lightweight structural checks" | "a trial where the skill" | "for each task we report" | "we call an effect significant" | "we also note which effects" | "for the bill we report" | "averages taken over a few" | "this protocol is sized to" | "two things are still required" | "the flagship skill (claim 65%)" | "we sign bill changes so" | "for the flagship, the bill" | "either way the answer is" | "the second tool (claim 63%)" | "it is also not significant." | "its one per-task cell that" | "the intervals are wide: at" | "but they exclude anything near" | "whatever room remains is capped" | "correctness." | "both arms passed the correctness" | "a sharper signal hides in" | "this is a short-session effect" | "this is not a quirk" | "both shares below are measured," | "pooled across all tasks and" | "if output is a fraction" | "even a perfect 65% output" | "the advertised 63--75% and the" | "the flagship's other feature trims" | "output is a small fraction" | "we measure cost: latency and" | "we do not claim \`\`these" | "nor do we dispute the" | "our point is that they" | "we label each by its" | "we run five trials per" | "that is itself part of" | "across tasks the flagship's output" | "the honest quantity is therefore" | "our design refutes the advertised" | "what caps those is the" | "a formal equivalence test (tost---a" | "the harness is not toothless," | "at that scale we read" | "we test one model, and" | "anthropic's tiers all share a" | "the ceiling scales linearly with" | "even if output were priced" | "our tasks top out around" | "this cuts in our favor." | "a longer session re-reads a" | "that pushes the ceiling lower," | "one might object that terser" | "but that downstream saving is" | "so it adds a few" | "the compounded ceiling is --," | "the overhead runs the other" | "an always-loaded tool prompt adds" | "that fixed cost amortizes over" | "the durable long-session prediction is" | "an empirical long-session demonstration is" | "every run in the main" | "the harness decides how many" | "a wordier model would raise" | "the ceiling of section 1" | "a pilot on claude haiku" | "nearly double, and still about" | "both runs ship in the" | "a second harness is reachable" | "the flagship now ships wrappers" | "measuring a second harness therefore" | "wrapper list and the rejected" | "we do not study multi-agent" | "we test two tools, and" | "the harness can a/b any" | "the bias runs in our" | "our per-run bill uses the" | "a user can also invalidate" | "(the first two rewrite the" | "that is, they push cost" | "they lower the ceiling and" | "so our clean-caching tasks, if" | "the correctness gate is a" | "so \`\`zero regressions'' bounds gross" | "a stronger gate that runs" | "still, a gate that never" | "without it, a harness would" | "the efficiency-claim failure is a" | "a community norm of cost-aware," | "execution-based, correctness-gated benchmarks such as" | "we keep their correctness-first discipline" | "several lines of work push" | "we take the same stance---price" | "closest to us, [1] argue" | "we turn that lens on" | "a long line of work" | "capability benchmarks show the same" | "we aim the same critique" | "the industry a/b test cited" | "what we add over such" | "the efficiency-tool market runs on" | "these tools trim what the" | "we measured two of the" | "the numbers are not fabricated;" | "the output you are shown" | "trimming the model's replies to" | "we release the measurements with" | "before you install the next" | "the complete harness of section" | "the measurements are released per" | "individual trial rows are not"
68
81
 
69
82
  (thresholds and their sources are in THRESHOLDS at the top of this file)
@@ -17,17 +17,18 @@
17
17
  *
18
18
  * A `.txt` is the text of the rendered PDF — the page a reviewer actually reads.
19
19
  *
20
- * The figure captions are measured too (`--flags-only`): every `\caption{}` in `paper.tex` and the
20
+ * The figure captions are measured too, in the report: every `\caption{}` in `paper.tex` and the
21
21
  * files it includes, or, beside a rendered page, in `figures/*.tex`.
22
22
  *
23
- * Advisory: it prints findings; `--flags-only` exits with code 1 if anything was found.
23
+ * Advisory: the report prints every check and exits 0. `--flags-only` prints the checks the report
24
+ * marks FLAG, each with the lines under it, and nothing on a clean paper; it exits 1 when there is
25
+ * one — the form for a hook.
24
26
  */
25
27
  import { paperProse } from "#src/paper-prose";
26
28
  import { paperRecord } from "#src/paper-record";
27
- import { latexReader } from "#src/adapters/latex/index";
29
+ import { captionsOf, latexReader, parseLatex } from "#src/adapters/latex/index";
28
30
  import { nodeFiles } from "#src/adapters/node/index";
29
31
  import { sourcesCodec } from "#src/adapters/sources-record/index";
30
- import { texToMdast } from "#eslint-rules/latex-language";
31
32
 
32
33
  const THRESHOLDS = {
33
34
  metadiscourseTotalPer1000: {
@@ -363,77 +364,116 @@ function figureFiles(pagePath, fs, path) {
363
364
  }
364
365
 
365
366
  /**
366
- * Every `\caption{…}` of `sources` (`{ file, text }`) a reader sees, measured. The LaTeX
367
- * language's projection blanks what never typesets — verbatim and code, macro definitions, the
368
- * preamble — and keeps a caption's argument; it keeps comments too, as `html` nodes of its tree,
369
- * for the rules that read notes. So a caption is skipped when its argument is blank in the
370
- * projection or it starts inside a comment: a quoted or commented-out `\caption` is not one.
367
+ * Every `\caption` of `sources` (`{ file, text }`) a reader sees, measured. The captions come from the
368
+ * parsed source (`captionsOf`, src/adapters/latex/captions.ts): its optional argument, star and spacing
369
+ * are the parser's, and a caption that is commented out, in verbatim or in a macro's definition is not one.
371
370
  */
372
371
  function captions(sources) {
373
- const out = [];
374
- for (const { file: f, text: s } of sources) {
375
- const { root, text: seen } = texToMdast(s);
376
- const comments = root.children
377
- .filter((n) => n.type === "html")
378
- .map((n) => [n.position.start.offset, n.position.end.offset]);
379
- for (const m of s.matchAll(/\\caption\{/g)) {
380
- let i = m.index + m[0].length,
381
- depth = 1,
382
- j = i;
383
- while (j < s.length && depth) {
384
- if (s[j] === "{") depth++;
385
- else if (s[j] === "}") depth--;
386
- j++;
387
- }
388
- if (
389
- !/\S/.test(seen.slice(i, j - 1)) ||
390
- comments.some(([a, b]) => m.index >= a && m.index < b)
391
- )
392
- continue;
393
- const text = s
394
- .slice(i, j - 1)
395
- .replace(/\\[a-zA-Z]+\s*/g, " ")
396
- .replace(/[{}]/g, " ")
397
- .replace(/---/g, " ")
398
- .replace(/\s+/g, " ")
399
- .trim();
400
- if (!text) continue;
401
- // A non-empty caption splits into at least one sentence.
402
- const sents = splitSentences(text);
403
- out.push({
404
- file: f,
405
- words: words(text),
372
+ return sources.flatMap(({ file, text }) =>
373
+ captionsOf(parseLatex(text)).map((raw) => {
374
+ // A dash with no spaces round it joins two words in the count, as it never did here.
375
+ const caption = raw.replace(/---/g, " ");
376
+ const sents = splitSentences(caption);
377
+ return {
378
+ file,
379
+ words: words(caption),
406
380
  longest: Math.max(...sents.map(words)),
407
381
  longestSentence: sents.slice().sort((a, b) => words(b) - words(a))[0],
408
- });
409
- }
410
- }
411
- return out;
382
+ };
383
+ }),
384
+ );
385
+ }
386
+
387
+ /**
388
+ * The findings on the captions of the file `f`: one line per caption sentence or caption over its
389
+ * limit. A paper's captions are in its own files — paper.tex and what it includes, wherever a figure
390
+ * is set; a rendered page has the `.tex` of `figures/` beside it. The report and `--flags-only` both
391
+ * read them from here.
392
+ */
393
+ function captionFindings(f, tex, fs, path) {
394
+ return captions(tex ? tex.files : figureFiles(f, fs, path)).flatMap((c) =>
395
+ CAPTION_LIMITS.filter(over(c)).map(say(c)),
396
+ );
397
+ }
398
+
399
+ /**
400
+ * The two limits a caption is held to: the `THRESHOLDS` key, what of a caption it is measured on, and
401
+ * how a finding reads. A caption is over a limit when its measure exceeds the threshold's `warn`.
402
+ */
403
+ const CAPTION_LIMITS = [
404
+ {
405
+ key: "captionSentenceWords",
406
+ measure: (c) => c.longest,
407
+ say: (c, warn) =>
408
+ `${c.file}: caption sentence of ${c.longest} words (warn >${warn}) — "${c.longestSentence.slice(0, 110)}…"`,
409
+ },
410
+ {
411
+ key: "captionWords",
412
+ measure: (c) => c.words,
413
+ say: (c, warn) =>
414
+ `${c.file}: caption is ${c.words} words (warn >${warn}) — a caption this long is a section under a picture`,
415
+ },
416
+ ];
417
+ const over = (c) => (limit) => limit.measure(c) > THRESHOLDS[limit.key].warn;
418
+ const say = (c) => (limit) => limit.say(c, THRESHOLDS[limit.key].warn);
419
+
420
+ /**
421
+ * Every line of the report as data, in the order it is printed: `ok` is true (passed), false (a
422
+ * finding, marked `FLAG`) or null (a figure, no check), `under` the lines that name the offenders.
423
+ * The report prints all of it; `--flags-only` prints the findings: one list, two renderings.
424
+ */
425
+ function checks(r, captionLines) {
426
+ const T = THRESHOLDS;
427
+ return [
428
+ {
429
+ ok: r.metadiscoursePer1000 <= T.metadiscourseTotalPer1000.warn,
430
+ text: `all metadiscourse/1000w ${r.metadiscoursePer1000} (Hyland RA baseline 64.9, warn >${T.metadiscourseTotalPer1000.warn})`,
431
+ under: [],
432
+ },
433
+ {
434
+ ok: null,
435
+ text: `nominalisations/1000w ${r.nominalisationsPer1000}`,
436
+ under: [],
437
+ },
438
+ {
439
+ ok: r.contrastivePer10k <= T.contrastivePer10k.warn,
440
+ text: `"rather than"/instead-of/10,000w ${r.contrastivePer10k} (warn >${T.contrastivePer10k.warn}; not epanorthosis, no published baseline)`,
441
+ under: [],
442
+ },
443
+ {
444
+ ok: r.wastedStress.length === 0,
445
+ text: `sentences ending on a cross-ref/citation/hedge (wasted stress position): ${r.wastedStress.length}`,
446
+ under: r.wastedStress.map((s) => `…${s.slice(-70)}`),
447
+ },
448
+ {
449
+ ok: r.negatedSentencePct <= T.negatedMainClausePct.warn,
450
+ text: `sentences whose main claim is a negation: ${r.negatedSentencePct}%`,
451
+ under: [],
452
+ },
453
+ {
454
+ ok: captionLines.length === 0,
455
+ text: `figure captions over the limit: ${captionLines.length}`,
456
+ under: captionLines,
457
+ },
458
+ {
459
+ ok: null,
460
+ text: `topic positions (whose story is this?): ${r.topics.map((t) => `"${t}"`).join(" | ")}`,
461
+ under: [],
462
+ },
463
+ ];
412
464
  }
413
465
 
414
- function report(name, r) {
415
- const flag = (ok) => (ok ? "ok " : "FLAG");
466
+ const MARK = { true: "ok ", false: "FLAG", null: " " };
467
+
468
+ function report(name, r, rows) {
416
469
  console.log(`\n=== ${name} — ${r.words} words, ${r.sentences} sentences ===`);
417
470
  console.log(
418
471
  `sentence lengths ${JSON.stringify(r.sentenceLens)} mean ${r.meanLen} sd ${r.sdLen} CV ${r.cv}`,
419
472
  );
420
- console.log(
421
- `${flag(r.metadiscoursePer1000 <= THRESHOLDS.metadiscourseTotalPer1000.warn)} all metadiscourse/1000w ${r.metadiscoursePer1000} (Hyland RA baseline 64.9, warn >${THRESHOLDS.metadiscourseTotalPer1000.warn})`,
422
- );
423
- console.log(` nominalisations/1000w ${r.nominalisationsPer1000}`);
424
- console.log(
425
- `${flag(r.contrastivePer10k <= THRESHOLDS.contrastivePer10k.warn)} "rather than"/instead-of/10,000w ${r.contrastivePer10k} (warn >${THRESHOLDS.contrastivePer10k.warn}; not epanorthosis, no published baseline)`,
426
- );
427
- console.log(
428
- `${flag(r.wastedStress.length === 0)} sentences ending on a cross-ref/citation/hedge (wasted stress position): ${r.wastedStress.length}`,
429
- );
430
- r.wastedStress.forEach((s) => console.log(` > …${s.slice(-70)}`));
431
- console.log(
432
- `${flag(r.negatedSentencePct <= THRESHOLDS.negatedMainClausePct.warn)} sentences whose main claim is a negation: ${r.negatedSentencePct}%`,
433
- );
434
- console.log(
435
- ` topic positions (whose story is this?): ${r.topics.map((t) => `"${t}"`).join(" | ")}`,
436
- );
473
+ rows.forEach(({ ok, text, under }) => {
474
+ console.log(`${MARK[ok]} ${text}`);
475
+ under.forEach((l) => console.log(` > ${l}`));
476
+ });
437
477
  }
438
478
 
439
479
  const args = process.argv.slice(2);
@@ -474,14 +514,14 @@ function pdfTextOnly(t) {
474
514
  .replace(/\f/g, "\n\n");
475
515
  }
476
516
 
477
- // 🔴 `--flags-only` and a NON-ZERO EXIT, added 2026-08-05, are what let anything downstream act.
478
- // Until today this file printed a wall of numbers and always exited 0, and it was wired to no hook
479
- // at all — it ran only if a human or a skill remembered it existed. Its epanorthosis metric sat at
480
- // 50.8 per 10,000 words (the LLM baseline is 7.8) through five consecutive writing passes and never
481
- // once flagged, because it had no threshold. The corpus owner found the tic by eye in a single reading and
482
- // asked why the tooling had not. It had not because it was measuring, and measuring is not checking
483
- // — which is, word for word, this paper's own thesis running loose inside its own toolchain.
484
- const flagsOnly = args.includes("--flags-only");
517
+ // `--flags-only` prints the findings of the report (every line it marks FLAG, with the lines under
518
+ // it) and nothing on a clean paper, and exits 1 when there is one: the form for a hook. The report
519
+ // prints all of `checks` and exits 0.
520
+ const mode = args.includes("--flags-only")
521
+ ? "flags"
522
+ : args.includes("--headings")
523
+ ? "headings"
524
+ : "report";
485
525
  let flagged = 0;
486
526
  for (const f of args.filter((a) => !a.startsWith("--"))) {
487
527
  const raw = fs.readFileSync(f, "utf8");
@@ -509,33 +549,25 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
509
549
  process.exit(2);
510
550
  }
511
551
  const r = analyse(text, sentences);
512
- if (flagsOnly) {
513
- const lines = [];
514
- // A paper's captions are in its own files — paper.tex and what it includes, wherever a figure
515
- // is set; a rendered page has the `.tex` of `figures/` beside it.
516
- for (const c of captions(tex ? tex.files : figureFiles(f, fs, path))) {
517
- if (c.longest > THRESHOLDS.captionSentenceWords.warn)
518
- lines.push(
519
- ` ${c.file}: caption sentence of ${c.longest} words (warn >${THRESHOLDS.captionSentenceWords.warn}) — "${c.longestSentence.slice(0, 110)}…"`,
520
- );
521
- if (c.words > THRESHOLDS.captionWords.warn)
522
- lines.push(
523
- ` ${c.file}: caption is ${c.words} words (warn >${THRESHOLDS.captionWords.warn}) — a caption this long is a section under a picture`,
524
- );
525
- }
526
- if (lines.length) {
527
- flagged += lines.length;
552
+ const rows = () => checks(r, captionFindings(f, tex, fs, path));
553
+ if (mode === "flags") {
554
+ const found = rows().filter((row) => row.ok === false);
555
+ if (found.length) {
556
+ flagged += found.length;
528
557
  // The header STATES the count, so a caller need not count output lines — header and footer
529
558
  // are not findings.
530
559
  console.error(
531
- `✍️ prose-lint — ${f.split("/").pop()} — ${lines.length} finding(s):`,
560
+ `✍️ prose-lint — ${f.split("/").pop()} — ${found.length} finding(s):`,
532
561
  );
533
- lines.forEach((l) => console.error(l));
562
+ found.forEach(({ text, under }) => {
563
+ console.error(` ${text}`);
564
+ under.forEach((l) => console.error(` > ${l}`));
565
+ });
534
566
  console.error(
535
- " run prose-lint.mjs on the file without --flags-only for the sentences",
567
+ " run prose-lint.mjs on the file without --flags-only for every metric",
536
568
  );
537
569
  }
538
- } else if (args.includes("--headings")) {
570
+ } else if (mode === "headings") {
539
571
  // The whole-document question, printed as one list. Not a gate: "does this heading name a
540
572
  // concrete noun" is judgement and pretending otherwise would be the exact failure this file
541
573
  // exists to stop. What was missing is cheaper and was the real cause — nobody had ever seen the
@@ -558,10 +590,10 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
558
590
  'a linter, a configuration, a repository, a rule — not "answers", "kinds", "moves".',
559
591
  );
560
592
  } else {
561
- report(f.split("/").pop(), r);
593
+ report(f.split("/").pop(), r, rows());
562
594
  }
563
595
  }
564
- if (!flagsOnly)
596
+ if (mode !== "flags")
565
597
  console.log(
566
598
  "\n(thresholds and their sources are in THRESHOLDS at the top of this file)",
567
599
  );