paperlint 4.0.0 → 4.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 (172) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +31 -11
  3. package/CONTRIBUTING.md +23 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +22 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +102 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +29 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -0
  16. package/dist/adapters/latex/index.js +18 -0
  17. package/dist/adapters/latex/index.js.map +1 -0
  18. package/dist/adapters/latex/nodes.d.ts +54 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  20. package/dist/adapters/latex/nodes.js +93 -0
  21. package/dist/adapters/latex/nodes.js.map +1 -0
  22. package/dist/adapters/latex/outline.d.ts +10 -0
  23. package/dist/adapters/latex/outline.d.ts.map +1 -0
  24. package/dist/adapters/latex/outline.js +39 -0
  25. package/dist/adapters/latex/outline.js.map +1 -0
  26. package/dist/adapters/latex/parse.d.ts +42 -0
  27. package/dist/adapters/latex/parse.d.ts.map +1 -0
  28. package/dist/adapters/latex/parse.js +94 -0
  29. package/dist/adapters/latex/parse.js.map +1 -0
  30. package/dist/adapters/latex/rendered.d.ts +10 -0
  31. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  32. package/dist/adapters/latex/rendered.js +39 -0
  33. package/dist/adapters/latex/rendered.js.map +1 -0
  34. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  35. package/dist/adapters/pdfjs/fill.js +12 -12
  36. package/dist/adapters/pdfjs/fill.js.map +1 -1
  37. package/dist/build.js +4 -4
  38. package/dist/build.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +29 -5
  41. package/dist/cli.js.map +1 -1
  42. package/dist/domain/tex-document.d.ts +84 -0
  43. package/dist/domain/tex-document.d.ts.map +1 -0
  44. package/dist/domain/tex-document.js +43 -0
  45. package/dist/domain/tex-document.js.map +1 -0
  46. package/dist/facts-file.d.ts.map +1 -1
  47. package/dist/facts-file.js +2 -2
  48. package/dist/facts-file.js.map +1 -1
  49. package/dist/new-paper.d.ts +5 -0
  50. package/dist/new-paper.d.ts.map +1 -1
  51. package/dist/new-paper.js +3 -0
  52. package/dist/new-paper.js.map +1 -1
  53. package/dist/package-dirs.d.ts +5 -0
  54. package/dist/package-dirs.d.ts.map +1 -0
  55. package/dist/package-dirs.js +14 -0
  56. package/dist/package-dirs.js.map +1 -0
  57. package/dist/ports/latex.d.ts +24 -0
  58. package/dist/ports/latex.d.ts.map +1 -0
  59. package/dist/ports/latex.js +2 -0
  60. package/dist/ports/latex.js.map +1 -0
  61. package/dist/presets.d.ts +15 -2
  62. package/dist/presets.d.ts.map +1 -1
  63. package/dist/presets.js +19 -6
  64. package/dist/presets.js.map +1 -1
  65. package/dist/tex-requirements.d.ts +11 -0
  66. package/dist/tex-requirements.d.ts.map +1 -1
  67. package/dist/tex-requirements.js +14 -8
  68. package/dist/tex-requirements.js.map +1 -1
  69. package/dist/tex-venue-rules.d.ts +83 -0
  70. package/dist/tex-venue-rules.d.ts.map +1 -0
  71. package/dist/tex-venue-rules.js +254 -0
  72. package/dist/tex-venue-rules.js.map +1 -0
  73. package/dist/venue-rules.d.ts +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/docs/configuration.md +3 -2
  76. package/docs/e2e.md +69 -40
  77. package/docs/install.md +1 -1
  78. package/docs/prior-art/README.md +1 -1
  79. package/docs/prior-art/package-location.md +5 -5
  80. package/docs/prior-art/repro/README.md +1 -1
  81. package/docs/prior-art/test-tooling.md +21 -10
  82. package/docs/rules/tex/required-section.md +73 -0
  83. package/docs/rules/tex/template.md +62 -0
  84. package/docs/rules/tex/venue-leftover.md +71 -0
  85. package/docs/rules.md +70 -48
  86. package/docs/testing.md +17 -4
  87. package/docs/texlive-install-decision.md +1 -1
  88. package/docs/toolchain.md +2 -2
  89. package/eslint.config.mjs +155 -15
  90. package/fixtures/build-e2e/acmart/expect.json +26 -0
  91. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  92. package/fixtures/build-e2e/aidc/expect.json +14 -0
  93. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  94. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  95. package/fixtures/build-e2e/broken/expect.json +11 -0
  96. package/fixtures/build-e2e/cite/expect.json +27 -0
  97. package/fixtures/build-e2e/empty/expect.json +12 -0
  98. package/fixtures/build-e2e/fallback/expect.json +53 -0
  99. package/fixtures/build-e2e/guards/expect.json +6 -0
  100. package/fixtures/build-e2e/no-source/expect.json +10 -0
  101. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.ts +1 -1
  104. package/fixtures/render-paper/build-clean.sh +3 -5
  105. package/package.json +13 -2
  106. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  107. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  108. package/presets/aidc.jsonc +73 -0
  109. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  110. package/presets/ieee-conference.jsonc +66 -0
  111. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  112. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  113. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  114. package/scripts/check.harness.ts +64 -10
  115. package/scripts/check.test.ts +60 -13
  116. package/scripts/check.ts +83 -30
  117. package/scripts/test.test.ts +1 -1
  118. package/scripts/test.ts +1 -1
  119. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  120. package/skills/paper-pipeline/SKILL.md +2 -2
  121. package/skills/paper-pipeline/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  123. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  124. package/skills/render-paper/SKILL.md +2 -2
  125. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  126. package/skills/render-paper/check-render.sh +16 -63
  127. package/skills/render-paper/render-paper.harness.mjs +17 -59
  128. package/src/adapters/banal/index.test.ts +1 -1
  129. package/src/adapters/banal/invocation.ts +1 -1
  130. package/src/adapters/banal/output.test.ts +1 -1
  131. package/src/adapters/banal/run.test.ts +1 -1
  132. package/src/adapters/banal/xml.test.ts +1 -1
  133. package/src/adapters/latex/document-class.ts +147 -0
  134. package/src/adapters/latex/hidden.ts +70 -0
  135. package/src/adapters/latex/index.test.ts +361 -0
  136. package/src/adapters/latex/index.ts +55 -0
  137. package/src/adapters/latex/nodes.ts +141 -0
  138. package/src/adapters/latex/outline.ts +64 -0
  139. package/src/adapters/latex/parse.ts +136 -0
  140. package/src/adapters/latex/rendered.ts +59 -0
  141. package/src/adapters/pdfjs/fill.test.ts +1 -1
  142. package/src/adapters/pdfjs/fill.ts +18 -10
  143. package/src/build.harness.mjs +5 -7
  144. package/src/build.ts +4 -4
  145. package/src/cli.harness.mjs +1 -1
  146. package/src/cli.test.ts +2 -8
  147. package/src/cli.ts +33 -8
  148. package/src/domain/tex-document.test.ts +80 -0
  149. package/src/domain/tex-document.ts +110 -0
  150. package/src/engine.harness.mjs +1 -1
  151. package/src/facts-file.test.ts +4 -7
  152. package/src/facts-file.ts +2 -2
  153. package/src/latex-loop.harness.ts +1 -1
  154. package/src/new-paper.test.ts +28 -3
  155. package/src/new-paper.ts +8 -0
  156. package/src/package-dirs.ts +17 -0
  157. package/src/paper-settings-commands.test.ts +6 -1
  158. package/src/ports/latex.ts +29 -0
  159. package/src/presets.test.ts +65 -4
  160. package/src/presets.ts +38 -15
  161. package/src/rule-cases.test.ts +474 -0
  162. package/src/rule-docs.test.ts +159 -0
  163. package/src/tex-requirements.harness.ts +13 -23
  164. package/src/tex-requirements.ts +35 -8
  165. package/src/tex-venue-rules.test.ts +659 -0
  166. package/src/tex-venue-rules.ts +429 -0
  167. package/src/toolchain.harness.mjs +1 -1
  168. package/src/venue-rules.test.ts +96 -4
  169. package/src/venue-rules.ts +1 -1
  170. package/templates/paper/paper.tex +2 -1
  171. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  172. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
