paperlint 9.0.0 → 10.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 (190) hide show
  1. package/.github/CLAUDE.md +22 -0
  2. package/.github/CLAUDE.md.spec.ts +20 -0
  3. package/CLAUDE.md +59 -515
  4. package/CONTRIBUTING.md +2 -2
  5. package/README.md +68 -128
  6. package/dist/CLAUDE.md.spec.d.ts +3 -0
  7. package/dist/CLAUDE.md.spec.d.ts.map +1 -0
  8. package/dist/CLAUDE.md.spec.js +62 -0
  9. package/dist/CLAUDE.md.spec.js.map +1 -0
  10. package/dist/build.d.ts +1 -1
  11. package/dist/build.js +2 -2
  12. package/dist/cli.d.ts +5 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +52 -6
  15. package/dist/cli.js.map +1 -1
  16. package/dist/domain/layout-tree.d.ts +19 -0
  17. package/dist/domain/layout-tree.d.ts.map +1 -0
  18. package/dist/domain/layout-tree.js +32 -0
  19. package/dist/domain/layout-tree.js.map +1 -0
  20. package/dist/domain/lookup-cache.d.ts +2 -2
  21. package/dist/domain/lookup-cache.js +2 -2
  22. package/dist/domain/paper-layout.d.ts +81 -0
  23. package/dist/domain/paper-layout.d.ts.map +1 -0
  24. package/dist/domain/paper-layout.js +259 -0
  25. package/dist/domain/paper-layout.js.map +1 -0
  26. package/dist/domain/repro-layout.d.ts +80 -0
  27. package/dist/domain/repro-layout.d.ts.map +1 -0
  28. package/dist/domain/repro-layout.js +247 -0
  29. package/dist/domain/repro-layout.js.map +1 -0
  30. package/dist/domain/scaffold-layout.d.ts +28 -0
  31. package/dist/domain/scaffold-layout.d.ts.map +1 -0
  32. package/dist/domain/scaffold-layout.js +81 -0
  33. package/dist/domain/scaffold-layout.js.map +1 -0
  34. package/dist/eslint-rules/CLAUDE.md.spec.d.ts +3 -0
  35. package/dist/eslint-rules/CLAUDE.md.spec.d.ts.map +1 -0
  36. package/dist/eslint-rules/CLAUDE.md.spec.js +39 -0
  37. package/dist/eslint-rules/CLAUDE.md.spec.js.map +1 -0
  38. package/dist/eslint-rules/install-path-literals.d.ts +1 -1
  39. package/dist/eslint-rules/install-path-literals.js +4 -4
  40. package/dist/eslint-rules/install-path-literals.js.map +1 -1
  41. package/dist/eslint-rules/pdf-last-page-balance.js +1 -1
  42. package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
  43. package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
  44. package/dist/eslint-rules/prereg-coverage.js +274 -0
  45. package/dist/eslint-rules/prereg-coverage.js.map +1 -0
  46. package/dist/folder-layout-hook.d.ts +21 -0
  47. package/dist/folder-layout-hook.d.ts.map +1 -0
  48. package/dist/folder-layout-hook.js +117 -0
  49. package/dist/folder-layout-hook.js.map +1 -0
  50. package/dist/folder-layout-rule.d.ts +22 -0
  51. package/dist/folder-layout-rule.d.ts.map +1 -0
  52. package/dist/folder-layout-rule.js +107 -0
  53. package/dist/folder-layout-rule.js.map +1 -0
  54. package/dist/init.js +1 -1
  55. package/dist/init.js.map +1 -1
  56. package/dist/lib/agent-cli-version.js +1 -1
  57. package/dist/lib/agent-cli-version.js.map +1 -1
  58. package/dist/new-paper.d.ts +4 -1
  59. package/dist/new-paper.d.ts.map +1 -1
  60. package/dist/new-paper.js +40 -6
  61. package/dist/new-paper.js.map +1 -1
  62. package/dist/references.d.ts +1 -1
  63. package/dist/references.d.ts.map +1 -1
  64. package/dist/references.js +2 -2
  65. package/dist/references.js.map +1 -1
  66. package/dist/repro-layout-rule.d.ts +16 -0
  67. package/dist/repro-layout-rule.d.ts.map +1 -0
  68. package/dist/repro-layout-rule.js +81 -0
  69. package/dist/repro-layout-rule.js.map +1 -0
  70. package/dist/tex-requirements.js +1 -1
  71. package/dist/toolchain.js +1 -1
  72. package/docs/ci.md +60 -0
  73. package/docs/configuration.md +2 -0
  74. package/docs/e2e.md +3 -3
  75. package/docs/incidents.md +5 -5
  76. package/docs/install.md +20 -16
  77. package/docs/layout.md +154 -0
  78. package/docs/optional-rules.md +5 -4
  79. package/docs/references.md +5 -2
  80. package/docs/rules/paper/allowed-files-only.md +192 -0
  81. package/docs/rules/repro/allowed-files-only.md +128 -0
  82. package/docs/rules/study/declare-sample.md +146 -0
  83. package/docs/rules.md +53 -50
  84. package/docs/skills.md +6 -3
  85. package/docs/venues.md +94 -0
  86. package/eslint-rules/CLAUDE.md +44 -0
  87. package/eslint-rules/CLAUDE.md.spec.ts +43 -0
  88. package/eslint-rules/install-path-literals.harness.mjs +1 -1
  89. package/eslint-rules/install-path-literals.ts +4 -4
  90. package/eslint-rules/pdf-last-page-balance.ts +1 -1
  91. package/eslint-rules/prereg-coverage.test.ts +448 -0
  92. package/eslint-rules/prereg-coverage.ts +419 -0
  93. package/eslint.config.mjs +6 -3
  94. package/hooks/CLAUDE.md +34 -0
  95. package/hooks/CLAUDE.md.spec.ts +30 -0
  96. package/lib/agent-cli-version.ts +1 -1
  97. package/lib/skill-trigger-cases.harness.mjs +51 -5
  98. package/lib/skill-trigger-cases.mjs +53 -15
  99. package/package.json +2 -2
  100. package/plugin/CLAUDE.md +22 -0
  101. package/plugin/CLAUDE.md.spec.ts +22 -0
  102. package/plugin/hooks/hooks.json +9 -0
  103. package/scripts/coverage-config.test.ts +2 -1
  104. package/skills/CLAUDE.md +19 -0
  105. package/skills/CLAUDE.md.spec.ts +18 -0
  106. package/skills/analyze-sibling-paper/SKILL.md +2 -2
  107. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
  108. package/skills/build-benchmark/SKILL.md +14 -1
  109. package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
  110. package/skills/build-label-page/SKILL.md +118 -0
  111. package/skills/build-label-page/SKILL.md.spec.ts +129 -0
  112. package/skills/build-label-page/build-label-page.eval.mjs +15 -0
  113. package/skills/build-label-page/build-label-page.test.ts +959 -0
  114. package/skills/build-label-page/references/checklist.md +148 -0
  115. package/skills/build-label-page/references/example-generator.py +803 -0
  116. package/skills/find-venue/SKILL.md +5 -3
  117. package/skills/find-venue/SKILL.md.spec.ts +4 -2
  118. package/skills/grade-paper-writing/SKILL.md +7 -7
  119. package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
  120. package/skills/map-prior-work/SKILL.md +2 -2
  121. package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/SKILL.md +8 -6
  123. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -5
  124. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
  125. package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
  126. package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
  127. package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
  128. package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
  129. package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
  130. package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
  131. package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
  132. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
  133. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
  134. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
  135. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
  136. package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
  137. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
  138. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
  139. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
  140. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
  141. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
  142. package/skills/paper-pipeline/repro/README.md +4 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
  145. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
  148. package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
  149. package/skills/paper-status/SKILL.md +2 -2
  150. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  151. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +21 -102
  152. package/skills/study-accepted-papers/SKILL.md +163 -161
  153. package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
  154. package/skills/study-accepted-papers/references/base-rates.md +69 -0
  155. package/skills/study-accepted-papers/references/mechanisms.md +181 -0
  156. package/skills/verify-citations/SKILL.md +2 -2
  157. package/skills/verify-citations/SKILL.md.spec.ts +1 -1
  158. package/src/CLAUDE.md +23 -1
  159. package/src/CLAUDE.md.spec.ts +72 -0
  160. package/src/build.ts +2 -2
  161. package/src/cli.harness.mjs +42 -0
  162. package/src/cli.test.ts +90 -6
  163. package/src/cli.ts +78 -5
  164. package/src/domain/layout-tree.test.ts +32 -0
  165. package/src/domain/layout-tree.ts +47 -0
  166. package/src/domain/lookup-cache.ts +2 -2
  167. package/src/domain/paper-layout.test.ts +264 -0
  168. package/src/domain/paper-layout.ts +336 -0
  169. package/src/domain/repro-layout.test.ts +222 -0
  170. package/src/domain/repro-layout.ts +311 -0
  171. package/src/domain/scaffold-layout.ts +104 -0
  172. package/src/folder-layout-hook.test.ts +214 -0
  173. package/src/folder-layout-hook.ts +141 -0
  174. package/src/folder-layout-rule.test.ts +235 -0
  175. package/src/folder-layout-rule.ts +151 -0
  176. package/src/hooks-settings.harness.ts +9 -8
  177. package/src/init.ts +1 -1
  178. package/src/layout-docs.test.ts +52 -0
  179. package/src/new-paper.harness.mjs +23 -1
  180. package/src/new-paper.test.ts +299 -6
  181. package/src/new-paper.ts +51 -5
  182. package/src/paper-files.test.ts +9 -6
  183. package/src/references.test.ts +5 -5
  184. package/src/references.ts +2 -2
  185. package/src/repro-layout-rule.test.ts +114 -0
  186. package/src/repro-layout-rule.ts +121 -0
  187. package/src/rule-cases.test.ts +98 -0
  188. package/src/tex-requirements.ts +1 -1
  189. package/src/toolchain.ts +1 -1
  190. package/templates/paper/PIPELINE-STATUS.md +41 -7
