paperlint 9.0.1 → 10.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.
- package/CONTRIBUTING.md +16 -4
- package/README.md +68 -128
- package/dist/build.d.ts +1 -1
- package/dist/build.js +1 -1
- package/dist/cli.d.ts +5 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +52 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/layout-tree.d.ts +19 -0
- package/dist/domain/layout-tree.d.ts.map +1 -0
- package/dist/domain/layout-tree.js +32 -0
- package/dist/domain/layout-tree.js.map +1 -0
- package/dist/domain/lookup-cache.d.ts +2 -2
- package/dist/domain/lookup-cache.js +2 -2
- package/dist/domain/paper-layout.d.ts +81 -0
- package/dist/domain/paper-layout.d.ts.map +1 -0
- package/dist/domain/paper-layout.js +259 -0
- package/dist/domain/paper-layout.js.map +1 -0
- package/dist/domain/repro-layout.d.ts +80 -0
- package/dist/domain/repro-layout.d.ts.map +1 -0
- package/dist/domain/repro-layout.js +247 -0
- package/dist/domain/repro-layout.js.map +1 -0
- package/dist/domain/scaffold-layout.d.ts +28 -0
- package/dist/domain/scaffold-layout.d.ts.map +1 -0
- package/dist/domain/scaffold-layout.js +81 -0
- package/dist/domain/scaffold-layout.js.map +1 -0
- package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
- package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
- package/dist/eslint-rules/js-needs-reason.js +35 -0
- package/dist/eslint-rules/js-needs-reason.js.map +1 -0
- package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
- package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
- package/dist/eslint-rules/prereg-coverage.js +274 -0
- package/dist/eslint-rules/prereg-coverage.js.map +1 -0
- package/dist/folder-layout-hook.d.ts +21 -0
- package/dist/folder-layout-hook.d.ts.map +1 -0
- package/dist/folder-layout-hook.js +117 -0
- package/dist/folder-layout-hook.js.map +1 -0
- package/dist/folder-layout-rule.d.ts +22 -0
- package/dist/folder-layout-rule.d.ts.map +1 -0
- package/dist/folder-layout-rule.js +107 -0
- package/dist/folder-layout-rule.js.map +1 -0
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/new-paper.d.ts +4 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +40 -6
- package/dist/new-paper.js.map +1 -1
- package/dist/package-dirs.d.ts +6 -0
- package/dist/package-dirs.d.ts.map +1 -1
- package/dist/package-dirs.js +6 -0
- package/dist/package-dirs.js.map +1 -1
- package/dist/references.d.ts +1 -1
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +2 -2
- package/dist/references.js.map +1 -1
- package/dist/repro-layout-rule.d.ts +16 -0
- package/dist/repro-layout-rule.d.ts.map +1 -0
- package/dist/repro-layout-rule.js +81 -0
- package/dist/repro-layout-rule.js.map +1 -0
- package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
- package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
- package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
- package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
- package/docs/ci.md +60 -0
- package/docs/configuration.md +2 -0
- package/docs/e2e.md +2 -2
- package/docs/install.md +20 -16
- package/docs/layout.md +154 -0
- package/docs/optional-rules.md +5 -4
- package/docs/references.md +5 -2
- package/docs/rules/paper/allowed-files-only.md +192 -0
- package/docs/rules/repro/allowed-files-only.md +128 -0
- package/docs/rules/study/declare-sample.md +146 -0
- package/docs/rules.md +53 -50
- package/docs/skills.md +8 -3
- package/docs/testing.md +13 -0
- package/docs/venues.md +94 -0
- package/eslint-rules/js-needs-reason.test.ts +141 -0
- package/eslint-rules/js-needs-reason.ts +77 -0
- package/eslint-rules/prereg-coverage.test.ts +448 -0
- package/eslint-rules/prereg-coverage.ts +419 -0
- package/eslint.config.mjs +22 -2
- package/lib/skill-trigger-cases.harness.mjs +51 -5
- package/lib/skill-trigger-cases.mjs +53 -15
- package/lib/skill-trigger-cases.test.ts +211 -0
- package/package.json +8 -4
- package/plugin/hooks/hooks.json +9 -0
- package/scripts/coverage-config.test.ts +5 -3
- package/skills/analyze-sibling-paper/SKILL.md +2 -2
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
- package/skills/build-benchmark/SKILL.md +17 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +16 -0
- package/skills/build-label-page/SKILL.md +120 -0
- package/skills/build-label-page/SKILL.md.spec.ts +131 -0
- package/skills/build-label-page/build-label-page.eval.mjs +15 -0
- package/skills/build-label-page/build-label-page.test.ts +959 -0
- package/skills/build-label-page/references/checklist.md +148 -0
- package/skills/build-label-page/references/example-generator.py +803 -0
- package/skills/find-venue/SKILL.md +5 -3
- package/skills/find-venue/SKILL.md.spec.ts +4 -2
- package/skills/grade-paper-writing/SKILL.md +7 -7
- package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
- package/skills/label-agent-runs/SKILL.md +103 -0
- package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
- package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
- package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
- package/skills/label-agent-runs/references/formats.md +64 -0
- package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
- package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
- package/skills/label-agent-runs/scripts/labels.js +396 -0
- package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
- package/skills/label-agent-runs/scripts/labels.ts +501 -0
- package/skills/map-prior-work/SKILL.md +2 -2
- package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +15 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +14 -5
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
- package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
- package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
- package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
- package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
- package/skills/paper-pipeline/repro/README.md +4 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
- package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
- package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
- package/skills/paper-status/SKILL.md +2 -2
- package/skills/paper-status/SKILL.md.spec.ts +1 -1
- package/skills/run-agent-grid/SKILL.md +181 -0
- package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
- package/skills/run-agent-grid/references/failure-catalog.md +36 -0
- package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
- package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
- package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
- package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
- package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
- package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
- package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
- package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
- package/skills/study-accepted-papers/SKILL.md +163 -161
- package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
- package/skills/study-accepted-papers/references/base-rates.md +69 -0
- package/skills/study-accepted-papers/references/mechanisms.md +181 -0
- package/skills/verify-citations/SKILL.md +2 -2
- package/skills/verify-citations/SKILL.md.spec.ts +1 -1
- package/src/build.ts +1 -1
- package/src/cli.harness.mjs +42 -0
- package/src/cli.test.ts +90 -6
- package/src/cli.ts +78 -5
- package/src/domain/layout-tree.test.ts +32 -0
- package/src/domain/layout-tree.ts +47 -0
- package/src/domain/lookup-cache.ts +2 -2
- package/src/domain/paper-layout.test.ts +264 -0
- package/src/domain/paper-layout.ts +336 -0
- package/src/domain/repro-layout.test.ts +222 -0
- package/src/domain/repro-layout.ts +311 -0
- package/src/domain/scaffold-layout.ts +104 -0
- package/src/folder-layout-hook.test.ts +214 -0
- package/src/folder-layout-hook.ts +141 -0
- package/src/folder-layout-rule.test.ts +235 -0
- package/src/folder-layout-rule.ts +151 -0
- package/src/hooks-settings.harness.ts +9 -8
- package/src/init.ts +1 -1
- package/src/layout-docs.test.ts +52 -0
- package/src/new-paper.harness.mjs +23 -1
- package/src/new-paper.test.ts +299 -6
- package/src/new-paper.ts +51 -5
- package/src/package-dirs.ts +8 -0
- package/src/paper-files.test.ts +9 -6
- package/src/references.test.ts +5 -5
- package/src/references.ts +2 -2
- package/src/repro-layout-rule.test.ts +114 -0
- package/src/repro-layout-rule.ts +121 -0
- package/src/rule-cases.test.ts +98 -0
- package/templates/paper/PIPELINE-STATUS.md +41 -7
- package/tsconfig.base.json +22 -0
- package/tsconfig.json +6 -24
- package/tsconfig.pkg.json +3 -1
- package/tsconfig.skills.json +14 -0
- package/tsconfig.src.json +12 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `paper-folder-layout` hook (`src/folder-layout-hook.ts`): a PreToolUse payload in, Claude Code's
|
|
3
|
+
* refusal out. Both halves: it denies creating a new entry the layout does not allow — at a paper
|
|
4
|
+
* folder's root or at the top of its `repro/`, and a study folder whose first file is not its
|
|
5
|
+
* README.md — with the rule's own text; it allows every existing file, every new file inside an
|
|
6
|
+
* existing entry, every allowed new entry, and anything outside a paper folder.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
folderLayoutHook,
|
|
12
|
+
paperFolderOf,
|
|
13
|
+
writtenPath,
|
|
14
|
+
} from "./folder-layout-hook.ts";
|
|
15
|
+
import { folderLayoutRules } from "./folder-layout-rule.ts";
|
|
16
|
+
import { reproLayoutRules } from "./repro-layout-rule.ts";
|
|
17
|
+
|
|
18
|
+
const DIR = "/work/papers/p";
|
|
19
|
+
const PAPER = {
|
|
20
|
+
[`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
|
|
21
|
+
[`${DIR}/paper.tex`]: "\\documentclass{article}\n",
|
|
22
|
+
[`${DIR}/paperlint.json`]: "{}\n",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A Write of `file`, which does not exist unless `tree` holds it, into the paper. */
|
|
26
|
+
const writing = (
|
|
27
|
+
file: string,
|
|
28
|
+
tree: Record<string, string> = {},
|
|
29
|
+
tool = "Write",
|
|
30
|
+
) => {
|
|
31
|
+
const files = memoryFiles({ ...PAPER, ...tree });
|
|
32
|
+
return folderLayoutHook(
|
|
33
|
+
{
|
|
34
|
+
hook_event_name: "PreToolUse",
|
|
35
|
+
tool_name: tool,
|
|
36
|
+
tool_input: { file_path: file },
|
|
37
|
+
cwd: "/work",
|
|
38
|
+
},
|
|
39
|
+
{ files, listDir: memoryListDir(files) },
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The reason a refusal gives, or null when the write is allowed. */
|
|
44
|
+
const reason = (
|
|
45
|
+
file: string,
|
|
46
|
+
tree: Record<string, string> = {},
|
|
47
|
+
tool = "Write",
|
|
48
|
+
): string | null =>
|
|
49
|
+
writing(file, tree, tool)?.hookSpecificOutput.permissionDecisionReason ??
|
|
50
|
+
null;
|
|
51
|
+
|
|
52
|
+
/** What both rules report once `tree` is on disk, as message text. */
|
|
53
|
+
function ruleSays(tree: Record<string, string>): string[] {
|
|
54
|
+
const files = memoryFiles({ ...PAPER, ...tree });
|
|
55
|
+
const deps = { files, listDir: memoryListDir(files) };
|
|
56
|
+
const out: string[] = [];
|
|
57
|
+
const report = (d: { readonly data: Record<string, unknown> }) =>
|
|
58
|
+
out.push(String(d.data["text"]));
|
|
59
|
+
const at = { getLocFromIndex: () => ({ line: 1, column: 0 }) };
|
|
60
|
+
const filename = `${DIR}/PIPELINE-STATUS.md`;
|
|
61
|
+
folderLayoutRules(deps)
|
|
62
|
+
["allowed-files-only"].create({ filename, sourceCode: at, report })
|
|
63
|
+
.root?.();
|
|
64
|
+
reproLayoutRules(deps)
|
|
65
|
+
["allowed-files-only"].create({ filename, sourceCode: at, report })
|
|
66
|
+
.root?.();
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("paper-folder-layout hook — denies a new stray entry", () => {
|
|
71
|
+
it("a new PLAN.md at the root: a PreToolUse deny, the rule's text exactly", () => {
|
|
72
|
+
const out = writing(`${DIR}/PLAN.md`);
|
|
73
|
+
expect(out).toEqual({
|
|
74
|
+
hookSpecificOutput: {
|
|
75
|
+
hookEventName: "PreToolUse",
|
|
76
|
+
permissionDecision: "deny",
|
|
77
|
+
permissionDecisionReason: ruleSays({ [`${DIR}/PLAN.md`]: "x" })[0],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(out?.hookSpecificOutput.permissionDecisionReason).toMatch(
|
|
81
|
+
/^`PLAN\.md` is at the root of a paper folder.*\n<paper>\/\n/su,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("a file that would create a root folder the layout does not allow, naming the folder", () => {
|
|
86
|
+
expect(reason(`${DIR}/archive/2026/old.pdf`)).toBe(
|
|
87
|
+
ruleSays({ [`${DIR}/archive/2026/old.pdf`]: "x" })[0],
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("a loose script at the top of repro/, and an undated folder there", () => {
|
|
92
|
+
expect(reason(`${DIR}/repro/run.py`)).toBe(
|
|
93
|
+
ruleSays({ [`${DIR}/repro/run.py`]: "x" })[0],
|
|
94
|
+
);
|
|
95
|
+
expect(reason(`${DIR}/repro/run.py`)).toMatch(
|
|
96
|
+
/Where it goes: repro\/tools\//,
|
|
97
|
+
);
|
|
98
|
+
expect(
|
|
99
|
+
reason(`${DIR}/repro/baseline/run.py`, {
|
|
100
|
+
[`${DIR}/repro/README.md`]: "x",
|
|
101
|
+
}),
|
|
102
|
+
).toMatch(/^`repro\/baseline\/` is loose at the top/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("a new study folder whose first file is not its README.md", () => {
|
|
106
|
+
expect(reason(`${DIR}/repro/ablation-2026-10-12/run.py`)).toMatch(
|
|
107
|
+
/^`repro\/ablation-2026-10-12\/` is a study folder without a README\.md/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("through an Edit and a MultiEdit too, and a path relative to the session's cwd", () => {
|
|
112
|
+
expect(reason(`${DIR}/NOTES-2.md`, {}, "Edit")).not.toBeNull();
|
|
113
|
+
expect(reason(`${DIR}/NOTES-2.md`, {}, "MultiEdit")).not.toBeNull();
|
|
114
|
+
const files = memoryFiles(PAPER);
|
|
115
|
+
expect(
|
|
116
|
+
folderLayoutHook(
|
|
117
|
+
{ tool_input: { file_path: "papers/p/x.py" }, cwd: "/work" },
|
|
118
|
+
{ files, listDir: memoryListDir(files) },
|
|
119
|
+
),
|
|
120
|
+
).not.toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("paper-folder-layout hook — allows", () => {
|
|
125
|
+
it("🔴 any file that already exists, even one out of place: editing is never blocked", () => {
|
|
126
|
+
expect(reason(`${DIR}/PLAN.md`, { [`${DIR}/PLAN.md`]: "x" })).toBeNull();
|
|
127
|
+
expect(
|
|
128
|
+
reason(`${DIR}/repro/run.py`, { [`${DIR}/repro/run.py`]: "x" }),
|
|
129
|
+
).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("a new file inside an entry that already exists, even a stray one — the rule reports the entry", () => {
|
|
133
|
+
expect(
|
|
134
|
+
reason(`${DIR}/archive/new.pdf`, { [`${DIR}/archive/old.pdf`]: "x" }),
|
|
135
|
+
).toBeNull();
|
|
136
|
+
expect(
|
|
137
|
+
reason(`${DIR}/repro/baseline/new.py`, {
|
|
138
|
+
[`${DIR}/repro/baseline/old.py`]: "x",
|
|
139
|
+
}),
|
|
140
|
+
).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a new allowed entry: at the root, in an allowed folder, and in repro/'s layout", () => {
|
|
144
|
+
expect(reason(`${DIR}/NOTES.md`)).toBeNull();
|
|
145
|
+
expect(reason(`${DIR}/reviews/2026-10-12-x.md`)).toBeNull();
|
|
146
|
+
expect(reason(`${DIR}/extra/anything.bin`)).toBeNull();
|
|
147
|
+
expect(reason(`${DIR}/repro/README.md`)).toBeNull();
|
|
148
|
+
expect(reason(`${DIR}/repro/tools/paper_numbers.py`)).toBeNull();
|
|
149
|
+
expect(reason(`${DIR}/repro/data/numbers.tsv`)).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a new study folder started with its README.md, then any file in it", () => {
|
|
153
|
+
expect(reason(`${DIR}/repro/ablation-2026-10-12/README.md`)).toBeNull();
|
|
154
|
+
expect(
|
|
155
|
+
reason(`${DIR}/repro/ablation-2026-10-12/run.py`, {
|
|
156
|
+
[`${DIR}/repro/ablation-2026-10-12/README.md`]: "x",
|
|
157
|
+
}),
|
|
158
|
+
).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("🔴 anything under extra/, an archived paper with its own PIPELINE-STATUS.md included: lint never reads it", () => {
|
|
162
|
+
const snapshot = { [`${DIR}/extra/old/PIPELINE-STATUS.md`]: "# old\n" };
|
|
163
|
+
expect(reason(`${DIR}/extra/old/notes.txt`, snapshot)).toBeNull();
|
|
164
|
+
expect(
|
|
165
|
+
paperFolderOf(
|
|
166
|
+
`${DIR}/extra/old/notes.txt`,
|
|
167
|
+
memoryFiles({ ...PAPER, ...snapshot }),
|
|
168
|
+
),
|
|
169
|
+
).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a file outside every paper folder", () => {
|
|
173
|
+
expect(reason("/work/README.md")).toBeNull();
|
|
174
|
+
expect(reason("/work/papers/notes.md")).toBeNull();
|
|
175
|
+
expect(reason("/elsewhere/PLAN.md")).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("a payload with no path it can read", () => {
|
|
179
|
+
const files = memoryFiles(PAPER);
|
|
180
|
+
const deps = { files, listDir: memoryListDir(files) };
|
|
181
|
+
expect(folderLayoutHook({}, deps)).toBeNull();
|
|
182
|
+
expect(folderLayoutHook({ tool_input: { file_path: 7 } }, deps)).toBeNull();
|
|
183
|
+
expect(folderLayoutHook(null, deps)).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("the cheap first step: the nearest folder holding PIPELINE-STATUS.md", () => {
|
|
188
|
+
const files = memoryFiles(PAPER);
|
|
189
|
+
|
|
190
|
+
it("is the paper folder for a file at its root or anywhere below", () => {
|
|
191
|
+
expect(paperFolderOf(`${DIR}/x.md`, files)).toBe(DIR);
|
|
192
|
+
expect(paperFolderOf(`${DIR}/a/b/c.md`, files)).toBe(DIR);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("is null outside a paper folder, up to the file system's root", () => {
|
|
196
|
+
expect(paperFolderOf("/work/papers/x.md", files)).toBeNull();
|
|
197
|
+
expect(paperFolderOf("/x.md", files)).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("reads the path from file_path, or notebook_path, made absolute against cwd", () => {
|
|
201
|
+
expect(writtenPath({ tool_input: { file_path: "/a/b.md" } })).toBe(
|
|
202
|
+
"/a/b.md",
|
|
203
|
+
);
|
|
204
|
+
expect(
|
|
205
|
+
writtenPath({ tool_input: { notebook_path: "n.ipynb" }, cwd: "/w" }),
|
|
206
|
+
).toBe("/w/n.ipynb");
|
|
207
|
+
expect(writtenPath({ tool_input: {} })).toBeNull();
|
|
208
|
+
// A relative path needs an absolute cwd to mean anything: never the process's own.
|
|
209
|
+
expect(writtenPath({ tool_input: { file_path: "a.md" } })).toBeNull();
|
|
210
|
+
expect(
|
|
211
|
+
writtenPath({ tool_input: { file_path: "a.md" }, cwd: "w" }),
|
|
212
|
+
).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `paper-folder-layout` hook — `paper/allowed-files-only` and `repro/allowed-files-only` BEFORE a file is
|
|
3
|
+
* created, not at the next lint. PreToolUse on `Edit|Write|MultiEdit` (`plugin/hooks/hooks.json`):
|
|
4
|
+
* when the file about to be written does not exist yet and would add an entry the layout does not
|
|
5
|
+
* allow — at a paper folder's root, or at the top of its `repro/` — the call is denied, and the
|
|
6
|
+
* reason is the rule's own message: where the entry goes, and the layout drawn. The agent writes it
|
|
7
|
+
* where it belongs instead, in the same turn.
|
|
8
|
+
*
|
|
9
|
+
* ── WHAT IT NEVER BLOCKS ─────────────────────────────────────────────────────────
|
|
10
|
+
* - A file that already exists. Editing is never refused, wherever the file is: a paper whose
|
|
11
|
+
* folder predates the layout keeps working, and the rule reports what is out of place.
|
|
12
|
+
* - A new file inside an entry that already exists, even one the layout does not allow: the entry is
|
|
13
|
+
* not new, and the rule reports it once, by name. Only a NEW entry is refused.
|
|
14
|
+
* - Anything outside a paper folder (no `PIPELINE-STATUS.md` above it).
|
|
15
|
+
* A new study folder in `repro/` is created with its `README.md` first: any other first file in it
|
|
16
|
+
* is refused with `repro/allowed-files-only`'s "without a README.md" message.
|
|
17
|
+
*
|
|
18
|
+
* ── WHAT IT CANNOT SEE ───────────────────────────────────────────────────────────
|
|
19
|
+
* A shell command (`mv`, `cp`, a script writing files) is not an Edit or a Write, and this hook never
|
|
20
|
+
* sees it. The guarantee is the lint: both rules fail `paperlint lint`, in CI, whatever wrote the
|
|
21
|
+
* entry. The hook only saves the round trip. It does not read the paper's `rules` either: a paper
|
|
22
|
+
* that switched a layout rule off is still refused a new stray entry.
|
|
23
|
+
*
|
|
24
|
+
* ── ONE FUNCTION PER RULE, NOT TWO LISTS ─────────────────────────────────────────
|
|
25
|
+
* The texts are `strayTexts` (`src/folder-layout-rule.ts`) and `reproTexts`
|
|
26
|
+
* (`src/repro-layout-rule.ts`), the functions the rules report with.
|
|
27
|
+
*
|
|
28
|
+
* ── WHY IT IS NOT A VIGILES PROGRAM LIKE ITS NEIGHBOURS (measured 2026-10-12, vigiles 38) ──────
|
|
29
|
+
* A shipped `.hook.mjs` may import nothing but `vigiles/hook` (`checkHookImports`), so it cannot call
|
|
30
|
+
* this package's function; its only way to a tool is `run()`, whose output reaches stderr and never
|
|
31
|
+
* the hook's answer (`paper-status-gates.hook.mjs` measured that); and a `provide()` command runs
|
|
32
|
+
* before the reaction without the event, so it cannot know which file is written. Each of the three
|
|
33
|
+
* would mean a second copy of the layout. So `paperlint hook paper-folder-layout` runs this module
|
|
34
|
+
* in-process (`runHook`, src/cli.ts), behind the same wiring command as the other hooks, which is
|
|
35
|
+
* what `paperlint init` merges and `paperlint doctor` reads.
|
|
36
|
+
*
|
|
37
|
+
* ── THE CHEAP FIRST STEP ─────────────────────────────────────────────────────────
|
|
38
|
+
* Before reading any settings or listing any folder, the hook looks for the nearest folder above the
|
|
39
|
+
* file that holds `PIPELINE-STATUS.md` — the file every paper has and the rules are reported on — with
|
|
40
|
+
* one `isFile` per ancestor and nothing else: no git, no lint. No such folder: no output.
|
|
41
|
+
*/
|
|
42
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
43
|
+
import { callerPath } from "./caller-path.ts";
|
|
44
|
+
import { ESCAPE_HATCH } from "./domain/paper-layout.ts";
|
|
45
|
+
import { README, REPRO_DIR, isStudyName } from "./domain/repro-layout.ts";
|
|
46
|
+
import { fieldOf } from "./domain/record.ts";
|
|
47
|
+
import { strayTexts, type FolderLayoutDeps } from "./folder-layout-rule.ts";
|
|
48
|
+
import type { Files } from "./ports/files.ts";
|
|
49
|
+
import { reproTexts } from "./repro-layout-rule.ts";
|
|
50
|
+
|
|
51
|
+
/** The file every paper folder has, and the rules are reported on. */
|
|
52
|
+
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
53
|
+
|
|
54
|
+
/** What Claude Code reads from a PreToolUse hook's stdout to refuse the call, with the reason. */
|
|
55
|
+
export interface HookOutput {
|
|
56
|
+
readonly hookSpecificOutput: {
|
|
57
|
+
readonly hookEventName: "PreToolUse";
|
|
58
|
+
readonly permissionDecision: "deny";
|
|
59
|
+
readonly permissionDecisionReason: string;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The target file's absolute path from the payload; null when there is none to read. */
|
|
64
|
+
export function writtenPath(payload: unknown): string | null {
|
|
65
|
+
const input = fieldOf(payload, "tool_input");
|
|
66
|
+
const raw = fieldOf(input, "file_path") ?? fieldOf(input, "notebook_path");
|
|
67
|
+
if (typeof raw !== "string" || raw === "") return null;
|
|
68
|
+
if (isAbsolute(raw)) return resolve(raw);
|
|
69
|
+
const cwd = fieldOf(payload, "cwd");
|
|
70
|
+
return typeof cwd === "string" && isAbsolute(cwd) ? resolve(cwd, raw) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The nearest folder above `file` holding `PIPELINE-STATUS.md`, up to the root; null when none, and
|
|
75
|
+
* null for anything under a folder named `extra/`, which lint never reads: an archived paper kept
|
|
76
|
+
* there carries its own status file and is not a paper to hold to the layout.
|
|
77
|
+
*/
|
|
78
|
+
export function paperFolderOf(file: string, files: Files): string | null {
|
|
79
|
+
// The same reach as lint's `**/extra/` ignore: a folder named extra/ anywhere above the file.
|
|
80
|
+
if (dirname(file).split(sep).includes(ESCAPE_HATCH)) return null;
|
|
81
|
+
const up = (dir: string): string | null => {
|
|
82
|
+
if (files.isFile(callerPath(join(dir, STATUS_FILE)))) return dir;
|
|
83
|
+
const parent = dirname(dir);
|
|
84
|
+
return parent === dir ? null : up(parent);
|
|
85
|
+
};
|
|
86
|
+
return up(dirname(file));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The entry `segments` would add under `dir`, as the layout judges it; null when it exists. */
|
|
90
|
+
const newEntry = (
|
|
91
|
+
dir: string,
|
|
92
|
+
[name = "", ...below]: readonly string[],
|
|
93
|
+
deps: FolderLayoutDeps,
|
|
94
|
+
): { readonly name: string; readonly kind: "file" | "dir" } | null =>
|
|
95
|
+
deps.listDir(callerPath(dir)).includes(name)
|
|
96
|
+
? null
|
|
97
|
+
: { name, kind: below.length > 0 ? "dir" : "file" };
|
|
98
|
+
|
|
99
|
+
/** Why creating `segments` (relative to the paper folder `dir`) breaks the layout; null when not. */
|
|
100
|
+
function refusal(
|
|
101
|
+
dir: string,
|
|
102
|
+
segments: readonly string[],
|
|
103
|
+
deps: FolderLayoutDeps,
|
|
104
|
+
): string | null {
|
|
105
|
+
const atRoot = newEntry(dir, segments, deps);
|
|
106
|
+
const [rootText] =
|
|
107
|
+
atRoot === null ? [] : strayTexts(dir, [atRoot], deps.files);
|
|
108
|
+
if (rootText !== undefined) return rootText;
|
|
109
|
+
const [top, ...inRepro] = segments;
|
|
110
|
+
if (top !== REPRO_DIR || inRepro.length === 0) return null;
|
|
111
|
+
const entry = newEntry(join(dir, REPRO_DIR), inRepro, deps);
|
|
112
|
+
// A new study folder starts with its README.md: that write is what makes it complete.
|
|
113
|
+
if (
|
|
114
|
+
entry === null ||
|
|
115
|
+
(isStudyName(entry.name) && inRepro.slice(1).join("/") === README)
|
|
116
|
+
)
|
|
117
|
+
return null;
|
|
118
|
+
const [text] = reproTexts(dir, [entry], deps.files);
|
|
119
|
+
return text ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The hook: the payload of an Edit or a Write in, Claude Code's refusal out, or null to allow. */
|
|
123
|
+
export function folderLayoutHook(
|
|
124
|
+
payload: unknown,
|
|
125
|
+
deps: FolderLayoutDeps,
|
|
126
|
+
): HookOutput | null {
|
|
127
|
+
const file = writtenPath(payload);
|
|
128
|
+
const dir = file === null ? null : paperFolderOf(file, deps.files);
|
|
129
|
+
if (file === null || dir === null || deps.files.isFile(callerPath(file)))
|
|
130
|
+
return null;
|
|
131
|
+
const reason = refusal(dir, relative(dir, file).split(/[\\/]/), deps);
|
|
132
|
+
return reason === null
|
|
133
|
+
? null
|
|
134
|
+
: {
|
|
135
|
+
hookSpecificOutput: {
|
|
136
|
+
hookEventName: "PreToolUse",
|
|
137
|
+
permissionDecision: "deny",
|
|
138
|
+
permissionDecisionReason: reason,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paper/allowed-files-only` (`src/folder-layout-rule.ts`) on paper folders held in memory, run the way
|
|
3
|
+
* ESLint runs it: `create`, then `root`. Both halves: a stray file or folder at the paper's root is
|
|
4
|
+
* reported once each, on the first line of `PIPELINE-STATUS.md`; the scaffold, and what the paper's
|
|
5
|
+
* settings name at its root, are not.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
|
|
9
|
+
import { KNOWN_CURES, strayMessage } from "./domain/paper-layout.ts";
|
|
10
|
+
import {
|
|
11
|
+
FOLDER_LAYOUT_RULE_LEVELS,
|
|
12
|
+
declaredRoots,
|
|
13
|
+
folderLayoutRules,
|
|
14
|
+
} from "./folder-layout-rule.ts";
|
|
15
|
+
|
|
16
|
+
const DIR = "/work/papers/p";
|
|
17
|
+
const SCAFFOLD = {
|
|
18
|
+
[`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
|
|
19
|
+
[`${DIR}/paper.tex`]: "\\documentclass{article}\n",
|
|
20
|
+
[`${DIR}/paperlint.json`]: "{}\n",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface Reported {
|
|
24
|
+
readonly messageId: string;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly line: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The rule over the folder `tree` describes, reached through `file` (default the scorecard). */
|
|
30
|
+
function lint(
|
|
31
|
+
tree: Readonly<Record<string, string>>,
|
|
32
|
+
file = `${DIR}/PIPELINE-STATUS.md`,
|
|
33
|
+
): Reported[] {
|
|
34
|
+
const files = memoryFiles(tree);
|
|
35
|
+
const rule = folderLayoutRules({
|
|
36
|
+
files,
|
|
37
|
+
listDir: memoryListDir(files),
|
|
38
|
+
})["allowed-files-only"];
|
|
39
|
+
const out: Reported[] = [];
|
|
40
|
+
const visitor = rule.create({
|
|
41
|
+
filename: file,
|
|
42
|
+
sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
|
|
43
|
+
report: (d) => {
|
|
44
|
+
const template = rule.meta.messages[d.messageId] ?? "";
|
|
45
|
+
const message = Object.entries(d.data).reduce(
|
|
46
|
+
(m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
|
|
47
|
+
template,
|
|
48
|
+
);
|
|
49
|
+
out.push({ messageId: d.messageId, message, line: d.loc.start.line });
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
visitor.root?.();
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("paper/allowed-files-only — reports", () => {
|
|
57
|
+
it("a stray file at the root, once, on the first line, with the one stray text", () => {
|
|
58
|
+
expect(lint({ ...SCAFFOLD, [`${DIR}/PLAN.md`]: "x" })).toEqual([
|
|
59
|
+
{
|
|
60
|
+
messageId: "stray",
|
|
61
|
+
message: strayMessage({
|
|
62
|
+
name: "PLAN.md",
|
|
63
|
+
kind: "file",
|
|
64
|
+
cure: KNOWN_CURES[0]?.cure ?? "",
|
|
65
|
+
}),
|
|
66
|
+
line: 1,
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("a stray folder, by its name with a slash, whatever it holds", () => {
|
|
72
|
+
expect(
|
|
73
|
+
lint({ ...SCAFFOLD, [`${DIR}/archive/old/x.pdf`]: "x" }).map(
|
|
74
|
+
(r) => r.message,
|
|
75
|
+
),
|
|
76
|
+
).toEqual([
|
|
77
|
+
expect.stringMatching(
|
|
78
|
+
/^`archive\/` is at the root of a paper folder.* Where it goes: versions\/ for frozen PDFs/,
|
|
79
|
+
),
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("every stray entry, each once", () => {
|
|
84
|
+
expect(
|
|
85
|
+
lint({
|
|
86
|
+
...SCAFFOLD,
|
|
87
|
+
[`${DIR}/a.pdf`]: "x",
|
|
88
|
+
[`${DIR}/data/x.json`]: "x",
|
|
89
|
+
[`${DIR}/run.py`]: "x",
|
|
90
|
+
}).length,
|
|
91
|
+
).toBe(3);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("a folder the settings name is allowed only while they name it", () => {
|
|
95
|
+
expect(
|
|
96
|
+
lint({ ...SCAFFOLD, [`${DIR}/video/talk.mp4`]: "x" }).map((r) =>
|
|
97
|
+
r.message.slice(0, 9),
|
|
98
|
+
),
|
|
99
|
+
).toEqual(["`video/` "]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("paper/allowed-files-only — silent", () => {
|
|
104
|
+
it("on the scaffold `paperlint new` writes", () => {
|
|
105
|
+
expect(lint(SCAFFOLD)).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("on the allowed folders, whatever they hold, and on extra/", () => {
|
|
109
|
+
expect(
|
|
110
|
+
lint({
|
|
111
|
+
...SCAFFOLD,
|
|
112
|
+
[`${DIR}/reviews/2026-10-12-x.md`]: "x",
|
|
113
|
+
[`${DIR}/repro/run.py`]: "x",
|
|
114
|
+
[`${DIR}/extra/anything/at/all.bin`]: "x",
|
|
115
|
+
[`${DIR}/_build/paper.facts.json`]: "{}",
|
|
116
|
+
[`${DIR}/paper.aux`]: "x",
|
|
117
|
+
[`${DIR}/refs.bib`]: "x",
|
|
118
|
+
}),
|
|
119
|
+
).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("on what the settings name at the root: the PDF, the talk folder, the paper's own preset", () => {
|
|
123
|
+
expect(
|
|
124
|
+
lint({
|
|
125
|
+
...SCAFFOLD,
|
|
126
|
+
[`${DIR}/paperlint.json`]: JSON.stringify({
|
|
127
|
+
extends: "./venue.jsonc",
|
|
128
|
+
pdf: "out/main.pdf",
|
|
129
|
+
talk: { mode: "remote-video", dir: "video" },
|
|
130
|
+
}),
|
|
131
|
+
[`${DIR}/venue.jsonc`]: "{}",
|
|
132
|
+
[`${DIR}/out/main.pdf`]: "x",
|
|
133
|
+
[`${DIR}/video/talk.mp4`]: "x",
|
|
134
|
+
}),
|
|
135
|
+
).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("on any file but PIPELINE-STATUS.md: the folder is judged once per paper", () => {
|
|
139
|
+
expect(
|
|
140
|
+
lint({ ...SCAFFOLD, [`${DIR}/PLAN.md`]: "x" }, `${DIR}/paper.tex`),
|
|
141
|
+
).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("declaredRoots — what a paper's settings name at its root", () => {
|
|
146
|
+
const roots = (settings: object, tree: Record<string, string> = {}) =>
|
|
147
|
+
declaredRoots(
|
|
148
|
+
DIR,
|
|
149
|
+
memoryFiles({
|
|
150
|
+
[`${DIR}/paperlint.json`]: JSON.stringify(settings),
|
|
151
|
+
...tree,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
it("the first segment of each path inside the folder", () => {
|
|
156
|
+
expect(
|
|
157
|
+
roots({
|
|
158
|
+
pdf: "build/out/paper.pdf",
|
|
159
|
+
talk: { mode: "remote-video", dir: "media/talk" },
|
|
160
|
+
}),
|
|
161
|
+
).toEqual(["build", "media"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("every cycle's own preset file, and none outside the folder or shipped", () => {
|
|
165
|
+
expect(
|
|
166
|
+
roots({
|
|
167
|
+
cycles: [
|
|
168
|
+
{
|
|
169
|
+
id: "a",
|
|
170
|
+
venue: { kind: "preset", extends: "./a.jsonc" },
|
|
171
|
+
opened: "2026-01-01",
|
|
172
|
+
deadlines: [],
|
|
173
|
+
outcome: {
|
|
174
|
+
kind: "rejected",
|
|
175
|
+
date: "2026-02-01",
|
|
176
|
+
evidence: "submission/a.eml",
|
|
177
|
+
desk: false,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "c",
|
|
182
|
+
venue: { kind: "named", name: "C", url: "https://c.example" },
|
|
183
|
+
opened: "2026-03-01",
|
|
184
|
+
deadlines: [],
|
|
185
|
+
outcome: {
|
|
186
|
+
kind: "withdrawn",
|
|
187
|
+
date: "2026-03-02",
|
|
188
|
+
evidence: "submission/c.eml",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "b",
|
|
193
|
+
venue: { kind: "preset", extends: "../shared/b.jsonc" },
|
|
194
|
+
opened: "2026-03-01",
|
|
195
|
+
deadlines: [],
|
|
196
|
+
outcome: { kind: "open" },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
).toEqual(["a.jsonc"]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("declaredRoots — nothing to add", () => {
|
|
205
|
+
const roots = (settings: object) =>
|
|
206
|
+
declaredRoots(
|
|
207
|
+
DIR,
|
|
208
|
+
memoryFiles({ [`${DIR}/paperlint.json`]: JSON.stringify(settings) }),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
it("nothing when the settings do not parse, or there are none", () => {
|
|
212
|
+
expect(roots({ venu: "x" })).toEqual([]);
|
|
213
|
+
expect(declaredRoots(DIR, memoryFiles({}))).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("an absolute path inside the folder counts; the folder itself, or a path outside it, does not", () => {
|
|
217
|
+
expect(
|
|
218
|
+
roots({
|
|
219
|
+
pdf: `${DIR}/out/paper.pdf`,
|
|
220
|
+
talk: { mode: "remote-video", dir: "." },
|
|
221
|
+
}),
|
|
222
|
+
).toEqual(["out"]);
|
|
223
|
+
expect(roots({ pdf: "/elsewhere/paper.pdf" })).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("a shipped preset names no file", () => {
|
|
227
|
+
expect(roots({ extends: "paperlint:aidc" })).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("is on at error in paperlint's own config", () => {
|
|
232
|
+
expect(FOLDER_LAYOUT_RULE_LEVELS).toEqual({
|
|
233
|
+
"paper/allowed-files-only": "error",
|
|
234
|
+
});
|
|
235
|
+
});
|