package/scripts/check.ts CHANGED
@@ -34,9 +34,11 @@
34
34
  * (no TeX, no network), and the tail names every skip. "I ran check" then means one thing.
35
35
  */
36
36
  import { spawnSync } from "node:child_process";
37
- import { existsSync } from "node:fs";
37
+ import { existsSync, readFileSync, rmSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
38
39
  import { delimiter, dirname, join } from "node:path";
39
40
  import { fileURLToPath } from "node:url";
41
+ import { z } from "zod";
40
42
  import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
41
43
 
42
44
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -72,11 +74,17 @@ type GateCommand =
72
74
  readonly script: string;
73
75
  readonly run?: undefined;
74
76
  readonly inCi?: RunInCi;
77
+ /**
78
+ * The script is a vitest run, so its JSON report says how many tests were SKIPPED: a run
79
+ * whose exit is 0 and whose report counts a skip is a skip, not a pass.
80
+ */
81
+ readonly vitest?: true;
75
82
  }
76
83
  | {
77
84
  readonly run: readonly [string, ...string[]];
78
85
  readonly script?: undefined;
79
86
  readonly inCi?: undefined;
87
+ readonly vitest?: undefined;
80
88
  };
81
89
 
82
90
  /**
@@ -160,22 +168,14 @@ export const GATES: readonly Gate[] = [
160
168
  {
161
169
  name: "install e2e — pack, install under npm and pnpm, run the binary",
162
170
  job: "gates",
163
- run: ["node", "test/e2e/install.ts"],
171
+ script: "test:e2e:install",
172
+ vitest: true,
164
173
  },
165
174
  {
166
- name: "build e2e — a real pdflatex, and the PDF's fonts are measured",
175
+ name: "TeX e2e — real TeX Live: paperlint toolchain, a real pdflatex build of every fixture, the real banal",
167
176
  job: "build-e2e",
168
- run: ["node", "test/e2e/build.ts"],
169
- },
170
- {
171
- name: "banal e2e — the real banal on pdf.js-written XML gives banal-on-pdftohtml's numbers",
172
- job: "build-e2e",
173
- run: ["node", "test/e2e/banal.ts"],
174
- },
175
- {
176
- name: "toolchain e2e — real TeX Live into $PAPERLINT_TEXLIVE_DIR, then a build with only it on PATH",
177
- job: "build-e2e",
178
- run: ["node", "test/e2e/toolchain.ts"],
177
+ script: "test:e2e:tex",
178
+ vitest: true,
179
179
  },
180
180
  ];
181
181
 
@@ -205,18 +205,41 @@ export const NOT_COVERED: Readonly<Record<string, string>> = {
205
205
  };
206
206
 
207
207
  /**
208
- * Exit 77 means "declared skip" — the same code vigiles' runner uses (`SKIP_EXIT_CODE`). The e2e steps exit 77 when a tool they need is
209
- * absent and --strict is off. Anything else nonzero is a failure.
208
+ * vitest's JSON report, as far as the verdict reads it: skipped (`pending`) and `todo` tests.
210
209
  *
211
- * 🔴 A SKIP IS A THIRD OUTCOME. Until the Codex review on #45 the e2e steps exited 0 on a skip,
212
- * this loop counted that as a pass, and `skipped` below was declared and never filled: a machine
213
- * without pnpm or TeX printed "all gates passed" for runs that never happened.
210
+ * 🔴 A SKIP IS A THIRD OUTCOME. Until the Codex review on #45 the e2e runs exited 0 on a skip and
211
+ * this loop counted that as a pass: a machine without pnpm or TeX printed "all gates passed" for
212
+ * runs that never happened. The count comes from the runner's own report, not from an exit code
213
+ * the tests would have to agree on.
214
214
  */
