paperlint 2.1.0 → 3.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 (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -20,95 +20,87 @@
20
20
  * COVERAGE.
21
21
  */
22
22
 
23
- /** The key in the consumer's `package.json` this package's settings are read from. */
24
- export const CONFIG_KEY = "paperlint";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
25
 
26
26
  /**
27
- * The key's name before 2.0.0, when the package was `research-paper-pipeline`. Still READ, with a
28
- * deprecation line from the CLI and `doctor`; `init` renames it. Both keys at once with different
29
- * contents is refused: there is no way to know which one the author means.
27
+ * The tool's name. Its settings file is named after it (`CONFIG_FILE`), its messages start with it,
28
+ * and the hooks resolve their scripts through it. It equals the package name, and
29
+ * `lib/paper-config.harness.mjs` checks that.
30
30
  */
31
- export const LEGACY_CONFIG_KEY = "research-paper-pipeline";
31
+ export const CONFIG_KEY = "paperlint";
32
32
 
33
33
  /**
34
- * THE THREE LEVELS OF SETTINGS, each named after the tool:
34
+ * THE SETTINGS FILE, ONE NAME AT TWO LEVELS, ONE SCHEMA (`SETTINGS_KEYS`):
35
35
  *
36
- * package.json → "paperlint" the project: where the papers are, what every paper gets
37
- * <paper>/paperlint.json one paper: the venue preset it extends, its kind, its own rules
38
- * a venue preset one venue: its format, TeX packages and rules — shipped with
39
- * paperlint (`paperlint:<name>`) or the project's own (`./x.jsonc`)
36
+ * paperlint.json optional, at the project root: where the papers are, the
37
+ * project's rules, and defaults for every paper
38
+ * <papersDir>/<paper>/paperlint.json one paper: its venue preset, its kind, its own rules
40
39
  *
41
- * The per-paper file was `venue.json` before 2.1.0. It is NOT read any more — a fallback would keep
42
- * it working forever — and `paperlint init` moves it.
40
+ * A paper's file merges over the root's: its `extends`, `kind` and `pdf` win, and its `rules` are
41
+ * applied after the root's. A venue preset (`paperlint:<name>`, or the project's own `./x.jsonc`)
42
+ * is the third level, below both.
43
43
  */
44
- export const PAPER_SETTINGS_FILE = "paperlint.json";
45
- export const LEGACY_PAPER_SETTINGS_FILE = "venue.json";
44
+ export const CONFIG_FILE = "paperlint.json";
46
45
 
47
46
  /**
48
- * Every key `paperlint.json` may hold. Any other key is REFUSED with its name: a typo would
49
- * otherwise read as "not set". `$comment` is JSON Schema's own keyword for a comment, the one way
50
- * to leave a note in a JSON file; paperlint ignores its value.
47
+ * The files that make a directory a PAPER. A `paperlint.json` beside one of them is that paper's,
48
+ * never the project's — so walking up from inside a paper does not stop at the paper's own file.
51
49
  */
52
- export const PAPER_SETTINGS_KEYS = Object.freeze({
53
- extends:
54
- "the venue preset: paperlint:<name> or ./path.jsonc — the pdf/ venue rules, paperlint build",
55
- kind: "the kind of paper, whose page limit applies — pdf/limits",
56
- pdf: "where the built PDF is, relative to the paper, when it is not paper.pdf — the facts",
57
- rules: "rule overrides for this paper alone — paperlint lint",
58
- $comment: "a note for humans; ignored",
59
- });
60
-
61
- /** What a leftover pre-2.1.0 file is told. */
62
- export const LEGACY_PAPER_SETTINGS_MESSAGE = `${LEGACY_PAPER_SETTINGS_FILE} is no longer read: it was renamed ${PAPER_SETTINGS_FILE} in paperlint 2.1.0. \`npx paperlint init\` moves it.`;
50
+ export const PAPER_MARKERS = Object.freeze([
51
+ "paper.tex",
52
+ "paper.md",
53
+ "draft.md",
54
+ "PIPELINE-STATUS.md",
55
+ ]);
63
56
 
64
57
  /**
65
- * Where a parsed `package.json` keeps this package's settings — the new key, else the old one.
58
+ * The project root: the nearest directory, walking up from `startDir`, that holds a root
59
+ * `paperlint.json` (one not beside a paper's files); else the nearest directory holding a
60
+ * `package.json`; else `startDir` itself. The same walk eslint, prettier and tsc use for theirs.
66
61
  *
67
- * @returns `settings` (undefined when neither key is there, or on a conflict), `legacy` (read
68
- * from the old key), `conflict` (the message when both keys are there and differ, else null).
62
+ * @param isFile whether a path exists — `existsSync` by default; `src/` passes its Files port.
69
63
  */
70
- export function declaredSettings(pkg) {
71
- const has = (k) =>
72
- pkg !== null && typeof pkg === "object" && Object.hasOwn(pkg, k);
73
- const current = has(CONFIG_KEY);
74
- const legacy = has(LEGACY_CONFIG_KEY);
75
- if (
76
- current &&
77
- legacy &&
78
- JSON.stringify(pkg[CONFIG_KEY]) !== JSON.stringify(pkg[LEGACY_CONFIG_KEY])
79
- )
80
- return {
81
- settings: undefined,
82
- legacy: false,
83
- conflict:
84
- `package.json has both "${CONFIG_KEY}" and "${LEGACY_CONFIG_KEY}", and they differ. ` +
85
- `Keep "${CONFIG_KEY}" and delete "${LEGACY_CONFIG_KEY}" (its old name).`,
86
- };
87
- if (current)
88
- return { settings: pkg[CONFIG_KEY], legacy: false, conflict: null };
89
- if (legacy)
90
- return { settings: pkg[LEGACY_CONFIG_KEY], legacy: true, conflict: null };
91
- return { settings: undefined, legacy: false, conflict: null };
64
+ export function findProjectRoot(startDir, isFile = existsSync) {
65
+ const start = resolve(startDir);
66
+ const isPaper = (d) => PAPER_MARKERS.some((m) => isFile(join(d, m)));
67
+ const walk = (found) => {
68
+ for (let d = start; ; d = dirname(d)) {
69
+ if (found(d)) return d;
70
+ if (dirname(d) === d) return null;
71
+ }
72
+ };
73
+ return (
74
+ walk((d) => isFile(join(d, CONFIG_FILE)) && !isPaper(d)) ??
75
+ walk((d) => isFile(join(d, "package.json"))) ??
76
+ start
77
+ );
92
78
  }
93
79
 
80
+ const isObject = (v) =>
81
+ v !== null && typeof v === "object" && !Array.isArray(v);
82
+
94
83
  /**
95
- * The settings object alone, for readers that only need a value: the new key, else the old one,
96
- * and nothing on a conflict (the CLI, `doctor` and the edit guard report the conflict).
84
+ * The project's settings: the object in `<root>/paperlint.json`, or undefined when there is no
85
+ * such file, or it does not parse, or it is not an object. For readers that only need one key and
86
+ * fall back to its default; `paperlint lint` parses the file strictly and names what is wrong.
97
87
  */
98
- export function settingsOf(pkg) {
99
- const d = declaredSettings(pkg);
100
- return d.conflict === null ? d.settings : undefined;
88
+ export function settingsOf(root) {
89
+ let v;
90
+ try {
91
+ v = JSON.parse(readFileSync(join(root, CONFIG_FILE), "utf8"));
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ return isObject(v) ? v : undefined;
101
96
  }
102
97
 
103
- /** The deprecation line for settings read from the old key. */
104
- export const LEGACY_KEY_MESSAGE = `"${LEGACY_CONFIG_KEY}" in package.json is the old name of this package's key — rename it to "${CONFIG_KEY}". \`npx paperlint init\` does it for you.`;
105
-
106
- /** Default: a consumer that declares nothing is assumed to keep papers in `papers/`. */
98
+ /** The default papers directory, used when the root `paperlint.json` declares none (or there is none). */
107
99
  export const DEFAULT_PAPERS_ROOT = "papers";
108
100
 
109
101
  /**
110
- * The field under CONFIG_KEY that names the papers directory:
111
- * `"paperlint": { "papersDir": "papers" }`.
102
+ * The field that names the papers directory: `{ "papersDir": "papers" }` in the root
103
+ * `paperlint.json`. Optional; absent means `DEFAULT_PAPERS_ROOT`.
112
104
  *
113
105
  * This is the one place the name is spelled. Code reads the field as
114
106
  * `settings[PAPERS_DIR_FIELD]`, never by a literal, so renaming it again is a one-line change
@@ -118,51 +110,24 @@ export const DEFAULT_PAPERS_ROOT = "papers";
118
110
  export const PAPERS_DIR_FIELD = "papersDir";
119
111
 
120
112
  /**
121
- * The field's old name. A config that still has it is REFUSED with `renamedFieldMessage()`,
122
- * even if it also has the new one: the package is unpublished, so there is nobody to migrate
123
- * quietly, and reading the old name as a fallback would leave it working forever.
124
- */
125
- export const OLD_PAPERS_DIR_FIELD = "papers";
126
-
127
- /**
128
- * The error text for a config that still uses the old field name, or `null` when it does not.
129
- *
130
- * @param settings the object under CONFIG_KEY
131
- * @param where where that object lives, as the reader should see it in the message
132
- */
133
- export function renamedFieldMessage(
134
- settings,
135
- where = `package.json → "${CONFIG_KEY}"`,
136
- ) {
137
- if (settings === null || typeof settings !== "object") return null;
138
- if (!Object.hasOwn(settings, OLD_PAPERS_DIR_FIELD)) return null;
139
- return `"${OLD_PAPERS_DIR_FIELD}" was renamed to "${PAPERS_DIR_FIELD}" in ${where}`;
140
- }
141
-
142
- /**
143
- * Every key the settings object may hold, with who reads it. A key not listed here is REFUSED
144
- * with its name (`paperlint lint`, `paperlint build`, `paperlint new`, `paperlint doctor` all read through the same
145
- * parser), because a typo in a key otherwise reads as "not set" — the defect `src/types.ts` names
146
- * in its first line.
113
+ * Every key a `paperlint.json` may hold — the root's and a paper's, ONE schema — with who reads it.
114
+ * A key not listed here is REFUSED with its name, because a typo in a key otherwise reads as "not
115
+ * set". The keys in `ROOT_ONLY_KEYS` describe the whole project and are refused in a paper's file.
147
116
  *
148
- * 🔴 ONE LIST, AND IT IS CHECKED AGAINST THE READERS, NOT TRUSTED. The settings object has readers
149
- * all over the package (the CLI, the skill scripts, the trigger-case loader), each reading its own
150
- * keys. `lib/paper-config.harness.mjs` walks the package's source, finds every
151
- * `[CONFIG_KEY]?.<key>` read and every field of `PaperlintConfig`, and requires each to be here — so a
152
- * new reader that forgets this list turns the harness red before a consumer's config does.
117
+ * 🔴 ONE LIST, AND IT IS CHECKED AGAINST THE READERS, NOT TRUSTED. `lib/paper-config.harness.mjs`
118
+ * walks the package's source, finds every `settingsOf(…)?.<key>` read and every field of
119
+ * `PaperlintConfig`, and requires each to be here — so a new reader that forgets this list turns
120
+ * the harness red before a consumer's config does.
153
121
  */
154
122
  export const SETTINGS_KEYS = Object.freeze({
155
- papersDir: "the papers directory (required) — every command, the hooks",
123
+ papersDir: `the papers directory, default "papers" — every command, the hooks`,
156
124
  structure: "which files a paper directory must hold — paperlint lint",
157
- authorListCommand: "paper/author-list — paperlint lint",
158
- typographyDebt: "paper/typography — paperlint lint",
159
- docFields: "doc/fields — paperlint lint",
160
- reviewSince: "review/findings-cause — paperlint lint",
161
- minFindings: "review/findings-cause — paperlint lint",
162
- causeMarker: "review/findings-cause — paperlint lint",
163
125
  rules:
164
- "extra ESLint config blocks, appended after paperlint's own — paperlint lint",
165
- buildScripts: "REMOVED; still named so paperlint build can say it is ignored",
126
+ "rule overrides: { id: severity } for every paper in scope, or ESLint blocks (files, ignores, rules) — paperlint lint",
127
+ extends:
128
+ "the venue preset: paperlint:<name> or ./path.jsonc — the pdf/ venue rules, paperlint build",
129
+ kind: "the kind of paper, whose page limit applies — pdf/limits",
130
+ pdf: "where the built PDF is, relative to the paper, when it is not paper.pdf — the facts",
166
131
  ledger: "the run ledger — skills/paper-pipeline/scripts/consumer.mjs",
167
132
  scripts: "how skill prose names the scripts — consumer.mjs",
168
133
  timezone: "dates in the ledger — consumer.mjs",
@@ -170,4 +135,17 @@ export const SETTINGS_KEYS = Object.freeze({
170
135
  citeChecks: "the consumer's citation checkers — run-mechanical.mjs",
171
136
  triggerCases:
172
137
  "the consumer's own skill trigger cases — lib/skill-trigger-cases.mjs",
138
+ $comment: "a note for humans (JSON Schema's own comment keyword); ignored",
173
139
  });
140
+
141
+ /** The keys that only mean something for the whole project: refused in a paper's `paperlint.json`. */
142
+ export const ROOT_ONLY_KEYS = Object.freeze([
143
+ "papersDir",
144
+ "structure",
145
+ "ledger",
146
+ "scripts",
147
+ "timezone",
148
+ "contactEmail",
149
+ "citeChecks",
150
+ "triggerCases",
151
+ ]);
@@ -117,8 +117,8 @@ process.exit(
117
117
  edits: [
118
118
  [
119
119
  DOCTOR,
120
- 'import {\n papersRoot,\n CONFIG_KEY,\n DEFAULT_PAPERS_ROOT,\n PAPERS_DIR_FIELD,\n OLD_PAPERS_DIR_FIELD,\n} from "../hooks/paper-edit-guard.hook.mjs";',
121
- 'import {\n papersRoot,\n DEFAULT_PAPERS_ROOT,\n PAPERS_DIR_FIELD,\n OLD_PAPERS_DIR_FIELD,\n} from "../hooks/paper-edit-guard.hook.mjs";\nconst CONFIG_KEY = "research-paper-pipeline";',
120
+ 'import {\n papersRoot,\n DEFAULT_PAPERS_ROOT,\n PAPERS_DIR_FIELD,\n CONFIG_FILE,\n} from "../hooks/paper-edit-guard.hook.mjs";',
121
+ 'import {\n papersRoot,\n DEFAULT_PAPERS_ROOT,\n PAPERS_DIR_FIELD,\n} from "../hooks/paper-edit-guard.hook.mjs";\nconst CONFIG_FILE = "paperlint.json";',
122
122
  ],
123
123
  ],
124
124
  },
@@ -70,7 +70,7 @@ import {
70
70
  //
71
71
  // The value now comes from the package, as the fourth carrier beside `papersRoot()` (where the
72
72
  // papers are), `ledgerPath()` (where the journal is) and `citeChecks` (where the consumer's own
73
- // citation checkers are). One declaration in `package.json`, default
73
+ // citation checkers are). One declaration in `paperlint.json`, default
74
74
  // `.claude/skills/paper-pipeline/scripts`, and the resolver REFUSES a root that is not on disk
75
75
  // or that resolves inside `node_modules`.
76
76
  //
@@ -53,9 +53,12 @@ function loadAt(root) {
53
53
  }).trim();
54
54
  }
55
55
 
56
- function fixture(pkgJson, casesFile) {
56
+ /** A consumer: a package.json, the root paperlint.json `settings` (none when null), a cases file. */
57
+ function fixture(settings, casesFile) {
57
58
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-cases-")));
58
- writeFileSync(join(root, "package.json"), JSON.stringify(pkgJson));
59
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "c" }));
60
+ if (settings)
61
+ writeFileSync(join(root, "paperlint.json"), JSON.stringify(settings));
59
62
  if (casesFile) {
60
63
  mkdirSync(join(root, ".claude", "lib"), { recursive: true });
61
64
  writeFileSync(
@@ -114,7 +117,7 @@ soft("I. irrelevantFor still yields a full set for a one-collider case", () => {
114
117
  soft(
115
118
  "II. undeclared and absent is SILENT — a consumer with no skills of its own",
116
119
  () => {
117
- const out = loadAt(fixture({ name: "plain-consumer" }, null));
120
+ const out = loadAt(fixture(null, null));
118
121
  assert.match(out, /^OK \d+$/, `expected a clean load, got: ${out}`);
119
122
  assert.equal(
120
123
  Number(out.slice(3)),
@@ -125,7 +128,7 @@ soft(
125
128
  );
126
129
 
127
130
  soft("II. the DEFAULT path is picked up without any declaration", () => {
128
- const out = loadAt(fixture({ name: "defaulting-consumer" }, LOCAL_ONE));
131
+ const out = loadAt(fixture(null, LOCAL_ONE));
129
132
  assert.equal(
130
133
  out,
131
134
  `OK ${CASES.length + 1}`,
@@ -137,9 +140,7 @@ soft("II. the DEFAULT path is picked up without any declaration", () => {
137
140
  // is the same one `scriptsRoot()` draws: a path someone typed is a keystroke away from being wrong,
138
141
  // and substituting silence for it hides the typo behind a working run.
139
142
  soft("II. declared and missing THROWS, naming the declaration", () => {
140
- const out = loadAt(
141
- fixture({ paperlint: { triggerCases: "nope.mjs" } }, null),
142
- );
143
+ const out = loadAt(fixture({ triggerCases: "nope.mjs" }, null));
143
144
  assert.match(out, /^THROW /, `a typoed declaration loaded quietly: ${out}`);
144
145
  assert.match(
145
146
  out,
@@ -150,7 +151,7 @@ soft("II. declared and missing THROWS, naming the declaration", () => {
150
151
 
151
152
  soft("II. a consumer may not redefine a case this package owns", () => {
152
153
  const clash = `export const CASES = [{ skill: "${CASES[0].skill}", why: "x", prompts: ["a","b","c","d"], collides: [] }];\n`;
153
- const out = loadAt(fixture({ name: "clashing-consumer" }, clash));
154
+ const out = loadAt(fixture(null, clash));
154
155
  assert.match(out, /^THROW /, `a shadowing case was accepted: ${out}`);
155
156
  assert.match(
156
157
  out,
@@ -334,8 +334,8 @@ const PACKAGE_CASES = [
334
334
  // out of the code and into a declaration. This is the fifth of those carriers and reads the same
335
335
  // key from the same file.
336
336
  //
337
- // // <consumer>/package.json
338
- // "paperlint": { "triggerCases": ".claude/lib/skill-trigger-cases.local.mjs" }
337
+ // // <consumer>/paperlint.json
338
+ // { "triggerCases": ".claude/lib/skill-trigger-cases.local.mjs" }
339
339
  //
340
340
  // The module it names must export `CASES` in the shape documented at the top of this file.
341
341
  //
@@ -347,8 +347,7 @@ const PACKAGE_CASES = [
347
347
  // ⚠️ A CONSUMER MAY NOT REDEFINE A PACKAGE CASE. Two tables answering "what should fire on this
348
348
  // prompt" is two truths; the override would live in the consumer and the original would keep
349
349
  // looking authoritative here. Throws, naming the skill.
350
- import { readFileSync } from "node:fs";
351
- import { join, resolve } from "node:path";
350
+ import { resolve } from "node:path";
352
351
  import { pathToFileURL } from "node:url";
353
352
  import {
354
353
  CONFIG_KEY,
@@ -360,14 +359,7 @@ const DEFAULT_LOCAL_CASES = ".claude/lib/skill-trigger-cases.local.mjs";
360
359
 
361
360
  async function consumerCases() {
362
361
  const root = consumerRoot();
363
- let declared;
364
- try {
365
- declared = settingsOf(
366
- JSON.parse(readFileSync(join(root, "package.json"), "utf8")),
367
- )?.triggerCases;
368
- } catch {
369
- declared = undefined;
370
- }
362
+ const declared = settingsOf(root)?.triggerCases;
371
363
  const rel = declared === undefined ? DEFAULT_LOCAL_CASES : declared;
372
364
  if (typeof rel !== "string" || rel.length === 0)
373
365
  throw new TypeError(
@@ -382,9 +374,9 @@ async function consumerCases() {
382
374
  throw new Error(
383
375
  `${CONFIG_KEY}: could not load the consumer's trigger cases from ${abs}` +
384
376
  (declared === undefined
385
- ? ` (the default location; declare another in package.json under ` +
386
- `"${CONFIG_KEY}": { "triggerCases": … } if the file lives elsewhere).`
387
- : ` — it is declared in package.json as "triggerCases": ${JSON.stringify(declared)}.`) +
377
+ ? ` (the default location; declare another in paperlint.json as ` +
378
+ `{ "triggerCases": … } if the file lives elsewhere).`
379
+ : ` — it is declared in paperlint.json as "triggerCases": ${JSON.stringify(declared)}.`) +
388
380
  `
389
381
  A missing case set does not shrink the measurement, it CHANGES it: ` +
390
382
  `\`irrelevantFor()\` builds the false-positive set out of a case's colliders, so an ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
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",
@@ -26,7 +26,7 @@ import { readFileSync } from "node:fs";
26
26
  export function guard(
27
27
  reportPath,
28
28
  eslintRc,
29
- { paths = ".", config = "eslint.config.mjs" } = {},
29
+ { paths = ".", config = "paperlint.json" } = {},
30
30
  ) {
31
31
  const lines = [];
32
32
  let res;
@@ -3,15 +3,12 @@
3
3
  "why": "A *.harness.* file tests the agent surface and imports runHook, runHarnessTest or runEval from vigiles. Plain unit tests are *.test.ts, run by vitest. These are the harness files that existed on main on 2026-09-25 and import none of the three. The list only shrinks: rename a file to *.test.ts (issue #77), or make it a real harness, then delete its entry. Checked by scripts/harness-api.test.ts, part of npm test.",
4
4
  "harnesses": [
5
5
  "action.harness.mjs",
6
- "eslint-rules/doc-fields.harness.mjs",
7
6
  "eslint-rules/install-path-literals.harness.mjs",
8
7
  "eslint-rules/latex-language.harness.mjs",
9
8
  "eslint-rules/paper-research-question.harness.mjs",
10
9
  "eslint-rules/paper-stages.harness.mjs",
11
- "eslint-rules/paper-typography.harness.mjs",
12
10
  "eslint-rules/papers.harness.mjs",
13
11
  "eslint-rules/pdf-last-page-balance.harness.mjs",
14
- "eslint-rules/review-findings-cause.harness.mjs",
15
12
  "eslint-rules/temp-root-realpath.harness.mjs",
16
13
  "eslint-rules/tex-build.harness.mjs",
17
14
  "fixtures/real-markdown-paper/real-markdown-paper.harness.mjs",
@@ -5,9 +5,6 @@
5
5
  "action.mutations.mjs": {
6
6
  "cases": 6
7
7
  },
8
- "eslint-rules/doc-fields.mutations.mjs": {
9
- "cases": 6
10
- },
11
8
  "eslint-rules/install-path-literals.mutations.mjs": {
12
9
  "cases": 3
13
10
  },
@@ -18,17 +15,11 @@
18
15
  "cases": 6
19
16
  },
20
17
  "eslint-rules/paper-stages.mutations.mjs": {
21
- "cases": 9
22
- },
23
- "eslint-rules/paper-typography.mutations.mjs": {
24
- "cases": 6
18
+ "cases": 4
25
19
  },
26
20
  "eslint-rules/papers.mutations.mjs": {
27
21
  "cases": 10
28
22
  },
29
- "eslint-rules/review-findings-cause.mutations.mjs": {
30
- "cases": 4
31
- },
32
23
  "eslint-rules/temp-root-realpath.mutations.mjs": {
33
24
  "cases": 5
34
25
  },
@@ -36,7 +27,7 @@
36
27
  "cases": 21
37
28
  },
38
29
  "fixtures/real-markdown-paper/real-markdown-paper.mutations.mjs": {
39
- "cases": 4
30
+ "cases": 3
40
31
  },
41
32
  "hooks/hooks.mutations.mjs": {
42
33
  "cases": 23
@@ -96,13 +87,13 @@
96
87
  "cases": 11
97
88
  },
98
89
  "src/cli.mutations.mjs": {
99
- "cases": 26
90
+ "cases": 25
100
91
  },
101
92
  "src/doctor.mutations.mjs": {
102
93
  "cases": 10
103
94
  },
104
95
  "src/hooks-settings.mutations.mjs": {
105
- "cases": 7
96
+ "cases": 6
106
97
  },
107
98
  "src/link-skills.mutations.mjs": {
108
99
  "cases": 6
@@ -5,7 +5,7 @@ context: fork
5
5
  allowed-tools: [WebSearch, WebFetch, Read, Write, Grep, Glob, Bash, Agent]
6
6
  ---
7
7
 
8
- <!-- vigiles:sha256:19d00fe70002d3fa compiled from skills/analyze-sibling-paper/SKILL.md.spec.ts -->
8
+ <!-- vigiles:sha256:f528cbe9e892d6d0 compiled from skills/analyze-sibling-paper/SKILL.md.spec.ts -->
9
9
 
10
10
  # analyze-sibling-paper — settle "isn't this just X?" before a reviewer does
11
11
 
@@ -44,9 +44,9 @@ far below the cost of a "novelty concern" reject.
44
44
  abstract → intro → method → claims (as much as is accessible). An abstract hides exactly the overlap
45
45
  that matters. Try the fetch routes in order — `arxiv.org/abs/<id>` for metadata, `arxiv.org/html/<id>v1`,
46
46
  `ar5iv.labs.arxiv.org/html/<id>`, then the PDF — and **a 403/404 on one route is not "inaccessible"**;
47
- switch channel before concluding. **Record what you actually read** in the card as a
48
- `**Read:**` line naming the sections you covered and the ones you could not — an abstract-only
49
- verdict is PROVISIONAL and must say so in that same line.
47
+ switch channel before concluding. **Record what you actually read** in the card's frontmatter as
48
+ `read: full | abstract | none`, and name in a `**Read:**` line the sections you covered and the
49
+ ones you could not — an abstract-only verdict is PROVISIONAL and must say so.
50
50
  🔴 **Verify the abstract's numbers against the body.** They disagree more often than you would expect,
51
51
  and a disagreement is itself a finding worth recording.
52
52
 
@@ -87,17 +87,21 @@ far below the cost of a "novelty concern" reject.
87
87
 
88
88
  ## Required shape of the saved card — two sections are machine-checked
89
89
 
90
- The card is not free-form. Two of its parts are checked by `.claude/hooks/paper-lint.mjs`
91
- (`checkSiblingCards`) and a card missing either is reported as an unfinished analysis:
90
+ The card is not free-form:
92
91
 
93
- - **`**Read:**`** — one line naming what of the full text you actually read and what you could
94
- not. This exists because "I read the paper" is the single easiest thing to skip while producing a
95
- card that looks complete.
92
+ - **`read:` in the frontmatter** — `full`, `abstract` or `none`: how much of the full text you
93
+ actually read. `paperlint lint` checks it (`sibling/frontmatter`, a warning on a card without it).
94
+ It exists because "I read the paper" is the single easiest thing to skip while producing a card that
95
+ looks complete. The `**Read:**` line under it names which sections.
96
96
  - **`## Sibling's References`** — their bibliography mined and diffed against yours (step 2), with the
97
97
  MUST CITE / SHOULD CONSIDER / NOT RELEVANT verdicts.
98
98
 
99
- Enforced only for cards dated on or after 2026-07-29; older cards get one summary nudge instead of
100
- burying every other finding. Frontmatter needs `title`, `created`, `tags` like any note.
99
+ ```yaml
100
+ ---
101
+ title: "Smith et al. 2025 — <title>"
102
+ read: abstract # full | abstract | none
103
+ ---
104
+ ```
101
105
 
102
106
  ## Save + surface — COLOCATED with the paper (mandatory — do not leave it in context)
103
107
  Each paper's competitive landscape lives **with that paper**, not in a shared research folder: write the
@@ -136,7 +140,7 @@ would have overwritten one another.
136
140
 
137
141
  ## Rules
138
142
  - **Read the paper, not the abstract.** An abstract-only "delta" is not a verdict — and the card must
139
- say, in its `**Read:**` line, which parts you actually read.
143
+ say so: `read: abstract` in its frontmatter, and which parts in its `**Read:**` line.
140
144
  - **Mine their references.** A sibling analysis that never opened the sibling's bibliography is half done;
141
145
  the scoop you missed is more likely to be in their reference list than in their results.
142
146
  - **Classify by contribution type** before judging overlap — it's the difference between a shared concept and a collision.
@@ -59,9 +59,9 @@ far below the cost of a "novelty concern" reject.
59
59
  abstract → intro → method → claims (as much as is accessible). An abstract hides exactly the overlap
60
60
  that matters. Try the fetch routes in order — \`arxiv.org/abs/<id>\` for metadata, \`arxiv.org/html/<id>v1\`,
61
61
  \`ar5iv.labs.arxiv.org/html/<id>\`, then the PDF — and **a 403/404 on one route is not "inaccessible"**;
62
- switch channel before concluding. **Record what you actually read** in the card as a
63
- \`**Read:**\` line naming the sections you covered and the ones you could not — an abstract-only
64
- verdict is PROVISIONAL and must say so in that same line.
62
+ switch channel before concluding. **Record what you actually read** in the card's frontmatter as
63
+ \`read: full | abstract | none\`, and name in a \`**Read:**\` line the sections you covered and the
64
+ ones you could not — an abstract-only verdict is PROVISIONAL and must say so.
65
65
  🔴 **Verify the abstract's numbers against the body.** They disagree more often than you would expect,
66
66
  and a disagreement is itself a finding worth recording.
67
67
 
@@ -102,17 +102,21 @@ far below the cost of a "novelty concern" reject.
102
102
 
103
103
  ## Required shape of the saved card — two sections are machine-checked
104
104
 
105
- The card is not free-form. Two of its parts are checked by \`.claude/hooks/paper-lint.mjs\`
106
- (\`checkSiblingCards\`) and a card missing either is reported as an unfinished analysis:
105
+ The card is not free-form:
107
106
 
108
- - **\`**Read:**\`** — one line naming what of the full text you actually read and what you could
109
- not. This exists because "I read the paper" is the single easiest thing to skip while producing a
110
- card that looks complete.
107
+ - **\`read:\` in the frontmatter** — \`full\`, \`abstract\` or \`none\`: how much of the full text you
108
+ actually read. \`paperlint lint\` checks it (\`sibling/frontmatter\`, a warning on a card without it).
109
+ It exists because "I read the paper" is the single easiest thing to skip while producing a card that
110
+ looks complete. The \`**Read:**\` line under it names which sections.
111
111
  - **\`## Sibling's References\`** — their bibliography mined and diffed against yours (step 2), with the
112
112
  MUST CITE / SHOULD CONSIDER / NOT RELEVANT verdicts.
113
113
 
114
- Enforced only for cards dated on or after 2026-07-29; older cards get one summary nudge instead of
115
- burying every other finding. Frontmatter needs \`title\`, \`created\`, \`tags\` like any note.
114
+ \`\`\`yaml
115
+ ---
116
+ title: "Smith et al. 2025 — <title>"
117
+ read: abstract # full | abstract | none
118
+ ---
119
+ \`\`\`
116
120
 
117
121
  ## Save + surface — COLOCATED with the paper (mandatory — do not leave it in context)
118
122
  Each paper's competitive landscape lives **with that paper**, not in a shared research folder: write the
@@ -151,7 +155,7 @@ would have overwritten one another.
151
155
 
152
156
  ## Rules
153
157
  - **Read the paper, not the abstract.** An abstract-only "delta" is not a verdict — and the card must
154
- say, in its \`**Read:**\` line, which parts you actually read.
158
+ say so: \`read: abstract\` in its frontmatter, and which parts in its \`**Read:**\` line.
155
159
  - **Mine their references.** A sibling analysis that never opened the sibling's bibliography is half done;
156
160
  the scoop you missed is more likely to be in their reference list than in their results.
157
161
  - **Classify by contribution type** before judging overlap — it's the difference between a shared concept and a collision.
@@ -5,7 +5,7 @@ context: fork
5
5
  allowed-tools: [Bash, Read, Agent]
6
6
  ---
7
7
 
8
- <!-- vigiles:sha256:f3c36e3e56bbd3cd compiled from skills/cold-read-diff/SKILL.md.spec.ts -->
8
+ <!-- vigiles:sha256:596fbd7e2eb85252 compiled from skills/cold-read-diff/SKILL.md.spec.ts -->
9
9
 
10
10
  # cold-read-diff — the reader who cannot fake understanding
11
11
 
@@ -101,6 +101,25 @@ Two more things the same run proved:
101
101
  comments, and hand the reader continuous prose. The findings that survive that are worth the run; the
102
102
  ones that do not are noise, and noise is how this check gets muted.
103
103
 
104
+ ## The report's frontmatter — the findings, as records
105
+
106
+ The report opens with YAML frontmatter that lists what it found, one record per finding.
107
+ `paperlint lint` validates it (`review/frontmatter`): an **open** finding must name the
108
+ pipeline `cause` that let it through — fix that, and the text edit falls out of running the
109
+ fixed tool.
110
+
111
+ ```yaml
112
+ ---
113
+ findings:
114
+ - id: 1
115
+ status: open # open | fixed | wontfix
116
+ cause: missing-skill # skill-defect | missing-skill | hook | rule — required when open
117
+ title: "§3 opens on a term the reader has not met"
118
+ - id: 2
119
+ status: fixed
120
+ ---
121
+ ```
122
+
104
123
  ## Record the verdict
105
124
 
106
125
  🔴 LAST step, once the deliverable exists:
@@ -106,6 +106,25 @@ Two more things the same run proved:
106
106
  comments, and hand the reader continuous prose. The findings that survive that are worth the run; the
107
107
  ones that do not are noise, and noise is how this check gets muted.
108
108
 
109
+ ## The report's frontmatter — the findings, as records
110
+
111
+ The report opens with YAML frontmatter that lists what it found, one record per finding.
112
+ \`paperlint lint\` validates it (\`review/frontmatter\`): an **open** finding must name the
113
+ pipeline \`cause\` that let it through — fix that, and the text edit falls out of running the
114
+ fixed tool.
115
+
116
+ \`\`\`yaml
117
+ ---
118
+ findings:
119
+ - id: 1
120
+ status: open # open | fixed | wontfix
121
+ cause: missing-skill # skill-defect | missing-skill | hook | rule — required when open
122
+ title: "§3 opens on a term the reader has not met"
123
+ - id: 2
124
+ status: fixed
125
+ ---
126
+ \`\`\`
127
+
109
128
  ## Record the verdict
110
129
 
111
130
  🔴 LAST step, once the deliverable exists: