paperlint 2.1.0 → 3.1.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 (219) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +243 -235
  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-engine.d.ts.map +1 -1
  13. package/dist/build-engine.js +6 -2
  14. package/dist/build-engine.js.map +1 -1
  15. package/dist/build.d.ts +15 -0
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +60 -7
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +94 -30
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +500 -288
  22. package/dist/cli.js.map +1 -1
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +40 -72
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/engine.d.ts +9 -0
  27. package/dist/engine.d.ts.map +1 -1
  28. package/dist/engine.js +27 -0
  29. package/dist/engine.js.map +1 -1
  30. package/dist/facts-file.d.ts +2 -2
  31. package/dist/facts-file.js +2 -2
  32. package/dist/hooks-settings.d.ts +0 -21
  33. package/dist/hooks-settings.d.ts.map +1 -1
  34. package/dist/hooks-settings.js +17 -135
  35. package/dist/hooks-settings.js.map +1 -1
  36. package/dist/init.d.ts +39 -29
  37. package/dist/init.d.ts.map +1 -1
  38. package/dist/init.js +144 -173
  39. package/dist/init.js.map +1 -1
  40. package/dist/link-skills.d.ts +1 -1
  41. package/dist/link-skills.d.ts.map +1 -1
  42. package/dist/link-skills.js +11 -29
  43. package/dist/link-skills.js.map +1 -1
  44. package/dist/new-paper.d.ts +8 -1
  45. package/dist/new-paper.d.ts.map +1 -1
  46. package/dist/new-paper.js +49 -25
  47. package/dist/new-paper.js.map +1 -1
  48. package/dist/paper-files.d.ts +85 -0
  49. package/dist/paper-files.d.ts.map +1 -0
  50. package/dist/paper-files.js +96 -0
  51. package/dist/paper-files.js.map +1 -0
  52. package/dist/paper-settings.d.ts +13 -41
  53. package/dist/paper-settings.d.ts.map +1 -1
  54. package/dist/paper-settings.js +71 -71
  55. package/dist/paper-settings.js.map +1 -1
  56. package/dist/ports/check-references.d.ts +31 -0
  57. package/dist/ports/check-references.d.ts.map +1 -0
  58. package/dist/ports/check-references.js +7 -0
  59. package/dist/ports/check-references.js.map +1 -0
  60. package/dist/presets.d.ts +1 -1
  61. package/dist/presets.d.ts.map +1 -1
  62. package/dist/presets.js +4 -6
  63. package/dist/presets.js.map +1 -1
  64. package/dist/reference-rules.d.ts +47 -0
  65. package/dist/reference-rules.d.ts.map +1 -0
  66. package/dist/reference-rules.js +156 -0
  67. package/dist/reference-rules.js.map +1 -0
  68. package/dist/references.d.ts +36 -0
  69. package/dist/references.d.ts.map +1 -0
  70. package/dist/references.js +91 -0
  71. package/dist/references.js.map +1 -0
  72. package/dist/rules-config.d.ts +5 -3
  73. package/dist/rules-config.d.ts.map +1 -1
  74. package/dist/rules-config.js +15 -6
  75. package/dist/rules-config.js.map +1 -1
  76. package/dist/structure.js +3 -3
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.js +2 -2
  79. package/dist/tex-requirements.js.map +1 -1
  80. package/dist/toolchain.d.ts +5 -1
  81. package/dist/toolchain.d.ts.map +1 -1
  82. package/dist/toolchain.js +22 -5
  83. package/dist/toolchain.js.map +1 -1
  84. package/dist/types.d.ts +26 -18
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/venue-rules.d.ts.map +1 -1
  87. package/dist/venue-rules.js +12 -10
  88. package/dist/venue-rules.js.map +1 -1
  89. package/docs/configuration.md +163 -104
  90. package/docs/e2e.md +1 -1
  91. package/docs/install.md +25 -40
  92. package/docs/optional-rules.md +5 -4
  93. package/docs/rules.md +65 -24
  94. package/docs/skills.md +80 -0
  95. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  96. package/eslint-rules/latex-language.mjs +29 -1
  97. package/eslint-rules/paper-stages.harness.mjs +1 -117
  98. package/eslint-rules/paper-stages.mjs +0 -120
  99. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  100. package/eslint-rules/paper-typography.mjs +306 -191
  101. package/eslint-rules/paper-typography.test.mjs +337 -0
  102. package/eslint-rules/papers.harness.mjs +8 -12
  103. package/eslint-rules/papers.mjs +17 -31
  104. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  105. package/eslint-rules/review-frontmatter.mjs +118 -0
  106. package/eslint-rules/review-frontmatter.schema.json +36 -0
  107. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  108. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  109. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  110. package/eslint.config.mjs +0 -15
  111. package/fixtures/real-markdown-paper/baseline.json +6 -7
  112. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  113. package/fixtures/toolchain-mirror/install-tl +4 -1
  114. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  115. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  116. package/hooks/hooks.harness.mjs +39 -98
  117. package/hooks/hooks.mutations.mjs +6 -6
  118. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  119. package/hooks/paper-edit-guard.hook.mjs +31 -42
  120. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  121. package/hooks/paper-status-gates.hook.mjs +21 -19
  122. package/hooks/paper-status-gates.sh +8 -13
  123. package/lib/paper-config.d.mts +15 -25
  124. package/lib/paper-config.harness.mjs +18 -35
  125. package/lib/paper-config.mjs +83 -105
  126. package/lib/paper-config.mutations.mjs +2 -2
  127. package/lib/skill-checks.mjs +1 -1
  128. package/lib/skill-trigger-cases.harness.mjs +9 -8
  129. package/lib/skill-trigger-cases.mjs +7 -15
  130. package/package.json +1 -1
  131. package/scripts/eslint-report-guard.mjs +1 -1
  132. package/scripts/harness-api.frozen.json +0 -3
  133. package/scripts/mutation-batteries.frozen.json +5 -14
  134. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  135. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  136. package/skills/cold-read-diff/SKILL.md +20 -1
  137. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  138. package/skills/grade-paper-writing/SKILL.md +20 -1
  139. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/render-paper.harness.mjs +2 -2
  151. package/skills/verify-citations/SKILL.md +10 -1
  152. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  153. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  154. package/src/adapters/references/index.ts +102 -0
  155. package/src/adapters/references/reach.io.ts +12 -0
  156. package/src/build-engine.ts +6 -1
  157. package/src/build.harness.mjs +54 -6
  158. package/src/build.ts +89 -11
  159. package/src/cli.harness.mjs +149 -294
  160. package/src/cli.mutations.mjs +35 -44
  161. package/src/cli.ts +637 -328
  162. package/src/doctor.harness.mjs +41 -84
  163. package/src/doctor.mutations.mjs +9 -9
  164. package/src/doctor.ts +44 -89
  165. package/src/engine.harness.mjs +50 -0
  166. package/src/engine.ts +35 -0
  167. package/src/facts-file.test.ts +0 -7
  168. package/src/facts-file.ts +2 -2
  169. package/src/hooks-settings.harness.mjs +7 -92
  170. package/src/hooks-settings.mutations.mjs +2 -17
  171. package/src/hooks-settings.ts +18 -162
  172. package/src/init.test.ts +206 -0
  173. package/src/init.ts +187 -231
  174. package/src/link-skills.harness.mjs +1 -38
  175. package/src/link-skills.mutations.mjs +9 -5
  176. package/src/link-skills.ts +10 -34
  177. package/src/lint-fix.test.ts +120 -0
  178. package/src/linted-set.test.ts +118 -0
  179. package/src/new-paper.harness.mjs +2 -0
  180. package/src/new-paper.mutations.mjs +0 -14
  181. package/src/new-paper.test.ts +173 -14
  182. package/src/new-paper.ts +82 -30
  183. package/src/paper-files.test.ts +395 -0
  184. package/src/paper-files.ts +200 -0
  185. package/src/paper-settings-commands.test.ts +121 -122
  186. package/src/paper-settings.test.ts +93 -64
  187. package/src/paper-settings.ts +99 -97
  188. package/src/parse-args.test.ts +48 -0
  189. package/src/ports/check-references.ts +28 -0
  190. package/src/presets.ts +5 -15
  191. package/src/reference-rules.ts +229 -0
  192. package/src/references.test.ts +209 -0
  193. package/src/references.ts +138 -0
  194. package/src/rules-config.harness.mjs +24 -8
  195. package/src/rules-config.ts +19 -7
  196. package/src/rules-off.test.ts +111 -0
  197. package/src/structure.harness.mjs +4 -2
  198. package/src/structure.ts +3 -3
  199. package/src/tex-requirements.ts +2 -2
  200. package/src/toolchain.harness.mjs +41 -6
  201. package/src/toolchain.ts +29 -5
  202. package/src/types.ts +36 -18
  203. package/src/venue-rules.test.ts +23 -11
  204. package/src/venue-rules.ts +12 -14
  205. package/templates/paper/paperlint.json +1 -1
  206. package/eslint-rules/doc-fields.harness.mjs +0 -336
  207. package/eslint-rules/doc-fields.mjs +0 -186
  208. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  209. package/eslint-rules/paper-typography.harness.mjs +0 -291
  210. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  211. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  212. package/eslint-rules/review-findings-cause.mjs +0 -135
  213. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  214. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  215. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  216. package/fixtures/review-findings-cause/clean.md +0 -17
  217. package/fixtures/review-findings-cause/defect.md +0 -14
  218. package/fixtures/review-findings-cause/old-debt.md +0 -14
  219. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,200 @@
