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.
- package/CLAUDE.md +6 -1
- package/README.md +233 -224
- package/action.yml +4 -6
- package/dist/adapters/references/index.d.ts +13 -0
- package/dist/adapters/references/index.d.ts.map +1 -0
- package/dist/adapters/references/index.js +55 -0
- package/dist/adapters/references/index.js.map +1 -0
- package/dist/adapters/references/reach.io.d.ts +3 -0
- package/dist/adapters/references/reach.io.d.ts.map +1 -0
- package/dist/adapters/references/reach.io.js +14 -0
- package/dist/adapters/references/reach.io.js.map +1 -0
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +38 -4
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +28 -25
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +277 -265
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +40 -72
- package/dist/doctor.js.map +1 -1
- package/dist/facts-file.d.ts +2 -2
- package/dist/facts-file.js +2 -2
- package/dist/hooks-settings.d.ts +0 -21
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +17 -135
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +39 -29
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +143 -172
- package/dist/init.js.map +1 -1
- package/dist/link-skills.d.ts +1 -1
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +11 -29
- package/dist/link-skills.js.map +1 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +2 -13
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +63 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +69 -0
- package/dist/paper-files.js.map +1 -0
- package/dist/paper-settings.d.ts +13 -41
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +71 -71
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/check-references.d.ts +31 -0
- package/dist/ports/check-references.d.ts.map +1 -0
- package/dist/ports/check-references.js +7 -0
- package/dist/ports/check-references.js.map +1 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -6
- package/dist/presets.js.map +1 -1
- package/dist/reference-rules.d.ts +47 -0
- package/dist/reference-rules.d.ts.map +1 -0
- package/dist/reference-rules.js +156 -0
- package/dist/reference-rules.js.map +1 -0
- package/dist/references.d.ts +36 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +91 -0
- package/dist/references.js.map +1 -0
- package/dist/rules-config.d.ts +5 -3
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +15 -6
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +3 -3
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.js +2 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/types.d.ts +20 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +11 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +119 -104
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +5 -4
- package/docs/rules.md +60 -23
- package/eslint-rules/bib-reachable-entry.mjs +75 -0
- package/eslint-rules/latex-language.mjs +29 -1
- package/eslint-rules/paper-stages.harness.mjs +1 -117
- package/eslint-rules/paper-stages.mjs +0 -120
- package/eslint-rules/paper-stages.mutations.mjs +0 -76
- package/eslint-rules/paper-typography.mjs +267 -192
- package/eslint-rules/paper-typography.test.mjs +297 -0
- package/eslint-rules/papers.harness.mjs +8 -12
- package/eslint-rules/papers.mjs +17 -31
- package/eslint-rules/pdf-last-page-balance.mjs +4 -7
- package/eslint-rules/review-frontmatter.mjs +118 -0
- package/eslint-rules/review-frontmatter.schema.json +36 -0
- package/eslint-rules/review-frontmatter.test.mjs +118 -0
- package/eslint-rules/sibling-frontmatter.mjs +19 -0
- package/eslint-rules/sibling-frontmatter.schema.json +14 -0
- package/eslint.config.mjs +0 -15
- package/fixtures/real-markdown-paper/baseline.json +6 -7
- package/hooks/hooks.harness.mjs +39 -98
- package/hooks/hooks.mutations.mjs +6 -6
- package/hooks/paper-edit-guard.hook.d.mts +2 -3
- package/hooks/paper-edit-guard.hook.mjs +31 -42
- package/hooks/paper-skills-nudge.hook.mjs +21 -19
- package/hooks/paper-status-gates.hook.mjs +21 -19
- package/hooks/paper-status-gates.sh +8 -13
- package/lib/paper-config.d.mts +15 -25
- package/lib/paper-config.harness.mjs +18 -35
- package/lib/paper-config.mjs +83 -105
- package/lib/paper-config.mutations.mjs +2 -2
- package/lib/skill-checks.mjs +1 -1
- package/lib/skill-trigger-cases.harness.mjs +9 -8
- package/lib/skill-trigger-cases.mjs +7 -15
- package/package.json +1 -1
- package/scripts/eslint-report-guard.mjs +1 -1
- package/scripts/harness-api.frozen.json +0 -3
- package/scripts/mutation-batteries.frozen.json +4 -13
- package/skills/analyze-sibling-paper/SKILL.md +16 -12
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
- package/skills/cold-read-diff/SKILL.md +20 -1
- package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
- package/skills/grade-paper-writing/SKILL.md +20 -1
- package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
- package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
- package/skills/plan-paper-timeline/SKILL.md +2 -2
- package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
- package/skills/render-paper/check-render.sh +2 -3
- package/skills/render-paper/render-paper.harness.mjs +2 -2
- package/skills/verify-citations/SKILL.md +10 -1
- package/skills/verify-citations/SKILL.md.spec.ts +9 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
- package/src/adapters/references/index.ts +102 -0
- package/src/adapters/references/reach.io.ts +12 -0
- package/src/build.harness.mjs +8 -4
- package/src/build.ts +62 -6
- package/src/cli.harness.mjs +147 -292
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +344 -304
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +44 -89
- package/src/facts-file.test.ts +0 -7
- package/src/facts-file.ts +2 -2
- package/src/hooks-settings.harness.mjs +7 -92
- package/src/hooks-settings.mutations.mjs +2 -17
- package/src/hooks-settings.ts +18 -162
- package/src/init.test.ts +206 -0
- package/src/init.ts +186 -230
- package/src/link-skills.harness.mjs +1 -38
- package/src/link-skills.mutations.mjs +9 -5
- package/src/link-skills.ts +10 -34
- package/src/lint-fix.test.ts +120 -0
- package/src/new-paper.test.ts +1 -13
- package/src/new-paper.ts +2 -17
- package/src/paper-files.test.ts +290 -0
- package/src/paper-files.ts +144 -0
- package/src/paper-settings-commands.test.ts +84 -121
- package/src/paper-settings.test.ts +93 -64
- package/src/paper-settings.ts +99 -97
- package/src/ports/check-references.ts +28 -0
- package/src/presets.ts +5 -15
- package/src/reference-rules.ts +229 -0
- package/src/references.test.ts +209 -0
- package/src/references.ts +138 -0
- package/src/rules-config.harness.mjs +24 -8
- package/src/rules-config.ts +19 -7
- package/src/structure.harness.mjs +3 -1
- package/src/structure.ts +3 -3
- package/src/tex-requirements.ts +2 -2
- package/src/types.ts +30 -18
- package/src/venue-rules.test.ts +20 -11
- package/src/venue-rules.ts +11 -13
- package/eslint-rules/doc-fields.harness.mjs +0 -336
- package/eslint-rules/doc-fields.mjs +0 -186
- package/eslint-rules/doc-fields.mutations.mjs +0 -96
- package/eslint-rules/paper-typography.harness.mjs +0 -291
- package/eslint-rules/paper-typography.mutations.mjs +0 -131
- package/eslint-rules/review-findings-cause.harness.mjs +0 -228
- package/eslint-rules/review-findings-cause.mjs +0 -135
- package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
- package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
- package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
- package/fixtures/review-findings-cause/clean.md +0 -17
- package/fixtures/review-findings-cause/defect.md +0 -14
- package/fixtures/review-findings-cause/old-debt.md +0 -14
- 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
|
|
11
|
-
"(no pdf, no frozen source) rather than about the prose
|
|
12
|
-
"the 18 p-values of paperlint#44
|
|
13
|
-
"
|
|
14
|
-
"
|
|
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/
|
|
21
|
+
"paper/leading-zero": 18
|
|
23
22
|
}
|
|
24
23
|
}
|
package/hooks/hooks.harness.mjs
CHANGED
|
@@ -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 `
|
|
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
|
-
* `
|
|
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
|
-
|
|
82
|
-
block
|
|
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
|
-
//
|
|
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
|
-
|
|
293
|
-
|
|
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/
|
|
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/
|
|
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}/
|
|
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}/
|
|
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
|
-
{
|
|
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
|
-
// (
|
|
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
|
-
// (
|
|
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
|
-
{
|
|
413
|
+
{ [PAPERS_DIR_FIELD]: DECLARED },
|
|
470
414
|
{ papers: DECLARED },
|
|
471
415
|
);
|
|
472
|
-
writeFileSync(
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
'
|
|
135
|
-
'
|
|
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
|
-
'
|
|
145
|
-
'
|
|
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
|
|
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: (
|
|
12
|
+
export declare const papersRoot: (rawConfig: string) => string | object;
|