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
@@ -1,12 +1,12 @@
1
1
  /**
2
- * `<paper>/paperlint.json` — the per-paper settings file (it was `venue.json` before 2.1.0): parsed
3
- * strictly, the old name never read, the move planned from bytes, and `rules` turned into an ESLint
4
- * block for that paper alone.
2
+ * `<paper>/paperlint.json` — the per-paper settings file: parsed strictly, merged over the root
3
+ * `paperlint.json`'s defaults, and `rules` turned into an ESLint block for that paper alone.
5
4
  */
6
5
  import { describe, expect, it } from "vitest";
7
6
  import { memoryFiles } from "./adapters/memory/index.ts";
7
+ import type { AbsolutePath } from "./domain/paths.ts";
8
+ import { findProjectRoot } from "../lib/paper-config.mjs";
8
9
  import {
9
- migrationOf,
10
10
  paperRules,
11
11
  parsePaperSettings,
12
12
  readPaperSettings,
@@ -16,9 +16,8 @@ const PAPER = "/work/papers/p";
16
16
  const SHIPPED = new Set([
17
17
  "pdf/last-page-balance",
18
18
  "pdf/profile",
19
- "paper/typography",
19
+ "paper/section-word",
20
20
  ]);
21
- const enc = (s: string) => new TextEncoder().encode(s);
22
21
 
23
22
  describe("parsePaperSettings", () => {
24
23
  it("reads extends, kind, pdf and rules; every absent field is null", () => {
@@ -58,10 +57,10 @@ describe("parsePaperSettings — optional keys", () => {
58
57
  [
59
58
  "an unknown key (a typo is not silent)",
60
59
  { venu: "aisec" },
61
- /unknown key "venu".*extends, kind, pdf, rules/,
60
+ /unknown key "venu" — known keys: papersDir, structure, rules, extends, kind, pdf/,
62
61
  ],
63
62
  [
64
- "the pre-2.1.0 comment key `_`",
63
+ "a comment key other than `$comment`",
65
64
  { extends: "paperlint:aisec", _: "note" },
66
65
  /unknown key "_"/,
67
66
  ],
@@ -76,10 +75,15 @@ describe("parsePaperSettings — optional keys", () => {
76
75
  /"extends" must be a non-empty string/,
77
76
  ],
78
77
  [
79
- "rules that are not an object",
80
- { rules: ["pdf/profile"] },
78
+ "rules that are neither an object nor a list of blocks",
79
+ { rules: "pdf/profile" },
81
80
  /"rules" must be an object/,
82
81
  ],
82
+ [
83
+ "🔴 papersDir — a project setting, refused in a paper's file",
84
+ { papersDir: "papers" },
85
+ /"papersDir" is a project setting — set it in the root paperlint\.json/,
86
+ ],
83
87
  ["not an object at all", ["aisec"], /must be a JSON object/],
84
88
  ])("refuses %s", (_, json, why) => {
85
89
  const r = parsePaperSettings(json);
@@ -104,21 +108,6 @@ describe("readPaperSettings", () => {
104
108
  expect(r.ok && r.value?.extends).toBe("paperlint:aisec");
105
109
  });
106
110
 
107
- it("🔴 a venue.json alone is NOT read — it is named, with the command that moves it", () => {
108
- const files = memoryFiles({ [`${PAPER}/venue.json`]: '{"venue":"aisec"}' });
109
- const r = readPaperSettings(files, PAPER);
110
- expect(r).toEqual({ ok: false, error: { kind: "legacy" } });
111
- });
112
-
113
- it("paperlint.json wins when both exist — the leftover is doctor's to report", () => {
114
- const files = memoryFiles({
115
- [`${PAPER}/venue.json`]: '{"venue":"realm"}',
116
- [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
117
- });
118
- const r = readPaperSettings(files, PAPER);
119
- expect(r.ok && r.value?.extends).toBe("paperlint:aisec");
120
- });
121
-
122
111
  it("not JSON: broken, with the parser's reason", () => {
123
112
  const files = memoryFiles({
124
113
  [`${PAPER}/paperlint.json`]: "{ extends: aisec",
@@ -129,52 +118,92 @@ describe("readPaperSettings", () => {
129
118
  });
130
119
  });
131
120
 
132
- describe("migrationOf — what `paperlint init` does with a paper's files", () => {
133
- const plan = (legacy: string | null, current: string | null) =>
134
- migrationOf(
135
- legacy === null ? null : enc(legacy),
136
- current === null ? null : enc(current),
137
- );
121
+ describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
122
+ const ROOT = "/work";
123
+ const withFiles = (f: Record<string, string>) =>
124
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
125
+ const read = (f: Record<string, string>) => {
126
+ const r = readPaperSettings(withFiles(f), PAPER);
127
+ if (!r.ok) throw new Error(r.error.why);
128
+ return r.value;
129
+ };
130
+
131
+ it("no file at either level: null", () => {
132
+ expect(read({})).toBeNull();
133
+ });
138
134
 
139
- it.each([
140
- ["no venue.json", null, null, "none"],
141
- [
142
- "no venue.json, paperlint.json present",
143
- null,
144
- '{"extends":"paperlint:a"}',
145
- "none",
146
- ],
147
- ["venue.json only", '{"venue":"a"}', null, "move"],
148
- [
149
- "both, and paperlint.json already says the same (spacing differs)",
150
- '{"venue":"a","kind":"short"}',
151
- '{ "extends": "paperlint:a", "kind": "short" }\n',
152
- "drop-legacy",
153
- ],
154
- [
155
- "both, different",
156
- '{"venue":"a"}',
157
- '{"extends":"paperlint:b"}',
158
- "conflict",
159
- ],
160
- ["venue.json that is not JSON", "{", null, "broken"],
161
- ["venue.json that is not an object", "[]", null, "broken"],
162
- ])("%s → %s", (_, legacy, current, want) => {
163
- expect(plan(legacy, current).kind).toBe(want);
135
+ it("root only: its extends and kind are this paper's", () => {
136
+ expect(
137
+ read({
138
+ [`${ROOT}/paperlint.json`]:
139
+ '{"papersDir":"papers","extends":"paperlint:agenticdev","kind":"short"}',
140
+ }),
141
+ ).toEqual({
142
+ extends: "paperlint:agenticdev",
143
+ kind: "short",
144
+ pdf: null,
145
+ rules: null,
146
+ });
164
147
  });
165
148
 
166
- it('the move rewrites "venue": "x" to "extends": "paperlint:x", and "_" to "$comment"', () => {
167
- const r = plan(
168
- '{"venue":"aisec","kind":"research","pdf":"b.pdf","_":"note"}',
169
- null,
170
- );
171
- expect(r.kind === "move" && JSON.parse(r.text)).toEqual({
149
+ it("paper only: its own values", () => {
150
+ expect(
151
+ read({ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}' }),
152
+ ).toEqual({
172
153
  extends: "paperlint:aisec",
154
+ kind: null,
155
+ pdf: null,
156
+ rules: null,
157
+ });
158
+ });
159
+
160
+ it("🔴 both: the paper's value wins, an absent one falls back to the root's, and `\"extends\": null` is absent", () => {
161
+ expect(
162
+ read({
163
+ [`${ROOT}/paperlint.json`]:
164
+ '{"extends":"paperlint:agenticdev","kind":"short","rules":{"pdf/profile":"off"}}',
165
+ [`${PAPER}/paperlint.json`]:
166
+ '{"extends":null,"kind":"research","rules":{"pdf/fonts":"off"}}',
167
+ }),
168
+ ).toEqual({
169
+ extends: "paperlint:agenticdev",
173
170
  kind: "research",
174
- pdf: "b.pdf",
175
- $comment: "note",
171
+ pdf: null,
172
+ // The root's rules are the project's blocks (cli.ts), not this paper's.
173
+ rules: { "pdf/fonts": "off" },
176
174
  });
177
175
  });
176
+
177
+ it("papersDir in a paper's file: broken, naming the root file as its place", () => {
178
+ const r = readPaperSettings(
179
+ withFiles({ [`${PAPER}/paperlint.json`]: '{"papersDir":"x"}' }),
180
+ PAPER,
181
+ );
182
+ expect(r.ok).toBe(false);
183
+ if (!r.ok) expect(r.error.why).toMatch(/project setting/);
184
+ });
185
+ });
186
+
187
+ describe("findProjectRoot — where the root paperlint.json is looked for", () => {
188
+ const ROOT = "/work";
189
+ const withFiles = (f: Record<string, string>) =>
190
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
191
+
192
+ it("🔴 from INSIDE a paper, the project root is not the paper: its paperlint.json sits beside paper.tex", () => {
193
+ const files = withFiles({
194
+ [`${PAPER}/paper.tex`]: "x",
195
+ [`${PAPER}/paperlint.json`]: '{"kind":"research"}',
196
+ [`${ROOT}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
197
+ });
198
+ const isFile = (p: string) => files.isFile(p as AbsolutePath);
199
+ expect(findProjectRoot(PAPER, isFile)).toBe(ROOT);
200
+ // No root file anywhere: the package.json directory.
201
+ expect(findProjectRoot(PAPER, (p) => p === `${ROOT}/package.json`)).toBe(
202
+ ROOT,
203
+ );
204
+ // Neither: where the walk started.
205
+ expect(findProjectRoot(PAPER, () => false)).toBe(PAPER);
206
+ });
178
207
  });
179
208
 
180
209
  describe("paperRules — `rules` in paperlint.json", () => {
@@ -1,27 +1,26 @@
1
1
  /**
2
- * `<paper>/paperlint.json` — ONE PAPER'S SETTINGS, parsed at the boundary.
2
+ * `<paper>/paperlint.json` — ONE PAPER'S SETTINGS, parsed at the boundary, merged over the root's.
3
3
  *
4
- * The middle of three levels, each named after the tool (lib/paper-config.mjs):
4
+ * One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
5
5
  *
6
- * package.json → "paperlint" the project
6
+ * paperlint.json the project (optional): papersDir, rules, defaults
7
7
  * <paper>/paperlint.json this paper: { extends, kind, pdf, rules }
8
8
  * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
9
9
  *
10
- * Every reader — the build, the facts writer, the venue rules, `paperlint lint` — goes through
11
- * `readPaperSettings`, so there is one answer to "what does this paper declare".
10
+ * The paper's `extends`, `kind` and `pdf` win over the root's; with no own value, the root's is
11
+ * the default. Every reader — the build, the facts writer, the venue rules, `paperlint lint` —
12
+ * goes through `readPaperSettings`, so there is one answer to "what does this paper declare".
12
13
  *
13
14
  * 🔴 STRICT. An unknown key is an error naming the known ones: `"venu": "aisec"` would otherwise
14
- * read as "no preset", and every venue check would be silently off.
15
- *
16
- * 🔴 THE OLD NAME IS NOT READ. Before 2.1.0 the file was `venue.json`. A fallback would keep it
17
- * working forever and leave two names for one file; instead a `venue.json` with no `paperlint.json`
18
- * is an error that names `npx paperlint init`, which moves it (`migrationOf` plans that move).
15
+ * read as "no preset", and every venue check would be silently off. A project-only key
16
+ * (`papersDir`, …) in a paper's file is refused too: it would be read nowhere.
19
17
  */
20
- import { join } from "node:path";
18
+ import { dirname, join } from "node:path";
21
19
  import {
22
- LEGACY_PAPER_SETTINGS_FILE,
23
- PAPER_SETTINGS_FILE,
24
- PAPER_SETTINGS_KEYS,
20
+ CONFIG_FILE,
21
+ ROOT_ONLY_KEYS,
22
+ SETTINGS_KEYS,
23
+ findProjectRoot,
25
24
  } from "../lib/paper-config.mjs";
26
25
  import type { AbsolutePath } from "./domain/paths.ts";
27
26
  import { err, ok, type Result } from "./domain/result.ts";
@@ -40,16 +39,17 @@ export interface PaperSettings {
40
39
  readonly kind: string | null;
41
40
  /** The built PDF relative to the paper, when it is not `paper.pdf`. */
42
41
  readonly pdf: string | null;
43
- /** Rule overrides for this paper alone, NOT yet checked against the shipped rules. */
44
- readonly rules: Readonly<Record<string, unknown>> | null;
42
+ /**
43
+ * Rule overrides for this paper alone — `{ id: severity }` or ESLint blocks — NOT yet checked
44
+ * against the shipped rules.
45
+ */
46
+ readonly rules: Readonly<Record<string, unknown>> | readonly unknown[] | null;
45
47
  }
46
48
 
47
49
  /** Why a paper's settings cannot be read. */
48
- export type SettingsProblem =
49
- | { readonly kind: "broken"; readonly why: string }
50
- | { readonly kind: "legacy" };
50
+ export type SettingsProblem = { readonly kind: "broken"; readonly why: string };
51
51
 
52
- const KNOWN = Object.keys(PAPER_SETTINGS_KEYS);
52
+ const KNOWN = Object.keys(SETTINGS_KEYS);
53
53
  const STRING_FIELDS = ["extends", "kind", "pdf"] as const;
54
54
 
55
55
  const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
@@ -68,8 +68,8 @@ function stringField(
68
68
  : err(`"${k}" must be a non-empty string, got ${JSON.stringify(v)}`);
69
69
  }
70
70
 
71
- /** `extends`, `kind` and `pdf`, each null when absent. */
72
- function stringFields(
71
+ /** `extends`, `kind` and `pdf`, each null when absent. The root file's defaults parse the same way. */
72
+ export function stringFields(
73
73
  d: Readonly<Record<string, unknown>>,
74
74
  ): Result<Pick<PaperSettings, (typeof STRING_FIELDS)[number]>, string> {
75
75
  const out: Record<string, string | null> = {};
@@ -93,11 +93,18 @@ export function parsePaperSettings(
93
93
  const unknown = Object.keys(json).filter((k) => !KNOWN.includes(k));
94
94
  if (unknown.length > 0)
95
95
  return err(`unknown key "${unknown[0]}" — known keys: ${KNOWN.join(", ")}`);
96
+ const project = Object.keys(json).filter((k) => ROOT_ONLY_KEYS.includes(k));
97
+ if (project.length > 0)
98
+ return err(
99
+ `"${project[0]}" is a project setting — set it in the root ${CONFIG_FILE}, not in a paper's`,
100
+ );
96
101
  const fields = stringFields(json);
97
102
  if (!fields.ok) return fields;
98
103
  const rules = json["rules"];
99
- if (rules !== undefined && !isObject(rules))
100
- return err(`"rules" must be an object of rule id → severity`);
104
+ if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
105
+ return err(
106
+ `"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
107
+ );
101
108
  return ok({
102
109
  ...fields.value,
103
110
  rules: rules ?? null,
@@ -106,101 +113,96 @@ export function parsePaperSettings(
106
113
 
107
114
  const at = (p: string): AbsolutePath => p as AbsolutePath;
108
115
 
109
- /**
110
- * A paper's settings: null when it has no `paperlint.json`; the problem when the file does not parse
111
- * or only the pre-2.1.0 `venue.json` is there. Reads through `files` only; never throws.
112
- */
113
- export function readPaperSettings(
116
+ /** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
117
+ function readJson(
114
118
  files: Files,
115
- paperDir: string,
116
- ): Result<PaperSettings | null, SettingsProblem> {
117
- const bytes = files.readBytes(at(join(paperDir, PAPER_SETTINGS_FILE)));
118
- if (bytes === null)
119
- return files.readBytes(at(join(paperDir, LEGACY_PAPER_SETTINGS_FILE))) ===
120
- null
121
- ? ok(null)
122
- : err({ kind: "legacy" });
123
- let json: unknown;
119
+ path: string,
120
+ ): Result<unknown, SettingsProblem> {
121
+ const bytes = files.readBytes(at(path));
122
+ if (bytes === null) return ok(undefined);
124
123
  try {
125
- json = JSON.parse(new TextDecoder().decode(bytes));
124
+ return ok(JSON.parse(new TextDecoder().decode(bytes)));
126
125
  } catch (e) {
127
126
  return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
128
127
  }
129
- const parsed = parsePaperSettings(json);
130
- return parsed.ok ? parsed : err({ kind: "broken", why: parsed.error });
131
128
  }
132
129
 
133
- // ── the move from venue.json ────────────────────────────────────────────────────────────
134
-
135
- /** What `paperlint init` does with one paper's files. */
136
- export type Migration =
137
- /** No `venue.json`: nothing to do. */
138
- | { readonly kind: "none" }
139
- /** Only `venue.json`: write `text` as `paperlint.json`, delete `venue.json`. */
140
- | { readonly kind: "move"; readonly text: string }
141
- /** Both, and `paperlint.json` already says the same: the old one is a leftover, delete it. */
142
- | { readonly kind: "drop-legacy" }
143
- /** Both, and they differ: refuse — there is no way to know which one the author means. */
144
- | { readonly kind: "conflict" }
145
- /** `venue.json` is not a JSON object: refuse, naming why. */
146
- | { readonly kind: "broken"; readonly why: string };
147
-
148
130
  /**
149
- * A `venue.json` object → the `paperlint.json` object that says the same, in one move:
150
- * `"venue": "aisec"` becomes `"extends": "paperlint:aisec"` (the only presets that existed were the
151
- * shipped ones), and the `"_"` some files used as a comment becomes `"$comment"`. Every other key
152
- * is carried as it is — an unknown one is then refused by name, as in any `paperlint.json`. Pure.
131
+ * The root file's defaults for every paper — `extends`, `kind`, `pdf` — found by walking up from
132
+ * the paper's parent. None when there is no root file, or it is not an object; a root file that
133
+ * is wrong in another way is reported by `paperlint lint`, which parses it strictly.
153
134
  */
154
- export function fromLegacy(
155
- legacy: Readonly<Record<string, unknown>>,
156
- ): Record<string, unknown> {
157
- const out: Record<string, unknown> = {};
158
- for (const [k, v] of Object.entries(legacy)) {
159
- if (k === "venue" && typeof v === "string")
160
- out["extends"] = `paperlint:${v}`;
161
- else if (k === "_") out["$comment"] = v;
162
- else out[k] = v;
163
- }
164
- return out;
135
+ function rootDefaults(
136
+ files: Files,
137
+ paperDir: string,
138
+ ): Result<Defaults | null, SettingsProblem> {
139
+ const root = findProjectRoot(dirname(paperDir), (p) => files.isFile(at(p)));
140
+ const json = readJson(files, join(root, CONFIG_FILE));
141
+ if (!json.ok) return json;
142
+ if (!isObject(json.value)) return ok(null);
143
+ const fields = stringFields(json.value);
144
+ return fields.ok
145
+ ? fields
146
+ : err({ kind: "broken", why: `the root ${CONFIG_FILE}: ${fields.error}` });
165
147
  }
166
148
 
167
- const jsonOf = (b: Uint8Array): unknown => {
168
- try {
169
- return JSON.parse(new TextDecoder().decode(b));
170
- } catch {
171
- return undefined;
172
- }
173
- };
149
+ /**
150
+ * A paper's settings, its own `paperlint.json` merged over the root's defaults: null when neither
151
+ * says anything; the problem when a file does not parse. Reads through `files` only; never throws.
152
+ */
153
+ export function readPaperSettings(
154
+ files: Files,
155
+ paperDir: string,
156
+ ): Result<PaperSettings | null, SettingsProblem> {
157
+ const own = readJson(files, join(paperDir, CONFIG_FILE));
158
+ if (!own.ok) return own;
159
+ const parsed =
160
+ own.value === undefined ? ok(null) : parsePaperSettings(own.value);
161
+ if (!parsed.ok) return err({ kind: "broken", why: parsed.error });
162
+ const defaults = rootDefaults(files, paperDir);
163
+ if (!defaults.ok) return defaults;
164
+ return ok(merge(defaults.value, parsed.value));
165
+ }
174
166
 
175
- /** The move, planned from the two files' bytes (null = absent). Pure. */
176
- export function migrationOf(
177
- legacy: Uint8Array | null,
178
- current: Uint8Array | null,
179
- ): Migration {
180
- if (legacy === null) return { kind: "none" };
181
- const old = jsonOf(legacy);
182
- if (!isObject(old)) return { kind: "broken", why: "it is not a JSON object" };
183
- const next = fromLegacy(old);
184
- if (current === null)
185
- return { kind: "move", text: `${JSON.stringify(next, null, 2)}\n` };
186
- return JSON.stringify(jsonOf(current)) === JSON.stringify(next)
187
- ? { kind: "drop-legacy" }
188
- : { kind: "conflict" };
167
+ type Defaults = Pick<PaperSettings, "extends" | "kind" | "pdf">;
168
+ const NO_DEFAULTS: Defaults = { extends: null, kind: null, pdf: null };
169
+
170
+ /** The paper's own values win; each absent one falls back to the root's. */
171
+ function merge(
172
+ root: Defaults | null,
173
+ paper: PaperSettings | null,
174
+ ): PaperSettings | null {
175
+ const r = root ?? NO_DEFAULTS;
176
+ const p = paper ?? { ...NO_DEFAULTS, rules: null };
177
+ const merged: PaperSettings = {
178
+ extends: p.extends ?? r.extends,
179
+ kind: p.kind ?? r.kind,
180
+ pdf: p.pdf ?? r.pdf,
181
+ rules: p.rules,
182
+ };
183
+ return paper === null && Object.values(merged).every((v) => v === null)
184
+ ? null
185
+ : merged;
189
186
  }
190
187
 
191
188
  // ── the paper's rule overrides ──────────────────────────────────────────────────────────
192
189
 
193
190
  /**
194
191
  * `rules` → the parsed entries (rule id → severity or `[severity, options]`), or the error naming
195
- * the file. Null when the paper sets none. The block they go into is built where the config is
196
- * (`paperRuleBlocks` in cli.ts), which also knows which files a paper's rules may reach.
192
+ * the file. Null when the paper sets none. Blocks (the list form) are parsed by the caller, which
193
+ * knows the file globs they are relative to (`paperRuleBlocks` in cli.ts).
197
194
  */
198
195
  export function paperRules(
199
196
  paperDir: string,
200
197
  settings: PaperSettings,
201
198
  shipped: ReadonlySet<string>,
202
199
  ): Parsed<Record<string, RuleEntry> | null> {
203
- if (settings.rules === null) return { ok: true, value: null };
204
- const where = `${join(paperDir, PAPER_SETTINGS_FILE)} → "rules"`;
205
- return parseRuleEntries(settings.rules, where, shipped);
200
+ if (settings.rules === null || Array.isArray(settings.rules))
201
+ return { ok: true, value: null };
202
+ const where = `${join(paperDir, CONFIG_FILE)} → "rules"`;
203
+ return parseRuleEntries(
204
+ settings.rules as Readonly<Record<string, unknown>>,
205
+ where,
206
+ shipped,
207
+ );
206
208
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `--flag=value` and unknown flags — the command line the CI action actually writes.
3
+ *
4
+ * action.yml runs `npx paperlint lint $PATHS --max-warnings="$MAXWARN" --json`. The parser knew
5
+ * only `--max-warnings <n>`, so `--max-warnings=-1` fell through to the list of PATHS: ESLint was
6
+ * handed a file named `--max-warnings=-1`, found nothing for it, and the run reported "nothing was
7
+ * linted under papers, --max-warnings=-1". Any flag the parser did not know became a path the same
8
+ * way, silently.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { parseArgs, run } from "./cli.ts";
12
+
13
+ describe("parseArgs — `--flag=value` is the same as `--flag value`", () => {
14
+ it("🔴 the action's own command line: `--max-warnings=-1` is a threshold, not a path", () => {
15
+ const a = parseArgs(["lint", "papers", "--max-warnings=-1", "--json"]);
16
+ // Guards: the defect — the flag landed in `paths`.
17
+ expect(a.paths).toEqual(["papers"]);
18
+ expect(a.maxWarnings).toBe(-1);
19
+ });
20
+
21
+ it.each([
22
+ ["--config=o.json", "config", "o.json"],
23
+ ["--venue=agenticdev", "venue", "agenticdev"],
24
+ ["--kind=short", "kind", "short"],
25
+ ["--format=md", "format", "md"],
26
+ ["--paper=p", "paper", "p"],
27
+ ] as const)("%s", (flag, key, value) => {
28
+ const a = parseArgs(["new", "x", flag]);
29
+ expect(a[key]).toBe(value);
30
+ expect(a.paths).toEqual(["x"]);
31
+ });
32
+
33
+ it("an empty value after `=` is a missing value, refused like `--config` with none", () => {
34
+ expect(parseArgs(["lint", "--config="]).missingValue).toBe("--config");
35
+ });
36
+ });
37
+
38
+ describe("an unknown flag is refused, not read as a path", () => {
39
+ it("exits 2 and names the flag", async () => {
40
+ const err: string[] = [];
41
+ const code = await run(["lint", "--frobnicate"], {
42
+ log: () => {},
43
+ err: (s: string) => err.push(s),
44
+ });
45
+ expect(code).toBe(2);
46
+ expect(err.join("\n")).toMatch(/unknown flag `--frobnicate`/);
47
+ });
48
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Checking a paper's references online — shaped by what the build records, not by what DBLP or
3
+ * Crossref answer. The adapter (`adapters/references/`) runs the package's two checkers; a test
4
+ * passes a function.
5
+ */
6
+
7
+ /** What one bibliography entry came to. */
8
+ export interface EntryVerdict {
9
+ readonly key: string;
10
+ /**
11
+ * Does the cited work exist, and does its title match? `false` only on a positive disproof (an
12
+ * identifier that resolves to nothing or to another work); a work that simply was not found is
13
+ * `unresolvable`, never `false`.
14
+ */
15
+ readonly exists: "true" | "false" | "unresolvable";
16
+ /** Are the authors those of the version cited (not the preprint's)? */
17
+ readonly authors: "match" | "mismatch" | "skipped" | "unchecked";
18
+ /** Why, in one line, when either is not a plain pass. */
19
+ readonly why?: string;
20
+ }
21
+
22
+ export type ReferencesCheck =
23
+ | { readonly kind: "checked"; readonly entries: readonly EntryVerdict[] }
24
+ /** Nothing could be asked — no network, every service down. Never a pass. */
25
+ | { readonly kind: "not-checked"; readonly why: string };
26
+
27
+ /** The bibliography's text (BibTeX) → the verdicts. Never throws: a failure is `not-checked`. */
28
+ export type CheckReferences = (bib: string) => Promise<ReferencesCheck>;
package/src/presets.ts CHANGED
@@ -47,11 +47,7 @@ import {
47
47
  type PaperSettings,
48
48
  type SettingsProblem,
49
49
  } from "./paper-settings.ts";
50
- import {
51
- LEGACY_PAPER_SETTINGS_FILE,
52
- LEGACY_PAPER_SETTINGS_MESSAGE,
53
- PAPER_SETTINGS_FILE,
54
- } from "../lib/paper-config.mjs";
50
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
55
51
 
56
52
  /** The prefix of a shipped preset's spec. */
57
53
  export const SHIPPED_PREFIX = "paperlint:";
@@ -268,7 +264,7 @@ export type PaperPreset =
268
264
  readonly settings: PaperSettings;
269
265
  readonly preset: Preset;
270
266
  }
271
- /** `paperlint.json` does not parse, or only a pre-2.1.0 `venue.json` is there. */
267
+ /** `paperlint.json` does not parse. */
272
268
  | { readonly kind: "settings-problem"; readonly problem: SettingsProblem }
273
269
  /** `extends` does not resolve. */
274
270
  | {
@@ -283,11 +279,7 @@ export function paperPreset(paperDir: string, deps: PresetDeps): PaperPreset {
283
279
  if (!read.ok) return { kind: "settings-problem", problem: read.error };
284
280
  const settings = read.value;
285
281
  if (settings?.extends == null) return { kind: "none", settings };
286
- const r = resolvePreset(
287
- settings.extends,
288
- join(paperDir, PAPER_SETTINGS_FILE),
289
- deps,
290
- );
282
+ const r = resolvePreset(settings.extends, join(paperDir, CONFIG_FILE), deps);
291
283
  return r.ok
292
284
  ? { kind: "resolved", settings, preset: r.value }
293
285
  : { kind: "preset-problem", settings, problem: r.error };
@@ -299,9 +291,7 @@ export function paperPresetProblem(
299
291
  p: PaperPreset,
300
292
  ): string | null {
301
293
  if (p.kind === "preset-problem")
302
- return `${join(paperDir, PAPER_SETTINGS_FILE)}: ${presetProblemText(p.problem)}`;
294
+ return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
303
295
  if (p.kind !== "settings-problem") return null;
304
- return p.problem.kind === "legacy"
305
- ? `${join(paperDir, LEGACY_PAPER_SETTINGS_FILE)}: ${LEGACY_PAPER_SETTINGS_MESSAGE}`
306
- : `${join(paperDir, PAPER_SETTINGS_FILE)}: ${p.problem.why}`;
296
+ return `${join(paperDir, CONFIG_FILE)}: ${p.problem.why}`;
307
297
  }