1
+ /**
2
+ * THE FILES PAPERLINT OWNS — read off its own config, and made the only thing ESLint may reach.
3
+ *
4
+ * paperlint's blocks name the files they lint (`PIPELINE-STATUS.md`, `paper.md`, `paper.tex`,
5
+ * `reviews/*.md`, …). That list is the whole scope of `paperlint lint`, and this module turns it
6
+ * into two things ESLint understands:
7
+ *
8
+ * 1. **A global ignore of everything else** (`scopeToOwned`). Handing ESLint a directory is not
9
+ * enough: its built-in defaults lint every JavaScript file it finds, whatever our config says.
10
+ * Measured on a consumer project with vendored code under a paper's `repro/` folder: 1,877
11
+ * files, 74 errors ("'return' outside of function", "Definition for rule … was not found"),
12
+ * zero of them on a paper file, exit 1 — and nothing in the settings could turn it off. With
13
+ * the ignore, a stray script under the papers directory is never enumerated at all, and a file
14
+ * named on the command line can be asked about (`ESLint#isPathIgnored`) with ESLint's own
15
+ * matcher instead of a hand-written one.
16
+ *
17
+ * 2. **Each rule scoped to the files it was written for** (`ruleOwners` + `narrowToOwners`).
18
+ * One plugin NAME is bound to different plugin objects in different blocks: `paper` holds
19
+ * `stages`/`source`/`author-list` beside `PIPELINE-STATUS.md` and `research-question`/
20
+ * `typography` beside `paper.md`/`paper.tex`. So a consumer block that named `paper/source`
21
+ * for every file under the papers directory reached `paper.tex`, where `paper` has no `source`
22
+ * — and ESLint threw `Could not find "source" in plugin "paper"` from inside `lintFiles`,
23
+ * after paperlint's own validation had (correctly) accepted the id. A block is therefore split
24
+ * per owner and its `files` ANDed with the owner's (ESLint's nested-array form), so a rule can
25
+ * only land where its plugin is registered.
26
+ *
27
+ * Both read ONE definition of "the files paperlint lints": `ownedScopes`, each own block's `files`
28
+ * together with its `ignores`. The globs alone are not that definition. The sibling-card block
29
+ * claims every markdown file under `siblings/` EXCEPT the index, and a rule whose plugin is
30
+ * registered for every file (`pdf`) used to keep the consumer block's `files` as they were — so a
31
+ * venue preset's rules reached `siblings/README.md`, a block with no `language` matched it, and
32
+ * ESLint parsed the markdown as JavaScript (#101). Now every generated block narrows to scopes,
33
+ * exceptions included, and a plugin registered everywhere owns exactly what paperlint lints.
34
+ *
35
+ * ESLint's semantics that this leans on, each measured against ESLint 10 before writing it:
36
+ * - a global-ignore pattern that matches a DIRECTORY hides everything inside it, so directories
37
+ * must be un-ignored (`!` + a pattern ending in a slash) before owned files can be;
38
+ * - that un-ignores `node_modules/` and `.git/` too — ESLint's own defaults — so they are
39
+ * re-ignored after, or a `paper.md` inside a dependency would be linted;
40
+ * - later global-ignore patterns win, across blocks, in config order;
41
+ * - `files: [["a", "b"]]` matches a file only when BOTH patterns match.
42
+ */
43
+ import type { RuleBlock, RuleEntry } from "./rules-config.ts";
44
+
45
+ /** What this module reads of a config block. */
46
+ interface Block {
47
+ readonly files?: readonly unknown[];
48
+ readonly ignores?: readonly unknown[];
49
+ readonly plugins?: Readonly<
50
+ Record<string, { readonly rules?: Readonly<Record<string, unknown>> }>
51
+ >;
52
+ }
53
+
54
+ /**
55
+ * A set of files paperlint lints: an own block's globs and the exceptions that block makes. A file is
56
+ * in it when it matches one of `files` and none of `ignores`.
57
+ */
58
+ export interface OwnedScope {
59
+ readonly files: readonly string[];
60
+ readonly ignores: readonly string[];
61
+ }
62
+
63
+ /** A `files` entry: one glob, or several that must ALL match. */
64
+ export type FilesEntry = string | readonly string[];
65
+
66
+ /** A consumer block after narrowing: its `files` may carry ANDed pairs. */
67
+ export interface ScopedBlock {
68
+ readonly basePath: string;
69
+ readonly files?: readonly FilesEntry[];
70
+ readonly ignores?: readonly string[];
71
+ readonly rules: Readonly<Record<string, RuleEntry>>;
72
+ }
73
+
74
+ const globsOf = (b: Block): string[] =>
75
+ (b.files ?? []).filter((f): f is string => typeof f === "string");
76
+
77
+ const stringsOf = (xs: readonly unknown[] | undefined): string[] =>
78
+ (xs ?? []).filter((x): x is string => typeof x === "string");
79
+
80
+ /**
81
+ * Scopes with the same exceptions, merged into one: their globs united, once, in order. Keeps a
82
+ * generated block per distinct set of exceptions rather than one per own block.
83
+ */
84
+ const merged = (scopes: readonly OwnedScope[]): OwnedScope[] => {
85
+ const byIgnores = new Map<string, OwnedScope>();
86
+ for (const s of scopes) {
87
+ const key = JSON.stringify(s.ignores);
88
+ const had = byIgnores.get(key);
89
+ byIgnores.set(key, {
90
+ files: [...new Set([...(had?.files ?? []), ...s.files])],
91
+ ignores: s.ignores,
92
+ });
93
+ }
94
+ return [...byIgnores.values()];
95
+ };
96
+
97
+ /**
98
+ * THE FILES PAPERLINT LINTS: every own block that names `files`, with its `ignores`. A block without
99
+ * `files` (a global ignore, or the block registering `pdf` for every file) claims nothing.
100
+ */
101
+ export function ownedScopes(own: readonly unknown[]): OwnedScope[] {
102
+ return merged(
103
+ own
104
+ .map((raw) => raw as Block)
105
+ .filter((b) => b.files !== undefined)
106
+ .map((b) => ({ files: globsOf(b), ignores: stringsOf(b.ignores) })),
107
+ );
108
+ }
109
+
110
+ /** Every glob paperlint's own blocks lint, once, in config order. */
111
+ export function ownedPatterns(own: readonly unknown[]): string[] {
112
+ return [...new Set(ownedScopes(own).flatMap((s) => s.files))];
113
+ }
114
+
115
+ /** ESLint's default ignores, re-applied after directories are un-ignored. */
116
+ const ESLINT_DEFAULT_IGNORES = ["**/node_modules/", "**/.git/"];
117
+
118
+ /** A global-ignore block that leaves ESLint nothing to lint but `owned`. */
119
+ export function scopeToOwned(owned: readonly string[]): {
120
+ readonly ignores: string[];
121
+ } {
122
+ return {
123
+ ignores: [
124
+ "**/*",
125
+ "!**/*/",
126
+ ...owned.map((p) => `!${p}`),
127
+ ...ESLINT_DEFAULT_IGNORES,
128
+ ],
129
+ };
130
+ }
131
+
132
+ /** Every `<plugin>/<rule>` a block registers. */
133
+ const ruleIdsOf = (b: Block): string[] =>
134
+ Object.entries(b.plugins ?? {}).flatMap(([name, plugin]) =>
135
+ Object.keys(plugin.rules ?? {}).map((rule) => `${name}/${rule}`),
136
+ );
137
+
138
+ /**
139
+ * Rule id → the scopes of the blocks that register its plugin with that rule. A plugin registered
140
+ * for every file (a block without `files`) owns every scope — everything paperlint lints, and not
141
+ * one file more.
142
+ */
143
+ export function ruleOwners(
144
+ own: readonly unknown[],
145
+ ): Map<string, readonly OwnedScope[]> {
146
+ const all = ownedScopes(own);
147
+ const found = new Map<string, OwnedScope[]>();
148
+ for (const raw of own) {
149
+ const b = raw as Block;
150
+ const scopes =
151
+ b.files === undefined
152
+ ? all
153
+ : [{ files: globsOf(b), ignores: stringsOf(b.ignores) }];
154
+ for (const id of ruleIdsOf(b))
155
+ found.set(id, [...(found.get(id) ?? []), ...scopes]);
156
+ }
157
+ return new Map([...found].map(([id, scopes]) => [id, merged(scopes)]));
158
+ }
159
+
160
+ /**
161
+ * One consumer block → one block per owner scope, each carrying only that owner's rules, its `files`
162
+ * ANDed with the scope's globs and its `ignores` joined by the scope's exceptions. A rule no block
163
+ * of this config registers (the LaTeX block is absent when its parser did not load) gets no block:
164
+ * it has no file to reach in this run, and naming it to ESLint would only crash.
165
+ */
166
+ export function narrowToOwners(
167
+ block: RuleBlock,
168
+ owners: ReadonlyMap<string, readonly OwnedScope[]>,
169
+ ): ScopedBlock[] {
170
+ const groups = new Map<string, Record<string, RuleEntry>>();
171
+ for (const [id, entry] of Object.entries(block.rules)) {
172
+ const owner = owners.get(id);
173
+ if (owner === undefined) continue;
174
+ const key = JSON.stringify(owner);
175
+ groups.set(key, { ...groups.get(key), [id]: entry });
176
+ }
177
+ return [...groups].flatMap(([key, rules]) =>
178
+ (JSON.parse(key) as OwnedScope[]).map((scope) =>
179
+ within(block, scope, rules),
180
+ ),
181
+ );
182
+ }
183
+
184
+ /** `block`'s rules over the files both it and `scope` claim, minus both sets of exceptions. */
185
+ function within(
186
+ block: RuleBlock,
187
+ scope: OwnedScope,
188
+ rules: Readonly<Record<string, RuleEntry>>,
189
+ ): ScopedBlock {
190
+ const files: readonly FilesEntry[] = block.files
191
+ ? block.files.flatMap((f) => scope.files.map((o) => [f, o]))
192
+ : scope.files;
193
+ const ignores = [...(block.ignores ?? []), ...scope.ignores];
194
+ return {
195
+ basePath: block.basePath,
196
+ files,
197
+ ...(ignores.length ? { ignores } : {}),
198
+ rules,
199
+ };
200
+ }
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * `paperlint.json` through the commands, on a real directory: `paperlint lint` applies a paper's
3
- * `rules` to that paper alone and refuses an unknown rule id; `paperlint init` moves a pre-2.1.0
4
- * `venue.json` and refuses when both files exist and differ; `paperlint doctor` names a leftover.
3
+ * `rules` to that paper alone and refuses an unknown rule id.
5
4
  */
