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,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
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* and a placeholder stage would be a red `paper/stages` on the very first run;
|
|
10
10
|
* - the scorecard carries the `**Readiness verdict:**` line the status hook surfaces on every
|
|
11
11
|
* paper edit — without it the hook reports that the paper cannot say whether it is ready;
|
|
12
|
+
* - a new folder also holds the layout (folders with a `.gitkeep`, `repro/README.md`), and lint
|
|
13
|
+
* still reports no finding on it: the rules that read those folders do not see the placeholders;
|
|
12
14
|
* - it never overwrites, and on an existing folder adds only what is missing;
|
|
13
15
|
* - the project's `<papers>/.template/` wins over the package, file by file.
|
|
14
16
|
*
|
|
@@ -82,6 +84,11 @@ try {
|
|
|
82
84
|
`\`${dot}\` is refused — discovery skips dot-directories, so it would never be linted`,
|
|
83
85
|
/starting with a dot/.test(nameProblem(dot, []) ?? ""),
|
|
84
86
|
);
|
|
87
|
+
for (const reserved of ["venues", "extra"])
|
|
88
|
+
check(
|
|
89
|
+
`\`${reserved}\` is refused — the papers directory's own folder, which no paper may take`,
|
|
90
|
+
/reserved/.test(nameProblem(reserved, []) ?? ""),
|
|
91
|
+
);
|
|
85
92
|
check(
|
|
86
93
|
"a refused name creates nothing",
|
|
87
94
|
newPaper(work, "Bad", { venues: [] }).ok === false &&
|
|
@@ -119,6 +126,20 @@ try {
|
|
|
119
126
|
/2 problems \(0 errors, 2 warnings\)/.test(l.text),
|
|
120
127
|
);
|
|
121
128
|
|
|
129
|
+
check(
|
|
130
|
+
"🔴 the layout is there: the five folders every paper uses, `repro/` with an index, tools/ and data/ — and none of the optional ones",
|
|
131
|
+
["reviews", "research", "figures", "versions", "submission"].every((d) =>
|
|
132
|
+
existsSync(join(papers, "demo", d, ".gitkeep")),
|
|
133
|
+
) &&
|
|
134
|
+
existsSync(join(papers, "demo", "repro", "README.md")) &&
|
|
135
|
+
existsSync(join(papers, "demo", "repro", "tools", ".gitkeep")) &&
|
|
136
|
+
existsSync(join(papers, "demo", "repro", "data", ".gitkeep")) &&
|
|
137
|
+
!existsSync(join(papers, "demo", "repro", ".gitkeep")) &&
|
|
138
|
+
["siblings", "sections", "talk", "extra", "_build"].every(
|
|
139
|
+
(d) => !existsSync(join(papers, "demo", d)),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
|
|
122
143
|
// What the status hook reads on every paper edit: the verdict line.
|
|
123
144
|
check(
|
|
124
145
|
"the scorecard carries the `**Readiness verdict:**` line the status hook surfaces",
|
|
@@ -135,7 +156,8 @@ try {
|
|
|
135
156
|
again.ok &&
|
|
136
157
|
!again.fresh &&
|
|
137
158
|
read(papers, "demo", STATUS_FILE) === "mine\n" &&
|
|
138
|
-
again.files.every((f) => f.status === "kept")
|
|
159
|
+
again.files.every((f) => f.status === "kept") &&
|
|
160
|
+
again.layout.length === 0,
|
|
139
161
|
);
|
|
140
162
|
writeFileSync(join(papers, "afile"), "x");
|
|
141
163
|
check(
|
package/src/new-paper.test.ts
CHANGED
|
@@ -16,13 +16,28 @@ import { tmpdir } from "node:os";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { afterEach, describe, expect, it } from "vitest";
|
|
18
18
|
import { existsSync } from "node:fs";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
newPaper,
|
|
21
|
+
OVERRIDE_DIR,
|
|
22
|
+
PACKAGE_TEMPLATES,
|
|
23
|
+
reportNewPaper,
|
|
24
|
+
} from "./new-paper.ts";
|
|
20
25
|
import { parsePaperSettings } from "./paper-settings.ts";
|
|
21
26
|
import { chooseVenue, run } from "./cli.ts";
|
|
22
27
|
import { shippedPresets } from "./presets.ts";
|
|
23
28
|
import { presetsDir } from "./package-dirs.ts";
|
|
24
29
|
import { lintReport } from "../test/lint-report.ts";
|
|
25
30
|
import { z } from "zod";
|
|
31
|
+
import { judgeLayout } from "./domain/paper-layout.ts";
|
|
32
|
+
import {
|
|
33
|
+
GITKEEP,
|
|
34
|
+
NOT_SCAFFOLDED,
|
|
35
|
+
SCAFFOLD_DIRS,
|
|
36
|
+
SCAFFOLD_FILES,
|
|
37
|
+
} from "./domain/scaffold-layout.ts";
|
|
38
|
+
import { ROOT_DIRS } from "./domain/paper-layout.ts";
|
|
39
|
+
import { judgeRepro, REPRO_DIR } from "./domain/repro-layout.ts";
|
|
40
|
+
import { statSync } from "node:fs";
|
|
26
41
|
|
|
27
42
|
const dirs: string[] = [];
|
|
28
43
|
afterEach(() => {
|
|
@@ -395,11 +410,13 @@ describe("paperlint new --allow-venue-name", () => {
|
|
|
395
410
|
const r = await newIn(["--allow-venue-name"], {}, "realm-of-agents");
|
|
396
411
|
expect(r.code).toBe(0);
|
|
397
412
|
expect(r.out).not.toMatch(/venue really belongs|names a venue/);
|
|
398
|
-
expect(readdirSync(r.dir)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
413
|
+
expect(readdirSync(r.dir)).toEqual(
|
|
414
|
+
expect.arrayContaining([
|
|
415
|
+
"PIPELINE-STATUS.md",
|
|
416
|
+
"paper.tex",
|
|
417
|
+
"paperlint.json",
|
|
418
|
+
]),
|
|
419
|
+
);
|
|
403
420
|
});
|
|
404
421
|
|
|
405
422
|
it.each<{
|
|
@@ -519,3 +536,279 @@ describe("a project's own template", () => {
|
|
|
519
536
|
).toBeGreaterThan(0);
|
|
520
537
|
});
|
|
521
538
|
});
|
|
539
|
+
|
|
540
|
+
describe("the PIPELINE-STATUS template — only stage tables are headed `id`", () => {
|
|
541
|
+
// A consumer's stage-table parser reads every table whose first column is `id` as stage rows, and a
|
|
542
|
+
// row under a heading that names no stage section is an error there: a fix-list table headed `id`
|
|
543
|
+
// gave a real paper two such errors (2026-10-12). Every other table starts with `#` or a word.
|
|
544
|
+
const SECTIONS = ["SETUP", "LOOP", "CONTINUOUS", "GATES", "AFTER"];
|
|
545
|
+
const text = readFileSync(
|
|
546
|
+
join(PACKAGE_TEMPLATES, "PIPELINE-STATUS.md"),
|
|
547
|
+
"utf8",
|
|
548
|
+
);
|
|
549
|
+
const idTablesUnder = text
|
|
550
|
+
.split("\n")
|
|
551
|
+
.reduce<{ heading: string; found: readonly string[] }>(
|
|
552
|
+
(acc, line) =>
|
|
553
|
+
line.startsWith("#")
|
|
554
|
+
? { ...acc, heading: line.replace(/^#+\s*/, "") }
|
|
555
|
+
: /^\|\s*id\s*\|/.test(line)
|
|
556
|
+
? { ...acc, found: [...acc.found, acc.heading] }
|
|
557
|
+
: acc,
|
|
558
|
+
{ heading: "", found: [] },
|
|
559
|
+
).found;
|
|
560
|
+
|
|
561
|
+
it("has stage tables at all", () => {
|
|
562
|
+
expect(idTablesUnder.length).toBeGreaterThanOrEqual(4);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("puts each one under a stage section's heading", () => {
|
|
566
|
+
expect(
|
|
567
|
+
idTablesUnder.filter((h) => !SECTIONS.includes(h.split(/\s/)[0] ?? "")),
|
|
568
|
+
).toEqual([]);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
/** Every file under `dir`, as a path from it, sorted. */
|
|
573
|
+
const filesUnder = (dir: string, at = ""): string[] =>
|
|
574
|
+
readdirSync(join(dir, at), { withFileTypes: true })
|
|
575
|
+
.flatMap((e) =>
|
|
576
|
+
e.isDirectory() ? filesUnder(dir, `${at}${e.name}/`) : [`${at}${e.name}`],
|
|
577
|
+
)
|
|
578
|
+
.sort();
|
|
579
|
+
|
|
580
|
+
describe("paperlint new — the layout is scaffolded", () => {
|
|
581
|
+
const EXPECTED = [
|
|
582
|
+
"PIPELINE-STATUS.md",
|
|
583
|
+
"figures/.gitkeep",
|
|
584
|
+
"paper.tex",
|
|
585
|
+
"paperlint.json",
|
|
586
|
+
"repro/README.md",
|
|
587
|
+
"repro/data/.gitkeep",
|
|
588
|
+
"repro/tools/.gitkeep",
|
|
589
|
+
"research/.gitkeep",
|
|
590
|
+
"reviews/.gitkeep",
|
|
591
|
+
"submission/.gitkeep",
|
|
592
|
+
"versions/.gitkeep",
|
|
593
|
+
];
|
|
594
|
+
|
|
595
|
+
it("a new paper has exactly the expected tree", () => {
|
|
596
|
+
const papers = join(tmp(), "papers");
|
|
597
|
+
const r = newPaper(papers, "demo", { venues: [] });
|
|
598
|
+
expect(r.ok && r.layout).toEqual(SCAFFOLD_FILES.map((f) => f.path));
|
|
599
|
+
expect(filesUnder(join(papers, "demo"))).toEqual(EXPECTED);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("derives from the layout constants: every root folder is scaffolded or excused, and an excuse names a folder the layout has", () => {
|
|
603
|
+
const excused = NOT_SCAFFOLDED.map((n) => n.name);
|
|
604
|
+
expect([...SCAFFOLD_DIRS, ...excused].sort()).toEqual(
|
|
605
|
+
[...ROOT_DIRS].sort(),
|
|
606
|
+
);
|
|
607
|
+
expect(excused.filter((n) => !ROOT_DIRS.includes(n))).toEqual([]);
|
|
608
|
+
expect(NOT_SCAFFOLDED.filter((n) => n.why.length < 20)).toEqual([]);
|
|
609
|
+
// The optional folders are decided, not forgotten: these four are not made.
|
|
610
|
+
expect(excused).toEqual(
|
|
611
|
+
expect.arrayContaining(["siblings", "sections", "talk", "extra"]),
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it(".gitkeep stands in an empty folder only, and no folder is empty but for it", () => {
|
|
616
|
+
const papers = join(tmp(), "papers");
|
|
617
|
+
newPaper(papers, "demo", { venues: [] });
|
|
618
|
+
const dir = join(papers, "demo");
|
|
619
|
+
const folders = new Set(
|
|
620
|
+
filesUnder(dir)
|
|
621
|
+
.filter((p) => p.includes("/"))
|
|
622
|
+
.map((p) => p.slice(0, p.lastIndexOf("/"))),
|
|
623
|
+
);
|
|
624
|
+
for (const f of folders) {
|
|
625
|
+
const names = readdirSync(join(dir, f)).filter((n) =>
|
|
626
|
+
statSync(join(dir, f, n)).isFile(),
|
|
627
|
+
);
|
|
628
|
+
const subdirs = readdirSync(join(dir, f)).length - names.length;
|
|
629
|
+
// A folder holds a .gitkeep alone, or real entries and no .gitkeep.
|
|
630
|
+
expect(
|
|
631
|
+
names.includes(GITKEEP) ? names.length === 1 && subdirs === 0 : true,
|
|
632
|
+
f,
|
|
633
|
+
).toBe(true);
|
|
634
|
+
}
|
|
635
|
+
// repro/ holds its index and folders, so it has no placeholder of its own.
|
|
636
|
+
expect(existsSync(join(dir, REPRO_DIR, GITKEEP))).toBe(false);
|
|
637
|
+
expect(existsSync(join(dir, GITKEEP))).toBe(false);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe("paperlint new — what the scaffold writes fits the layout", () => {
|
|
642
|
+
it("the folders it makes are the layout's: nothing it writes is a stray, at the root or in repro/", () => {
|
|
643
|
+
const papers = join(tmp(), "papers");
|
|
644
|
+
newPaper(papers, "demo", { venues: [] });
|
|
645
|
+
const dir = join(papers, "demo");
|
|
646
|
+
const entries = (d: string) =>
|
|
647
|
+
readdirSync(d, { withFileTypes: true }).map((e) => ({
|
|
648
|
+
name: e.name,
|
|
649
|
+
kind: e.isDirectory() ? ("dir" as const) : ("file" as const),
|
|
650
|
+
}));
|
|
651
|
+
expect(judgeLayout(entries(dir), { roots: [] })).toEqual([]);
|
|
652
|
+
expect(
|
|
653
|
+
judgeRepro(
|
|
654
|
+
entries(join(dir, REPRO_DIR)).map((e) => ({
|
|
655
|
+
...e,
|
|
656
|
+
hasReadme: false,
|
|
657
|
+
})),
|
|
658
|
+
),
|
|
659
|
+
).toEqual([]);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("repro/README.md is a stub index: one line saying one line per study", () => {
|
|
663
|
+
const papers = join(tmp(), "papers");
|
|
664
|
+
newPaper(papers, "demo", { venues: [] });
|
|
665
|
+
expect(
|
|
666
|
+
readFileSync(join(papers, "demo", "repro", "README.md"), "utf8"),
|
|
667
|
+
).toMatch(/One line per study/);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("the report names the folders in one line", () => {
|
|
671
|
+
const papers = join(tmp(), "papers");
|
|
672
|
+
const lines = reportNewPaper(
|
|
673
|
+
newPaper(papers, "demo", { venues: [] }),
|
|
674
|
+
(p) => p,
|
|
675
|
+
);
|
|
676
|
+
expect(lines.filter((l) => l.includes(GITKEEP))).toEqual([
|
|
677
|
+
" + versions/ reviews/ research/ repro/ repro/tools/ repro/data/ figures/ submission/ (the paper's layout; an empty folder holds a .gitkeep, so git keeps it)",
|
|
678
|
+
]);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("paperlint new — the scaffold on a folder that is not new", () => {
|
|
683
|
+
it("a second run changes nothing: all kept, no layout, same tree", () => {
|
|
684
|
+
const papers = join(tmp(), "papers");
|
|
685
|
+
newPaper(papers, "demo", { venues: [] });
|
|
686
|
+
const before = filesUnder(join(papers, "demo"));
|
|
687
|
+
const again = newPaper(papers, "demo", { venues: [] });
|
|
688
|
+
expect(again.ok && again.layout).toEqual([]);
|
|
689
|
+
expect(again.ok && again.files.every((f) => f.status === "kept")).toBe(
|
|
690
|
+
true,
|
|
691
|
+
);
|
|
692
|
+
expect(filesUnder(join(papers, "demo"))).toEqual(before);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("a folder that holds something gets only its missing three files, no layout", () => {
|
|
696
|
+
const papers = join(tmp(), "papers");
|
|
697
|
+
mkdirSync(join(papers, "old"), { recursive: true });
|
|
698
|
+
writeFileSync(join(papers, "old", "paper.tex"), "mine\n");
|
|
699
|
+
const r = newPaper(papers, "old", { venues: [] });
|
|
700
|
+
expect(r.ok && r.layout).toEqual([]);
|
|
701
|
+
expect(filesUnder(join(papers, "old"))).toEqual([
|
|
702
|
+
"PIPELINE-STATUS.md",
|
|
703
|
+
"paper.tex",
|
|
704
|
+
"paperlint.json",
|
|
705
|
+
]);
|
|
706
|
+
expect(readFileSync(join(papers, "old", "paper.tex"), "utf8")).toBe(
|
|
707
|
+
"mine\n",
|
|
708
|
+
);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("an empty folder made by hand is new: it gets the layout", () => {
|
|
712
|
+
const papers = join(tmp(), "papers");
|
|
713
|
+
mkdirSync(join(papers, "made"), { recursive: true });
|
|
714
|
+
const r = newPaper(papers, "made", { venues: [] });
|
|
715
|
+
expect(r.ok && !r.fresh).toBe(true);
|
|
716
|
+
expect(filesUnder(join(papers, "made"))).toContain("reviews/.gitkeep");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("🔴 a folder the author emptied of its placeholder is not given it back, and a file in one stays", () => {
|
|
720
|
+
const papers = join(tmp(), "papers");
|
|
721
|
+
newPaper(papers, "demo", { venues: [] });
|
|
722
|
+
const dir = join(papers, "demo");
|
|
723
|
+
rmSync(join(dir, "reviews", GITKEEP));
|
|
724
|
+
writeFileSync(join(dir, "reviews", "2026-10-13-pass.md"), "x\n");
|
|
725
|
+
rmSync(join(dir, "figures"), { recursive: true });
|
|
726
|
+
newPaper(papers, "demo", { venues: [] });
|
|
727
|
+
expect(readdirSync(join(dir, "reviews"))).toEqual(["2026-10-13-pass.md"]);
|
|
728
|
+
expect(existsSync(join(dir, "figures"))).toBe(false);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("an existing repro/README.md is never overwritten", () => {
|
|
732
|
+
const papers = join(tmp(), "papers");
|
|
733
|
+
mkdirSync(join(papers, "p", "repro"), { recursive: true });
|
|
734
|
+
writeFileSync(join(papers, "p", "repro", "README.md"), "mine\n");
|
|
735
|
+
newPaper(papers, "p", { venues: [] });
|
|
736
|
+
expect(readFileSync(join(papers, "p", "repro", "README.md"), "utf8")).toBe(
|
|
737
|
+
"mine\n",
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
/** A consumer project with one scaffolded paper and `added` files in it, linted: the rule ids found. */
|
|
743
|
+
async function lintScaffold(added: Record<string, string> = {}) {
|
|
744
|
+
const root = tmp();
|
|
745
|
+
writeFileSync(
|
|
746
|
+
join(root, "package.json"),
|
|
747
|
+
JSON.stringify({ name: "c", private: true }),
|
|
748
|
+
);
|
|
749
|
+
newPaper(join(root, "papers"), "demo", { venues: [] });
|
|
750
|
+
for (const [p, text] of Object.entries(added)) {
|
|
751
|
+
mkdirSync(join(root, "papers", "demo", p, ".."), { recursive: true });
|
|
752
|
+
writeFileSync(join(root, "papers", "demo", p), text);
|
|
753
|
+
}
|
|
754
|
+
const out: string[] = [];
|
|
755
|
+
const code = await run(["lint", "--json"], {
|
|
756
|
+
cwd: root,
|
|
757
|
+
log: (s: string) => out.push(s),
|
|
758
|
+
err: () => {},
|
|
759
|
+
});
|
|
760
|
+
return {
|
|
761
|
+
code,
|
|
762
|
+
ids: lintReport(out.join("\n")).flatMap((r) =>
|
|
763
|
+
r.messages.map((m) => m.ruleId),
|
|
764
|
+
),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
describe("paperlint lint — a scaffolded paper has no layout finding", () => {
|
|
769
|
+
const LAYOUT_RULES = [
|
|
770
|
+
"paper/allowed-files-only",
|
|
771
|
+
"repro/allowed-files-only",
|
|
772
|
+
"review/frontmatter",
|
|
773
|
+
"sibling/frontmatter",
|
|
774
|
+
"paper/stages",
|
|
775
|
+
"study/declare-sample",
|
|
776
|
+
];
|
|
777
|
+
|
|
778
|
+
it("none of the rules that read the layout's folders reports it; only the not-built warnings remain", async () => {
|
|
779
|
+
const { code, ids } = await lintScaffold();
|
|
780
|
+
expect(ids.filter((id) => LAYOUT_RULES.includes(id ?? ""))).toEqual([]);
|
|
781
|
+
expect(ids).toEqual(["pdf/measured", "paper/sources-fresh"]);
|
|
782
|
+
expect(code).toBe(0);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("🔴 a .gitkeep beside real entries, in any folder the rules read, is not reported either", async () => {
|
|
786
|
+
const { ids } = await lintScaffold({
|
|
787
|
+
"siblings/.gitkeep": "",
|
|
788
|
+
"talk/.gitkeep": "",
|
|
789
|
+
"sections/.gitkeep": "",
|
|
790
|
+
"repro/some-study-2026-10-13/README.md": "# s\n",
|
|
791
|
+
"repro/some-study-2026-10-13/.gitkeep": "",
|
|
792
|
+
});
|
|
793
|
+
expect(ids.filter((id) => LAYOUT_RULES.includes(id ?? ""))).toEqual([]);
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
describe("paperlint lint — extra/ is never read", () => {
|
|
798
|
+
// An archived snapshot of an older paper carries every marker a paper does. Under extra/ it is
|
|
799
|
+
// spillover the layout promises never to judge; anywhere else its folder reads as a paper root.
|
|
800
|
+
const SNAPSHOT = (dir: string) => ({
|
|
801
|
+
[`${dir}/PIPELINE-STATUS.md`]: "# Pipeline status\n",
|
|
802
|
+
[`${dir}/notes.txt`]: "x\n",
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("🔴 an archived paper under extra/ is never linted, by any rule", async () => {
|
|
806
|
+
const { ids } = await lintScaffold(SNAPSHOT("extra/old-2025"));
|
|
807
|
+
expect(ids).toEqual(["pdf/measured", "paper/sources-fresh"]);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("the same snapshot outside extra/ is reported, so the test above can fail", async () => {
|
|
811
|
+
const { ids } = await lintScaffold(SNAPSHOT("research/old-2025"));
|
|
812
|
+
expect(ids).toContain("paper/allowed-files-only");
|
|
813
|
+
});
|
|
814
|
+
});
|