paperlint 10.1.0 → 10.2.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/README.md +2 -0
- package/dist/adapters/study-registry/index.d.ts +6 -0
- package/dist/adapters/study-registry/index.d.ts.map +1 -0
- package/dist/adapters/study-registry/index.js +58 -0
- package/dist/adapters/study-registry/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -2
- package/dist/cli.js.map +1 -1
- package/dist/domain/repro-layout.d.ts +12 -4
- package/dist/domain/repro-layout.d.ts.map +1 -1
- package/dist/domain/repro-layout.js +49 -18
- package/dist/domain/repro-layout.js.map +1 -1
- package/dist/domain/scaffold-layout.d.ts +4 -3
- package/dist/domain/scaffold-layout.d.ts.map +1 -1
- package/dist/domain/scaffold-layout.js +7 -4
- package/dist/domain/scaffold-layout.js.map +1 -1
- package/dist/domain/study-registry.d.ts +176 -0
- package/dist/domain/study-registry.d.ts.map +1 -0
- package/dist/domain/study-registry.js +371 -0
- package/dist/domain/study-registry.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +11 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -0
- package/dist/ports/study-registry-codec.js +2 -0
- package/dist/ports/study-registry-codec.js.map +1 -0
- package/dist/repro-layout-rule.d.ts.map +1 -1
- package/dist/repro-layout-rule.js +4 -3
- package/dist/repro-layout-rule.js.map +1 -1
- package/dist/studies.d.ts +27 -0
- package/dist/studies.d.ts.map +1 -0
- package/dist/studies.js +160 -0
- package/dist/studies.js.map +1 -0
- package/dist/study-registry-rule.d.ts +17 -0
- package/dist/study-registry-rule.d.ts.map +1 -0
- package/dist/study-registry-rule.js +115 -0
- package/dist/study-registry-rule.js.map +1 -0
- package/docs/design/study-registry.md +130 -0
- package/docs/layout.md +47 -44
- package/docs/rules/paper/allowed-files-only.md +3 -2
- package/docs/rules/repro/allowed-files-only.md +40 -30
- package/docs/rules/repro/study-registered.md +162 -0
- package/docs/rules/study/declare-sample.md +1 -1
- package/docs/rules.md +2 -1
- package/package.json +1 -1
- package/skills/build-benchmark/SKILL.md +20 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +19 -0
- package/skills/build-label-page/SKILL.md +7 -4
- package/skills/build-label-page/SKILL.md.spec.ts +6 -3
- package/skills/paper-pipeline/references/artifact-checklist.md +2 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +5 -3
- package/skills/render-paper/check-render.sh +9 -0
- package/skills/render-paper/render-paper.harness.mjs +102 -0
- package/skills/run-agent-grid/SKILL.md +8 -1
- package/skills/run-agent-grid/SKILL.md.spec.ts +7 -0
- package/src/adapters/study-registry/index.test.ts +144 -0
- package/src/adapters/study-registry/index.ts +70 -0
- package/src/cli.ts +51 -2
- package/src/domain/paper-layout.test.ts +1 -1
- package/src/domain/repro-layout.test.ts +18 -9
- package/src/domain/repro-layout.ts +58 -18
- package/src/domain/scaffold-layout.ts +7 -4
- package/src/domain/study-registry.test.ts +657 -0
- package/src/domain/study-registry.ts +598 -0
- package/src/folder-layout-hook.test.ts +10 -0
- package/src/new-paper.test.ts +28 -2
- package/src/ports/study-registry-codec.ts +11 -0
- package/src/repro-layout-rule.ts +4 -3
- package/src/rule-cases.test.ts +41 -0
- package/src/studies-cli.test.ts +113 -0
- package/src/studies.test.ts +353 -0
- package/src/studies.ts +259 -0
- package/src/study-registry-rule.test.ts +317 -0
- package/src/study-registry-rule.ts +172 -0
package/src/studies.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint studies new|index <paper-dir>` — the two commands that write a paper's study registry,
|
|
3
|
+
* `repro/studies.json`, and its index in `repro/README.md` (docs/design/study-registry.md).
|
|
4
|
+
*
|
|
5
|
+
* new <paper-dir> "<title>" [--kind <kind>]
|
|
6
|
+
* the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter; the
|
|
7
|
+
* row, `status: designing`, today's date; the index regenerated
|
|
8
|
+
* index <paper-dir>
|
|
9
|
+
* the index block of `repro/README.md`, regenerated from the registry
|
|
10
|
+
*
|
|
11
|
+
* Nothing else changes a row: a study's status, result and `in_paper` are edited in `studies.json`, and
|
|
12
|
+
* `repro/study-registered` checks them. The prefix is the paper's own choice, so a paper without a
|
|
13
|
+
* registry is refused with the file to write rather than given a guessed one.
|
|
14
|
+
*/
|
|
15
|
+
import { join, relative, resolve } from "node:path";
|
|
16
|
+
import { callerPath } from "./caller-path.ts";
|
|
17
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
18
|
+
import { README, REPRO_DIR } from "./domain/repro-layout.ts";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_KIND,
|
|
21
|
+
STUDIES_FILE,
|
|
22
|
+
STUDY_KINDS,
|
|
23
|
+
addStudy,
|
|
24
|
+
indexInSync,
|
|
25
|
+
isStudyKind,
|
|
26
|
+
registryText,
|
|
27
|
+
studyReadme,
|
|
28
|
+
withIndex,
|
|
29
|
+
type Study,
|
|
30
|
+
type StudyKind,
|
|
31
|
+
type StudyRegistry,
|
|
32
|
+
} from "./domain/study-registry.ts";
|
|
33
|
+
import type { Files } from "./ports/files.ts";
|
|
34
|
+
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
35
|
+
import type { ListDir } from "./ports/talk-media.ts";
|
|
36
|
+
import { registryOf } from "./study-registry-rule.ts";
|
|
37
|
+
|
|
38
|
+
/** What one `studies` run was asked, after the command line. */
|
|
39
|
+
export interface StudiesArgs {
|
|
40
|
+
readonly sub: string | undefined;
|
|
41
|
+
readonly paperDir: string | undefined;
|
|
42
|
+
/** `new`'s title; for `index`, an argument too many. */
|
|
43
|
+
readonly title: string | undefined;
|
|
44
|
+
/** Positional arguments past the title: refused. */
|
|
45
|
+
readonly extra: readonly string[];
|
|
46
|
+
/** `--kind <kind>`; null: the default. */
|
|
47
|
+
readonly kind: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StudiesDeps {
|
|
51
|
+
readonly files: Files;
|
|
52
|
+
readonly listDir: ListDir;
|
|
53
|
+
readonly codec: StudyRegistryCodec;
|
|
54
|
+
readonly cwd: string;
|
|
55
|
+
/** Today, `YYYY-MM-DD`: the root reads the clock. */
|
|
56
|
+
readonly today: string;
|
|
57
|
+
readonly log: (line: string) => void;
|
|
58
|
+
readonly err: (line: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const NEW_USAGE =
|
|
62
|
+
'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
|
|
63
|
+
const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
|
|
64
|
+
const SUBS = `new or index — ${NEW_USAGE} · ${INDEX_USAGE}`;
|
|
65
|
+
|
|
66
|
+
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
67
|
+
|
|
68
|
+
/** The paper folder a run works in: absolute, and as the messages show it. */
|
|
69
|
+
interface Paper {
|
|
70
|
+
readonly dir: string;
|
|
71
|
+
readonly shown: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A run's outcome: the exit code, and the line that explains a refusal. */
|
|
75
|
+
type Refusal = { readonly code: 1 | 2; readonly line: string };
|
|
76
|
+
|
|
77
|
+
const refuse = (code: 1 | 2, line: string): Refusal => ({ code, line });
|
|
78
|
+
|
|
79
|
+
/** The paper folder's registry, or the refusal naming why there is none to write to. */
|
|
80
|
+
function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
|
|
81
|
+
const r = registryOf(p.dir, deps);
|
|
82
|
+
const file = `${p.shown}/${REPRO_DIR}/${STUDIES_FILE}`;
|
|
83
|
+
switch (r.kind) {
|
|
84
|
+
case "absent":
|
|
85
|
+
return refuse(
|
|
86
|
+
1,
|
|
87
|
+
`${file} does not exist. Its prefix is the paper's own choice — a capital letter, then capitals or digits, one per paper (P1) — so write the file, then run this again:\n\n{ "prefix": "P1", "studies": [] }`,
|
|
88
|
+
);
|
|
89
|
+
case "unreadable":
|
|
90
|
+
return refuse(1, `${file} is not a study registry: ${r.why}`);
|
|
91
|
+
case "read":
|
|
92
|
+
return r.registry;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isRefusal = (v: unknown): v is Refusal =>
|
|
97
|
+
typeof v === "object" && v !== null && "code" in v && "line" in v;
|
|
98
|
+
|
|
99
|
+
const text = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
100
|
+
|
|
101
|
+
/** The README's text; null when there is none. */
|
|
102
|
+
const readmeOf = (dir: string, files: Files): string | null => {
|
|
103
|
+
const b = files.readBytes(callerPath(join(dir, REPRO_DIR, README)));
|
|
104
|
+
return b === null ? null : new TextDecoder().decode(b);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const studiesCount = (r: StudyRegistry): string =>
|
|
108
|
+
`${String(r.studies.length)} ${r.studies.length === 1 ? "study" : "studies"}`;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
|
|
112
|
+
* is, so the padding a formatter gave the table is never undone.
|
|
113
|
+
*/
|
|
114
|
+
function writeIndex(dir: string, r: StudyRegistry, files: Files): boolean {
|
|
115
|
+
const before = readmeOf(dir, files);
|
|
116
|
+
if (indexInSync(before, r)) return false;
|
|
117
|
+
files.writeAtomic(
|
|
118
|
+
callerPath(join(dir, REPRO_DIR, README)),
|
|
119
|
+
text(withIndex(before, r)),
|
|
120
|
+
);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The refusal when something that is not a file sits where `repro/README.md` goes; null when none. */
|
|
125
|
+
function readmeBlocked(p: Paper, deps: StudiesDeps): Refusal | null {
|
|
126
|
+
const listed = deps.listDir(callerPath(join(p.dir, REPRO_DIR)));
|
|
127
|
+
const path = callerPath(join(p.dir, REPRO_DIR, README));
|
|
128
|
+
return listed.includes(README) && !deps.files.isFile(path)
|
|
129
|
+
? refuse(
|
|
130
|
+
1,
|
|
131
|
+
`${p.shown}/${REPRO_DIR}/${README} is not a file, so the index cannot be written there. Move it, then run this again.`,
|
|
132
|
+
)
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `studies index`: the README's block regenerated from the registry. */
|
|
137
|
+
function runIndex(
|
|
138
|
+
p: Paper,
|
|
139
|
+
a: StudiesArgs,
|
|
140
|
+
deps: StudiesDeps,
|
|
141
|
+
): Refusal | readonly string[] {
|
|
142
|
+
const [surplus] = [...(a.title === undefined ? [] : [a.title]), ...a.extra];
|
|
143
|
+
if (surplus !== undefined)
|
|
144
|
+
return refuse(
|
|
145
|
+
2,
|
|
146
|
+
`studies index takes only the paper's folder — \`${surplus}\` is one argument too many`,
|
|
147
|
+
);
|
|
148
|
+
const r = registryIn(p, deps);
|
|
149
|
+
if (isRefusal(r)) return r;
|
|
150
|
+
const blocked = readmeBlocked(p, deps);
|
|
151
|
+
if (blocked !== null) return blocked;
|
|
152
|
+
const readme = `${p.shown}/${REPRO_DIR}/${README}`;
|
|
153
|
+
return [
|
|
154
|
+
writeIndex(p.dir, r, deps.files)
|
|
155
|
+
? `wrote the index of ${studiesCount(r)} in ${readme}`
|
|
156
|
+
: `the index in ${readme} is in sync with ${studiesCount(r)}`,
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `new`'s title and kind from the command line, or the refusal. */
|
|
161
|
+
function newArgs(
|
|
162
|
+
a: StudiesArgs,
|
|
163
|
+
): Refusal | { readonly title: string; readonly kind: StudyKind } {
|
|
164
|
+
if (a.title === undefined)
|
|
165
|
+
return refuse(2, `studies new needs the study's title: ${NEW_USAGE}`);
|
|
166
|
+
const [surplus] = a.extra;
|
|
167
|
+
if (surplus !== undefined)
|
|
168
|
+
return refuse(
|
|
169
|
+
2,
|
|
170
|
+
`studies new takes the paper's folder and one title — \`${surplus}\` is one argument too many (quote a title of several words)`,
|
|
171
|
+
);
|
|
172
|
+
const kind = a.kind ?? DEFAULT_KIND;
|
|
173
|
+
return isStudyKind(kind)
|
|
174
|
+
? { title: a.title, kind }
|
|
175
|
+
: refuse(
|
|
176
|
+
2,
|
|
177
|
+
`--kind ${kind} is not a kind of study: one of ${STUDY_KINDS.join(", ")}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Write a new study's README.md, the registry, and the index; the lines that say so. */
|
|
182
|
+
function writeStudy(
|
|
183
|
+
p: Paper,
|
|
184
|
+
added: { readonly registry: StudyRegistry; readonly study: Study },
|
|
185
|
+
files: Files,
|
|
186
|
+
): readonly string[] {
|
|
187
|
+
const { study, registry } = added;
|
|
188
|
+
const at = (...names: readonly string[]): AbsolutePath =>
|
|
189
|
+
callerPath(join(p.dir, REPRO_DIR, ...names));
|
|
190
|
+
files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
|
|
191
|
+
files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
|
|
192
|
+
writeIndex(p.dir, registry, files);
|
|
193
|
+
const repro = `${p.shown}/${REPRO_DIR}`;
|
|
194
|
+
return [
|
|
195
|
+
`created ${repro}/${study.folder}/${README}`,
|
|
196
|
+
`added ${study.id} to ${repro}/${STUDIES_FILE}: kind ${study.kind}, status ${study.status}, ${study.date}`,
|
|
197
|
+
`wrote the index of ${studiesCount(registry)} in ${repro}/${README}`,
|
|
198
|
+
`Next: write the study's one-sentence question in its row (\`question\`) and in its README.md. When it ends, set \`status\`, \`result\`, \`result_file\` and \`in_paper\` in its row, then run \`npx paperlint studies index ${p.shown}\`.`,
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `studies new`: the folder, its README.md, the row, the index. */
|
|
203
|
+
function runNew(
|
|
204
|
+
p: Paper,
|
|
205
|
+
a: StudiesArgs,
|
|
206
|
+
deps: StudiesDeps,
|
|
207
|
+
): Refusal | readonly string[] {
|
|
208
|
+
const args = newArgs(a);
|
|
209
|
+
if (isRefusal(args)) return args;
|
|
210
|
+
const r = registryIn(p, deps);
|
|
211
|
+
if (isRefusal(r)) return r;
|
|
212
|
+
const blocked = readmeBlocked(p, deps);
|
|
213
|
+
if (blocked !== null) return blocked;
|
|
214
|
+
const added = addStudy(r, { ...args, date: deps.today });
|
|
215
|
+
if (!added.ok) return refuse(2, added.why);
|
|
216
|
+
const folder = added.study.folder;
|
|
217
|
+
return deps.listDir(callerPath(join(p.dir, REPRO_DIR))).includes(folder)
|
|
218
|
+
? refuse(
|
|
219
|
+
1,
|
|
220
|
+
`${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
|
|
221
|
+
)
|
|
222
|
+
: writeStudy(p, added, deps.files);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The paper folder the command line names, or the refusal. */
|
|
226
|
+
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Paper {
|
|
227
|
+
if (a.sub === undefined)
|
|
228
|
+
return refuse(2, `studies needs a subcommand: ${SUBS}`);
|
|
229
|
+
if (a.sub !== "new" && a.sub !== "index")
|
|
230
|
+
return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
|
|
231
|
+
if (a.paperDir === undefined)
|
|
232
|
+
return refuse(
|
|
233
|
+
2,
|
|
234
|
+
`studies ${a.sub} needs the paper's folder: ${a.sub === "new" ? NEW_USAGE : INDEX_USAGE}`,
|
|
235
|
+
);
|
|
236
|
+
const dir = resolve(deps.cwd, a.paperDir);
|
|
237
|
+
const shown = relative(deps.cwd, dir) || ".";
|
|
238
|
+
return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
|
|
239
|
+
? { dir, shown }
|
|
240
|
+
: refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
|
|
244
|
+
export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
|
|
245
|
+
const p = paperOf(a, deps);
|
|
246
|
+
const out = isRefusal(p)
|
|
247
|
+
? p
|
|
248
|
+
: a.sub === "new"
|
|
249
|
+
? runNew(p, a, deps)
|
|
250
|
+
: runIndex(p, a, deps);
|
|
251
|
+
if (isRefusal(out)) {
|
|
252
|
+
deps.err(out.line);
|
|
253
|
+
return out.code;
|
|
254
|
+
}
|
|
255
|
+
out.forEach((line) => {
|
|
256
|
+
deps.log(line);
|
|
257
|
+
});
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `repro/study-registered` (`src/study-registry-rule.ts`) on paper folders held in memory, run the way
|
|
3
|
+
* ESLint runs it: `create`, then `root`. Both halves: a registered `repro/` with its index in sync is
|
|
4
|
+
* silent; each defect — an unregistered folder, a dangling row, a bad id, `done` without a result,
|
|
5
|
+
* `superseded` without a target, a stale index, a missing or unreadable registry — is reported once,
|
|
6
|
+
* on the first line of `PIPELINE-STATUS.md`, with the domain's text.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
|
|
10
|
+
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
11
|
+
import {
|
|
12
|
+
registryText,
|
|
13
|
+
studyMessage,
|
|
14
|
+
withIndex,
|
|
15
|
+
type Study,
|
|
16
|
+
type StudyRegistry,
|
|
17
|
+
} from "./domain/study-registry.ts";
|
|
18
|
+
import {
|
|
19
|
+
registryOf,
|
|
20
|
+
STUDY_REGISTRY_RULE_LEVELS,
|
|
21
|
+
studyFindings,
|
|
22
|
+
studyRegistryRules,
|
|
23
|
+
} from "./study-registry-rule.ts";
|
|
24
|
+
|
|
25
|
+
const DIR = "/work/papers/example";
|
|
26
|
+
const SCAFFOLD = {
|
|
27
|
+
[`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
|
|
28
|
+
[`${DIR}/paper.tex`]: "\\documentclass{article}\n",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const study = (over: Partial<Study> = {}): Study => ({
|
|
32
|
+
id: "P1-S01",
|
|
33
|
+
folder: "P1-S01-baseline",
|
|
34
|
+
title: "Baseline pass rate",
|
|
35
|
+
question: "How often does the agent pass?",
|
|
36
|
+
kind: "measurement",
|
|
37
|
+
status: "designing",
|
|
38
|
+
date: "2026-10-15",
|
|
39
|
+
in_paper: "no",
|
|
40
|
+
...over,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A registered `repro/`: the registry, its index, and a README.md in each row's folder. */
|
|
44
|
+
const registered = (
|
|
45
|
+
r: StudyRegistry,
|
|
46
|
+
readme: string | null = withIndex("# Reproduction index\n", r),
|
|
47
|
+
): Record<string, string> => ({
|
|
48
|
+
...SCAFFOLD,
|
|
49
|
+
[`${DIR}/repro/studies.json`]: registryText(r),
|
|
50
|
+
...(readme === null ? {} : { [`${DIR}/repro/README.md`]: readme }),
|
|
51
|
+
...Object.fromEntries(
|
|
52
|
+
r.studies.map((s) => [`${DIR}/repro/${s.folder}/README.md`, "# s\n"]),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const P1 = (...studies: Study[]): StudyRegistry => ({ prefix: "P1", studies });
|
|
57
|
+
|
|
58
|
+
interface Reported {
|
|
59
|
+
readonly messageId: string;
|
|
60
|
+
readonly message: string;
|
|
61
|
+
readonly line: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The rule over the folder `tree` describes, reached through `file` (default the scorecard). */
|
|
65
|
+
function lint(
|
|
66
|
+
tree: Readonly<Record<string, string>>,
|
|
67
|
+
file = `${DIR}/PIPELINE-STATUS.md`,
|
|
68
|
+
): Reported[] {
|
|
69
|
+
const files = memoryFiles(tree);
|
|
70
|
+
const rule = studyRegistryRules({
|
|
71
|
+
files,
|
|
72
|
+
listDir: memoryListDir(files),
|
|
73
|
+
codec: studyRegistryCodec,
|
|
74
|
+
})["study-registered"];
|
|
75
|
+
const out: Reported[] = [];
|
|
76
|
+
const visitor = rule.create({
|
|
77
|
+
filename: file,
|
|
78
|
+
sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
|
|
79
|
+
report: (d) => {
|
|
80
|
+
const template = rule.meta.messages[d.messageId] ?? "";
|
|
81
|
+
const message = Object.entries(d.data).reduce(
|
|
82
|
+
(m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
|
|
83
|
+
template,
|
|
84
|
+
);
|
|
85
|
+
out.push({ messageId: d.messageId, message, line: d.loc.start.line });
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
visitor.root?.();
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messages = (tree: Readonly<Record<string, string>>): string[] =>
|
|
93
|
+
lint(tree).map((r) => r.message);
|
|
94
|
+
|
|
95
|
+
describe("repro/study-registered — silent", () => {
|
|
96
|
+
it("on a registered repro/ with its index in sync, beside tools/, data/ and byproducts", () => {
|
|
97
|
+
const done = study({
|
|
98
|
+
id: "P1-S02",
|
|
99
|
+
folder: "P1-S02-ablation",
|
|
100
|
+
status: "done",
|
|
101
|
+
result: "41 of 60 pass",
|
|
102
|
+
result_file: "out/results.tsv",
|
|
103
|
+
});
|
|
104
|
+
expect(
|
|
105
|
+
lint({
|
|
106
|
+
...registered(P1(study(), done)),
|
|
107
|
+
[`${DIR}/repro/P1-S02-ablation/out/results.tsv`]: "x\n",
|
|
108
|
+
[`${DIR}/repro/tools/run.py`]: "x",
|
|
109
|
+
[`${DIR}/repro/data/numbers.tsv`]: "x",
|
|
110
|
+
[`${DIR}/repro/__pycache__/x.pyc`]: "x",
|
|
111
|
+
[`${DIR}/repro/node_modules/a/index.js`]: "x",
|
|
112
|
+
[`${DIR}/repro/.gitignore`]: "x",
|
|
113
|
+
}),
|
|
114
|
+
).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("on a paper with no repro/", () => {
|
|
118
|
+
expect(lint(SCAFFOLD)).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("on a repro/ with only its shared folders and no registry", () => {
|
|
122
|
+
expect(
|
|
123
|
+
lint({
|
|
124
|
+
...SCAFFOLD,
|
|
125
|
+
[`${DIR}/repro/README.md`]: "# index\n",
|
|
126
|
+
[`${DIR}/repro/tools/.gitkeep`]: "",
|
|
127
|
+
[`${DIR}/repro/data/.gitkeep`]: "",
|
|
128
|
+
}),
|
|
129
|
+
).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("on any file but PIPELINE-STATUS.md", () => {
|
|
133
|
+
expect(
|
|
134
|
+
lint(
|
|
135
|
+
{ ...SCAFFOLD, [`${DIR}/repro/spike-2026-10-12/README.md`]: "x" },
|
|
136
|
+
`${DIR}/paper.tex`,
|
|
137
|
+
),
|
|
138
|
+
).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("repro/study-registered — reports, once each, on line 1", () => {
|
|
143
|
+
it("an unregistered folder", () => {
|
|
144
|
+
expect(
|
|
145
|
+
lint({
|
|
146
|
+
...registered(P1(study())),
|
|
147
|
+
[`${DIR}/repro/spike-2026-10-12/README.md`]: "x",
|
|
148
|
+
}),
|
|
149
|
+
).toEqual([
|
|
150
|
+
{
|
|
151
|
+
messageId: "unregistered",
|
|
152
|
+
message: studyMessage({
|
|
153
|
+
problem: "unregistered",
|
|
154
|
+
folder: "spike-2026-10-12",
|
|
155
|
+
next: "P1-S02",
|
|
156
|
+
}),
|
|
157
|
+
line: 1,
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("a dangling row", () => {
|
|
163
|
+
const tree = registered(P1(study()));
|
|
164
|
+
const { [`${DIR}/repro/P1-S01-baseline/README.md`]: gone, ...rest } = tree;
|
|
165
|
+
expect(gone).toBeDefined();
|
|
166
|
+
expect(messages(rest)).toEqual([
|
|
167
|
+
studyMessage({
|
|
168
|
+
problem: "no-folder",
|
|
169
|
+
id: "P1-S01",
|
|
170
|
+
folder: "P1-S01-baseline",
|
|
171
|
+
}),
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("a bad id", () => {
|
|
176
|
+
expect(
|
|
177
|
+
messages(registered(P1(study({ id: "P1-S1", folder: "P1-S1-x" })))),
|
|
178
|
+
).toEqual([studyMessage({ problem: "bad-id", id: "P1-S1", prefix: "P1" })]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("repro/study-registered — reports a row's status and the index", () => {
|
|
183
|
+
it("done without a result", () => {
|
|
184
|
+
expect(messages(registered(P1(study({ status: "done" }))))).toEqual([
|
|
185
|
+
studyMessage({
|
|
186
|
+
problem: "no-result",
|
|
187
|
+
id: "P1-S01",
|
|
188
|
+
missing: ["result", "result_file"],
|
|
189
|
+
}),
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("done with a result_file that is not there", () => {
|
|
194
|
+
expect(
|
|
195
|
+
messages(
|
|
196
|
+
registered(
|
|
197
|
+
P1(study({ status: "done", result: "x", result_file: "r.tsv" })),
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
).toEqual([
|
|
201
|
+
studyMessage({
|
|
202
|
+
problem: "result-file",
|
|
203
|
+
id: "P1-S01",
|
|
204
|
+
path: "r.tsv",
|
|
205
|
+
why: "missing",
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("superseded without a target", () => {
|
|
211
|
+
expect(messages(registered(P1(study({ status: "superseded" }))))).toEqual([
|
|
212
|
+
studyMessage({
|
|
213
|
+
problem: "no-successor",
|
|
214
|
+
id: "P1-S01",
|
|
215
|
+
superseded_by: null,
|
|
216
|
+
}),
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("a stale index", () => {
|
|
221
|
+
const r = P1(study());
|
|
222
|
+
expect(messages(registered(r, withIndex("# x\n", P1())))).toEqual([
|
|
223
|
+
studyMessage({ problem: "stale-index", why: "differs" }),
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("repro/study-registered — reports the registry itself, once", () => {
|
|
229
|
+
it("study folders and no studies.json: one finding naming the file", () => {
|
|
230
|
+
expect(
|
|
231
|
+
lint({
|
|
232
|
+
...SCAFFOLD,
|
|
233
|
+
[`${DIR}/repro/README.md`]: "# index\n",
|
|
234
|
+
[`${DIR}/repro/b-2026-10-02/README.md`]: "x",
|
|
235
|
+
[`${DIR}/repro/a-2026-10-01/README.md`]: "x",
|
|
236
|
+
[`${DIR}/repro/tools/run.py`]: "x",
|
|
237
|
+
}),
|
|
238
|
+
).toEqual([
|
|
239
|
+
{
|
|
240
|
+
messageId: "no-registry",
|
|
241
|
+
message: studyMessage({
|
|
242
|
+
problem: "no-registry",
|
|
243
|
+
folders: ["a-2026-10-01", "b-2026-10-02"],
|
|
244
|
+
}),
|
|
245
|
+
line: 1,
|
|
246
|
+
},
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("a studies.json that is not a registry: one finding with the reasons", () => {
|
|
251
|
+
expect(
|
|
252
|
+
lint({
|
|
253
|
+
...SCAFFOLD,
|
|
254
|
+
[`${DIR}/repro/studies.json`]: '{ "studies": [] }',
|
|
255
|
+
[`${DIR}/repro/P1-S01-x/README.md`]: "x",
|
|
256
|
+
}),
|
|
257
|
+
).toEqual([
|
|
258
|
+
{
|
|
259
|
+
messageId: "unreadable",
|
|
260
|
+
message: studyMessage({
|
|
261
|
+
problem: "unreadable",
|
|
262
|
+
why: "prefix: Invalid input: expected string, received undefined",
|
|
263
|
+
}),
|
|
264
|
+
line: 1,
|
|
265
|
+
},
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("repro/study-registered — level", () => {
|
|
271
|
+
it("is an error from the start", () => {
|
|
272
|
+
expect(STUDY_REGISTRY_RULE_LEVELS).toEqual({
|
|
273
|
+
"repro/study-registered": "error",
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("repro/study-registered — a directory where the registry should be", () => {
|
|
279
|
+
it("is read as no registry, not thrown on (the disk adapter throws EISDIR on a directory)", () => {
|
|
280
|
+
const files = {
|
|
281
|
+
...memoryFiles({}),
|
|
282
|
+
isFile: () => false,
|
|
283
|
+
readBytes: (): Uint8Array => {
|
|
284
|
+
throw Object.assign(new Error("EISDIR"), { code: "EISDIR" });
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
expect(registryOf(DIR, { files, codec: studyRegistryCodec })).toEqual({
|
|
288
|
+
kind: "absent",
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("repro/study-registered — a result_file the disk cannot hold", () => {
|
|
294
|
+
it("reads as missing, and the disk is never asked (the disk adapter throws on a NUL)", () => {
|
|
295
|
+
const r = P1(study({ status: "running", result_file: "a\u0000b.tsv" }));
|
|
296
|
+
const files = memoryFiles({
|
|
297
|
+
...SCAFFOLD,
|
|
298
|
+
[`${DIR}/repro/studies.json`]: registryText(r),
|
|
299
|
+
[`${DIR}/repro/README.md`]: withIndex("# index\n", r),
|
|
300
|
+
[`${DIR}/repro/P1-S01-baseline/README.md`]: "# s\n",
|
|
301
|
+
});
|
|
302
|
+
const probe = {
|
|
303
|
+
...files,
|
|
304
|
+
isFile: (p: Parameters<typeof files.isFile>[0]): boolean => {
|
|
305
|
+
if (p.includes("\0")) throw new Error("ERR_INVALID_ARG_VALUE");
|
|
306
|
+
return files.isFile(p);
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
expect(
|
|
310
|
+
studyFindings(DIR, {
|
|
311
|
+
files: probe,
|
|
312
|
+
listDir: memoryListDir(files),
|
|
313
|
+
codec: studyRegistryCodec,
|
|
314
|
+
}).map((f) => f.problem),
|
|
315
|
+
).toEqual(["result-file"]);
|
|
316
|
+
});
|
|
317
|
+
});
|