package/CLAUDE.md CHANGED
@@ -1,560 +1,104 @@
1
- # CLAUDE.md — paperlint
1
+ <!-- vigiles:sha256:e445489aca0dd970 compiled from CLAUDE.md.spec.ts -->
2
2
 
3
- Machine-checkable gates for writing a research paper in git, extracted from a private
4
- knowledge base.
3
+ # CLAUDE.md
5
4
 
6
- ## 🎯 THE GOAL, in one sentence
7
-
8
- **Move every paper-writing convention a machine can decide OUT of prose and INTO an engine
9
- that fails the build** — and keep everything else honestly labelled as prose. The engine is
10
- ESLint. The unit of progress is "one more verdict decided by the engine instead of by a
11
- hand-written script".
12
-
13
- That last clause is the whole direction, and it is the part that keeps being forgotten:
14
-
15
- ```
16
- prose in a guideline → hand-written script → ESLint rule in the engine
17
- rots silently runs, but is OURS editor-time · free AST · suppressible
18
- ← where this started ← where most of it is ← where it is going
19
- ```
5
+ ## Paperlint
20
6
 
21
- 🔴 **A hand-written script is a WAYPOINT, not a destination.** Writing a new one is allowed
22
- only when the engine genuinely cannot express the check — and "cannot" means MEASURED, not
23
- assumed. Two things that sound like limits and are not:
24
-
25
- - _"ESLint only sees one file"_ — true of its AST, false of the rule: a rule is an ordinary
26
- JS module and may call `execFileSync("git", …)` or read a sibling file. If the reason to
27
- stay a script is "it needs git", that reason is weak; measure the real cost before using it.
28
- - _"this runs programs, not lints files"_ — that is a real limit, and the answer is the seam
29
- already proven here: **a script PRODUCES facts into a JSON file, and ESLint JUDGES that
30
- file.** The verdict lands in the engine even though the work did not.
31
-
32
- ## 📊 STATE — measured 2026-09-16 (re-measure, never cite)
33
-
34
- | | |
35
- | ----------------------- | -----------------------------------------------------------------------------------------: |
36
- | ESLint rules | **5** — `latex-language` · `tex-build` · `papers` · `review-findings-cause` · `doc-fields` |
37
- | harnesses | **57** |
38
- | skills | **24** |
39
- | hooks (runnable `.mjs`) | **5** |
40
- | repo-wide scripts | 5 |
41
- | files tracked / commits | 308 / 43 |
42
-
43
- **A real consumer dogfoods this package on every CI run**, so a breaking change here turns a
44
- paper pipeline red somewhere else the same day. That is deliberate — it is the only thing
45
- keeping the extraction honest.
46
-
47
- ⚠️ **This table is a SNAPSHOT, not a fact about today, and it has already gone stale once.**
48
- The line standing here until 2026-09-16 said "two ESLint rule modules" while six were shipped,
49
- 24 skills had moved in, and hooks existed at all. Re-measure with `git ls-files` before
50
- repeating any number from it.
7
+ Machine-checkable gates for writing a research paper in git, extracted from a private knowledge base.
51
8
 
52
- ## First command in a fresh container
9
+ This file loads on every turn, so it holds only what applies to **any** change here. A rule that applies to one kind of work lives in that folder's `CLAUDE.md`, which loads when a file there is read or edited (table below); a fact a machine checks lives beside that check. Every `CLAUDE.md` here is compiled from the `CLAUDE.md.spec.ts` beside it: edit the spec, then `npx vigiles compile`. What broke is in `docs/incidents.md`; the measurements behind the rules are in [git](https://github.com/zernie/paperlint/blob/b4a3dc3a81d436186f41db2d69b65fd93f1af3b6/CLAUDE.md#L62-L199).
53
10
 
