paperlint 9.0.0 → 10.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/.github/CLAUDE.md +22 -0
- package/.github/CLAUDE.md.spec.ts +20 -0
- package/CLAUDE.md +59 -515
- package/CONTRIBUTING.md +2 -2
- package/README.md +68 -128
- package/dist/CLAUDE.md.spec.d.ts +3 -0
- package/dist/CLAUDE.md.spec.d.ts.map +1 -0
- package/dist/CLAUDE.md.spec.js +62 -0
- package/dist/CLAUDE.md.spec.js.map +1 -0
- package/dist/build.d.ts +1 -1
- package/dist/build.js +2 -2
- 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/CLAUDE.md.spec.d.ts +3 -0
- package/dist/eslint-rules/CLAUDE.md.spec.d.ts.map +1 -0
- package/dist/eslint-rules/CLAUDE.md.spec.js +39 -0
- package/dist/eslint-rules/CLAUDE.md.spec.js.map +1 -0
- package/dist/eslint-rules/install-path-literals.d.ts +1 -1
- package/dist/eslint-rules/install-path-literals.js +4 -4
- package/dist/eslint-rules/install-path-literals.js.map +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.js +1 -1
- 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/lib/agent-cli-version.js +1 -1
- package/dist/lib/agent-cli-version.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/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/tex-requirements.js +1 -1
- package/dist/toolchain.js +1 -1
- package/docs/ci.md +60 -0
- package/docs/configuration.md +2 -0
- package/docs/e2e.md +3 -3
- package/docs/incidents.md +5 -5
- 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 +6 -3
- package/docs/venues.md +94 -0
- package/eslint-rules/CLAUDE.md +44 -0
- package/eslint-rules/CLAUDE.md.spec.ts +43 -0
- package/eslint-rules/install-path-literals.harness.mjs +1 -1
- package/eslint-rules/install-path-literals.ts +4 -4
- package/eslint-rules/pdf-last-page-balance.ts +1 -1
- package/eslint-rules/prereg-coverage.test.ts +448 -0
- package/eslint-rules/prereg-coverage.ts +419 -0
- package/eslint.config.mjs +6 -3
- package/hooks/CLAUDE.md +34 -0
- package/hooks/CLAUDE.md.spec.ts +30 -0
- package/lib/agent-cli-version.ts +1 -1
- package/lib/skill-trigger-cases.harness.mjs +51 -5
- package/lib/skill-trigger-cases.mjs +53 -15
- package/package.json +2 -2
- package/plugin/CLAUDE.md +22 -0
- package/plugin/CLAUDE.md.spec.ts +22 -0
- package/plugin/hooks/hooks.json +9 -0
- package/scripts/coverage-config.test.ts +2 -1
- package/skills/CLAUDE.md +19 -0
- package/skills/CLAUDE.md.spec.ts +18 -0
- 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 +14 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
- package/skills/build-label-page/SKILL.md +118 -0
- package/skills/build-label-page/SKILL.md.spec.ts +129 -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/map-prior-work/SKILL.md +2 -2
- package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +8 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +7 -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/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +21 -102
- 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/CLAUDE.md +23 -1
- package/src/CLAUDE.md.spec.ts +72 -0
- package/src/build.ts +2 -2
- 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/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/src/tex-requirements.ts +1 -1
- package/src/toolchain.ts +1 -1
- package/templates/paper/PIPELINE-STATUS.md +41 -7
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paper/allowed-files-only` — the root of a paper folder holds only paperlint's own files and folders.
|
|
3
|
+
*
|
|
4
|
+
* What may stand there, and why each entry, is `src/domain/paper-layout.ts` (the one list) and
|
|
5
|
+
* docs/rules/paper/allowed-files-only.md. This file reads the folder and the paper's settings, and reports.
|
|
6
|
+
*
|
|
7
|
+
* ── WHY A RULE THAT LISTS A DIRECTORY ───────────────────────────────────────────
|
|
8
|
+
* A structure linter over file NAMES (ls-lint, ESLint's `project-structure`) judges the files handed
|
|
9
|
+
* to it, and ESLint is handed only the files paperlint lints: a stray `.pdf`, `.py` or `.md` is never
|
|
10
|
+
* one of them, so a rule fired per file cannot see it. Listing the folder is one `readdir`; a rule is
|
|
11
|
+
* plain JS and may do it (root CLAUDE.md, "Measure before calling it impossible").
|
|
12
|
+
*
|
|
13
|
+
* ── WHICH FILE IT REPORTS ON ─────────────────────────────────────────────────────
|
|
14
|
+
* The folder is not a file ESLint lints; `PIPELINE-STATUS.md` is, in every paper (`paperlint lint`
|
|
15
|
+
* requires it, `paperlint new` writes it). Each stray entry is one finding on its first line, the
|
|
16
|
+
* place `paper/folder-venue-leftover` reports for the same reason.
|
|
17
|
+
*
|
|
18
|
+
* ── WHAT THE SETTINGS ADD ────────────────────────────────────────────────────────
|
|
19
|
+
* `pdf`, `talk.dir`, and a preset file of the paper's own (`extends`, or any cycle's) may name an
|
|
20
|
+
* entry at the root; each such entry is allowed while the settings name it. Settings that do not parse
|
|
21
|
+
* add nothing: `paperlint lint` refuses them before ESLint runs, and `pdf/profile` names the problem.
|
|
22
|
+
*/
|
|
23
|
+
import { basename, dirname, join, posix, relative, resolve } from "node:path";
|
|
24
|
+
import { callerPath } from "./caller-path.ts";
|
|
25
|
+
import {
|
|
26
|
+
judgeLayout,
|
|
27
|
+
strayMessage,
|
|
28
|
+
type LayoutEntry,
|
|
29
|
+
} from "./domain/paper-layout.ts";
|
|
30
|
+
import type {
|
|
31
|
+
FolderRuleContext,
|
|
32
|
+
FolderRuleModule,
|
|
33
|
+
} from "./folder-venue-rule.ts";
|
|
34
|
+
import { readPaperSettings, type PaperSettings } from "./paper-settings.ts";
|
|
35
|
+
import type { Files } from "./ports/files.ts";
|
|
36
|
+
import type { ListDir } from "./ports/talk-media.ts";
|
|
37
|
+
import { rulePageUrl } from "./venue-rules.ts";
|
|
38
|
+
|
|
39
|
+
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
40
|
+
|
|
41
|
+
/** What the rule reads with: the disk, and the names in a directory. */
|
|
42
|
+
export interface FolderLayoutDeps {
|
|
43
|
+
readonly files: Files;
|
|
44
|
+
readonly listDir: ListDir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The first segment of `p`, relative to the paper folder `dir`; null when it is not inside it. */
|
|
48
|
+
function rootOf(dir: string, p: string): string | null {
|
|
49
|
+
const first = relative(dir, resolve(dir, p)).split(/[\\/]/, 1).join("");
|
|
50
|
+
return first === "" || first === ".." ? null : first;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A preset spec that names a file (`./venue.jsonc`), not a shipped preset (`paperlint:aidc`). */
|
|
54
|
+
const isPresetFile = (spec: string): boolean =>
|
|
55
|
+
spec.startsWith("./") || spec.startsWith("../");
|
|
56
|
+
|
|
57
|
+
/** Every path the settings name relative to the paper: the PDF, the talk folder, its preset files. */
|
|
58
|
+
const pathsOf = (s: PaperSettings): readonly string[] => [
|
|
59
|
+
...(s.pdf === null ? [] : [s.pdf]),
|
|
60
|
+
...(s.talk === null ? [] : [s.talk.dir]),
|
|
61
|
+
...[
|
|
62
|
+
s.extends,
|
|
63
|
+
...(s.cycles?.list ?? []).map((c) =>
|
|
64
|
+
c.venue.kind === "preset" ? c.venue.extends : null,
|
|
65
|
+
),
|
|
66
|
+
].filter((spec): spec is string => spec !== null && isPresetFile(spec)),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** What the paper's settings name at the root of `dir`, each once; none without readable settings. */
|
|
70
|
+
export function declaredRoots(dir: string, files: Files): readonly string[] {
|
|
71
|
+
const read = readPaperSettings(files, dir);
|
|
72
|
+
if (!read.ok || read.value === null) return [];
|
|
73
|
+
return [
|
|
74
|
+
...new Set(
|
|
75
|
+
pathsOf(read.value)
|
|
76
|
+
.map((p) => rootOf(dir, posix.normalize(p)))
|
|
77
|
+
.filter((r): r is string => r !== null),
|
|
78
|
+
),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The folder's root entries, each a file or a folder. */
|
|
83
|
+
const entriesOf = (
|
|
84
|
+
dir: string,
|
|
85
|
+
deps: FolderLayoutDeps,
|
|
86
|
+
): readonly LayoutEntry[] =>
|
|
87
|
+
[...deps.listDir(callerPath(dir))].sort().map((name) => ({
|
|
88
|
+
name,
|
|
89
|
+
kind: deps.files.isFile(callerPath(join(dir, name))) ? "file" : "dir",
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The text of each stray entry among `entries` of the paper folder `dir` — THE one function behind
|
|
94
|
+
* both the rule and the `paper-folder-layout` hook (`src/folder-layout-hook.ts`), so the two cannot
|
|
95
|
+
* disagree about what is allowed or what to say.
|
|
96
|
+
*/
|
|
97
|
+
export const strayTexts = (
|
|
98
|
+
dir: string,
|
|
99
|
+
entries: readonly LayoutEntry[],
|
|
100
|
+
files: Files,
|
|
101
|
+
): readonly string[] =>
|
|
102
|
+
judgeLayout(entries, { roots: declaredRoots(dir, files) }).map(strayMessage);
|
|
103
|
+
|
|
104
|
+
const META: FolderRuleModule["meta"] = {
|
|
105
|
+
type: "suggestion",
|
|
106
|
+
docs: {
|
|
107
|
+
description:
|
|
108
|
+
"the paper folder's root holds only paperlint's own files and folders; anything else goes into the folder for its kind, or extra/",
|
|
109
|
+
url: rulePageUrl("paper/allowed-files-only"),
|
|
110
|
+
},
|
|
111
|
+
schema: [],
|
|
112
|
+
messages: {
|
|
113
|
+
// The whole text is `strayMessage` (src/domain/paper-layout.ts), the one the hook that fires on
|
|
114
|
+
// writing such an entry sends too.
|
|
115
|
+
stray: "{{text}}",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** The rule, reading the folder and the paper's settings through `deps`. */
|
|
120
|
+
export function folderLayoutRules(
|
|
121
|
+
deps: FolderLayoutDeps,
|
|
122
|
+
): Readonly<Record<"allowed-files-only", FolderRuleModule>> {
|
|
123
|
+
return {
|
|
124
|
+
"allowed-files-only": {
|
|
125
|
+
meta: META,
|
|
126
|
+
create(context: FolderRuleContext) {
|
|
127
|
+
if (basename(context.filename) !== STATUS_FILE) return {};
|
|
128
|
+
return {
|
|
129
|
+
root() {
|
|
130
|
+
const dir = dirname(context.filename);
|
|
131
|
+
const at = context.sourceCode.getLocFromIndex(0);
|
|
132
|
+
strayTexts(dir, entriesOf(dir, deps), deps.files).forEach(
|
|
133
|
+
(text) => {
|
|
134
|
+
context.report({
|
|
135
|
+
loc: { start: at, end: at },
|
|
136
|
+
messageId: "stray",
|
|
137
|
+
data: { text },
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The level it is on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
|
|
149
|
+
export const FOLDER_LAYOUT_RULE_LEVELS: Readonly<
|
|
150
|
+
Record<"paper/allowed-files-only", "error">
|
|
151
|
+
> = { "paper/allowed-files-only": "error" };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Both halves for `hooks-settings.ts` — the
|
|
2
|
+
* Both halves for `hooks-settings.ts` — the four hooks written into `.claude/settings.json`.
|
|
3
3
|
*
|
|
4
4
|
* The merge under test is vigiles' REAL one (`vigiles/claude-code`), not a stand-in: the claims
|
|
5
5
|
* that matter — a user's hook in the same matcher survives, a second run is byte-identical — are
|
|
@@ -65,9 +65,9 @@ check(
|
|
|
65
65
|
// ── the one source: plugin/hooks/hooks.json ─────────────────────────────────────────────
|
|
66
66
|
const wiring = shippedWiring();
|
|
67
67
|
check(
|
|
68
|
-
"the hook names are DERIVED from hooks.json —
|
|
68
|
+
"the hook names are DERIVED from hooks.json — four, in its order",
|
|
69
69
|
wiring.names.join() ===
|
|
70
|
-
"paper-edit-guard,paper-skills-nudge,paper-status-gates",
|
|
70
|
+
"paper-edit-guard,paper-folder-layout,paper-skills-nudge,paper-status-gates",
|
|
71
71
|
);
|
|
72
72
|
// ── settings written by hand: shapes Claude Code would ignore are skipped, not crashed on ──
|
|
73
73
|
check(
|
|
@@ -194,7 +194,7 @@ try {
|
|
|
194
194
|
const r = wireHooks(dir, merge, wiring);
|
|
195
195
|
const s = settingsIn(dir);
|
|
196
196
|
check(
|
|
197
|
-
"no settings file → it is created with the
|
|
197
|
+
"no settings file → it is created with the four hooks",
|
|
198
198
|
r.status === "written" &&
|
|
199
199
|
wiredCounts(s, wiring.names).every(
|
|
200
200
|
(c) => c.ours === 1 && c.other === 0,
|
|
@@ -283,7 +283,8 @@ try {
|
|
|
283
283
|
check(
|
|
284
284
|
"and names the hooks that are not wired in ANY form",
|
|
285
285
|
r.status === "foreign" &&
|
|
286
|
-
r.missing.join() ===
|
|
286
|
+
r.missing.join() ===
|
|
287
|
+
"paper-folder-layout,paper-skills-nudge,paper-status-gates",
|
|
287
288
|
);
|
|
288
289
|
// The control: without the check, vigiles' merge WOULD add a second copy. This is the
|
|
289
290
|
// reason the check exists, measured rather than asserted.
|
|
@@ -319,13 +320,13 @@ try {
|
|
|
319
320
|
const wiredOnce = merge({}, wiring.compiled, MANAGED_BY);
|
|
320
321
|
check(
|
|
321
322
|
"wired once each → ✓ wired, with the fresh-clone consequence",
|
|
322
|
-
/✓ wired — paper-edit-guard, paper-skills-nudge, paper-status-gates, once each/.test(
|
|
323
|
+
/✓ wired — paper-edit-guard, paper-folder-layout, paper-skills-nudge, paper-status-gates, once each/.test(
|
|
323
324
|
doc(wiredOnce),
|
|
324
325
|
) && /cannot run until `npm install`/.test(doc(wiredOnce)),
|
|
325
326
|
);
|
|
326
327
|
check(
|
|
327
328
|
"nothing wired → ⚠ not wired, with the remedy",
|
|
328
|
-
/⚠ not wired — none of the
|
|
329
|
+
/⚠ not wired — none of the 4 hooks/.test(doc({})),
|
|
329
330
|
);
|
|
330
331
|
const twice = PreToolUseSettings.parse(structuredClone(wiredOnce));
|
|
331
332
|
twice.hooks.PreToolUse[0]?.hooks.push({
|
|
@@ -353,7 +354,7 @@ try {
|
|
|
353
354
|
});
|
|
354
355
|
check(
|
|
355
356
|
"🔴 partly wired BY HAND → the remedy is NOT `npx paperlint init`, which would refuse to write",
|
|
356
|
-
/partly wired — missing: paper-skills-nudge, paper-status-gates/.test(
|
|
357
|
+
/partly wired — missing: paper-folder-layout, paper-skills-nudge, paper-status-gates/.test(
|
|
357
358
|
handPartial,
|
|
358
359
|
) && /writes nothing then/.test(handPartial),
|
|
359
360
|
);
|
package/src/init.ts
CHANGED
|
@@ -691,7 +691,7 @@ function papersLines(decl: DeclarationResult, why: string): string[] {
|
|
|
691
691
|
];
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
-
// Documented in README.md
|
|
694
|
+
// Documented in README.md#-getting-started and docs/install.md — update them when this changes.
|
|
695
695
|
export async function init(
|
|
696
696
|
dir: string,
|
|
697
697
|
opts: InitOptions = {},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pages draw the layout the code enforces. Each page embeds the drawing between
|
|
3
|
+
* `<!-- layout:begin … -->` and `<!-- layout:end -->`, and that block must equal the render of the
|
|
4
|
+
* constants that decide what is allowed (`PAPER_LAYOUT_TREE`, `REPRO_LAYOUT_TREE`, and
|
|
5
|
+
* `PAPERS_LAYOUT_TREE`, which draws the paper folder beside the venues' corpora) — so a page cannot
|
|
6
|
+
* show an entry the rule reports, or miss one it allows. Both halves: the committed pages match, and
|
|
7
|
+
* a page with one entry changed or with no block at all does not.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
PAPER_LAYOUT_TREE,
|
|
15
|
+
PAPERS_LAYOUT_TREE,
|
|
16
|
+
} from "./domain/paper-layout.ts";
|
|
17
|
+
import { REPRO_LAYOUT_TREE } from "./domain/repro-layout.ts";
|
|
18
|
+
|
|
19
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
|
+
|
|
21
|
+
const PAGES: readonly (readonly [string, string])[] = [
|
|
22
|
+
["docs/rules/paper/allowed-files-only.md", PAPER_LAYOUT_TREE],
|
|
23
|
+
["docs/rules/repro/allowed-files-only.md", REPRO_LAYOUT_TREE],
|
|
24
|
+
["docs/layout.md", PAPERS_LAYOUT_TREE],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** The drawing a page embeds, as its fenced block holds it; null when the page has no block. */
|
|
28
|
+
function embedded(page: string): string | null {
|
|
29
|
+
const m =
|
|
30
|
+
/<!-- layout:begin[^\n]*-->\n\n```text\n([\s\S]*?)\n```\n\n<!-- layout:end -->/u.exec(
|
|
31
|
+
page,
|
|
32
|
+
);
|
|
33
|
+
return m === null ? null : (m[1] ?? null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("the pages draw the layout the code enforces", () => {
|
|
37
|
+
it.each(PAGES)("%s embeds the current render", (path, tree) => {
|
|
38
|
+
expect(
|
|
39
|
+
embedded(readFileSync(join(ROOT, path), "utf8")),
|
|
40
|
+
`${path}: paste this between its layout markers:\n\n${tree}\n`,
|
|
41
|
+
).toBe(tree);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("a page whose drawing lost an entry differs from the render", () => {
|
|
45
|
+
const page = `<!-- layout:begin x -->\n\n\`\`\`text\n${PAPER_LAYOUT_TREE.replace(/\n[^\n]*extra\/[^\n]*/u, "")}\n\`\`\`\n\n<!-- layout:end -->\n`;
|
|
46
|
+
expect(embedded(page)).not.toBe(PAPER_LAYOUT_TREE);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("a page with no block has no drawing", () => {
|
|
50
|
+
expect(embedded("# a page\n")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|