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/repro-layout-rule.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `repro/allowed-files-only` — the top of a paper's `repro/` holds an index, the
|
|
3
|
-
* `data/`, and one
|
|
2
|
+
* `repro/allowed-files-only` — the top of a paper's `repro/` holds an index, the study registry, the
|
|
3
|
+
* shared `tools/` and `data/`, and one folder per study, each with a `README.md`. Whether each study
|
|
4
|
+
* folder has its row in the registry is `repro/study-registered`'s (src/study-registry-rule.ts).
|
|
4
5
|
*
|
|
5
6
|
* What may stand there, and why, is `src/domain/repro-layout.ts` and docs/rules/repro/allowed-files-only.md.
|
|
6
7
|
* This file lists the folder and reports.
|
|
@@ -76,7 +77,7 @@ const META: FolderRuleModule["meta"] = {
|
|
|
76
77
|
type: "suggestion",
|
|
77
78
|
docs: {
|
|
78
79
|
description:
|
|
79
|
-
"the top of a paper's repro/ holds README.md, .gitignore, tools/, data/ and one <slug>-YYYY-MM-DD folder per study, each with a README.md",
|
|
80
|
+
"the top of a paper's repro/ holds README.md, studies.json, .gitignore, tools/, data/ and one <id>-<slug> (or <slug>-YYYY-MM-DD) folder per study, each with a README.md",
|
|
80
81
|
url: rulePageUrl("repro/allowed-files-only"),
|
|
81
82
|
},
|
|
82
83
|
schema: [],
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -17,6 +17,11 @@ import { describe, expect, it } from "vitest";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { run, rulePlugins } from "./cli.ts";
|
|
19
19
|
import { sha256Hex } from "./domain/sha256.ts";
|
|
20
|
+
import {
|
|
21
|
+
registryText,
|
|
22
|
+
withIndex,
|
|
23
|
+
type StudyRegistry,
|
|
24
|
+
} from "./domain/study-registry.ts";
|
|
20
25
|
import { recordText } from "../test/recorded-fixture.ts";
|
|
21
26
|
import { useTempDir, venuePreset, writeTree } from "../test/support.ts";
|
|
22
27
|
|
|
@@ -39,6 +44,28 @@ const paper = (
|
|
|
39
44
|
...extra,
|
|
40
45
|
});
|
|
41
46
|
|
|
47
|
+
/** A registered study: `repro/studies.json` with its row, the study's folder, and the index in sync. */
|
|
48
|
+
const REGISTRY: StudyRegistry = {
|
|
49
|
+
prefix: "P1",
|
|
50
|
+
studies: [
|
|
51
|
+
{
|
|
52
|
+
id: "P1-S01",
|
|
53
|
+
folder: "P1-S01-ablation",
|
|
54
|
+
title: "Ablation",
|
|
55
|
+
question: "Which step does the work?",
|
|
56
|
+
kind: "measurement",
|
|
57
|
+
status: "designing",
|
|
58
|
+
date: "2026-10-15",
|
|
59
|
+
in_paper: "no",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
const REGISTERED_STUDY: Readonly<Record<string, string>> = {
|
|
64
|
+
[`${P}/repro/studies.json`]: registryText(REGISTRY),
|
|
65
|
+
[`${P}/repro/README.md`]: withIndex("# repro\n", REGISTRY),
|
|
66
|
+
[`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
|
|
67
|
+
};
|
|
68
|
+
|
|
42
69
|
/** `study/declare-sample` is off by default; a paper turns it on in its `paperlint.json`. */
|
|
43
70
|
const DECLARE_SAMPLE_ON: Readonly<Record<string, string>> = {
|
|
44
71
|
[`${P}/paperlint.json`]: JSON.stringify({
|
|
@@ -730,6 +757,20 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
730
757
|
[`${P}/repro/ablation-2026-10-12/README.md`]: "# ablation\n",
|
|
731
758
|
}),
|
|
732
759
|
},
|
|
760
|
+
// A study folder with no row in repro/studies.json.
|
|
761
|
+
"repro/study-registered": {
|
|
762
|
+
reports: {
|
|
763
|
+
tree: paper(tex("x"), {
|
|
764
|
+
...REGISTERED_STUDY,
|
|
765
|
+
[`${P}/repro/spike-2026-10-12/README.md`]: "# spike\n",
|
|
766
|
+
}),
|
|
767
|
+
file: `${P}/PIPELINE-STATUS.md`,
|
|
768
|
+
severity: 2,
|
|
769
|
+
line: 1,
|
|
770
|
+
},
|
|
771
|
+
// Every folder registered, and the index the registry generates.
|
|
772
|
+
silent: paper(tex("x"), REGISTERED_STUDY),
|
|
773
|
+
},
|
|
733
774
|
// A study's prereg.json that declares none of population, strata, screens, coverage, author_check.
|
|
734
775
|
// Off by default: the paper turns it on, as both trees do.
|
|
735
776
|
"study/declare-sample": {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint studies new|index` through the CLI's own `run`, on a real paper folder in a temp
|
|
3
|
+
* directory: the wiring (`src/cli.ts`), the disk, today's date — and the promise both commands make,
|
|
4
|
+
* that what they write is what `repro/study-registered` accepts. Both halves: a registry left stale
|
|
5
|
+
* by hand is reported by the same lint, and `studies index` makes it clean again.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
11
|
+
import { run } from "./cli.ts";
|
|
12
|
+
import { lintReport } from "../test/lint-report.ts";
|
|
13
|
+
import { useTempDir, writeTree } from "../test/support.ts";
|
|
14
|
+
|
|
15
|
+
const root = useTempDir("studies-cli-");
|
|
16
|
+
|
|
17
|
+
/** `paperlint <args>` from the project root: the exit code and both streams. */
|
|
18
|
+
async function cli(cwd: string, args: readonly string[]) {
|
|
19
|
+
const log: string[] = [];
|
|
20
|
+
const err: string[] = [];
|
|
21
|
+
const code = await run(args, {
|
|
22
|
+
cwd,
|
|
23
|
+
log: (s: string) => log.push(s),
|
|
24
|
+
err: (s: string) => err.push(s),
|
|
25
|
+
});
|
|
26
|
+
return { code, log, err };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The `repro/study-registered` findings of `paperlint lint` over the project. */
|
|
30
|
+
async function registryFindings(cwd: string): Promise<readonly string[]> {
|
|
31
|
+
const { log } = await cli(cwd, ["lint", "--json"]);
|
|
32
|
+
return lintReport(log.join("\n")).flatMap((r) =>
|
|
33
|
+
r.messages
|
|
34
|
+
.filter((m) => m.ruleId === "repro/study-registered")
|
|
35
|
+
.map((m) => m.message),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A local `YYYY-MM-DD`, as the CLI reads the clock. */
|
|
40
|
+
const localDate = (d: Date): string =>
|
|
41
|
+
`${String(d.getFullYear())}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
42
|
+
|
|
43
|
+
const dir = join(root, "project");
|
|
44
|
+
writeTree(dir, {
|
|
45
|
+
"package.json": "{}",
|
|
46
|
+
"papers/example/PIPELINE-STATUS.md": "---\nstages: []\n---\n# status\n",
|
|
47
|
+
"papers/example/paper.tex":
|
|
48
|
+
"\\documentclass{article}\n\\begin{document}\nx\n\\end{document}\n",
|
|
49
|
+
"papers/example/repro/README.md": "# Reproduction index\n",
|
|
50
|
+
"papers/example/repro/studies.json": '{ "prefix": "P1", "studies": [] }\n',
|
|
51
|
+
});
|
|
52
|
+
const repro = join(dir, "papers/example/repro");
|
|
53
|
+
|
|
54
|
+
describe("paperlint studies new — through the CLI", () => {
|
|
55
|
+
it("new registers a study dated today, and the lint finds nothing to report", async () => {
|
|
56
|
+
const before = localDate(new Date());
|
|
57
|
+
const out = await cli(dir, [
|
|
58
|
+
"studies",
|
|
59
|
+
"new",
|
|
60
|
+
"papers/example",
|
|
61
|
+
"Baseline pass rate",
|
|
62
|
+
"--kind",
|
|
63
|
+
"spike",
|
|
64
|
+
]);
|
|
65
|
+
const after = localDate(new Date());
|
|
66
|
+
expect([out.code, out.err]).toEqual([0, []]);
|
|
67
|
+
expect(out.log[0]).toBe(
|
|
68
|
+
"created papers/example/repro/P1-S01-baseline-pass-rate/README.md",
|
|
69
|
+
);
|
|
70
|
+
const parsed = studyRegistryCodec.parse(
|
|
71
|
+
readFileSync(join(repro, "studies.json"), "utf8"),
|
|
72
|
+
);
|
|
73
|
+
const studies = parsed.ok ? parsed.registry.studies : [];
|
|
74
|
+
expect(studies.map((s) => [s.id, s.kind])).toEqual([["P1-S01", "spike"]]);
|
|
75
|
+
expect([before, after]).toContain(studies[0]?.date);
|
|
76
|
+
expect(await registryFindings(dir)).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("paperlint studies index — through the CLI", () => {
|
|
81
|
+
it("a row edited by hand leaves the index stale; studies index makes it clean again", async () => {
|
|
82
|
+
const file = join(repro, "studies.json");
|
|
83
|
+
writeTree(repro, {
|
|
84
|
+
"studies.json": readFileSync(file, "utf8").replace(
|
|
85
|
+
'"designing"',
|
|
86
|
+
'"running"',
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
expect(await registryFindings(dir)).toEqual([
|
|
90
|
+
expect.stringMatching(/^The index in `repro\/README\.md`/u),
|
|
91
|
+
]);
|
|
92
|
+
const out = await cli(dir, ["studies", "index", "papers/example"]);
|
|
93
|
+
expect([out.code, out.log, out.err]).toEqual([
|
|
94
|
+
0,
|
|
95
|
+
["wrote the index of 1 study in papers/example/repro/README.md"],
|
|
96
|
+
[],
|
|
97
|
+
]);
|
|
98
|
+
expect(readFileSync(join(repro, "README.md"), "utf8")).toContain(
|
|
99
|
+
"| P1-S01 | Baseline pass rate | running |",
|
|
100
|
+
);
|
|
101
|
+
expect(await registryFindings(dir)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a refusal exits non-zero and says why", async () => {
|
|
105
|
+
const out = await cli(dir, ["studies", "new", "papers/example"]);
|
|
106
|
+
expect([out.code, out.err]).toEqual([
|
|
107
|
+
2,
|
|
108
|
+
[
|
|
109
|
+
'studies new needs the study\'s title: npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]',
|
|
110
|
+
],
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint studies new|index <paper-dir>` (`src/studies.ts`) on paper folders held in memory. Both
|
|
3
|
+
* halves: each command writes exactly the files it promises, and a run that cannot do what it was
|
|
4
|
+
* asked writes nothing and says why, with the exit code (2 for the command line, 1 for the paper's
|
|
5
|
+
* state).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
|
|
9
|
+
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
indexBlock,
|
|
12
|
+
registryText,
|
|
13
|
+
studyReadme,
|
|
14
|
+
withIndex,
|
|
15
|
+
type Study,
|
|
16
|
+
type StudyRegistry,
|
|
17
|
+
} from "./domain/study-registry.ts";
|
|
18
|
+
import { runStudies, type StudiesArgs } from "./studies.ts";
|
|
19
|
+
|
|
20
|
+
const CWD = "/work";
|
|
21
|
+
const DIR = "/work/papers/example";
|
|
22
|
+
const REPRO = `${DIR}/repro`;
|
|
23
|
+
|
|
24
|
+
const study = (over: Partial<Study> = {}): Study => ({
|
|
25
|
+
id: "P1-S01",
|
|
26
|
+
folder: "P1-S01-baseline",
|
|
27
|
+
title: "Baseline",
|
|
28
|
+
question: "q?",
|
|
29
|
+
kind: "measurement",
|
|
30
|
+
status: "done",
|
|
31
|
+
date: "2026-10-01",
|
|
32
|
+
result: "41 of 60",
|
|
33
|
+
result_file: "r.tsv",
|
|
34
|
+
in_paper: "yes",
|
|
35
|
+
role: "counted",
|
|
36
|
+
...over,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const P1 = (...studies: Study[]): StudyRegistry => ({ prefix: "P1", studies });
|
|
40
|
+
|
|
41
|
+
const paper = (
|
|
42
|
+
extra: Readonly<Record<string, string>> = {},
|
|
43
|
+
): Record<string, string> => ({
|
|
44
|
+
[`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
|
|
45
|
+
...extra,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** `studies` over `tree`, from `/work`: the exit code, what it printed, and the files after. */
|
|
49
|
+
function studies(
|
|
50
|
+
a: Partial<StudiesArgs>,
|
|
51
|
+
tree: Readonly<Record<string, string>>,
|
|
52
|
+
cwd = CWD,
|
|
53
|
+
) {
|
|
54
|
+
const files = memoryFiles(tree);
|
|
55
|
+
const log: string[] = [];
|
|
56
|
+
const err: string[] = [];
|
|
57
|
+
const code = runStudies(
|
|
58
|
+
{
|
|
59
|
+
sub: "new",
|
|
60
|
+
paperDir: "papers/example",
|
|
61
|
+
title: undefined,
|
|
62
|
+
extra: [],
|
|
63
|
+
kind: null,
|
|
64
|
+
...a,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
files,
|
|
68
|
+
listDir: memoryListDir(files),
|
|
69
|
+
codec: studyRegistryCodec,
|
|
70
|
+
cwd,
|
|
71
|
+
today: "2026-10-15",
|
|
72
|
+
log: (l) => log.push(l),
|
|
73
|
+
err: (l) => err.push(l),
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
const after = Object.fromEntries(
|
|
77
|
+
[...files.map].map(([k, v]) => [k, new TextDecoder().decode(v)]),
|
|
78
|
+
);
|
|
79
|
+
return { code, log, err, after };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A paper with one registered study and a README without the index yet. */
|
|
83
|
+
const before = paper({
|
|
84
|
+
[`${REPRO}/studies.json`]: registryText(P1(study())),
|
|
85
|
+
[`${REPRO}/README.md`]: "# Reproduction index\n\nIntro.\n",
|
|
86
|
+
[`${REPRO}/P1-S01-baseline/README.md`]: "# s\n",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("studies new", () => {
|
|
90
|
+
it("creates the folder's README.md, appends the row, regenerates the index", () => {
|
|
91
|
+
const { code, log, err, after } = studies(
|
|
92
|
+
{ title: "Ablation of the guard", kind: "spike" },
|
|
93
|
+
before,
|
|
94
|
+
);
|
|
95
|
+
const added: Study = {
|
|
96
|
+
id: "P1-S02",
|
|
97
|
+
folder: "P1-S02-ablation-of-the-guard",
|
|
98
|
+
title: "Ablation of the guard",
|
|
99
|
+
question: "",
|
|
100
|
+
kind: "spike",
|
|
101
|
+
status: "designing",
|
|
102
|
+
date: "2026-10-15",
|
|
103
|
+
in_paper: "no",
|
|
104
|
+
};
|
|
105
|
+
const r = P1(study(), added);
|
|
106
|
+
expect({ code, err }).toEqual({ code: 0, err: [] });
|
|
107
|
+
expect(after).toEqual({
|
|
108
|
+
...before,
|
|
109
|
+
[`${REPRO}/P1-S02-ablation-of-the-guard/README.md`]: studyReadme(added),
|
|
110
|
+
[`${REPRO}/studies.json`]: registryText(r),
|
|
111
|
+
[`${REPRO}/README.md`]: `# Reproduction index\n\nIntro.\n\n${indexBlock(r)}\n`,
|
|
112
|
+
});
|
|
113
|
+
expect(log).toEqual([
|
|
114
|
+
"created papers/example/repro/P1-S02-ablation-of-the-guard/README.md",
|
|
115
|
+
"added P1-S02 to papers/example/repro/studies.json: kind spike, status designing, 2026-10-15",
|
|
116
|
+
"wrote the index of 2 studies in papers/example/repro/README.md",
|
|
117
|
+
"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 papers/example`.",
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("is a measurement when no kind is named, and keeps a row's extra fields", () => {
|
|
122
|
+
const { code, after } = studies({ title: "Second run" }, before);
|
|
123
|
+
expect(code).toBe(0);
|
|
124
|
+
const r = studyRegistryCodec.parse(after[`${REPRO}/studies.json`] ?? "");
|
|
125
|
+
expect(
|
|
126
|
+
(r.ok ? r.registry.studies : []).map((s) => [s.id, s.kind, s["role"]]),
|
|
127
|
+
).toEqual([
|
|
128
|
+
["P1-S01", "measurement", "counted"],
|
|
129
|
+
["P1-S02", "measurement", undefined],
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("writes repro/README.md when there is none", () => {
|
|
134
|
+
const tree = Object.fromEntries(
|
|
135
|
+
Object.entries(before).filter(([k]) => k !== `${REPRO}/README.md`),
|
|
136
|
+
);
|
|
137
|
+
const { code, after } = studies({ title: "Second run" }, tree);
|
|
138
|
+
expect(code).toBe(0);
|
|
139
|
+
expect(after[`${REPRO}/README.md`]).toMatch(
|
|
140
|
+
/^# Reproduction index\n\n<!-- studies:begin/u,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("studies new — refuses, writing nothing", () => {
|
|
146
|
+
it.each([
|
|
147
|
+
[
|
|
148
|
+
"no title",
|
|
149
|
+
{ title: undefined },
|
|
150
|
+
2,
|
|
151
|
+
'studies new needs the study\'s title: npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]',
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
"an unknown kind",
|
|
155
|
+
{ title: "x", kind: "benchmark" },
|
|
156
|
+
2,
|
|
157
|
+
"--kind benchmark is not a kind of study: one of measurement, spike, tooling, data, artifact, salvage",
|
|
158
|
+
],
|
|
159
|
+
[
|
|
160
|
+
"a title over eight words",
|
|
161
|
+
{ title: "one two three four five six seven eight nine" },
|
|
162
|
+
2,
|
|
163
|
+
"the title has 9 words; a title is a plain name of at most 8 — the detail goes in the row's `question`",
|
|
164
|
+
],
|
|
165
|
+
[
|
|
166
|
+
"an argument past the title",
|
|
167
|
+
{ title: "x", extra: ["y"] },
|
|
168
|
+
2,
|
|
169
|
+
"studies new takes the paper's folder and one title — `y` is one argument too many (quote a title of several words)",
|
|
170
|
+
],
|
|
171
|
+
] as const)("%s", (_why, a, code, line) => {
|
|
172
|
+
const out = studies(a, before);
|
|
173
|
+
expect([out.code, out.err, out.after]).toEqual([code, [line], before]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("studies new — refuses by the paper's state, writing nothing", () => {
|
|
178
|
+
it("a folder that already exists", () => {
|
|
179
|
+
const tree = { ...before, [`${REPRO}/P1-S02-second-run/notes.md`]: "x" };
|
|
180
|
+
const out = studies({ title: "Second run" }, tree);
|
|
181
|
+
expect([out.code, out.err, out.after]).toEqual([
|
|
182
|
+
1,
|
|
183
|
+
[
|
|
184
|
+
"papers/example/repro/P1-S02-second-run/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.",
|
|
185
|
+
],
|
|
186
|
+
tree,
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("a paper with no studies.json: names the file, and does not guess the prefix", () => {
|
|
191
|
+
const tree = paper();
|
|
192
|
+
const out = studies({ title: "x" }, tree);
|
|
193
|
+
expect([out.code, out.err, out.after]).toEqual([
|
|
194
|
+
1,
|
|
195
|
+
[
|
|
196
|
+
'papers/example/repro/studies.json 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": [] }',
|
|
197
|
+
],
|
|
198
|
+
tree,
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("a studies.json that is not a registry", () => {
|
|
203
|
+
const tree = paper({ [`${REPRO}/studies.json`]: "{" });
|
|
204
|
+
const out = studies({ title: "x" }, tree);
|
|
205
|
+
expect([out.code, out.err]).toEqual([
|
|
206
|
+
1,
|
|
207
|
+
["papers/example/repro/studies.json is not a study registry: not JSON"],
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("studies index", () => {
|
|
213
|
+
const r = P1(study());
|
|
214
|
+
|
|
215
|
+
it("regenerates the block in place", () => {
|
|
216
|
+
const tree = paper({
|
|
217
|
+
[`${REPRO}/studies.json`]: registryText(r),
|
|
218
|
+
[`${REPRO}/README.md`]: `# I\n\n${indexBlock(P1())}\n\nTail.\n`,
|
|
219
|
+
});
|
|
220
|
+
const { code, log, err, after } = studies(
|
|
221
|
+
{ sub: "index", paperDir: "papers/example" },
|
|
222
|
+
tree,
|
|
223
|
+
);
|
|
224
|
+
expect({ code, log, err }).toEqual({
|
|
225
|
+
code: 0,
|
|
226
|
+
log: ["wrote the index of 1 study in papers/example/repro/README.md"],
|
|
227
|
+
err: [],
|
|
228
|
+
});
|
|
229
|
+
expect(after).toEqual({
|
|
230
|
+
...tree,
|
|
231
|
+
[`${REPRO}/README.md`]: `# I\n\n${indexBlock(r)}\n\nTail.\n`,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("says so and writes nothing when the index is in sync", () => {
|
|
236
|
+
const tree = paper({
|
|
237
|
+
[`${REPRO}/studies.json`]: registryText(r),
|
|
238
|
+
[`${REPRO}/README.md`]: withIndex("# I\n", r),
|
|
239
|
+
});
|
|
240
|
+
const out = studies({ sub: "index" }, tree);
|
|
241
|
+
expect([out.code, out.log, out.after]).toEqual([
|
|
242
|
+
0,
|
|
243
|
+
["the index in papers/example/repro/README.md is in sync with 1 study"],
|
|
244
|
+
tree,
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("refuses a title, which only `new` takes", () => {
|
|
249
|
+
const tree = paper({ [`${REPRO}/studies.json`]: registryText(r) });
|
|
250
|
+
const out = studies({ sub: "index", title: "x" }, tree);
|
|
251
|
+
expect([out.code, out.err]).toEqual([
|
|
252
|
+
2,
|
|
253
|
+
[
|
|
254
|
+
"studies index takes only the paper's folder — `x` is one argument too many",
|
|
255
|
+
],
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("studies index — a table a formatter padded", () => {
|
|
261
|
+
it("leaves an index in sync as it is, whatever its padding", () => {
|
|
262
|
+
const r = P1(study({ title: "測定 A" }));
|
|
263
|
+
const generated = withIndex("# I\n", r);
|
|
264
|
+
const padded = generated.replace("測定 A", "測定 A ");
|
|
265
|
+
expect(padded).not.toBe(generated);
|
|
266
|
+
const tree = paper({
|
|
267
|
+
[`${REPRO}/studies.json`]: registryText(r),
|
|
268
|
+
[`${REPRO}/README.md`]: padded,
|
|
269
|
+
});
|
|
270
|
+
const out = studies({ sub: "index" }, tree);
|
|
271
|
+
expect([out.code, out.log, out.after]).toEqual([
|
|
272
|
+
0,
|
|
273
|
+
["the index in papers/example/repro/README.md is in sync with 1 study"],
|
|
274
|
+
tree,
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("studies — a directory where repro/README.md goes", () => {
|
|
280
|
+
const tree = paper({
|
|
281
|
+
[`${REPRO}/studies.json`]: registryText(P1(study())),
|
|
282
|
+
[`${REPRO}/README.md/notes.txt`]: "x",
|
|
283
|
+
});
|
|
284
|
+
const refusal =
|
|
285
|
+
"papers/example/repro/README.md is not a file, so the index cannot be written there. Move it, then run this again.";
|
|
286
|
+
|
|
287
|
+
it.each([
|
|
288
|
+
["index", {}],
|
|
289
|
+
["new", { title: "Second run" }],
|
|
290
|
+
] as const)("studies %s refuses and writes nothing", (sub, extra) => {
|
|
291
|
+
const out = studies({ sub, ...extra }, tree);
|
|
292
|
+
expect([out.code, out.err, out.after]).toEqual([1, [refusal], tree]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("studies index — refuses by the paper's state", () => {
|
|
297
|
+
it("a paper with no studies.json, and writes nothing", () => {
|
|
298
|
+
const tree = paper();
|
|
299
|
+
const out = studies({ sub: "index" }, tree);
|
|
300
|
+
expect([out.code, out.err[0]?.split(".", 1)[0], out.after]).toEqual([
|
|
301
|
+
1,
|
|
302
|
+
"papers/example/repro/studies",
|
|
303
|
+
tree,
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("studies — run from inside the paper folder", () => {
|
|
309
|
+
it("names the folder `.`", () => {
|
|
310
|
+
const tree = paper({
|
|
311
|
+
[`${REPRO}/studies.json`]: registryText(P1()),
|
|
312
|
+
[`${REPRO}/README.md`]: withIndex("# I\n", P1()),
|
|
313
|
+
});
|
|
314
|
+
const out = studies({ sub: "index", paperDir: "." }, tree, DIR);
|
|
315
|
+
expect([out.code, out.log]).toEqual([
|
|
316
|
+
0,
|
|
317
|
+
["the index in ./repro/README.md is in sync with 0 studies"],
|
|
318
|
+
]);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("studies — the command line", () => {
|
|
323
|
+
it.each([
|
|
324
|
+
[
|
|
325
|
+
"no subcommand",
|
|
326
|
+
{ sub: undefined },
|
|
327
|
+
'studies needs a subcommand: new or index — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir>',
|
|
328
|
+
],
|
|
329
|
+
[
|
|
330
|
+
"an unknown subcommand",
|
|
331
|
+
{ sub: "add" },
|
|
332
|
+
'unknown subcommand `studies add`: new or index — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir>',
|
|
333
|
+
],
|
|
334
|
+
[
|
|
335
|
+
"new with no paper folder",
|
|
336
|
+
{ sub: "new", paperDir: undefined },
|
|
337
|
+
'studies new needs the paper\'s folder: npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]',
|
|
338
|
+
],
|
|
339
|
+
[
|
|
340
|
+
"no paper folder",
|
|
341
|
+
{ sub: "index", paperDir: undefined },
|
|
342
|
+
"studies index needs the paper's folder: npx paperlint studies index <paper-dir>",
|
|
343
|
+
],
|
|
344
|
+
[
|
|
345
|
+
"a folder that is no paper",
|
|
346
|
+
{ sub: "index", paperDir: "papers" },
|
|
347
|
+
"papers is not a paper folder: it has no PIPELINE-STATUS.md",
|
|
348
|
+
],
|
|
349
|
+
] as const)("refuses %s with 2", (_why, a, line) => {
|
|
350
|
+
const out = studies(a, paper());
|
|
351
|
+
expect([out.code, out.err, out.log]).toEqual([2, [line], []]);
|
|
352
|
+
});
|
|
353
|
+
});
|