54
- ```bash
55
- npm install
56
- ```
11
+ ## 🎯 THE GOAL, in one sentence
57
12
 
58
- Not optional and not "when something breaks": `vigiles` is a real dependency, and every
59
- harness, spec and hook resolves through it. A container where `npm install` never ran fails
60
- in ways that look like broken code rather than a missing install.
61
-
62
- ## The ten rules that decide what may live here — and what may not be written
63
-
64
- **1. Mechanism goes to vigiles, data stays here.** A file that names nothing local — no rule
65
- of ours, no fixture of ours — is machinery, and machinery belongs in
66
- [vigiles](https://github.com/zernie/vigiles). Ask it in two steps: is this mechanism or data?
67
- If mechanism — does it know about _this_ domain? If not, it is not ours.
68
-
69
- **2. A check over an AST is a LINT RULE, not a script.** If it walks `.ts`/`.js`/`.tex` and
70
- looks at declarations, names or nodes, write an ESLint rule: it fires in the editor on save,
71
- gets the AST for free, and has a suppression syntax people already know. Scripts are for
72
- corpus-wide questions — index connectivity, ratios across many files — not for one file's
73
- nodes.
74
-
75
- **3. Every check needs BOTH halves, or it is not tested.** It must FIRE on a planted defect
76
- and stay QUIET on a clean fixture. A check that has only been seen quiet is
77
- indistinguishable from a dead one — silence is its success state. Prove the fire half by
78
- seeing the test go RED before the fix lands ([`docs/testing.md`](docs/testing.md)).
79
-
80
- **One rule, one requirement.** A rule checks one thing a user would want to switch off, or set a
81
- severity for, on its own; two checks with different reasons to disable them are two rules. Several
82
- messageIds for facets of one requirement are fine. Example: `pdf/limits` checked body pages,
83
- reference pages and the reference font size — the first two are one page budget and became
84
- `format/page-limit`; the font size is a different requirement and moved to `pdf/body-size`.
85
-
86
- **A rule that judges a venue requirement is validated on real accepted papers of that venue** (or
87
- of its parent venue, with the same template), built to PDF, with the ground truth established
88
- independently of the rule — rendered and looked at — plus variant fixtures derived from those real
89
- sources, one change each. Synthetic minimal fixtures alone are not enough: they are written by the
90
- same hand as the rule, and share its blind spots. Example: `format/page-limit`'s body count, on the
91
- built ACSAC papers in `fixtures/accepted-papers/` (`test/e2e/tex/page-count.e2e.ts`).
92
-
93
- **A new rule ships with its page**, `docs/rules/<group>/<rule>.md`, in these sections: What it
94
- catches · Why · Examples (failing / passing) · Options / preset fields · What it does not check ·
95
- How to fix. Its `meta.docs.url` points at that page, and its row in `docs/rules.md` is one line
96
- linking there. `src/rule-docs.test.ts` holds every rule the config registers to this; the rules
97
- older than the convention wait in its `AWAITING_PAGE` list, which only shrinks — #131 writes their
98
- pages.
99
-
100
- **4. `exit 0` with empty output is NOT "clean".** A rule whose glob matched no files reports
101
- exactly like a rule that passed. Any rule shipped here must be loud when its input set is
102
- empty. This is the specific defect that blocks stage 1 of the plan: in the source base a
103
- fresh clone yields RC=0, 652 findings, zero errors — because 19 rules saw no files at all.
104
-
105
- **5. "It can't be done in the engine" must be MEASURED, not assumed.** Every one of these was
106
- stated confidently on 2026-09-16 and every one fell to a single command:
107
-
108
- | the claim | what one command showed |
109
- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
- | "a lint rule can't know a git date, so this stays a script" | a rule is plain JS; `execFileSync("git", …)` is legal in it. The real costs (per-file invocation, `--cache` keyed on content) are solvable, so this was a preference dressed as a limit |
111
- | "we need our own glob expander" | `fs.globSync` ships in Node 22 and returned the identical set. 31 hand-written lines of regex existed for nothing |
112
- | "the tool can't run a file that config excludes" | it can, and says so: `matches exclude … — running because you named it` |
113
-
114
- ⇒ Before writing machinery, **run the thing you are about to replace and paste its output.**
115
- "I couldn't get it to work" is data about the attempt, not about the tool.
116
-
117
- **6. A program shipped by this package MUST NOT depend on the consumer's cwd.** Measured
118
- 2026-09-16: three hooks here read their config as `provide("pkg", "cat package.json")`. The
119
- consumer's session changed directory into a subfolder for unrelated reasons, `cat` failed, and
120
- the Bash gate — which fails closed, correctly — denied **every command in that session**,
121
- including the one that would undo it. The nudges next to it would have failed _silently_,
122
- which is worse.
123
-
124
- Resolve paths from the repository root (`git rev-parse --show-toplevel`) or from the module's
125
- own location, never from where the caller happens to stand. The blast radius of a cwd
126
- assumption is not this package — it is somebody else's whole session.
127
-
128
- **7. Do not describe what you have not opened.** A `README` here nearly shipped the line
129
- "MIT — see LICENSE" on 2026-09-16. There is no `LICENSE` file and `package.json` says
130
- `UNLICENSED`. Publishing is irreversible and this repo is public: every factual claim in a
131
- document meant for strangers gets checked against the disk in the same pass that writes it.
132
-
133
- **8. 🔴 NEVER WRITE A GLOB OR A REGEX INSIDE A BLOCK COMMENT.** An asterisk followed by a
134
- slash **ends the comment**, wherever it appears — in a path (a folder wildcard), in a regex
135
- whose last literal is an asterisk (one matching bold markup, for instance), in a quoted
136
- example. The rest of the comment becomes code, the file stops parsing, and the error points
137
- at a line further down that is perfectly fine.
138
-
139
- ⚠️ Note this rule does not quote the sequence either, not even here. A documented example is
140
- the thing that gets copied into code — and that is exactly how the fourth occurrence
141
- happened: it was copied out of a comment written to explain the first three.
142
-
143
- This is not a hypothetical and not a rare slip. **It fired four times in a single session on
144
- 2026-09-16** — three in the consumer, once here — and each time the diagnosis cost minutes
145
- because `SyntaxError: Unexpected token '.'` says nothing about comments.
146
-
147
- | instead of | write |
148
- | --------------------------------------- | ----------------------------------------------------- |
149
- | a glob with an asterisk and slash | spell it: "every folder under papers" |
150
- | a regex with an asterisk before a slash | describe what it matched, in words |
151
- | an example needing both | put it in a line comment (two slashes), never a block |
152
-
153
- ⇒ **In a block comment, prose describes the pattern; it never quotes it.** If the exact
154
- characters matter, they belong in the code or in a line comment beside it.
155
-
156
- **9. Measure the DEFECT before proposing the fix — and read "this is quick" as a warning.**
157
- Rule 5 is about the tool you are replacing; this one is about the order of work.
158
-
159
- 1. **Show the defect**: command output, or a file quote with a line number. A proposed fix with
160
- no exhibited defect is not a fix, it is a preference.
161
- 2. **Name the layer and the channel** it touches: ESLint rule · skill · hook · CLI · CI action
162
- · path resolution. More than one is a conversation, not a commit.
163
- 3. **An adversarial second pass is encouraged, and it is not free.** Spend it on a fork in the
164
- road, on anything that goes outward, and on a conclusion you are about to act on.
165
- 4. **A quick fix is almost never quick** — it is quick to _propose_ precisely because nothing
166
- was opened.
167
-
168
- Four proposals were made and withdrawn in one session on 2026-09-17 for exactly this reason —
169
- [`docs/incidents.md`](docs/incidents.md).
170
-
171
- **10. Effects live in adapters, and WHERE paperlint IS INSTALLED lives in ONE of them.** Rule 6 generalised: the caller's cwd is one case of it. Checking logic — lint rules,
172
- skills, hooks — must not know its own location, nor its distance from anything else. Every
173
- answer to _where_ comes from one of two modules, each for one side: the consumer (its root, the
174
- package's name) from `skills/paper-pipeline/scripts/consumer.mjs`, which adapts per channel — own
175
- checkout · `node_modules` · plugin cache · CI; the package's own data (`presets/`, `presets/tex/`)
176
- from `src/package-dirs.ts`. A skill naming a script by an install-specific path in its own prose
177
- walks around that door, and 208 such literals across 190 lines do exactly that.
178
-
179
- <!-- The ports' paths above are resolved by `npm run check` (vigiles lint), since #67. -->
180
- <!-- vigiles:file skills/paper-pipeline/scripts/consumer.mjs -->
181
- <!-- vigiles:file src/package-dirs.ts -->
182
-
183
- The layer rules for `src/` live in [`src/CLAUDE.md`](src/CLAUDE.md) and are enforced by the linter.
184
-
185
- ⏳ **Still owed: the install-path half** — a lint rule that makes an install-specific path literal
186
- outside `consumer.mjs` a finding. Prose will not hold this class — four silent breakages happened
187
- _while_ comments explaining the hazard sat directly above the code
188
- ([`docs/incidents.md`](docs/incidents.md)).
189
-
190
- **11. Installing and using paperlint must be as smooth as possible.** Count the actions between "I
191
- want this" and "it works": every command to copy, flag to pass or file to edit is one more place
192
- to give up. The target is `npm i` plus one command. A per-paper script, a manual TeX install or a
193
- "now add this to your config" step is a defect in paperlint, not a user task. The only exception is a
194
- choice that really belongs to the user (irreversible, paid, privacy), and then it is named at the
195
- moment it is asked. The converse holds too: an automatic step that can fail silently is worse
196
- than an explicit one — it works, or it says loudly that it did not. Measured example of the
197
- converse: Tectonic installs as one file but silently replaced Times with Latin Modern on a plain
198
- `article` paper and still exited 0 (#35, #59).
199
-
200
- ## Before changing the command surface or a delivery channel — read the prior art
201
-
202
- [`docs/prior-art/`](docs/prior-art/README.md) records how comparable tools solved the same
203
- problems, each claim with the URL that was checked: Quarto and Vale (the domain and content
204
- analogues), Biome (one tool, one config, one command), Danger and reviewdog (who decides to
205
- fail a run), `unicorn/expiring-todo-comments` and Semgrep (checks that read a clock or a diff).
206
-
207
- It is here as a POINTER and stays a pointer: this file is read on every turn, so it carries
208
- the instruction and never the evidence — the same split as rules 9 and 10 and `docs/incidents.md`.
209
-
210
- 🔴 The argument "we need another command for X" is, in every tool examined, an argument that
211
- the CONFIG is not declaring something. Check that before adding a verb.
212
-
213
- Rejected on 2026-09-27, so it is not proposed again: a separate `paperlint refs` command for the
214
- reference check. The slowness it would have moved (#107) was a cache thrown away after every build,
215
- not a missing verb. The fix keeps the check in `build` and commits what the services answered in
216
- `<paper>/repro/references-cache.json` — **raw responses, never verdicts**: verdicts are derived
217
- again on every build, so a checker fix reaches every paper ([`docs/references.md`](docs/references.md)).
218
-
219
- ## Distribution — one install path: npm, then `paperlint init`
220
-
221
- `npm i -D paperlint` brings all the code — rules, skills, hooks, scripts. `paperlint init`
222
- then does what only a command can, because it depends on the project it lands in: it finds the
223
- papers directory and declares it in a root `paperlint.json` when it is not the default `papers`, links each skill into `.claude/skills/`, writes
224
- the hook commands into `.claude/settings.json` (vigiles' `mergeRegistrations`, reading
225
- `plugin/hooks/hooks.json` as the one source), and offers a CI workflow that runs the action from
226
- `node_modules` after `npm ci`. `paperlint doctor` reads all of it back. Details: `docs/install.md`.
227
-
228
- **There is no Claude Code plugin or marketplace entry; it was removed in the release after 1.0.0
229
- (#82).** Do not bring it back without answering these, each measured:
230
-
231
- - **A plugin cannot carry the code.** Claude Code runs `npm ci --ignore-scripts` for a plugin only
232
- when its root holds a `package.json` and a lockfile, with a 60-second timeout, and _"a failed or
233
- skipped install never blocks the plugin"_: on a slow network the hooks load and fail with
234
- `cannot find module vigiles`, silently.
235
- - **The skills need the npm package anyway.** 23 of 24 skills run scripts under
236
- `paper-pipeline/scripts`, which resolve only through `node_modules/paperlint/`.
237
- A plugin-only consumer got skills whose first command fails.
238
- - **Its manifests carried versions nothing updated** (0.0.1 and 0.1.0 while npm was at 1.0.0), and
239
- Claude Code decides plugin updates from that number.
240
-
241
- `plugin/hooks/hooks.json` stays where it is: it is not a plugin any more, it is the hook wiring
242
- `paperlint init` merges into the consumer's settings.
243
-
244
- ## Delivery — how this repo's contents reach a consumer (measured 2026-09-11)
245
-
246
- Three channels, each measured on a fixture rather than assumed. The consumer here is the
247
- private knowledge base this was extracted from; nothing below is specific to it.
248
-
249
- ### Skills ship as `skills/`, and the consumer SYMLINKS them
250
-
251
- **Do not move skills to `.claude/skills/` inside this repo.** They live in `skills/`
252
- (`SHIPPED_SKILLS_DIR` in `skills/paper-pipeline/scripts/consumer.mjs`, read by the linker and the
253
- install e2e), which is the standard layout. An ecosystem scan of 855 npm packages (by published
254
- tarball, not repository) found **336 shipping `skills/<n>/SKILL.md` against 15 shipping `.claude/skills/`** — 22 : 1. The
255
- top of the market is entirely on `skills/`: `@vitejs/devtools-kit` (330 896 downloads/wk),
256
- `@slidev/cli` (56 809), `anthropics/skills` (175 673 stars).
257
-
258
- **The consumer's side is a symlink per skill, made by `paperlint init` (`src/link-skills.ts`):**
13
+ **Move every paper-writing convention a machine can decide OUT of prose and INTO an engine that fails the build** — and keep everything else honestly labelled as prose. The engine is ESLint. The unit of progress is "one more verdict decided by the engine instead of by a hand-written script".
259
14
 
260
15
  ```
261
- <consumer>/.claude/skills/<name> -> node_modules/paperlint/skills/<name>
16
+ prose in a guideline → hand-written script → ESLint rule in the engine
17
+ rots silently runs, but is OURS editor-time · free AST · suppressible
262
18
  ```
263
19
 
264
- ⚠️ The skill's name in the listing comes from the **link directory's name**, not from
265
- `name:` in the frontmatter — so the link must be named exactly as the skill.
266
-
267
- ⚠️ Do NOT rely on a consumer picking `.claude/skills/` up out of `node_modules` on its own.
268
- It does happen — such a directory is an ordinary nested one — but only **lazily and
269
- silently**, the first time the agent happens to read a file inside that package. A symlink
270
- loads at startup, deterministically. Measured both ways on claude 2.1.268.
271
-
272
- ### Hooks ship as `.mjs`, NEVER as `.hook.ts`
273
-
274
- Measured on a fixture — one hook, four locations, both halves (an input that must be denied
275
- and one that must pass), exit code taken without a pipe:
20
+ 🔴 **A hand-written script is a WAYPOINT, not a destination.** A new one is allowed only when the engine genuinely cannot express the check, and "cannot" means MEASURED. A rule is an ordinary JS module and may call `execFileSync("git", …)` or read a sibling file. When the work has to run a program, **a script PRODUCES facts into a JSON file, and ESLint JUDGES that file.**
276
21
 
277
- | hook location | deny input | allow input |
278
- | ----------------------------------------------------- | ----------------------- | --------------------------- |
279
- | `node_modules/<pkg>/.claude/hooks/probe.hook.**mjs**` | RC=2, fires | RC=0, silent |
280
- | symlink into `node_modules`, `.mjs` | RC=2, fires | RC=0, silent |
281
- | local control, `.mjs` | RC=2, fires | RC=0, silent |
282
- | `node_modules/<pkg>/.claude/hooks/probe.hook.**ts**` | RC=2 `cannot be loaded` | **RC=2 `cannot be loaded`** |
22
+ **A real consumer dogfoods this package on every CI run**, so a breaking change here turns a paper pipeline red somewhere else the same day. CI runs on every pull request (`.github/workflows/ci.yml`). Counts of rules, skills and hooks: measure with `git ls-files`.
283
23
 
284
- The last row is not "it blocks the dangerous thing" — it fails to load and therefore blocks
285
- **everything**, including `echo hi`. A consumer in that state cannot run any Bash command,
286
- and the one command that would repair it is also Bash.
287
-
288
- ⇒ **The package ships `.mjs`** — the consumer gets something that loads.
289
-
290
- (Not established: _why_ the TypeScript loader refuses a path inside `node_modules`. The real
291
- cause is swallowed by a `catch` in vigiles' `hook-runtime.js`, and calling `loadHookProgram`
292
- directly measures a different load path — it fails even on the control. Knowing _that_ is
293
- enough to choose the carrier.)
294
-
295
- #### 🔴 CORRECTED 2026-09-12, when the first three hooks actually moved: THERE IS NO `.hook.ts` TWIN
296
-
297
- This section used to promise «the `.hook.ts` source lives HERE and is typechecked HERE; the
298
- package ships the compiled `.mjs`». Shipping the first three hooks retired that plan, and the
299
- reason is worth keeping: **a twin can drift from its build, and nothing would notice.** One file
300
- cannot.
24
+ ## First command in a fresh container
301
25
 
302
- What the twin was for was the CAPABILITY CHECK — `vigiles compile` refuses a hook that imports
303
- anything but `vigiles/hook`, because the import list _is_ the capability surface. That check is a
304
- function, `checkHookImports`, and `hooks/hooks.harness.ts` runs it over every shipped
305
- `.hook.mjs` directly. Same check, applied to the artifact that actually executes, with no second
306
- file to keep in step. What is lost is `tsc` on the hook body and the typed `e.ctx` — named here
307
- rather than left as an omission.
26
+ `npm install` — not optional: `vigiles` is a real dependency, and every harness, spec and hook resolves through it. Without it, failures look like broken code rather than a missing install.
308
27
 
309
- ⚠️ **`checkHookImports` IS A TEXT REGEX, so it counts an import-shaped sentence in a COMMENT.**
310
- Measured 2026-09-12: the check failed on `paper-edit-guard.hook.mjs`'s own docblock, which quoted
311
- a rejected import while explaining why it was rejected. Do not loosen the check to make prose
312
- fit — it is the same check any future `compile`/`lint` pass applies to the shipped file. Describe
313
- a forbidden import in words instead of writing one.
28
+ ## Where the other rules live
314
29
 
315
- #### 🔴 AND NOT A THIN SPEC IN THE CONSUMER EITHER — measured, and it is the form that looks right
30
+ | when you are… | read | holds |
31
+ | --- | --- | --- |
32
+ | writing a lint rule, or a check that judges prose | [`eslint-rules/CLAUDE.md`](eslint-rules/CLAUDE.md) | lint rule over script, what holds each requirement of a new rule, the five writing principles |
33
+ | writing or changing a hook | [`hooks/CLAUDE.md`](hooks/CLAUDE.md) | how hooks ship, and what was rejected |
34
+ | writing or changing a skill | [`skills/CLAUDE.md`](skills/CLAUDE.md) | how skills ship |
35
+ | in `src/` | [`src/CLAUDE.md`](src/CLAUDE.md) | the two layer axes |
36
+ | thinking of a Claude Code plugin | [`plugin/CLAUDE.md`](plugin/CLAUDE.md) | why there is none, and what must be measured to bring it back |
37
+ | changing CI, Dependabot or the release | [`.github/CLAUDE.md`](.github/CLAUDE.md) | cost, Dependabot cadence, release from the PR title |
38
+ | writing a test | [`docs/testing.md`](docs/testing.md), [`docs/e2e.md`](docs/e2e.md) | which kind of test, fakes, the e2e triggers |
316
39
 
317
- The obvious alternative is a small `.hook.ts` in the consumer that pulls the decision logic out of
318
- this package. It RUNS — deny input RC=2 with the reason, allow input RC=0 and silent — and it
319
- cannot be maintained:
40
+ ## Before every push
320
41
 
321
- ```
322
- $ npx vigiles compile
323
- ✗ .vigiles/hooks/probe.hook.ts — hook program uses capabilities outside `vigiles/hook`:
324
- <pkg>/hooks/decide.mjs — only the sanctioned API is allowed (capability = API surface).
325
- ```
42
+ `npm run check`, exit code read without a pipe. Red first · assert the whole value · no test touches the real network · 100% coverage, no `c8 ignore`. In a container without TeX Live, only the TeX e2e gate fails, and it says so; `npx paperlint toolchain` installs it.
326
43
 
327
- `compile` is also what writes the tamper-evident stamp, so a hook it refuses **can never be
328
- re-stamped** — and the runtime fails CLOSED on a stamp that no longer matches its source:
44
+ ## Commits
329
45
 
330
- ```
331
- vigiles: hook … does not match its compiled stamp (tampered).
332
- … the way out is a FILE WRITE, not a command — this refusal blocks the recompile too.
333
- ```
46
+ Conventional-commit subject, body says what was MEASURED, not what was intended. A number in a commit message that no command produced is the thing this repo exists to make impossible.
334
47
 
335
- Measured end to end: editing such a file makes the gate refuse `echo hi`, and the only steady
336
- state is clearing the stamp to `{}` and running permanently unstamped. Shipping the whole program
337
- keeps the stamp question from arising (no sidecar ⇒ no check) and pins the source by lockfile
338
- integrity instead — stronger than a local stamp, since a consumer cannot hand-edit an installed
339
- tree without the next install reverting it.
48
+ **The subject is also the release**: semantic-release reads the squash commit, which is the PR title. `feat:` → minor, `fix:` or `perf:` → patch, `!` or a `BREAKING CHANGE:` footer → major,
49
+ `docs:` `chore:` `ci:` `test:` `refactor:` → no release. `.github/workflows/pr-title.yml` rejects a title without a conventional prefix.
340
50
 
341
- #### How a consumer wires a shipped hook
51
+ ## Rules
342
52
 
343
- `.claude/settings.json`, one block per hook, pointing straight into the install — no symlink and
344
- no compile step on the consumer's side:
53
+ ### Mechanism Goes To Vigiles
345
54
 
346
- ```json
347
- {
348
- "type": "command",
349
- "command": "node \"$CLAUDE_PROJECT_DIR/node_modules/vigiles/dist/cli.js\" hook-runtime run-program \"$CLAUDE_PROJECT_DIR/node_modules/paperlint/hooks/paper-edit-guard.hook.mjs\""
350
- }
351
- ```
55
+ **Guidance only** — A file that names nothing local — no rule or fixture of ours — is machinery, and belongs in [vigiles](https://github.com/zernie/vigiles); data stays here.
352
56
 
353
- ⚠️ **A shipped hook cannot import a sibling module of this package** — capability closure being
354
- the point — so the papers-root resolver is spelled out in all three hook files. Duplication that
355
- cannot be removed is CHECKED instead: part VII of the harness compares the captured values
356
- against each other, rather than grepping for a literal (a substring search finds the same text in
357
- the prose _about_ the value one line above it).
57
+ ### Measure Before Calling It Impossible
358
58
 
359
- ### `vigiles` is a devDependency, and its pin is TIED to the consumer's
59
+ **Guidance only** — "It can't be done in the engine" must be MEASURED, not assumed. Each of these was stated confidently on 2026-09-16 and fell to a single command:
360
60
 
361
- `vigiles/hook` resolves **upward** from a hook inside a package — measured:
61
+ | the claim | what one command showed |
62
+ | --- | --- |
63
+ | "a lint rule can't know a git date, so this stays a script" | a rule is plain JS; `execFileSync("git", …)` is legal in it, and its real costs are solvable |
64
+ | "we need our own glob expander" | `fs.globSync` ships in Node 22 and returned the identical set |
65
+ | "the tool can't run a file that config excludes" | it can, and says so: `matches exclude … — running because you named it` |
362
66
 
363
- ```
364
- resolve OK -> <consumer>/node_modules/vigiles/dist/hook.js
365
- ```
67
+ Before writing machinery, **run the thing you are about to replace and paste its output.**
366
68
 
367
- So this package needs no copy of its own at the consumer's runtime; it needs `vigiles` only
368
- for its own `vigiles test` and `vigiles compile`. That is `devDependencies`, which `npm i` of
369
- a dependency does not install. Putting it in `dependencies` risks npm installing a **second**
370
- copy under `node_modules/paperlint/node_modules/vigiles` whenever the ranges
371
- drift — two runtimes, two sets of stamps and state.
372
-
373
- 🔴 **THE PARAGRAPH ABOVE WAS TRUE AND THE INSTALL DID THE OPPOSITE — measured 2026-09-17.**
374
- `devDependencies` is not the only entry naming `vigiles`: `peerDependencies` names it too, and
375
- **npm 7+ installs peers automatically**. So every consumer got it anyway, together with its
376
- transitive weight. `npm pack`, then install the tarball into an empty project:
377
-
378
- | | packages | `du -sm node_modules` |
379
- | ------------------------------------------------------- | -------: | --------------------: |
380
- | peer as declared before | 188 | **142 MB** |
381
- | `peerDependenciesMeta: { vigiles: { optional: true } }` | 164 | **56 MB** |
382
-
383
- The 86 MB are `@ast-grep/napi` (51 MB) and `typescript` (23 MB), pulled through `vigiles` — and
384
- paid for by a consumer who only wants the ESLint rules and never loads a hook.
385
-
386
- `optional: true` is the entry that matches what this section already argues: the consumer brings
387
- its own `vigiles` _when it uses the hooks_, and npm stops deciding that for them. Both halves
388
- measured on the 56 MB tree: `eslint-rules/latex-language.mjs` and the skill scripts load and
389
- run (RC=0), while
390
- `hooks/paper-edit-guard.hook.mjs` fails with `ERR_MODULE_NOT_FOUND` — which is this contract
391
- working, not a defect, exactly as argued below.
392
-
393
- ⚠️ The consumer in this project's own base is unaffected: it declares `vigiles` itself
394
- (`devDependencies: ^27.2.0`), so nothing about its tree changes.
395
-
396
- ### The `.bib` parser is a dependency: the one bibtex reader
397
-
398
- `@retorquere/bibtex-parser` is a `dependency`. It is the one reader of `.bib` text in paperlint
399
- (`src/ports/bib-reader.ts`, adapter `src/adapters/bibtex/`): `recordedBibliography`, `bib/reachable-entry`,
400
- the build's reference check, `extract-ref-facts` and `bib-authors` all read through it, so it can
401
- no longer be opt-in. It costs about 15 MB of the install (9 MB itself, plus
402
- `wink-eng-lite-web-model` and `unicode2latex`).
403
-
404
- Where it reads differently from bibtex — bibtex has no comment syntax, so an entry behind `%` or
405
- inside `@comment{…}` is one bibtex typesets — paperlint does not emulate bibtex: the build records
406
- what bibtex read, and `paper/refs-checked` names the difference (docs/design/paper-sources.md §7).
407
-
408
- 🔴 **Therefore the pin here and the pin in the consumer move TOGETHER, in one pass.** A major
409
- mismatch means a hook compiled by one version is executed by another: the stamp does not
410
- verify, the hook does not load, and `PreToolUse` refuses every command. That already happened
411
- in the consumer on 2026-09-10 (25.1.0 -> 27.1.4) and cost real recovery work.
412
-
413
- ```bash
414
- npm ls vigiles # prints `invalid` when what is installed does not satisfy the manifest
415
- ```
69
+ ### Do Not Describe What You Have Not Opened
416
70
 
417
- ⚠️ **What that command does NOT tell you, and the boundary matters because the command reads
418
- like a freshness check.** It compares what is INSTALLED against this repo's MANIFEST. It says
419
- nothing about the registry. Measured 2026-09-16: manifest `^27.1.4`, installed `27.1.6`,
420
- published `27.2.0` — exit code **0**, because the range is satisfied. The repo had been one
421
- minor behind for days and every local check was green.
71
+ **Guidance only** — Every factual claim in a document meant for strangers is checked against the disk in the same pass that writes it.
422
72
 
423
- #### Dependabot carries the half `npm ls` cannot — and its two delays STACK
73
+ ### No Glob Or Regex In A Block Comment
424
74
 
425
- That staleness is why `.github/dependabot.yml` exists here. The reasoning is worth keeping
426
- because the obvious objection to a bot in this org is already recorded and does NOT apply:
427
- a bot was switched off in a sibling repository for burning Actions minutes — **that
428
- repository is private**. This one is public, minutes are free, so the objection does not
429
- travel. If this repo is ever made private, revisit the file along with it.
75
+ **Guidance only** — 🔴 An asterisk followed by a slash ends the comment wherever it appears. In a block comment, prose describes the pattern and never quotes it; exact characters go in code or a line comment.
430
76
 
431
- 🔴 **A new release does NOT wake the bot.** Two delays add up, and the second one is invisible
432
- until you read the reference:
77
+ ### Measure The Defect Before The Fix
433
78
 
434
- | | default | what the docs say |
435
- | ------------------- | ---------: | --------------------------------------------------------------------------------------- |
436
- | `schedule.interval` | — | the check runs on the schedule and only on the schedule |
437
- | **`cooldown`** | **3 days** | _"a new version is not considered for a version update until 3 days after its release"_ |
79
+ **Guidance only** — Read "this is quick" as a warning. Show the defect (output, or a quoted line), name the layer and channel (more than one is a conversation, not a commit), and spend an adversarial pass on forks and on anything that goes outward.
438
80
 
439
- With the weekly schedule this file shipped with first, the window was **3–10 days**. It is now
440
- `daily`, and `vigiles` is listed in `cooldown.exclude`, so for THIS package the window is one
441
- schedule tick.
81
+ ### Installing Paperlint Is Smooth
442
82
 
443
- **Why `vigiles` and nothing else is exempt:** the cooldown guards against a release that gets
444
- yanked hours later. That is a real risk for a third-party package and an empty one for our own
445
- — we would be the ones yanking it, and we can ship several versions of it in a single day, so
446
- a three-day hold would have the bot proposing the version from the day before yesterday.
83
+ **Guidance only** — The target is `npm i` plus one command; any further manual step is a paperlint defect, except a choice that really belongs to the user, named when it is asked. An automatic step that can fail silently is worse than an explicit one.
447
84
 
448
- 🔴 **And the conclusion is bigger than a cadence knob: for our OWN package no bot schedule is
449
- the primary path, because no schedule can outrun same-day releases.** The primary path is the
450
- rule already recorded in the consumer's base — merge a PR in `vigiles`, bump every consumer in
451
- the same pass. The bot is the backstop for the case that actually bit us: the rule named ONE
452
- consumer while there were two, and this repo sat forgotten on `^27.1.4`.
85
+ ### Every Check Has Both Halves
453
86
 
454
- ⚠️ **What the file does not control**, recorded because the sibling repo already lost a day to
455
- it: `dependabot.yml` configures _version_ updates only. **Security** updates are a separate
456
- mechanism driven by advisories and a repository SETTING; their cadence cannot be changed from
457
- this file, and deleting the file would not stop them.
87
+ **Guidance only** — Every check — lint rule, script, hook, harness — fires on a planted defect AND stays quiet on a clean fixture, and the test goes red before the fix lands. A check only ever seen quiet is indistinguishable from a dead one. [Details](docs/testing.md)
458
88
 
459
- Need it now rather than at the next tick: **Insights → Dependency graph → Dependabot → Check
460
- for updates**.
89
+ ### Never The Callers Cwd
461
90
 
462
- ## The guard against a green zero
91
+ **Guidance only** — No program this package ships (`bin/`, `lib/`, hooks, skill scripts) depends on the directory it is started from: resolve from `git rev-parse --show-toplevel` or from the module's own location.
463
92
 
464
- Rule 4 is enforced, not asserted: `scripts/rules-see-files.mjs` loads `eslint.config.mjs`,
465
- lints the repository, and asks ESLint for the effective config of every linted file. A rule
466
- enabled for **zero** files is named and the script exits 1.
93
+ ### One Module Knows Where Paperlint Is Installed
467
94
 
468
- ```bash
469
- node scripts/rules-see-files.mjs # also part of npm run check
470
- ```
95
+ **Guidance only** — Lint rules, skills, hooks and scripts never compute where paperlint is installed: the package's own data comes from `src/package-dirs.ts`, the consumer's side from `skills/paper-pipeline/scripts/consumer.mjs`. `port/js-install-path` and `port/md-install-path` flag a literal. [Details](eslint-rules/install-path-literals.ts)
471
96
 
472
- It is per RULE, not per glob, and that distinction is the point: a rule can be enabled in one
473
- block whose glob is empty while a different block is busy, so "some glob matched something" is
474
- not evidence about the rule you care about. Both halves are tested
475
- (`scripts/rules-see-files.harness.mjs`), under-reporting and over-reporting each by an
476
- assertion of its own.
477
-
478
- ## Cost
479
-
480
- ⛽ **This repository is PUBLIC, so its Actions minutes are FREE.** Verified against the API on
481
- 2026-09-17: `"private": false`, `"visibility": "public"`, and three active workflows — `ci`,
482
- `dependabot auto-merge`, and Dependabot's own updates runner.
483
-
484
- 🔴 **This paragraph said the exact opposite until now, and the correction is the lesson, not the
485
- fact.** It read «This is a **private** repository … Until then there is no CI here, and that is
486
- deliberate» — both halves false, and false in the file an agent loads FIRST. The flip to public
487
- happened on 2026-09-12 and _was_ recorded, at `.github/workflows/ci.yml:8-9`, which is a file
488
- nobody opens before deciding whether there is any CI to check. Reported as issue #6.
489
-
490
- ⚠️ So the rule this leaves behind is about WHERE a correction lands: a measurement written into
491
- the artifact it describes is not written down for the reader who needs it. **Status that changes
492
- what a session DOES belongs in this file**; the workflow header can carry the detail.
493
-
494
- What stays true, because the reasoning outlives the flip: minutes on a **private** repo come out
495
- of the account-wide 3000/month shared with every other private repo, and the budget is decided
496
- **before** the first workflow file, not after the first bill. **If this repository is ever made
497
- private again, this section and `.github/dependabot.yml` are revisited together** — the bot is
498
- justified two hundred lines above precisely by these minutes being free.
499
-
500
- ## Writing checks — what keeps them from pushing papers toward a blog post
501
-
502
- The rules and skills that judge a paper's prose follow five principles. A new rule, or a new
503
- criterion in a skill, is held to them.
504
-
505
- 1. **Every writing check is two-sided.** A check with only a ceiling — too long, too many markers,
506
- too many of X — pushes every paper toward clipped, unsignposted prose, and the one equilibrium of
507
- a set of ceilings is a blog post. A new check has a floor as well, or measures the distance to a
508
- reference (accepted papers of the target venue); never only "less is better".
509
- 2. **Rules count, skills judge.** What is countable on the parse tree — a density, a number with no
510
- owner (`tex/claim-provenance`), a missing section — is a lint rule. What needs reading — register
511
- relative to a venue, genre, whether a claim changed strength — is a skill criterion. A skill does
512
- not grep prose for what a rule can count; a rule is not asked to judge register.
513
- 3. **The model reader is the venue's reviewer, not only a smart outsider.** A check that simulates a
514
- reader includes a skimming reviewer who reads the abstract, the introduction and the conclusion,
515
- and judges whether it reads as a paper at that venue and whose result each sentence states.
516
- 4. **Thresholds are calibrated, not borrowed.** A threshold is set with the same counter on real
517
- accepted papers, never taken from a published figure measured by a different counter. Those
518
- papers are [`fixtures/accepted-papers/`](fixtures/accepted-papers/README.md), and every rule's
519
- count on them is recorded there.
520
- 5. **A rewrite is accepted on what reviewers see.** That is the combined count of every surface form
521
- of a tic (a narrow rule is gamed by rewording), plus a claims-preservation check that no claim was
522
- softened or strengthened — not the one pattern the triggering rule matched.
523
-
524
- ## Testing
525
-
526
- | you are testing | kind |
527
- | -------------------------------------------------------------------------- | ----------------------------------------------------------- |
528
- | a pure function | unit — vitest, `<module>.test.ts` beside it |
529
- | a use case or adapter that reaches disk, a process, the network, the clock | integration — vitest, fakes through the ports |
530
- | a hook's decision, a skill's contract | harness — vigiles, `<surface>.harness.ts` |
531
- | the installed package, a real TeX build | e2e — `test/e2e/<area>/*.e2e.ts`, `npm run test:e2e:<area>` |
532
-
533
- Red first · assert the whole value · test what the code does, never what its source says · no test
534
- touches the real network · 100% coverage, no `c8 ignore`. **Before every push: `npm run check`,
535
- exit code read without a pipe.** Everything else — where files live, how fakes are injected, the
536
- `--min=1` trap: [`docs/testing.md`](docs/testing.md).
537
-
538
- **A new rule** needs unit tests beside it and a reporting and a silent case in
539
- `src/rule-cases.test.ts`, which runs every registered rule through real ESLint and fails on one
540
- without cases. An e2e fixture only when [`docs/e2e.md`](docs/e2e.md)'s triggers say so.
541
-
542
- **New code is functional.** In `src/`, `eslint-rules/` and `lib/` (tests aside) there is no
543
- `let`, no mutation, no loop, and types are read-only (`eslint-plugin-functional`); a `switch` over a
544
- union names every member. Collections go through [Remeda](https://remedajs.com/) — `lodash`,
545
- `lodash-es` and `ramda` are refused by `npm run lint`. Old code is listed in
546
- `eslint-suppressions.json`, ESLint's own bulk suppressions, which may only shrink: fixing a listed
547
- site without `npx eslint . --prune-suppressions` fails lint, and #134 burns the list down.
97
+ ### Prior Art Before A Command Or A Channel
548
98
 
549
- ## Commits
99
+ **Guidance only** — Before adding a CLI command or changing a delivery channel (skill links, hook wiring, the composite action, the npm package), read how comparable tools did it. "We need another command for X" has, in every tool examined, been the CONFIG not declaring something. [Details](docs/prior-art/README.md)
550
100
 
551
- Conventional-commit subject, body says what was MEASURED, not what was intended. A number in
552
- a commit message that no command produced is the thing this repo exists to make impossible.
101
+ ### New Code Is Functional
553
102
 
554
- **The subject is also the release.** Every push to `main` runs semantic-release
555
- (`.github/workflows/release.yml`), and it reads the squash commit, which is the PR title:
556
- `feat:` → minor, `fix:` or `perf:` → patch, `!` or a `BREAKING CHANGE:` footer → major,
557
- `docs:` `chore:` `ci:` `test:` `refactor:` → no release. `pr-title.yml` rejects a title without
558
- a conventional prefix, and for a one-commit PR checks the commit subject too, since GitHub
559
- squashes to that. npm, the git tag and the GitHub Release always carry the same version. Never
560
- `npm publish` by hand.
103
+ **Enforced by:** `eslint/functional/no-let`
104
+ **Why:** In `src/`, `eslint-rules/` and `lib/`, tests and harnesses aside: no `let`, no mutation, no loop, read-only types; collections through [Remeda](https://remedajs.com/). `functional/immutable-data`, `functional/no-loop-statements` and `functional/prefer-immutable-types` hold the rest, and `eslint-suppressions.json` (old code) may only shrink.