6
5
  import {
7
- existsSync,
8
6
  mkdirSync,
9
7
  mkdtempSync,
10
- readFileSync,
11
8
  realpathSync,
12
9
  rmSync,
13
10
  writeFileSync,
@@ -16,8 +13,6 @@ import { tmpdir } from "node:os";
16
13
  import { join } from "node:path";
17
14
  import { afterEach, describe, expect, it } from "vitest";
18
15
  import { run, toolchainTex } from "./cli.ts";
19
- import { migratePaperSettings } from "./init.ts";
20
- import { doctor } from "./doctor.ts";
21
16
 
22
17
  const dirs: string[] = [];
23
18
  afterEach(() => {
@@ -29,11 +24,7 @@ function project(files: Record<string, string> = {}): string {
29
24
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-settings-")));
30
25
  dirs.push(root);
31
26
  const all: Record<string, string> = {
32
- "package.json": JSON.stringify({
33
- name: "c",
34
- private: true,
35
- paperlint: { papersDir: "papers" },
36
- }),
27
+ "package.json": JSON.stringify({ name: "c", private: true }),
37
28
  "papers/a/paper.tex":
38
29
  "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
39
30
  "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
@@ -51,10 +42,11 @@ function project(files: Record<string, string> = {}): string {
51
42
 
52
43
  async function lint(
53
44
  root: string,
45
+ args: string[] = [],
54
46
  ): Promise<{ code: number; out: string; err: string }> {
55
47
  const out: string[] = [];
56
48
  const err: string[] = [];
57
- const code = await run(["lint", "--json"], {
49
+ const code = await run(["lint", "--json", ...args], {
58
50
  cwd: root,
59
51
  log: (s: string) => out.push(s),
60
52
  err: (s: string) => err.push(s),
@@ -67,6 +59,43 @@ const rulesIn = (stdout: string, paper: string): string[] =>
67
59
  .filter((r) => r.filePath.endsWith(join(paper, "paper.tex")))
68
60
  .flatMap((r) => r.messages.map((m) => m.ruleId));
69
61
 
62
+ describe("🔴 an optional rule that reaches no paper — judged against the PROJECT, not the run (#103)", () => {
63
+ const THIRD = {
64
+ "papers/c/paper.tex":
65
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
66
+ "papers/c/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
67
+ };
68
+ const rootRules = (files: string[]) => ({
69
+ "paperlint.json": JSON.stringify({
70
+ rules: [{ files, rules: { "pdf/last-page-balance": "error" } }],
71
+ }),
72
+ });
73
+
74
+ it("linting paper c alone passes when the block reaches papers a and b", async () => {
75
+ const root = project({
76
+ ...THIRD,
77
+ ...rootRules(["papers/a/**", "papers/b/**"]),
78
+ });
79
+ const r = await lint(root, ["papers/c"]);
80
+ // Guards: the defect — the guard counted only the paper.tex files this run linted.
81
+ expect(r.err).not.toMatch(/no linted paper\.tex gets it/);
82
+ expect(r.code).toBe(0);
83
+ expect(rulesIn(r.out, "c")).not.toContain("pdf/last-page-balance");
84
+ });
85
+
86
+ it("…and a glob that reaches no paper of the project still fails, from a subset too", async () => {
87
+ const root = project({ ...THIRD, ...rootRules(["papers/typo/**"]) });
88
+ for (const args of [["papers/c"], []]) {
89
+ const r = await lint(root, args);
90
+ // Guards: the other half — widening the judged set did not turn the guard off.
91
+ expect(r.err).toMatch(
92
+ /pdf\/last-page-balance is turned on in "rules", but no paper\.tex/,
93
+ );
94
+ expect(r.code).toBe(1);
95
+ }
96
+ });
97
+ });
98
+
70
99
  describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
71
100
  it("turns an optional rule on for that paper alone", async () => {
72
101
  const root = project({
@@ -80,26 +109,18 @@ describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
80
109
  expect(rulesIn(r.out, "b")).not.toContain("pdf/last-page-balance");
81
110
  });
82
111
 
83
- it("the project's own `rules` blocks come after, so they win", async () => {
112
+ it("🔴 the paper's own `rules` come after the root paperlint.json's, so they win", async () => {
84
113
  const root = project({
85
- "package.json": JSON.stringify({
86
- name: "c",
87
- private: true,
88
- paperlint: {
89
- papersDir: "papers",
90
- rules: [
91
- {
92
- files: ["papers/a/**"],
93
- rules: { "pdf/last-page-balance": "off" },
94
- },
95
- ],
96
- },
114
+ "paperlint.json": JSON.stringify({
115
+ rules: [
116
+ {
117
+ files: ["papers/*/**"],
118
+ rules: { "pdf/last-page-balance": "error" },
119
+ },
120
+ ],
97
121
  }),
98
122
  "papers/a/paperlint.json": JSON.stringify({
99
- rules: { "pdf/last-page-balance": "error" },
100
- }),
101
- "papers/b/paperlint.json": JSON.stringify({
102
- rules: { "pdf/last-page-balance": "error" },
123
+ rules: { "pdf/last-page-balance": "off" },
103
124
  }),
104
125
  });
105
126
  const r = await lint(root);
@@ -125,6 +146,61 @@ describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
125
146
  });
126
147
  });
127
148
 
149
+ describe("papersDir — optional, `papers` by default", () => {
150
+ it("no paperlint.json, papers/x/paper.tex: that paper is linted", async () => {
151
+ const r = await lint(project());
152
+ expect(r.code).toBe(0);
153
+ expect(
154
+ (JSON.parse(r.out) as { filePath: string }[]).map((f) =>
155
+ f.filePath.slice(f.filePath.indexOf("papers/")),
156
+ ),
157
+ ).toEqual(
158
+ expect.arrayContaining(["papers/a/paper.tex", "papers/b/paper.tex"]),
159
+ );
160
+ });
161
+
162
+ it("🔴 no paperlint.json and no papers/: exit 2 and the one clear sentence", async () => {
163
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-empty-")));
164
+ dirs.push(root);
165
+ writeFileSync(join(root, "package.json"), "{}");
166
+ const r = await lint(root);
167
+ expect(r.code).toBe(2);
168
+ expect(r.err).toBe(
169
+ 'no papers in papers/ — create one with `npx paperlint new <name>`, or set "papersDir" in paperlint.json if your papers live elsewhere',
170
+ );
171
+ });
172
+
173
+ it("an explicit papersDir is honored", async () => {
174
+ const root = project({
175
+ "paperlint.json": JSON.stringify({ papersDir: "docs/drafts" }),
176
+ "docs/drafts/c/paper.tex":
177
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
178
+ "docs/drafts/c/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
179
+ });
180
+ const r = await lint(root);
181
+ const linted = (JSON.parse(r.out) as { filePath: string }[]).map((f) =>
182
+ f.filePath.slice(root.length + 1),
183
+ );
184
+ expect(linted).toContain("docs/drafts/c/paper.tex");
185
+ expect(linted.some((f) => f.startsWith("papers/"))).toBe(false);
186
+ });
187
+
188
+ it("run from inside a paper, the root file is still found and papersDir is relative to it", async () => {
189
+ const root = project({
190
+ "paperlint.json": JSON.stringify({ papersDir: "papers" }),
191
+ "papers/a/paperlint.json": JSON.stringify({ kind: "short" }),
192
+ });
193
+ const out: string[] = [];
194
+ const code = await run(["lint", "--json"], {
195
+ cwd: join(root, "papers/a"),
196
+ log: (x: string) => out.push(x),
197
+ err: () => {},
198
+ });
199
+ expect(code).toBe(0);
200
+ expect(out.join("\n")).toMatch(/papers\/b\/paper\.tex/);
201
+ });
202
+ });
203
+
128
204
  describe("paperlint lint — the venue preset's rules", () => {
129
205
  it("agenticdev's preset turns pdf/last-page-balance on for its paper alone", async () => {
130
206
  const root = project({
@@ -136,16 +212,17 @@ describe("paperlint lint — the venue preset's rules", () => {
136
212
  const cfgOf = await import("./cli.ts").then((m) =>
137
213
  m.paperRuleBlocks([join(root, "papers")]),
138
214
  );
139
- expect(cfgOf.ok && cfgOf.value).toEqual([
140
- {
141
- basePath: join(root, "papers/a"),
142
- files: expect.arrayContaining([
143
- "**/paper.tex",
144
- "**/PIPELINE-STATUS.md",
145
- ]),
146
- rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
147
- },
148
- ]);
215
+ expect(cfgOf.ok && cfgOf.value).toEqual({
216
+ preset: [
217
+ {
218
+ // Scoped to the paper by `basePath` alone: which of its files the rule reaches is decided
219
+ // once, by narrowing to paperlint's owned scopes (#101), not by a copy of their globs.
220
+ basePath: join(root, "papers/a"),
221
+ rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
222
+ },
223
+ ],
224
+ own: [],
225
+ });
149
226
  });
150
227
 
151
228
  it("🔴 an unbuilt agenticdev paper: ONE warning (pdf/measured), and no balance error", async () => {
@@ -172,7 +249,11 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
172
249
  const blocks = await import("./cli.ts").then((m) =>
173
250
  m.paperRuleBlocks([join(root, "papers")]),
174
251
  );
175
- expect(blocks.ok && blocks.value[0]?.rules).toEqual({
252
+ // The preset's block comes first and the paper's own after it: the later block wins.
253
+ expect(blocks.ok && blocks.value.preset[0]?.rules).toEqual({
254
+ "pdf/last-page-balance": ["error", { tolerancePt: 120 }],
255
+ });
256
+ expect(blocks.ok && blocks.value.own[0]?.rules).toEqual({
176
257
  "pdf/last-page-balance": "off",
177
258
  });
178
259
  });
@@ -227,85 +308,3 @@ describe("paperlint toolchain — installs what the project's own presets need",
227
308
  expect("acmart" in tex.packages).toBe(true);
228
309
  });
229
310
  });
230
-
231
- describe("paperlint init — moves venue.json to paperlint.json", () => {
232
- const legacy = '{ "venue": "aisec", "kind": "research" }\n';
233
-
234
- it('moves it, "venue" becoming "extends": "paperlint:<name>", and says so', () => {
235
- const root = project({ "papers/a/venue.json": legacy });
236
- const r = migratePaperSettings(join(root, "papers"));
237
- expect(r.code).toBe(0);
238
- expect(existsSync(join(root, "papers/a/venue.json"))).toBe(false);
239
- expect(
240
- JSON.parse(readFileSync(join(root, "papers/a/paperlint.json"), "utf8")),
241
- ).toEqual({ extends: "paperlint:aisec", kind: "research" });
242
- expect(r.lines.join("\n")).toMatch(/a\/venue\.json → a\/paperlint\.json/);
243
- });
244
-
245
- it("both, with the same content: the leftover is removed", () => {
246
- const root = project({
247
- "papers/a/venue.json": legacy,
248
- "papers/a/paperlint.json":
249
- '{"extends":"paperlint:aisec","kind":"research"}',
250
- });
251
- expect(migratePaperSettings(join(root, "papers")).code).toBe(0);
252
- expect(existsSync(join(root, "papers/a/venue.json"))).toBe(false);
253
- });
254
-
255
- it("🔴 both, and they differ: refused, naming both, and nothing is touched", () => {
256
- const root = project({
257
- "papers/a/venue.json": legacy,
258
- "papers/a/paperlint.json": '{"venue":"realm"}',
259
- });
260
- const r = migratePaperSettings(join(root, "papers"));
261
- expect(r.code).toBe(2);
262
- expect(r.lines.join("\n")).toMatch(/a\/venue\.json.*a\/paperlint\.json/);
263
- expect(readFileSync(join(root, "papers/a/venue.json"), "utf8")).toBe(
264
- legacy,
265
- );
266
- expect(readFileSync(join(root, "papers/a/paperlint.json"), "utf8")).toBe(
267
- '{"venue":"realm"}',
268
- );
269
- });
270
-
271
- it("no venue.json anywhere: nothing to say", () => {
272
- const root = project();
273
- expect(migratePaperSettings(join(root, "papers"))).toEqual({
274
- code: 0,
275
- lines: [],
276
- });
277
- });
278
- });
279
-
280
- describe("paperlint doctor — names a leftover venue.json", () => {
281
- it("✗ with the command that moves it, and a failing exit", () => {
282
- const root = project({ "papers/a/venue.json": '{"venue":"aisec"}' });
283
- const out: string[] = [];
284
- const code = doctor({
285
- log: (s: string) => out.push(s),
286
- cwd: root,
287
- projectDir: root,
288
- cliPapers: "papers",
289
- run: (() => ({ status: 0, stdout: "", stderr: "" })) as never,
290
- skillLinks: () => ({ ok: false, error: "not checked in this test" }),
291
- });
292
- const text = out.join("\n");
293
- expect(text).toMatch(/✗ papers\/a\/venue\.json is no longer read/);
294
- expect(text).toMatch(/npx paperlint init/);
295
- expect(code).not.toBe(0);
296
- });
297
-
298
- it("says nothing about it when there is none", () => {
299
- const root = project();
300
- const out: string[] = [];
301
- doctor({
302
- log: (s: string) => out.push(s),
303
- cwd: root,
304
- projectDir: root,
305
- cliPapers: "papers",
306
- run: (() => ({ status: 0, stdout: "", stderr: "" })) as never,
307
- skillLinks: () => ({ ok: false, error: "not checked in this test" }),
308
- });
309
- expect(out.join("\n")).not.toMatch(/venue\.json/);
310
- });
311
- });