215
- export const SKIP_EXIT: number = 77;
216
- export function outcome(status: number | null): Outcome {
217
- if (status === 0) return "pass";
218
- if (status === SKIP_EXIT) return "skip";
219
- return "fail";
215
+ const VitestReport = z.looseObject({
216
+ numPendingTests: z.number().int().nonnegative(),
217
+ numTodoTests: z.number().int().nonnegative(),
218
+ });
219
+
220
+ /** How many tests a vitest JSON report says were not run, or null when it is not one. */
221
+ export function skippedIn(report: string | null): number | null {
222
+ if (report === null) return null;
223
+ try {
224
+ const r = VitestReport.safeParse(JSON.parse(report));
225
+ return r.success ? r.data.numPendingTests + r.data.numTodoTests : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * A gate's outcome from its exit status and, for a vitest gate, the skip count its report gave
233
+ * (`null`: the report is missing or unreadable, which cannot be read as a pass).
234
+ */
235
+ export function outcome(
236
+ status: number | null,
237
+ vitest?: { readonly skipped: number | null },
238
+ ): Outcome {
239
+ if (status !== 0) return "fail";
240
+ if (vitest === undefined) return "pass";
241
+ if (vitest.skipped === null) return "fail";
242
+ return vitest.skipped > 0 ? "skip" : "pass";
220
243
  }
221
244
 
222
245
  /** The gates that did not pass, each as the line the verdict prints, split by outcome. */
@@ -225,11 +248,29 @@ interface NotPassed {
225
248
  skipped: string[];
226
249
  }
227
250
 
251
+ /** A report file's text, read once and removed; null when the run wrote none. */
252
+ export type ReadReport = (file: string) => string | null;
253
+ const readReport: ReadReport = (file) => {
254
+ if (!existsSync(file)) return null;
255
+ const text = readFileSync(file, "utf8");
256
+ rmSync(file, { force: true });
257
+ return text;
258
+ };
259
+
260
+ /** The arguments that make a vitest gate write its JSON report to `file`, beside the usual output. */
261
+ export const reportArgs = (file: string): string[] => [
262
+ "--",
263
+ "--reporter=default",
264
+ "--reporter=json",
265
+ `--outputFile.json=${file}`,
266
+ ];
267
+
228
268
  /** Run each gate in order; `write` announces it before it starts. */
229
269
  function runEach(
230
270
  gates: readonly Gate[],
231
271
  spawn: Spawn,
232
272
  write: (text: string) => unknown,
273
+ reportOf: ReadReport,
233
274
  ): NotPassed {
234
275
  const BIN_FIRST_PATH = [join(ROOT, "node_modules", ".bin"), process.env.PATH]
235
276
  .filter(Boolean)
@@ -237,10 +278,14 @@ function runEach(
237
278
  const failed: string[] = [];
238
279
  const skipped: string[] = [];
239
280
 
240
- for (const g of gates) {
281
+ for (const [i, g] of gates.entries()) {
241
282
  write(`── ${g.name}\n`);
242
283
  const [cmd, ...args] = commandOf(g);
243
- const r = spawn(cmd, args, {
284
+ const file = join(
285
+ tmpdir(),
286
+ `paperlint-check-${String(process.pid)}-${String(i)}.json`,
287
+ );
288
+ const r = spawn(cmd, g.vitest ? [...args, ...reportArgs(file)] : args, {
244
289
  cwd: ROOT,
245
290
  stdio: "inherit",
246
291
  encoding: "utf8",
@@ -249,12 +294,18 @@ function runEach(
249
294
  // 🔴 The exit code is read from the command itself, never from a pipe. `cmd | tail && …`
250
295
  // reports the FILTER's status, which is almost always zero — that is how a red harness
251
296
  // shipped on 2026-09-16.
252
- const o = outcome(r.status);
297
+ const skips = g.vitest ? { skipped: skippedIn(reportOf(file)) } : undefined;
298
+ const o = outcome(r.status, skips);
253
299
  if (o === "pass") continue;
254
300
  const shown = commandOf(g).join(" ");
255
301
  if (o === "skip")
256
- skipped.push(`${g.name} (${shown} → ${String(SKIP_EXIT)})`);
257
- else failed.push(`${g.name} (${shown} → ${String(r.status)})`);
302
+ skipped.push(
303
+ `${g.name} (${shown} → ${String(skips?.skipped)} test(s) skipped)`,
304
+ );
305
+ else
306
+ failed.push(
307
+ `${g.name} (${shown} → ${r.status === 0 ? "exit 0, but no vitest JSON report to read skips from" : String(r.status)})`,
308
+ );
258
309
  }
259
310
  return { failed, skipped };
260
311
  }
@@ -299,14 +350,16 @@ export function runGates({
299
350
  log = console.log,
300
351
  err = console.error,
301
352
  write = (s: string) => process.stdout.write(s),
353
+ reportOf = readReport,
302
354
  }: {
303
355
  gates?: readonly Gate[];
304
356
  spawn?: Spawn;
305
357
  log?: (line: string) => void;
306
358
  err?: (line: string) => void;
307
359
  write?: (text: string) => unknown;
360
+ reportOf?: ReadReport;
308
361
  } = {}): number {
309
- const notPassed = runEach(gates, spawn, write);
362
+ const notPassed = runEach(gates, spawn, write, reportOf);
310
363
  report(gates, notPassed, { log, err });
311
364
  return notPassed.failed.length ? 1 : 0;
312
365
  }
@@ -24,7 +24,7 @@ test("a failing vitest does not skip the harnesses, and is named as the failure"
24
24
  1,
25
25
  );
26
26
  assert.deepEqual(calls, [
27
- ["vitest", "run"],
27
+ ["vitest", "run", "--project", "unit"],
28
28
  ["vigiles", "test", "--min=1", "--no-skip"],
29
29
  ]);
30
30
  assert.deepEqual(said, [
package/scripts/test.ts CHANGED
@@ -46,7 +46,7 @@ export function withSources(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
46
46
  }
47
47
 
48
48
  export const SUITES: readonly Suite[] = [
49
- { name: "vitest", command: ["vitest", "run"] },
49
+ { name: "vitest", command: ["vitest", "run", "--project", "unit"] },
50
50
  { name: "harnesses", command: ["vigiles", "test", "--min=1"] },
51
51
  ];
52
52
 
@@ -200,7 +200,7 @@ format.
200
200
  ## 3. Venue constants belong in a card, not in code
201
201
 
202
202
  > **Done (#79, 2.1.0), in a different shape than planned below.** The constants live in **venue
203
- > presets**, JSONC files in `submit-paper/references/venues/`: `acm-sigconf.jsonc` is the ACM
203
+ > presets**, JSONC files in the package's `presets/`: `acm-sigconf.jsonc` is the ACM
204
204
  > family (the `format` block — page size, columns, fonts, font sizes — plus the `tex` packages),
205
205
  > `agenticdev.jsonc` and `aisec.jsonc` extend it and add their `kinds` (page limits) and `rules`,
206
206
  > `realm.jsonc` stands alone. A paper picks one in `<paper>/paperlint.json`
@@ -332,7 +332,7 @@ point. Nothing to check.
332
332
  | page limit | **4** | `venues/realm.md` 8 · `build-submission.sh` 8 · `PIPELINE-STATUS` "8/8" · the build prints **9** |
333
333
  | the TeX package list | **5** | `ensure-toolchain.sh` (executable) · `SKILL.md` · `render-paper.harness.mjs` (pins it ✅) · `build-submission.sh` · `PIPELINE-STATUS` |
334
334
 
335
- The canonical source is the venue preset (`venues/<name>.jsonc`, resolved through its `extends`
335
+ The canonical source is the venue preset (`presets/<name>.jsonc`, resolved through its `extends`
336
336
  chain): its `format` block for the numbers (page size, columns, fonts, font sizes, and `kinds` for
337
337
  page limits), its `tex` block (plus `tex-base.jsonc`) for packages — since 2026-09-24;
338
338
  `ensure-toolchain.sh` held them until then — and its `rules` block for the checks the venue
@@ -4,7 +4,7 @@ description: The orchestrator for writing a research paper end-to-end, from idea
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:a15a3fcf5fdfedf7 compiled from skills/paper-pipeline/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:c3eea3ac60e9c816 compiled from skills/paper-pipeline/SKILL.md.spec.ts -->
8
8
 
9
9
  # paper-pipeline — the conductor for the whole organism
10
10
 
@@ -30,7 +30,7 @@ of that — who is filing what, and when — stays in the author's own private n
30
30
  space, anonymity, OpenReview mechanics. Fetch once, not per paper. Venue-specific facts stay in
31
31
  `submit-paper/references/venues/<venue>.md`. (No prose equivalent yet for ACM or IEEE — write one
32
32
  when the next paper goes there. ACM's machine-checked format already exists: the
33
- `paperlint:acm-sigconf` preset, `submit-paper/references/venues/acm-sigconf.jsonc`.)
33
+ `paperlint:acm-sigconf` preset, `presets/acm-sigconf.jsonc` in the package.)
34
34
 
35
35
  ## The readiness scorecard (which boxes are checked)
36
36
  Stages run in subagents and report into chat, which evaporates — so "did everything run?" becomes
@@ -47,7 +47,7 @@ of that — who is filing what, and when — stays in the author's own private n
47
47
  space, anonymity, OpenReview mechanics. Fetch once, not per paper. Venue-specific facts stay in
48
48
  \`submit-paper/references/venues/<venue>.md\`. (No prose equivalent yet for ACM or IEEE — write one
49
49
  when the next paper goes there. ACM's machine-checked format already exists: the
50
- \`paperlint:acm-sigconf\` preset, \`submit-paper/references/venues/acm-sigconf.jsonc\`.)
50
+ \`paperlint:acm-sigconf\` preset, \`presets/acm-sigconf.jsonc\` in the package.)
51
51
 
52
52
  ## The readiness scorecard (which boxes are checked)
53
53
  Stages run in subagents and report into chat, which evaporates — so "did everything run?" becomes
@@ -32,8 +32,6 @@ export interface SkillsFs {
32
32
  }
33
33
  export declare function installedSkills(dir: string, fs?: SkillsFs): string[];
34
34
 
35
- /** The directory of venue TeX files this package ships; `paperlint build` prepends it to TEXINPUTS. */
36
- export declare function packageVenuesDir(): string;
37
35
  export declare const SHIPPED_SKILLS_DIR: string;
38
36
  export declare const PACKAGE_NAME: "paperlint";
39
37
  export declare const BIN_FILE: "bin/paperlint.mjs";
@@ -57,7 +57,7 @@ import {
57
57
  statSync,
58
58
  } from "node:fs";
59
59
  import { join, resolve, sep } from "node:path";
60
- import { fileURLToPath, pathToFileURL } from "node:url";
60
+ import { pathToFileURL } from "node:url";
61
61
 
62
62
  // Re-exported, not re-declared — see the note in `lib/paper-config.mjs`. This file is not a hook
63
63
  // and never needed its own copy.
@@ -98,20 +98,6 @@ export function consumerRoot({ env = process.env, cwd = process.cwd() } = {}) {
98
98
  return env.CLAUDE_PROJECT_DIR || cwd;
99
99
  }
100
100
 
101
- /**
102
- * The directory of venue TeX files THIS PACKAGE ships — `paper-guards.tex` and each venue's
103
- * `<venue>.tex` — which a paper's preamble `\input`s and `paperlint build` puts on `TEXINPUTS`.
104
- *
105
- * Resolved from this file's own location, never from the caller's cwd: the directory travels
106
- * with the package, wherever the package is installed (own checkout, `node_modules`, a plugin
107
- * cache). `import.meta.url` is the realpath, so a symlinked skills directory changes nothing.
108
- */
109
- export function packageVenuesDir() {
110
- return fileURLToPath(
111
- new URL("../../submit-paper/references/venues", import.meta.url),
112
- );
113
- }
114
-
115
101
  /**
116
102
  * The package's npm name — the directory it lands in under `node_modules`, and the name Node
117
103
  * resolves it by. Every path into the installed package is built from this, never re-spelled;
@@ -4,7 +4,7 @@ description: Compile a LaTeX paper (ACM/IEEE/arXiv) to PDF and render its pages
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, SendUserFile]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:a1ec1d392f144c80 compiled from skills/render-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:5e2ee8850c098375 compiled from skills/render-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # render-paper — .tex → PDF → readable page PNGs
10
10
 
@@ -34,7 +34,7 @@ That list was replaced on 2026-08-03; nothing about the toolchain below changed.
34
34
  by `kpsewhich` against the files each profile names. The last line it prints is `bin: <dir>` —
35
35
  put that directory first on PATH before calling `pdflatex` or `check-render.sh` by hand.
36
36
 
37
- 🔴 The package list lives in the venue profiles (`submit-paper/references/venues/*.jsonc`, the
37
+ 🔴 The package list lives in the venue profiles (`presets/*.jsonc` in the package, the
38
38
  `tex` block; `tex-base.jsonc` for every paper) and nowhere else. A new venue that needs a new
39
39
  package gets it there — never in a script, never here. A TeX Live without `libertine` builds an
40
40
  acmart paper GREEN in Computer Modern; that is why the files are checked, not the exit code.
@@ -40,7 +40,7 @@ That list was replaced on 2026-08-03; nothing about the toolchain below changed.
40
40
  by \`kpsewhich\` against the files each profile names. The last line it prints is \`bin: <dir>\` —
41
41
  put that directory first on PATH before calling \`pdflatex\` or \`check-render.sh\` by hand.
42
42
 
43
- 🔴 The package list lives in the venue profiles (\`submit-paper/references/venues/*.jsonc\`, the
43
+ 🔴 The package list lives in the venue profiles (\`presets/*.jsonc\` in the package, the
44
44
  \`tex\` block; \`tex-base.jsonc\` for every paper) and nowhere else. A new venue that needs a new
45
45
  package gets it there — never in a script, never here. A TeX Live without \`libertine\` builds an
46
46
  acmart paper GREEN in Computer Modern; that is why the files are checked, not the exit code.
@@ -25,75 +25,28 @@ set -uo pipefail
25
25
  # check — that is, the defect the guard was set up for comes back together with a green report.
26
26
  # A broken `import` in JS fails loudly; here we have to fail on our own.
27
27
  #
28
- # A ladder of THREE candidates, because the directory MOVED (12.09): `venues/` is now carried by the
29
- # `paperlint` package, and at the consumer's old location there is a symlink into it.
30
- # The order "the declared package → a sibling inside the package → the consumer's directory" is
31
- # deliberate: the package must win silently. It only gets loud when there is NOT A SINGLE ONE.
32
- # 🔴 THE SCRIPT'S OWN DIRECTORY — TAKEN ABSOLUTE AND BEFORE ANY `cd` (12.09.2026, evening).
33
- # Found by a RED HARNESS, `gates.harness.mjs`, not by proofreading, and it is my own regression from
34
- # the same day: below, the script does `cd "$DIR"` into the paper directory, and the venues resolve
35
- # stood AFTER that `cd`. So `git rev-parse --show-toplevel` was already running in the fixture's
36
- # temporary directory, the root was not found, and the build honestly stopped — on a test that had
37
- # been passing until then.
28
+ # TWO candidates, in this order: the package by its NAME, from the current directory (a consumer's
29
+ # installed paperlint wins), then the package THIS script ships in. Loud only when neither holds it.
38
30
  #
39
- # ⚠️ The previous version was wrong THE SAME WAY, but silently: it did not check the result and built
40
- # the PDF without the reference guard. That is, going red is not a new breakage but a manifestation
41
- # of an old one; still, a test that was green yesterday and is red today is mine to fix.
42
- #
43
- # The neighbouring `build.sh` already carries this same lesson in a comment ("The script directory is
44
- # taken ONCE and ABSOLUTE, before cd") — CI failed on it there on 30.08. A second instance of the same
45
- # class in a file right next door.
31
+ # 🔴 THE SCRIPT'S OWN DIRECTORY IS TAKEN ABSOLUTE AND BEFORE ANY `cd`: below, the script `cd`s into
32
+ # the paper directory, and a lookup after that would start from the paper, not from here.
46
33
  SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
47
34
 
48
35
  resolve_venues() {
49
- local root pkg
50
- # Three sources for the root, from the most explicit to the most reliable. The last one is the
51
- # SCRIPT's location (`.claude/skills/render-paper/` ⇒ three levels up), and it is the only one that
52
- # depends neither on the current directory nor on whether the caller sits inside a git tree.
53
- # ⚠️ THE PARENTHESES ARE MANDATORY, and the harness caught this a minute after I wrote the line
54
- # without them. In bash `A || B && C` parses as `(A || B) && C`, so on a SUCCESSFUL `git rev-parse`
55
- # the `pwd` ran as well, and the substitution returned TWO lines — the repository root plus the
56
- # current directory. By hand from a directory outside git this did not reproduce: there git failed,
57
- # and only one branch ran.
58
- # ⚠️ THE LAST RUNG SEARCHES, IT DOES NOT COUNT LEVELS. `cd "$SELF_DIR/../../.."` used to stand here
59
- # — correct for `.claude/skills/render-paper/` (three levels to the root) and silently wrong after
60
- # the skill moved into the package, where it lies in `skills/render-paper/` (two): the climb
61
- # overshot ABOVE the repository, and rung 2 looked for venues in someone else's directory. Climbing
62
- # up to the directory that holds `.claude` does not depend on the depth.
63
- local probe="$SELF_DIR" fallback=""
64
- while [ "$probe" != "/" ]; do
65
- if [ -d "$probe/.claude" ]; then fallback="$probe"; break; fi
66
- probe=$(dirname "$probe")
67
- done
68
- root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || printf '%s' "$fallback")}"
69
-
70
- # 1) the package, if it already carries venues/ (after step 5)
71
- pkg=$(node -e 'try{process.stdout.write(require.resolve("paperlint/venues/paper-guards.tex"))}catch{}' 2>/dev/null || true)
36
+ local pkg own
37
+ # 1) the package by its name. `node -e` resolves from the current directory.
38
+ pkg=$(node -e 'try{process.stdout.write(require.resolve("paperlint/presets/tex/paper-guards.tex"))}catch{}' 2>/dev/null || true)
72
39
  if [ -n "$pkg" ] && [ -f "$pkg" ]; then
73
40
  dirname "$pkg"
74
41
  return 0
75
42
  fi
76
43
 
77
- # 2) THE SIBLING SKILL, from THIS script's directory. Added 12.09 together with the move of
78
- # `submit-paper` into the package, and it is the only one of the three that works in BOTH worlds at
79
- # once:
80
- # • package checkout `skills/render-paper/..` → `skills/submit-paper/references/venues`
81
- # • consumer `.claude/skills/render-paper/..` → `.claude/skills/submit-paper/...`
82
- # (both directories are symlinks into the package, `-f` goes through them, and `cd`+`pwd`
83
- # leaves the logical path)
84
- # Rung 1 depends on cwd (`node -e` resolves from the current directory), rung 3 depends on the
85
- # consumer's root having been found at all. This one depends on neither — exactly the anchor whose
86
- # absence had `gates.harness.mjs` red on 12.09.
87
- local sibling="$SELF_DIR/../submit-paper/references/venues"
88
- if [ -f "$sibling/paper-guards.tex" ]; then
89
- (cd "$sibling" && pwd)
90
- return 0
91
- fi
92
-
93
- # 3) the skill directory in the consumer — for a consumer that mounted the skills differently
94
- local local_dir="$root/.claude/skills/submit-paper/references/venues"
95
- if [ -f "$local_dir/paper-guards.tex" ]; then
96
- printf '%s\n' "$local_dir"
44
+ # 2) the package this script is part of: `skills/render-paper/` → `presets/tex/`. `cd -P` follows
45
+ # the symlinks a consumer's `.claude/skills/render-paper` is made of, so `../..` is the PACKAGE's
46
+ # root, not the consumer's `.claude/`. Depends neither on the cwd nor on a git tree.
47
+ own="$(cd -P "$SELF_DIR" && pwd)/../../presets/tex"
48
+ if [ -f "$own/paper-guards.tex" ]; then
49
+ (cd "$own" && pwd)
97
50
  return 0
98
51
  fi
99
52
 
@@ -107,8 +60,8 @@ if [ "${1:-}" = "--print-venues" ]; then
107
60
  printf '%s\n' "$VD"
108
61
  exit 0
109
62
  fi
110
- echo "✗ paper-guards.tex not found: not under the package name paperlint, not beside the script" >&2
111
- echo " (skills/submit-paper/references/venues), not at the consumer (.claude/skills/submit-paper/references/venues)." >&2
63
+ echo "✗ paper-guards.tex not found: not under the package name paperlint (paperlint/presets/tex)," >&2
64
+ echo " not in the package this script ships in (../../presets/tex from it)." >&2
112
65
  echo " Building the paper without it is not allowed: \\input{paper-guards} will not fail, it will vanish silently," >&2
113
66
  echo " and the PDF will be built WITHOUT the check for dangling \\ref and \\cite." >&2
114
67
  exit 2
@@ -135,7 +88,7 @@ if [ "${NO_COMPILE:-0}" != "1" ] && [ -f "$BASE.tex" ]; then
135
88
  # Two passes: overfull boxes surface on every pass; a second pass settles refs
136
89
  # so Reference/Citation-undefined warnings are accurate.
137
90
  # 🔴 TEXINPUTS is mandatory, and that was found by a crash on 26.08. Papers pull in their venue's
138
- # numbers via `\input{<venue>}`, and the file lies in `.claude/skills/submit-paper/references/venues/`.
91
+ # numbers via `\input{<venue>}`, and the file lies in the package's `presets/tex/`.
139
92
  # Without that path the build fails with `File \`agenticdev.tex' not found` — and the `|| true`
140
93
  # below swallows it, after which the script reads a log of UNKNOWN origin and reports on it. That
141
94
  # is, compiling this paper here silently did not work, and it only became visible once a pass
@@ -68,34 +68,11 @@ await checkSkill("render-paper");
68
68
  const run = (args, opts = {}) =>
69
69
  spawnSync("bash", [CHECK, ...args], { encoding: "utf8", ...opts });
70
70
 
71
- // First half — on TODAY's tree, resolution must succeed IF THERE IS ANYTHING TO
72
- // resolve.
73
- //
74
- // 🔴 THERE USED TO BE AN UNCONDITIONAL `assert.equal(status, 0)` HERE, AND THE MOVE BROKE IT —
75
- // for a real reason, not by accident. It relied on the SURROUNDING tree: while the skill lived
76
- // at the consumer, the second rung of the ladder won
77
- // (`.claude/skills/submit-paper/references/venues/`). The package checkout has neither that nor
78
- // the first rung — `venues/` arrives together with `submit-paper`, which hasn't moved yet. So
79
- // the assertion wasn't checking resolution, it was checking for the presence of an unrelated
80
- // directory nearby.
81
- //
82
- // The form now is: resolution must succeed IF AND ONLY IF at least one candidate exists on
83
- // disk. This is still a real assertion — it fails if a candidate exists and the script didn't
84
- // find it — but it stops requiring the existence of something this repository doesn't have yet.
85
- // The absence of both candidates gets PRINTED, not swallowed: "the check found nothing to
86
- // check" and "the check passed" have to look different.
71
+ // First half — on this tree, resolution must succeed IF AND ONLY IF a candidate exists on disk:
72
+ // the package's own `presets/tex/`. The absence of it is PRINTED, not swallowed: "the check found
73
+ // nothing to check" and "the check passed" have to look different.
87
74
  const candidates = [
88
- // rung 1 — the package
89
- join(HERE, "..", "..", "venues", "paper-guards.tex"),
90
- // rung 2 — the skill directory at the consumer
91
- join(
92
- HERE,
93
- "..",
94
- "submit-paper",
95
- "references",
96
- "venues",
97
- "paper-guards.tex",
98
- ),
75
+ join(HERE, "..", "..", "presets", "tex", "paper-guards.tex"),
99
76
  ].filter((c) => existsSync(c));
100
77
 
101
78
  const ok = run(["--print-venues"]);
@@ -108,9 +85,8 @@ await checkSkill("render-paper");
108
85
  `silent success this whole block was written to catch.`,
109
86
  );
110
87
  console.log(
111
- ` --print-venues: NOT VERIFIED on the live tree — paper-guards.tex is absent from both ` +
112
- `the package (venues/) and next to it (submit-paper/references/venues/). It arrives with ` +
113
- `\`submit-paper\`; until then only the fixtures below hold this half.`,
88
+ ` --print-venues: NOT VERIFIED on the live tree — paper-guards.tex is absent from the ` +
89
+ `package's presets/tex/; only the fixtures below hold this half.`,
114
90
  );
115
91
  } else {
116
92
  assert.equal(
@@ -159,18 +135,9 @@ await checkSkill("render-paper");
159
135
  // When the file is NOWHERE, the script must exit with code 2 and say why. A silent success here
160
136
  // is exactly the defect the whole ladder was written for.
161
137
  //
162
- // 🔴 RUN AGAINST A COPY OF THE SCRIPT, AND THIS IS NOT A TEST WORKAROUND, IT'S A CONSEQUENCE OF
163
- // THE MOVE (09-12, wave ③). Before `submit-paper` moved, "nowhere" was reproduced by an empty
164
- // consumer root: both former rungs looked outward (the package name from cwd · `.claude/` under
165
- // the root), and an empty directory found nothing at either. Now the script has a rung anchored
166
- // to ITS OWN directory (`$SELF_DIR/../submit-paper/references/venues`) — added for
167
- // cwd-independence — so as long as the script sits inside the package, venues next to it exist
168
- // ALWAYS, and "nowhere" can no longer be reached through the environment. The only honest way to
169
- // demonstrate that state is to move the script itself out of the package. The property being
170
- // checked is otherwise identical, word for word: nothing resolved ⇒ exit 2 + the name of the
171
- // missing file. ⚠️ What this form no longer checks: that an empty consumer root won't make the
172
- // script lie — and it shouldn't, because such a root is now legitimately shadowed by the copy in
173
- // the package.
138
+ // 🔴 RUN AGAINST A COPY OF THE SCRIPT. Inside the package its second rung (the package it ships
139
+ // in) always holds the file, so "nowhere" is reachable only by moving the script out of the
140
+ // package: nothing resolved ⇒ exit 2 + the name of the missing file.
174
141
  const empty = realpathSync(mkdtempSync(join(tmpdir(), "venues-none-")));
175
142
  const lonely = join(empty, "check-render.sh");
176
143
  copyFileSync(CHECK, lonely);
@@ -190,30 +157,21 @@ await checkSkill("render-paper");
190
157
  "the failure message must name the missing file",
191
158
  );
192
159
 
193
- // Third half — the ORDER of the rungs. Not cosmetic: on the day of the move, both rungs will be
194
- // true at once, and the package has to win, otherwise the consumer silently keeps building with
195
- // their old copy. Plant BOTH candidates and see which one is chosen.
160
+ // Third half — the ORDER of the rungs. The package the consumer installed, found by its name,
161
+ // must win over the copy this script ships in; otherwise a consumer silently builds with a stale
162
+ // copy. Plant a package by name beside the real script and see which one is chosen.
196
163
  const both = realpathSync(mkdtempSync(join(tmpdir(), "venues-both-")));
197
- const pkgVenues = join(both, "node_modules", "paperlint", "venues");
198
- const skillVenues = join(
199
- both,
200
- ".claude",
201
- "skills",
202
- "submit-paper",
203
- "references",
204
- "venues",
205
- );
206
- for (const d of [pkgVenues, skillVenues]) mkdirSync(d, { recursive: true });
164
+ const pkgVenues = join(both, "node_modules", "paperlint", "presets", "tex");
165
+ mkdirSync(pkgVenues, { recursive: true });
207
166
  writeFileSync(
208
167
  join(both, "node_modules", "paperlint", "package.json"),
209
168
  JSON.stringify({
210
169
  name: "paperlint",
211
170
  version: "0.0.0",
212
- exports: { "./venues/*": "./venues/*" },
171
+ exports: { "./*": "./*" },
213
172
  }),
214
173
  );
215
174
  writeFileSync(join(pkgVenues, "paper-guards.tex"), "% from package\n");
216
- writeFileSync(join(skillVenues, "paper-guards.tex"), "% from skill dir\n");
217
175
  const race = run(["--print-venues"], {
218
176
  cwd: both,
219
177
  env: { ...process.env, CLAUDE_PROJECT_DIR: both },
@@ -226,7 +184,7 @@ await checkSkill("render-paper");
226
184
  assert.equal(
227
185
  race.stdout.trim(),
228
186
  pkgVenues,
229
- "with both candidates present, the PACKAGE wins, not the skill directory",
187
+ "with both candidates present, the INSTALLED package wins, not the copy the script ships in",
230
188
  );
231
189
  }
232
190
 
@@ -276,7 +234,7 @@ await checkSkill("render-paper");
276
234
  );
277
235
  assert.match(
278
236
  build,
279
- /require\.resolve\("paperlint\/venues\/paper-guards\.tex"\)/,
237
+ /require\.resolve\("paperlint\/presets\/tex\/paper-guards\.tex"\)/,
280
238
  `${where}: the ladder's first rung (the package) must remain`,
281
239
  );
282
240
  assert.match(
@@ -2,7 +2,7 @@
2
2
  * `adapters/banal/index.ts` — banal as the two ports the app sees. What `run.test.ts` checks in
3
3
  * banal's own terms is checked here as the domain receives it: a `Geometry` whose provenance names
4
4
  * banal and the rule that found it, a reason that is one line of text, a `Ready` that says what was
5
- * verified. In-memory ports throughout; the real banal is `test/e2e/banal.ts`.
5
+ * verified. In-memory ports throughout; the real banal is `test/e2e/tex/banal.e2e.ts`.
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { test } from "vitest";
@@ -7,7 +7,7 @@
7
7
  * (`Error: Failed to run pdftohtml`, measured); told a version below 0.85 it applies a larger
8
8
  * correction and every font size moves. So `$PDFTOHTML` (banal's own override, line 166) points at a
9
9
  * stub that answers `-v` with `XML_DIALECT.version` — the pdftohtml whose XML `xml.ts` writes — and
10
- * refuses anything else. `test/e2e/banal.ts` shows both failures with the real banal.
10
+ * refuses anything else. `test/e2e/tex/banal.e2e.ts` shows both failures with the real banal.
11
11
  */
12
12
  import type { Opaque } from "ts-essentials";
13
13
  import type { AbsolutePath } from "../../domain/paths.ts";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `output.ts` — every way a banal run can end, as a table of `ProcessExit` → `Result`, and the
3
- * measurement → geometry projection. Pure: no perl, no disk. The real banal is `test/e2e/banal.ts`.
3
+ * measurement → geometry projection. Pure: no perl, no disk. The real banal is `test/e2e/tex/banal.e2e.ts`.
4
4
  */
5
5
  import assert from "node:assert/strict";
6
6
  import { test } from "vitest";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `adapters/banal/run.ts` — running and installing banal, on in-memory ports: which `Command` it builds, what it stages,
3
3
  * what it downloads and writes, and what it concludes. No perl, no disk, no network: the adapters
4
- * record, and each assertion reads the record. Real perl and the real banal are `test/e2e/banal.ts`;
4
+ * record, and each assertion reads the record. Real perl and the real banal are `test/e2e/tex/banal.e2e.ts`;
5
5
  * the adapters' own behaviour is `src/adapters/node/*.test.ts`.
6
6
  */
7
7
  import assert from "node:assert/strict";
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Every page here is written by hand. The real-PDF half — pdf.js boxes of a committed fixture,
6
6
  * written as XML, measured by the real banal and compared with banal on real pdftohtml — is
7
- * `test/e2e/banal.ts`, which runs where `paperlint toolchain` has installed banal.
7
+ * `test/e2e/tex/banal.e2e.ts`, which runs where `paperlint toolchain` has installed banal.
8
8
  */
9
9
  import assert from "node:assert/strict";
10
10
  import { test } from "vitest";