paperlint 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,118 @@
1
+ /**
2
+ * review/frontmatter — a review's findings are records in its frontmatter, validated by JSON
3
+ * Schema — and sibling cards, the same mechanism with the package's sibling-card schema.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { ESLint } from "eslint";
7
+ import markdown from "@eslint/markdown";
8
+ import review from "./review-frontmatter.mjs";
9
+ import sibling from "./sibling-frontmatter.mjs";
10
+
11
+ async function lint(text) {
12
+ const eslint = new ESLint({
13
+ overrideConfigFile: true,
14
+ overrideConfig: [
15
+ {
16
+ files: ["**/*.md"],
17
+ plugins: { markdown, review },
18
+ language: "markdown/gfm",
19
+ languageOptions: { frontmatter: "yaml" },
20
+ rules: {
21
+ "review/frontmatter": "error",
22
+ },
23
+ },
24
+ ],
25
+ });
26
+ const [res] = await eslint.lintText(text, { filePath: "reviews/r1.md" });
27
+ expect(res.messages.filter((m) => m.fatal)).toEqual([]);
28
+ return res.messages.map((m) => m.message);
29
+ }
30
+ const fm = (yaml) => `---\n${yaml}\n---\n# Review\n\nProse.\n`;
31
+
32
+ describe("review/frontmatter — the findings record", () => {
33
+ it("silent on a valid record: open with a cause, fixed and wontfix without", async () => {
34
+ expect(
35
+ await lint(
36
+ fm(
37
+ "findings:\n - id: 1\n status: open\n cause: missing-skill\n - id: 2\n status: fixed\n - id: 3\n status: wontfix",
38
+ ),
39
+ ),
40
+ ).toEqual([]);
41
+ });
42
+
43
+ it("🔴 an OPEN finding without a cause is a finding — the schema's if/then, not code", async () => {
44
+ const msgs = await lint(fm("findings:\n - id: 1\n status: open"));
45
+ expect(msgs).toEqual([
46
+ "`findings[0]` should have required property 'cause'",
47
+ ]);
48
+ });
49
+
50
+ it("a cause or status outside the list names the allowed values", async () => {
51
+ const msgs = await lint(
52
+ fm(
53
+ "findings:\n - id: 1\n status: done\n - id: 2\n status: open\n cause: typo",
54
+ ),
55
+ );
56
+ expect(msgs).toEqual([
57
+ "`findings[0].status` must be one of: open, fixed, wontfix",
58
+ "`findings[1].cause` must be one of: skill-defect, missing-skill, hook, rule",
59
+ ]);
60
+ });
61
+
62
+ it("a misspelt field is named, not silently accepted", async () => {
63
+ expect(
64
+ await lint(
65
+ fm("findings:\n - id: 1\n status: fixed\n casue: hook"),
66
+ ),
67
+ ).toEqual(["`findings[0]` has an unknown field `casue`"]);
68
+ });
69
+
70
+ it("a review with no `findings` key is not a findings record — no date needed", async () => {
71
+ expect(await lint(fm("created: 2020-01-01\ntitle: old review"))).toEqual(
72
+ [],
73
+ );
74
+ expect(await lint("# An old review with no frontmatter\n")).toEqual([]);
75
+ });
76
+
77
+ it("YAML that does not parse is reported, not crashed on", async () => {
78
+ expect(await lint(fm("findings: [unclosed"))).toEqual([
79
+ expect.stringMatching(/does not parse as YAML/),
80
+ ]);
81
+ });
82
+ });
83
+
84
+ describe("sibling/frontmatter — the same mechanism, the package's own sibling-card schema", () => {
85
+ async function card(text) {
86
+ const eslint = new ESLint({
87
+ overrideConfigFile: true,
88
+ overrideConfig: [
89
+ {
90
+ files: ["**/*.md"],
91
+ plugins: { markdown, sibling },
92
+ language: "markdown/gfm",
93
+ languageOptions: { frontmatter: "yaml" },
94
+ rules: { "sibling/frontmatter": "warn" },
95
+ },
96
+ ],
97
+ });
98
+ const [res] = await eslint.lintText(text, { filePath: "siblings/x.md" });
99
+ return res.messages.map((m) => m.message);
100
+ }
101
+ it("read: full | abstract | none — silent", async () => {
102
+ for (const v of ["full", "abstract", "none"])
103
+ expect(await card(fm(`read: ${v}`))).toEqual([]);
104
+ });
105
+ it("🔴 a card without the field — or without any frontmatter — is a finding", async () => {
106
+ expect(await card(fm("title: x"))).toEqual([
107
+ "the frontmatter should have required property 'read'",
108
+ ]);
109
+ expect(await card("# no header\n")).toEqual([
110
+ "the frontmatter should have required property 'read'",
111
+ ]);
112
+ });
113
+ it("a value outside the three names them", async () => {
114
+ expect(await card(fm("read: skimmed"))).toEqual([
115
+ "`read` must be one of: full, abstract, none",
116
+ ]);
117
+ });
118
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `sibling/frontmatter` — a sibling card (`<paper>/siblings/*.md`, written by the
3
+ * `analyze-sibling-paper` skill) says in its frontmatter how much of the competing paper was READ:
4
+ * `read: full | abstract | none`. A verdict like "not a scoop" means something different when only
5
+ * the abstract was read, and a card that does not say is a finding.
6
+ *
7
+ * The package writes the cards, so it ships the schema: `sibling-frontmatter.schema.json`. Same
8
+ * mechanism as `review/frontmatter`.
9
+ */
10
+ import { frontmatterRule } from "./review-frontmatter.mjs";
11
+
12
+ export default {
13
+ rules: {
14
+ frontmatter: frontmatterRule(
15
+ new URL("./sibling-frontmatter.schema.json", import.meta.url),
16
+ "a sibling card's frontmatter records how much of the competing paper was read (`read: full | abstract | none`)",
17
+ ),
18
+ },
19
+ };
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "urn:paperlint:sibling-frontmatter",
4
+ "title": "paperlint sibling-card frontmatter",
5
+ "description": "The YAML frontmatter of a sibling card under <paper>/siblings/ (written by the analyze-sibling-paper skill): how much of the competing paper was actually read. A verdict about a paper read only at the abstract is a different claim from one read in full, and the card must say which.",
6
+ "type": "object",
7
+ "required": ["read"],
8
+ "properties": {
9
+ "read": {
10
+ "description": "full: the whole paper was read. abstract: only the abstract (and maybe the figures). none: the card is from citations or a summary alone.",
11
+ "enum": ["full", "abstract", "none"]
12
+ }
13
+ }
14
+ }
package/eslint.config.mjs CHANGED
@@ -14,7 +14,6 @@
14
14
  import { texLanguage } from "./eslint-rules/latex-language.mjs";
15
15
  import texBuild from "./eslint-rules/tex-build.mjs";
16
16
  import markdown from "@eslint/markdown";
17
- import reviewRules from "./eslint-rules/review-findings-cause.mjs";
18
17
  import localRules from "./eslint-rules/temp-root-realpath.mjs";
19
18
  import portRules from "./eslint-rules/install-path-literals.mjs";
20
19
  import n from "eslint-plugin-n";
@@ -453,20 +452,6 @@ export default [
453
452
  plugins: { n },
454
453
  rules: { "n/no-missing-import": "error" },
455
454
  },
456
- /**
457
- * The package's first markdown rule — unit 1 of step 9 (moved from the consumer).
458
- * The block targets OWN fixtures: in the consumer the same plugin is applied to its
459
- * review report directory. `warn` for the same reason as .tex below: fixtures
460
- * are DELIBERATELY defective, and `error` would mean `npx eslint .` reds on a healthy
461
- * checkout. The signal lives in `npm test`, not in the warning count.
462
- */
463
- {
464
- files: ["fixtures/review-findings-cause/**/*.md"],
465
- plugins: { markdown, review: reviewRules },
466
- language: "markdown/gfm",
467
- languageOptions: { frontmatter: "yaml" },
468
- rules: { "review/findings-cause": "warn" },
469
- },
470
455
  /**
471
456
  * Rule 10's mechanical half, prose side — and this is where the debt actually is: 76
472
457
  * findings across 29 skills on a healthy checkout, every one of them a command that
@@ -7,18 +7,17 @@
7
7
  "this file names the rule. That is not expressible on an eleven-line stub, where every line was",
8
8
  "written by someone who already knew which rule would read it.",
9
9
  "",
10
- "Recorded 2026-09-19. Three of the five are about the scorecard being deliberately thin",
11
- "(no pdf, no frozen source) rather than about the prose; paper/typography's single entry is",
12
- "the 18 p-values of paperlint#44. They are real findings against IEEE / ISO 80000-1 style, which",
13
- "requires the leading zero, in a blog post written for a general audience rather than for an",
14
- "IEEE venue. All 18 sit in prose and table cells, so the count from the parsed tree keeps them.",
10
+ "Recorded 2026-09-19, re-recorded 2026-09-26 for 3.0.0. Three of the four rules are about the",
11
+ "scorecard being deliberately thin (no pdf, no frozen source) rather than about the prose.",
12
+ "paper/leading-zero reports each of the 18 p-values of paperlint#44 where it is (one finding per",
13
+ "occurrence since 3.0.0; before, paper/typography reported them as one count). They are real",
14
+ "findings against IEEE / ISO 80000-1 style, in a blog post written for a general audience.",
15
15
  "The count is expected to stay. A drop is never a failure here, only growth is."
16
16
  ],
17
17
  "findings": {
18
18
  "paper/stages": 2,
19
19
  "paper/source": 1,
20
- "paper/author-list": 1,
21
20
  "paper/research-question": 1,
22
- "paper/typography": 1
21
+ "paper/leading-zero": 18
23
22
  }
24
23
  }
@@ -39,10 +39,7 @@ import {
39
39
  import { tmpdir } from "node:os";
40
40
  import { dirname, join, resolve } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
42
- import {
43
- OLD_PAPERS_DIR_FIELD,
44
- PAPERS_DIR_FIELD,
45
- } from "../lib/paper-config.mjs";
42
+ import { PAPERS_DIR_FIELD } from "../lib/paper-config.mjs";
46
43
 
47
44
  const HOOKS = dirname(fileURLToPath(import.meta.url));
48
45
  const ROOT = resolve(HOOKS, "..");
@@ -61,13 +58,14 @@ const check = (label, cond) => {
61
58
  };
62
59
 
63
60
  /**
64
- * A throwaway consumer repository: a `package.json` with the given block, a papers tree, and
61
+ * A throwaway consumer repository: a root `paperlint.json` holding the given settings (none when
62
+ * `null`), a `package.json`, a papers tree, and
65
63
  * `node_modules` wired so both `vigiles/hook` and `require.resolve("<this package>/…")` work
66
64
  * from inside it.
67
65
  *
68
66
  * 🔴 IT IS A REAL DIRECTORY, NOT A MOCK, because what is under test is precisely the part a
69
67
  * mock would stub out: whether the hook can READ a declaration out of the consumer's
70
- * `package.json` via a provider command, and whether a path resolves once this package is
68
+ * `paperlint.json` via a provider command, and whether a path resolves once this package is
71
69
  * installed rather than adjacent. The `node_modules/<pkg>` entry is a symlink to this very
72
70
  * checkout, which is what `npm install` of a local package does anyway.
73
71
  */
@@ -77,12 +75,9 @@ const consumer = (block, { papers = "docs/papers", paper = "alpha" } = {}) => {
77
75
  mkdirSync(nm, { recursive: true });
78
76
  symlinkSync(join(ROOT, "node_modules", "vigiles"), join(nm, "vigiles"));
79
77
  symlinkSync(ROOT, join(nm, "paperlint"));
80
- writeFileSync(
81
- join(dir, "package.json"),
82
- block === null
83
- ? '{"name":"c","type":"module"}\n'
84
- : JSON.stringify({ name: "c", type: "module", ...block }) + "\n",
85
- );
78
+ writeFileSync(join(dir, "package.json"), '{"name":"c","type":"module"}\n');
79
+ if (block !== null)
80
+ writeFileSync(join(dir, "paperlint.json"), JSON.stringify(block) + "\n");
86
81
  if (papers !== null) {
87
82
  mkdirSync(join(dir, papers, paper), { recursive: true });
88
83
  writeFileSync(
@@ -183,9 +178,7 @@ try {
183
178
  // failure, so a diagnostic written at the END only ever prints when nothing else is broken,
184
179
  // which is when it diagnoses nothing.
185
180
  {
186
- const dir = fixture({
187
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers" },
188
- });
181
+ const dir = fixture({ [PAPERS_DIR_FIELD]: "docs/papers" });
189
182
  for (const f of SHIPPED) {
190
183
  const name = f.replace(/\.hook\.mjs$/, "");
191
184
  const r = at(dir, name, STOP);
@@ -200,9 +193,7 @@ try {
200
193
  // III. paper-edit-guard — the blocking half and the allowing half
201
194
  // ═══════════════════════════════════════════════════════════════════════════
202
195
  {
203
- const dir = fixture({
204
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers" },
205
- });
196
+ const dir = fixture({ [PAPERS_DIR_FIELD]: "docs/papers" });
206
197
  const P = "docs/papers/alpha/paper.md";
207
198
  const T = "docs/papers/alpha/paper.tex";
208
199
  const deny = (label, cmd) => {
@@ -279,7 +270,7 @@ try {
279
270
  // III-b. CWD DRIFT — every hook reads its manifest from the PROJECT ROOT
280
271
  // ═══════════════════════════════════════════════════════════════════════════
281
272
  // 🔴 Reported live 2026-09-16 by a consumer session: after a `cd` into a subdirectory with no
282
- // `package.json`, `paper-edit-guard` denied EVERY Bash command — `pwd` included — and the wedge
273
+ // manifest, `paper-edit-guard` denied EVERY Bash command — `pwd` included — and the wedge
283
274
  // could not be escaped from the shell, because a PreToolUse hook fires before the command that
284
275
  // would fix it. The cause is one character of scope: the provider read `package.json`
285
276
  // relatively, and vigiles runs providers «via execSync in the hook's cwd».
@@ -289,15 +280,19 @@ try {
289
280
  // block anything — it turns them OFF. Silence is a nudge's success state, so a dead nudge and a
290
281
  // working one produce identical output. The lockout announces itself; this does not.
291
282
  {
292
- const dir = fixture({
293
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers" },
294
- });
283
+ // A root the DEFAULT would miss: under drift, a provider that read relatively would fall back
284
+ // to `papers`, and a guard whose test is depth-agnostic still matches `docs/papers/…` — so
285
+ // only a root with no `papers` segment shows whether the declaration was actually read.
286
+ const dir = fixture(
287
+ { [PAPERS_DIR_FIELD]: "writing/drafts" },
288
+ { papers: "writing/drafts" },
289
+ );
295
290
 
296
291
  // paper-edit-guard: still GUARDS from a foreign cwd, rather than denying everything.
297
292
  const write = at(
298
293
  dir,
299
294
  "paper-edit-guard",
300
- onBash(`sed -i s/a/b/ docs/papers/alpha/paper.tex`),
295
+ onBash(`sed -i s/a/b/ writing/drafts/alpha/paper.tex`),
301
296
  );
302
297
  check("drift · guard still blocks a paper write", write.exitCode === 2);
303
298
  const idle = adrift(dir, "paper-edit-guard", onBash("echo hi"));
@@ -308,7 +303,7 @@ try {
308
303
  const guarded = adrift(
309
304
  dir,
310
305
  "paper-edit-guard",
311
- onBash(`sed -i s/a/b/ docs/papers/alpha/paper.tex`),
306
+ onBash(`sed -i s/a/b/ writing/drafts/alpha/paper.tex`),
312
307
  );
313
308
  check(
314
309
  "drift · guard STILL blocks the paper write it exists for",
@@ -319,13 +314,13 @@ try {
319
314
  const nudge = adrift(
320
315
  dir,
321
316
  "paper-skills-nudge",
322
- onEdit(`${dir}/docs/papers/alpha/paper.tex`),
317
+ onEdit(`${dir}/writing/drafts/alpha/paper.tex`),
323
318
  );
324
319
  check("drift · the skills nudge still fires", injected(nudge).length > 0);
325
320
  const gates = adrift(
326
321
  dir,
327
322
  "paper-status-gates",
328
- onEdit(`${dir}/docs/papers/alpha/PIPELINE-STATUS.md`),
323
+ onEdit(`${dir}/writing/drafts/alpha/PIPELINE-STATUS.md`),
329
324
  );
330
325
  check(
331
326
  `drift · the status-gates hook still runs (rc=${gates.exitCode})`,
@@ -357,7 +352,7 @@ try {
357
352
  // (a) a declared root is used AS DECLARED — and the default is NOT.
358
353
  {
359
354
  const dir = fixture(
360
- { paperlint: { [PAPERS_DIR_FIELD]: DECLARED } },
355
+ { [PAPERS_DIR_FIELD]: DECLARED },
361
356
  { papers: DECLARED },
362
357
  );
363
358
  check(
@@ -373,36 +368,7 @@ try {
373
368
  at(dir, "paper-edit-guard", harmless).exitCode === 0,
374
369
  );
375
370
  }
376
- // (a½) the key's OLD name (before 2.0.0) is still read — an upgrade must not unguard papers.
377
- {
378
- const dir = fixture(
379
- { "research-paper-pipeline": { [PAPERS_DIR_FIELD]: DECLARED } },
380
- { papers: DECLARED },
381
- );
382
- check(
383
- "carrier: 🔴 a root declared under the OLD key is still guarded",
384
- at(dir, "paper-edit-guard", declaredWrite).exitCode === 2 &&
385
- at(dir, "paper-edit-guard", defaultWrite).exitCode === 0,
386
- );
387
- }
388
- {
389
- const dir = fixture(
390
- {
391
- paperlint: { [PAPERS_DIR_FIELD]: DECLARED },
392
- "research-paper-pipeline": { [PAPERS_DIR_FIELD]: "papers" },
393
- },
394
- { papers: DECLARED },
395
- );
396
- const r = at(dir, "paper-edit-guard", harmless);
397
- check(
398
- "carrier: both keys with different contents — the gate refuses and names both",
399
- r.exitCode === 2 &&
400
- /has both "paperlint" and "research-paper-pipeline"/.test(
401
- r.stderr + r.stdout,
402
- ),
403
- );
404
- }
405
- // (b) no key at all → the documented default, and the converse of (a).
371
+ // (b) no paperlint.json at all → the documented default, and the converse of (a).
406
372
  {
407
373
  const dir = fixture(null, { papers: "papers" });
408
374
  check(
@@ -417,9 +383,7 @@ try {
417
383
  // (c) an explicit `null` is a KEYSTROKE, not an absence. `??` would read it as "undeclared"
418
384
  // and silently substitute the default.
419
385
  {
420
- const dir = fixture({
421
- paperlint: { [PAPERS_DIR_FIELD]: null },
422
- });
386
+ const dir = fixture({ [PAPERS_DIR_FIELD]: null });
423
387
  const r = at(dir, "paper-edit-guard", harmless);
424
388
  check(
425
389
  `carrier: "${PAPERS_DIR_FIELD}": null REFUSES (rc=${r.exitCode})`,
@@ -432,9 +396,7 @@ try {
432
396
  }
433
397
  // (d) the empty string — the value that makes every prefix test vacuously true.
434
398
  {
435
- const dir = fixture({
436
- paperlint: { [PAPERS_DIR_FIELD]: "" },
437
- });
399
+ const dir = fixture({ [PAPERS_DIR_FIELD]: "" });
438
400
  const r = at(dir, "paper-edit-guard", harmless);
439
401
  check(
440
402
  `carrier: "${PAPERS_DIR_FIELD}": "" REFUSES (rc=${r.exitCode})`,
@@ -445,34 +407,19 @@ try {
445
407
  /matches nothing/.test(r.stderr),
446
408
  );
447
409
  }
448
- // (d2) the field's OLD name is refused and named — never read as a fallback, never ignored
449
- // in favour of the default directory.
450
- {
451
- const dir = fixture({
452
- paperlint: { [OLD_PAPERS_DIR_FIELD]: "writing/drafts" },
453
- });
454
- const r = at(dir, "paper-edit-guard", harmless);
455
- check(
456
- `carrier: the old field name "${OLD_PAPERS_DIR_FIELD}" REFUSES (rc=${r.exitCode})`,
457
- r.exitCode === 2,
458
- );
459
- check(
460
- "carrier: …and the refusal says what it was renamed to",
461
- r.stderr.includes(
462
- `"${OLD_PAPERS_DIR_FIELD}" was renamed to "${PAPERS_DIR_FIELD}" in package.json → "paperlint"`,
463
- ),
464
- );
465
- }
466
- // (e) an unreadable package.json — the case that arrives by itself, mid-merge.
410
+ // (e) an unreadable paperlint.json — the case that arrives by itself, mid-merge.
467
411
  {
468
412
  const dir = fixture(
469
- { paperlint: { [PAPERS_DIR_FIELD]: DECLARED } },
413
+ { [PAPERS_DIR_FIELD]: DECLARED },
470
414
  { papers: DECLARED },
471
415
  );
472
- writeFileSync(join(dir, "package.json"), '{ "name": "c" <<<<<<< HEAD\n');
416
+ writeFileSync(
417
+ join(dir, "paperlint.json"),
418
+ '{ "papersDir": "x" <<<<<<< HEAD\n',
419
+ );
473
420
  const r = at(dir, "paper-edit-guard", harmless);
474
421
  check(
475
- `carrier: an unparseable package.json REFUSES (rc=${r.exitCode})`,
422
+ `carrier: an unparseable paperlint.json REFUSES (rc=${r.exitCode})`,
476
423
  r.exitCode === 2,
477
424
  );
478
425
  check(
@@ -485,7 +432,7 @@ try {
485
432
  // nothing. This defect made an earlier version of the guard a silent no-op.
486
433
  {
487
434
  const dir = fixture(
488
- { paperlint: { [PAPERS_DIR_FIELD]: DECLARED + "/" } },
435
+ { [PAPERS_DIR_FIELD]: DECLARED + "/" },
489
436
  { papers: DECLARED },
490
437
  );
491
438
  check(
@@ -499,9 +446,7 @@ try {
499
446
  // V. paper-skills-nudge — it must LAND, not merely fire
500
447
  // ═══════════════════════════════════════════════════════════════════════════
501
448
  {
502
- const dir = fixture({
503
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers" },
504
- });
449
+ const dir = fixture({ [PAPERS_DIR_FIELD]: "docs/papers" });
505
450
  const lands = (label, p) => {
506
451
  const r = at(dir, "paper-skills-nudge", onEdit(p));
507
452
  const ctx = injected(r);
@@ -532,7 +477,7 @@ try {
532
477
  // The advisory's own asymmetry: it goes quiet where the gate refuses.
533
478
  {
534
479
  const broken = fixture(
535
- { paperlint: { [PAPERS_DIR_FIELD]: null } },
480
+ { [PAPERS_DIR_FIELD]: null },
536
481
  { papers: "papers" },
537
482
  );
538
483
  const r = at(
@@ -564,9 +509,7 @@ try {
564
509
  // VI. paper-status-gates — the tool runs, and only for a validated directory
565
510
  // ═══════════════════════════════════════════════════════════════════════════
566
511
  {
567
- const dir = fixture({
568
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers" },
569
- });
512
+ const dir = fixture({ [PAPERS_DIR_FIELD]: "docs/papers" });
570
513
  const fires = (label, p) => {
571
514
  const r = at(dir, "paper-status-gates", onEdit(p));
572
515
  check(
@@ -600,7 +543,7 @@ try {
600
543
  // 🔴 The `??` discriminator again, for this hook's own copy of the carrier.
601
544
  {
602
545
  const broken = fixture(
603
- { paperlint: { [PAPERS_DIR_FIELD]: null } },
546
+ { [PAPERS_DIR_FIELD]: null },
604
547
  { papers: "papers" },
605
548
  );
606
549
  check(
@@ -612,7 +555,7 @@ try {
612
555
  // The declared root is regex-ESCAPED: `.` in a root must not act as a wildcard.
613
556
  {
614
557
  const dotted = fixture(
615
- { paperlint: { [PAPERS_DIR_FIELD]: "docs.v2/papers" } },
558
+ { [PAPERS_DIR_FIELD]: "docs.v2/papers" },
616
559
  { papers: "docs.v2/papers" },
617
560
  );
618
561
  check(
@@ -654,9 +597,7 @@ try {
654
597
  // VIII. paper-status-gates.sh — a TOOL, and it must refuse to be a hook
655
598
  // ═══════════════════════════════════════════════════════════════════════════
656
599
  {
657
- const dir = fixture({
658
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers" },
659
- });
600
+ const dir = fixture({ [PAPERS_DIR_FIELD]: "docs/papers" });
660
601
  const sh = (args) =>
661
602
  runHook(
662
603
  `bash ${JSON.stringify(join(HOOKS, "paper-status-gates.sh"))} ${args}`,
@@ -75,10 +75,10 @@ const M = [
75
75
  [
76
76
  GUARD,
77
77
  "guard carrier/parse failure DEFAULTS instead of denying",
78
- "treat an unreadable package.json as «nothing declared» — the measured failure: the provider " +
78
+ "treat an unreadable paperlint.json as «nothing declared» — the measured failure: the provider " +
79
79
  "hands back the EMPTY STRING in silence, so the gate quietly re-roots at the default",
80
80
  " } catch {\n return deny(\n `${CONFIG_KEY}: this gate could not read",
81
- " } catch {\n pkg = {};\n void deny(\n `${CONFIG_KEY}: this gate could not read",
81
+ " } catch {\n config = {};\n void deny(\n `${CONFIG_KEY}: this gate could not read",
82
82
  ],
83
83
  [
84
84
  GUARD,
@@ -131,8 +131,8 @@ const M = [
131
131
  "drop the anchor to the project root — the read fails once cwd drifts, and under the rule " +
132
132
  '"an unreadable declaration denies" the guard blocks ANY Bash command, including the one ' +
133
133
  "that would fix it",
134
- 'needs: [provide("pkg", \'cat "${CLAUDE_PROJECT_DIR:-.}/package.json"\')],',
135
- 'needs: [provide("pkg", "cat package.json")],',
134
+ 'const READ_CONFIG = `f="\\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}";',
135
+ 'const READ_CONFIG = `f="${CONFIG_FILE}";',
136
136
  ],
137
137
  [
138
138
  "hooks/paper-skills-nudge.hook.mjs",
@@ -141,8 +141,8 @@ const M = [
141
141
  "hook does not deny, it returns `nothing()` — i.e. it just quietly stops firing. Silence " +
142
142
  "is exactly what a nudge's success state looks like, so a dead hook is indistinguishable " +
143
143
  "from a working one except by this mutation",
144
- 'needs: [provide("pkg", \'cat "${CLAUDE_PROJECT_DIR:-.}/package.json"\')],',
145
- 'needs: [provide("pkg", "cat package.json")],',
144
+ 'const READ_CONFIG = `f="\\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}";',
145
+ 'const READ_CONFIG = `f="${CONFIG_FILE}";',
146
146
  ],
147
147
  [
148
148
  GUARD,
@@ -4,10 +4,9 @@
4
4
  // silently stopped covering the import once a formatter wrapped it across five.
5
5
 
6
6
  export declare const CONFIG_KEY: "paperlint";
7
- export declare const LEGACY_CONFIG_KEY: "research-paper-pipeline";
7
+ export declare const CONFIG_FILE: "paperlint.json";
8
8
  export declare const DEFAULT_PAPERS_ROOT: "papers";
9
9
  export declare const PAPERS_DIR_FIELD: string;
10
- export declare const OLD_PAPERS_DIR_FIELD: string;
11
10
 
12
11
  /** The declared papers root, or the hook's rejection object; callers tell them apart by `typeof`. */
13
- export declare const papersRoot: (rawPkg: string) => string | object;
12
+ export declare const papersRoot: (rawConfig: string) => string | object;