paperlint 10.2.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- package/dist/adapters/git/index.d.ts +10 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +151 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/memory/index.d.ts +6 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +8 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts +3 -0
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
- package/dist/adapters/node/list-entries.io.js +24 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +412 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +245 -8
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +100 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +102 -0
- package/dist/domain/decide-first.js.map +1 -0
- package/dist/domain/index-columns.d.ts +45 -0
- package/dist/domain/index-columns.d.ts.map +1 -0
- package/dist/domain/index-columns.js +62 -0
- package/dist/domain/index-columns.js.map +1 -0
- package/dist/domain/study-mode.d.ts +37 -0
- package/dist/domain/study-mode.d.ts.map +1 -0
- package/dist/domain/study-mode.js +45 -0
- package/dist/domain/study-mode.js.map +1 -0
- package/dist/domain/study-registry.d.ts +253 -10
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +325 -27
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +17 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -0
- package/dist/outputs-before-decision-rule.js +90 -0
- package/dist/outputs-before-decision-rule.js.map +1 -0
- package/dist/ports/git-history.d.ts +44 -0
- package/dist/ports/git-history.d.ts.map +1 -0
- package/dist/ports/git-history.js +2 -0
- package/dist/ports/git-history.js.map +1 -0
- package/dist/ports/list-entries.d.ts +17 -0
- package/dist/ports/list-entries.d.ts.map +1 -0
- package/dist/ports/list-entries.js +2 -0
- package/dist/ports/list-entries.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +9 -2
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +55 -24
- package/dist/studies.js.map +1 -1
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +95 -0
- package/dist/study-gate.js.map +1 -0
- package/dist/study-index-input.d.ts +5 -0
- package/dist/study-index-input.d.ts.map +1 -0
- package/dist/study-index-input.js +13 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +45 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +70 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +29 -6
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +91 -40
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-run.d.ts +19 -0
- package/dist/study-run.d.ts.map +1 -0
- package/dist/study-run.js +9 -0
- package/dist/study-run.js.map +1 -0
- package/docs/design/study-modes.md +247 -0
- package/docs/design/study-registry.md +137 -15
- package/docs/e2e.md +8 -4
- package/docs/layout.md +26 -21
- package/docs/rules/repro/outputs-before-decision.md +119 -0
- package/docs/rules/repro/study-plan-name.md +70 -0
- package/docs/rules/repro/study-registered.md +206 -15
- package/docs/rules.md +54 -52
- package/docs/testing.md +1 -0
- package/package.json +2 -1
- package/scripts/check.ts +6 -0
- package/skills/build-benchmark/SKILL.md +24 -12
- package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
- package/skills/build-label-page/SKILL.md +9 -7
- package/skills/build-label-page/SKILL.md.spec.ts +8 -6
- package/skills/camera-ready/SKILL.md +6 -1
- package/skills/camera-ready/SKILL.md.spec.ts +5 -0
- package/skills/draft-paper/SKILL.md +11 -3
- package/skills/draft-paper/SKILL.md.spec.ts +10 -2
- package/skills/harden-paper/SKILL.md +12 -2
- package/skills/harden-paper/SKILL.md.spec.ts +11 -1
- package/skills/osf-artifact-upload/SKILL.md +7 -1
- package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
- package/skills/pc-panel-review/SKILL.md +10 -3
- package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
- package/skills/run-agent-grid/SKILL.md +10 -5
- package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
- package/skills/tighten-paper/SKILL.md +5 -1
- package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
- package/src/adapters/git/history.test.ts +121 -0
- package/src/adapters/git/index.test.ts +268 -0
- package/src/adapters/git/index.ts +227 -0
- package/src/adapters/memory/index.test.ts +15 -1
- package/src/adapters/memory/index.ts +13 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-entries.io.ts +26 -0
- package/src/adapters/node/list-entries.test.ts +43 -0
- package/src/adapters/study-registry/index.test.ts +343 -0
- package/src/adapters/study-registry/index.ts +374 -10
- package/src/adapters/study-registry/mode.test.ts +162 -0
- package/src/cli.ts +52 -6
- package/src/domain/decide-first.test.ts +258 -0
- package/src/domain/decide-first.ts +243 -0
- package/src/domain/index-columns.test.ts +216 -0
- package/src/domain/index-columns.ts +105 -0
- package/src/domain/study-measurements.test.ts +633 -0
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +48 -32
- package/src/domain/study-registry.ts +621 -36
- package/src/new-paper.test.ts +6 -1
- package/src/outputs-before-decision-rule.test.ts +206 -0
- package/src/outputs-before-decision-rule.ts +124 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +18 -0
- package/src/ports/study-registry-codec.ts +13 -2
- package/src/rule-cases.test.ts +103 -1
- package/src/studies-cli.test.ts +2 -2
- package/src/studies-index-modes.test.ts +129 -0
- package/src/studies.test.ts +39 -9
- package/src/studies.ts +99 -29
- package/src/study-gate.test.ts +316 -0
- package/src/study-gate.ts +161 -0
- package/src/study-index-input.ts +23 -0
- package/src/study-modes.ts +120 -0
- package/src/study-registry-rule.test.ts +333 -7
- package/src/study-registry-rule.ts +145 -41
- package/src/study-run.ts +21 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The git ports over a scripted `RunProcess`: the commands they run and each answer git can give —
|
|
3
|
+
* including each way git can fail to give one. The same ports over a real repository are in
|
|
4
|
+
* `history.test.ts` beside this file.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import { exitedWith, scriptedProcess } from "../memory/index.ts";
|
|
8
|
+
import { absolutePath } from "../../domain/paths.ts";
|
|
9
|
+
import type { Command, ProcessExit } from "../../ports/process.ts";
|
|
10
|
+
import { firstCommitsOf, gitHistory } from "./index.ts";
|
|
11
|
+
|
|
12
|
+
const PAPER = absolutePath("/repo/papers/p");
|
|
13
|
+
const FILE = absolutePath("/repo/papers/p/repro/S/decision.md");
|
|
14
|
+
const STUDY = absolutePath("/repo/papers/p/repro/S");
|
|
15
|
+
const D = "d".repeat(40);
|
|
16
|
+
const A = "a".repeat(40);
|
|
17
|
+
const B = "b".repeat(40);
|
|
18
|
+
const DATE = "2026-10-16T15:23:00+05:00";
|
|
19
|
+
|
|
20
|
+
/** HEAD's tree holds the decision, unless a test says otherwise. */
|
|
21
|
+
const IN_HEAD = exitedWith("repro/S/decision.md\n");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ports over git answering by its subcommand (`args[2]`, after `-c core.quotepath=false`); the
|
|
25
|
+
* decision in HEAD's tree unless `ls-tree` is answered.
|
|
26
|
+
*/
|
|
27
|
+
const over = (answers: Readonly<Record<string, ProcessExit>>) => {
|
|
28
|
+
const all: Readonly<Record<string, ProcessExit>> = {
|
|
29
|
+
"ls-tree": IN_HEAD,
|
|
30
|
+
...answers,
|
|
31
|
+
};
|
|
32
|
+
const run = scriptedProcess(
|
|
33
|
+
(c: Command) => all[String(c.args[2])] ?? exitedWith(""),
|
|
34
|
+
);
|
|
35
|
+
return { git: gitHistory(run, { PATH: "/usr/bin" }), calls: run.calls };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
it("committed: the last commit and its date when the file is as committed — asked from the paper", () => {
|
|
39
|
+
const { git, calls } = over({ log: exitedWith(`${D}\t${DATE}\n`) });
|
|
40
|
+
// Guards: the answer, and that git runs in the paper's folder with the file's absolute path.
|
|
41
|
+
expect(git.committed(PAPER, FILE)).toEqual({
|
|
42
|
+
kind: "committed",
|
|
43
|
+
commit: D,
|
|
44
|
+
date: DATE,
|
|
45
|
+
});
|
|
46
|
+
expect(calls.map((c) => [c.args, c.cwd])).toEqual([
|
|
47
|
+
[
|
|
48
|
+
[
|
|
49
|
+
"-c",
|
|
50
|
+
"core.quotepath=false",
|
|
51
|
+
"log",
|
|
52
|
+
"-1",
|
|
53
|
+
"--format=%H%x09%cI",
|
|
54
|
+
"--",
|
|
55
|
+
FILE,
|
|
56
|
+
],
|
|
57
|
+
PAPER,
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
[
|
|
61
|
+
"-c",
|
|
62
|
+
"core.quotepath=false",
|
|
63
|
+
"ls-tree",
|
|
64
|
+
"--name-only",
|
|
65
|
+
"HEAD",
|
|
66
|
+
"--",
|
|
67
|
+
FILE,
|
|
68
|
+
],
|
|
69
|
+
PAPER,
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
["-c", "core.quotepath=false", "status", "--porcelain=v1", "--", FILE],
|
|
73
|
+
PAPER,
|
|
74
|
+
],
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("committed: a decision whose last commit deleted it is not in HEAD — deleted, never committed", () => {
|
|
79
|
+
// Guards: `log -1` names the deleting commit and the status is clean; only HEAD's tree tells.
|
|
80
|
+
expect([
|
|
81
|
+
over({
|
|
82
|
+
log: exitedWith(`${D}\t${DATE}\n`),
|
|
83
|
+
"ls-tree": exitedWith(""),
|
|
84
|
+
}).git.committed(PAPER, FILE),
|
|
85
|
+
over({
|
|
86
|
+
log: exitedWith(`${D}\t${DATE}\n`),
|
|
87
|
+
"ls-tree": { kind: "spawn-failed", message: "EIO" },
|
|
88
|
+
}).git.committed(PAPER, FILE),
|
|
89
|
+
]).toEqual([
|
|
90
|
+
{ kind: "deleted", commit: D },
|
|
91
|
+
{ kind: "unknown", why: "EIO" },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("committed: changed since its commit, never committed, or git unable to say at either step", () => {
|
|
96
|
+
// Guards: an edited decision is not the recorded one; a file only on disk records nothing.
|
|
97
|
+
expect([
|
|
98
|
+
over({
|
|
99
|
+
log: exitedWith(`${D}\t${DATE}\n`),
|
|
100
|
+
status: exitedWith(" M decision.md\n"),
|
|
101
|
+
}).git.committed(PAPER, FILE),
|
|
102
|
+
over({ log: exitedWith("") }).git.committed(PAPER, FILE),
|
|
103
|
+
over({ log: { kind: "not-found", file: "git" } }).git.committed(
|
|
104
|
+
PAPER,
|
|
105
|
+
FILE,
|
|
106
|
+
),
|
|
107
|
+
over({
|
|
108
|
+
log: exitedWith(`${D}\t${DATE}\n`),
|
|
109
|
+
status: { kind: "spawn-failed", message: "EACCES" },
|
|
110
|
+
}).git.committed(PAPER, FILE),
|
|
111
|
+
]).toEqual([
|
|
112
|
+
{ kind: "changed", commit: D },
|
|
113
|
+
{ kind: "not-committed" },
|
|
114
|
+
{ kind: "unknown", why: "git is not installed" },
|
|
115
|
+
{ kind: "unknown", why: "EACCES" },
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("every way git can fail is a reason, never an answer", () => {
|
|
120
|
+
const ask = (x: ProcessExit) =>
|
|
121
|
+
over({ "rev-parse": x }).git.order(STUDY, FILE);
|
|
122
|
+
// Guards: no git, no repository, a hang, a kill — each named, none read as "no commits".
|
|
123
|
+
expect([
|
|
124
|
+
ask(exitedWith("", 128)),
|
|
125
|
+
ask({
|
|
126
|
+
kind: "exited",
|
|
127
|
+
status: 128,
|
|
128
|
+
stdout: "",
|
|
129
|
+
stderr: "fatal: not a git repository\n",
|
|
130
|
+
}),
|
|
131
|
+
ask({ kind: "timed-out", afterMs: 1, stdout: "", stderr: "" }),
|
|
132
|
+
ask({ kind: "signalled", signal: "SIGTERM", stdout: "", stderr: "" }),
|
|
133
|
+
]).toEqual([
|
|
134
|
+
{ kind: "unknown", why: "git exited 128" },
|
|
135
|
+
{ kind: "unknown", why: "fatal: not a git repository" },
|
|
136
|
+
{ kind: "unknown", why: "git did not finish (timed-out)" },
|
|
137
|
+
{ kind: "unknown", why: "git did not finish (signalled)" },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("order: a shallow clone is said, not judged", () => {
|
|
142
|
+
// Guards: in a shallow clone every file starts at the clone — no order can be read.
|
|
143
|
+
expect(
|
|
144
|
+
over({ "rev-parse": exitedWith("true\n") }).git.order(STUDY, FILE),
|
|
145
|
+
).toEqual({ kind: "shallow" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("order: each file's first commit against the decision's — after, the same, not after", () => {
|
|
149
|
+
const log = `\x01${D}\t${DATE}\nx.csv\n\x01${A}\t${DATE}\na.csv\nx.csv\n\x01${B}\t${DATE}\nb.csv\n`;
|
|
150
|
+
// Guards: the decision's own commit is `same`; the others are ordered by ancestry.
|
|
151
|
+
const result = gitHistory(
|
|
152
|
+
scriptedProcess((c) => {
|
|
153
|
+
const sub = String(c.args[2]);
|
|
154
|
+
if (sub === "rev-parse") return exitedWith("false\n");
|
|
155
|
+
if (sub === "ls-tree") return IN_HEAD;
|
|
156
|
+
if (sub === "log" && c.args.includes("--reverse")) return exitedWith(log);
|
|
157
|
+
if (sub === "log") return exitedWith(`${A}\t${DATE}\n${D}\t${DATE}\n`);
|
|
158
|
+
return exitedWith("", String(c.args[5]) === A ? 0 : 1);
|
|
159
|
+
}),
|
|
160
|
+
{},
|
|
161
|
+
).order(STUDY, FILE);
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
kind: "read",
|
|
164
|
+
decision: { commit: D, date: DATE },
|
|
165
|
+
decisionDeleted: false,
|
|
166
|
+
files: [
|
|
167
|
+
{ path: "x.csv", commit: D, date: DATE, order: "same" },
|
|
168
|
+
{ path: "a.csv", commit: A, date: DATE, order: "after" },
|
|
169
|
+
{ path: "b.csv", commit: B, date: DATE, order: "not-after" },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("order: no decision commit makes every file not-after; a failed ancestry question is a reason", () => {
|
|
175
|
+
const log = `\x01${A}\t${DATE}\na.csv\n`;
|
|
176
|
+
const answer = (mergeBase: ProcessExit, decisionLog: string) =>
|
|
177
|
+
gitHistory(
|
|
178
|
+
scriptedProcess((c) => {
|
|
179
|
+
const sub = String(c.args[2]);
|
|
180
|
+
if (sub === "rev-parse") return exitedWith("false\n");
|
|
181
|
+
if (sub === "ls-tree") return IN_HEAD;
|
|
182
|
+
if (sub === "log" && c.args.includes("--reverse"))
|
|
183
|
+
return exitedWith(log);
|
|
184
|
+
if (sub === "log") return exitedWith(decisionLog);
|
|
185
|
+
return mergeBase;
|
|
186
|
+
}),
|
|
187
|
+
{},
|
|
188
|
+
).order(STUDY, FILE);
|
|
189
|
+
// Guards: an uncommitted decision orders nothing; git failing mid-way is not "not after".
|
|
190
|
+
expect(answer(exitedWith(""), "")).toEqual({
|
|
191
|
+
kind: "read",
|
|
192
|
+
decision: null,
|
|
193
|
+
decisionDeleted: false,
|
|
194
|
+
files: [{ path: "a.csv", commit: A, date: DATE, order: "not-after" }],
|
|
195
|
+
});
|
|
196
|
+
expect(
|
|
197
|
+
answer(
|
|
198
|
+
{
|
|
199
|
+
kind: "exited",
|
|
200
|
+
status: 128,
|
|
201
|
+
stdout: "",
|
|
202
|
+
stderr: "fatal: bad object\n",
|
|
203
|
+
},
|
|
204
|
+
`${D}\t${DATE}\n`,
|
|
205
|
+
),
|
|
206
|
+
).toEqual({ kind: "unknown", why: "fatal: bad object" });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("order: git failing on the decision's log or the folder's log is a reason", () => {
|
|
210
|
+
const fail = {
|
|
211
|
+
kind: "exited",
|
|
212
|
+
status: 128,
|
|
213
|
+
stdout: "",
|
|
214
|
+
stderr: "fatal: x\n",
|
|
215
|
+
} as const;
|
|
216
|
+
const answer = (decision: ProcessExit, folder: ProcessExit) =>
|
|
217
|
+
gitHistory(
|
|
218
|
+
scriptedProcess((c) => {
|
|
219
|
+
const sub = String(c.args[2]);
|
|
220
|
+
if (sub === "rev-parse") return exitedWith("false\n");
|
|
221
|
+
return c.args.includes("--reverse") ? folder : decision;
|
|
222
|
+
}),
|
|
223
|
+
{},
|
|
224
|
+
).order(STUDY, FILE);
|
|
225
|
+
// Guards: each step's failure surfaces.
|
|
226
|
+
expect([answer(fail, exitedWith("")), answer(exitedWith(""), fail)]).toEqual([
|
|
227
|
+
{ kind: "unknown", why: "fatal: x" },
|
|
228
|
+
{ kind: "unknown", why: "fatal: x" },
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("order: a decision not in HEAD is said deleted; git failing on HEAD's tree is a reason", () => {
|
|
233
|
+
const answer = (lsTree: ProcessExit) =>
|
|
234
|
+
gitHistory(
|
|
235
|
+
scriptedProcess((c) => {
|
|
236
|
+
const sub = String(c.args[2]);
|
|
237
|
+
if (sub === "rev-parse") return exitedWith("false\n");
|
|
238
|
+
if (sub === "ls-tree") return lsTree;
|
|
239
|
+
if (c.args.includes("--reverse"))
|
|
240
|
+
return exitedWith(`\x01${D}\t${DATE}\ndecision.md\n`);
|
|
241
|
+
return exitedWith(`${D}\t${DATE}\n`);
|
|
242
|
+
}),
|
|
243
|
+
{},
|
|
244
|
+
).order(STUDY, FILE);
|
|
245
|
+
// Guards: the decision's first commit is still read, and its absence from HEAD is carried with it.
|
|
246
|
+
expect([
|
|
247
|
+
answer(exitedWith("")),
|
|
248
|
+
answer({ kind: "spawn-failed", message: "EIO" }),
|
|
249
|
+
]).toEqual([
|
|
250
|
+
{
|
|
251
|
+
kind: "read",
|
|
252
|
+
decision: { commit: D, date: DATE },
|
|
253
|
+
decisionDeleted: true,
|
|
254
|
+
files: [{ path: "decision.md", commit: D, date: DATE, order: "same" }],
|
|
255
|
+
},
|
|
256
|
+
{ kind: "unknown", why: "EIO" },
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("firstCommitsOf keeps each path's first commit and skips blank lines", () => {
|
|
261
|
+
// Guards: the reverse log is read oldest first; a later commit of a path does not move it.
|
|
262
|
+
expect([
|
|
263
|
+
...firstCommitsOf(`\x01${A}\t${DATE}\na\n\n\x01${B}\t${DATE}\na\nb\n`),
|
|
264
|
+
]).toEqual([
|
|
265
|
+
["a", { commit: A, date: DATE }],
|
|
266
|
+
["b", { commit: B, date: DATE }],
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The git ports over the git CLI, through `RunProcess`. Run from a folder of the paper (which exists),
|
|
3
|
+
* with absolute pathspecs, so it answers for whichever repository holds the paper.
|
|
4
|
+
*
|
|
5
|
+
* committed the last commit that touched a file (`git log -1`), and whether the file changed since
|
|
6
|
+
* (`git status --porcelain`)
|
|
7
|
+
* order whether the clone is shallow; the decision file's first commit; every file's first
|
|
8
|
+
* commit under the study's folder (`git log --reverse --name-only`); and for each, whether
|
|
9
|
+
* the decision's first commit is its strict ancestor (`git merge-base --is-ancestor`)
|
|
10
|
+
*/
|
|
11
|
+
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
12
|
+
import type {
|
|
13
|
+
CommitAt,
|
|
14
|
+
Committed,
|
|
15
|
+
FirstCommit,
|
|
16
|
+
GitHistory,
|
|
17
|
+
GitOrder,
|
|
18
|
+
Order,
|
|
19
|
+
OrderFacts,
|
|
20
|
+
} from "../../ports/git-history.ts";
|
|
21
|
+
import type { ProcessExit, RunProcess } from "../../ports/process.ts";
|
|
22
|
+
|
|
23
|
+
const TIMEOUT_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/** git's stdout, or why it gave none; `allowed` lists the exit statuses that are answers. */
|
|
26
|
+
const stdoutOf = (
|
|
27
|
+
x: ProcessExit,
|
|
28
|
+
allowed: readonly number[] = [0],
|
|
29
|
+
):
|
|
30
|
+
| { readonly out: string; readonly status: number }
|
|
31
|
+
| { readonly why: string } => {
|
|
32
|
+
switch (x.kind) {
|
|
33
|
+
case "exited":
|
|
34
|
+
return allowed.includes(x.status)
|
|
35
|
+
? { out: x.stdout, status: x.status }
|
|
36
|
+
: { why: x.stderr.trim() || `git exited ${String(x.status)}` };
|
|
37
|
+
case "not-found":
|
|
38
|
+
return { why: "git is not installed" };
|
|
39
|
+
case "spawn-failed":
|
|
40
|
+
return { why: x.message };
|
|
41
|
+
case "signalled":
|
|
42
|
+
case "timed-out":
|
|
43
|
+
return { why: `git did not finish (${x.kind})` };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** git, run through `run` in `cwd` with `env` as its whole environment. */
|
|
48
|
+
const gitIn =
|
|
49
|
+
(run: RunProcess, env: Readonly<Record<string, string>>) =>
|
|
50
|
+
(cwd: AbsolutePath, args: readonly string[], allowed?: readonly number[]) =>
|
|
51
|
+
stdoutOf(
|
|
52
|
+
run.run({
|
|
53
|
+
file: "git",
|
|
54
|
+
args: ["-c", "core.quotepath=false", ...args],
|
|
55
|
+
env,
|
|
56
|
+
timeoutMs: TIMEOUT_MS,
|
|
57
|
+
cwd,
|
|
58
|
+
maxOutputBytes: 64 * 1024 * 1024,
|
|
59
|
+
}),
|
|
60
|
+
allowed,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** `<sha>\t<date>` as a commit. */
|
|
64
|
+
const commitAt = (line: string): CommitAt => {
|
|
65
|
+
const parts = line.split("\t");
|
|
66
|
+
return { commit: nth(parts, 0), date: nth(parts, 1) };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The `i`th of `parts`, "" when there are fewer. */
|
|
70
|
+
const nth = (parts: readonly string[], i: number): string =>
|
|
71
|
+
parts.slice(i, i + 1).join("");
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The first commit of each path in `git log --reverse --format=%x01%H%x09%cI --name-only` output:
|
|
75
|
+
* a line starting with \x01 opens a commit, the lines after it name its files.
|
|
76
|
+
*/
|
|
77
|
+
export const firstCommitsOf = (log: string): ReadonlyMap<string, CommitAt> =>
|
|
78
|
+
log
|
|
79
|
+
.split("\x01")
|
|
80
|
+
.slice(1)
|
|
81
|
+
.reduce((seen, block) => {
|
|
82
|
+
const lines = block.split("\n");
|
|
83
|
+
const at = commitAt(nth(lines, 0));
|
|
84
|
+
const names = lines.slice(1);
|
|
85
|
+
return names
|
|
86
|
+
.filter((n) => n !== "" && !seen.has(n))
|
|
87
|
+
.reduce((m, n) => new Map([...m, [n, at]]), seen);
|
|
88
|
+
}, new Map<string, CommitAt>());
|
|
89
|
+
|
|
90
|
+
/** The committed state of files. */
|
|
91
|
+
export function gitHistory(
|
|
92
|
+
run: RunProcess,
|
|
93
|
+
env: Readonly<Record<string, string>>,
|
|
94
|
+
): GitHistory & GitOrder {
|
|
95
|
+
const git = gitIn(run, env);
|
|
96
|
+
return {
|
|
97
|
+
committed: (paper, file): Committed => {
|
|
98
|
+
const log = git(paper, ["log", "-1", "--format=%H%x09%cI", "--", file]);
|
|
99
|
+
if ("why" in log) return { kind: "unknown", why: log.why };
|
|
100
|
+
const { commit, date } = commitAt(log.out.trim());
|
|
101
|
+
if (commit === "") return { kind: "not-committed" };
|
|
102
|
+
// `git log -1` names a deleting commit too, and a deleted file leaves a clean status.
|
|
103
|
+
const head = inHead(git, paper, file);
|
|
104
|
+
if ("why" in head) return { kind: "unknown", why: head.why };
|
|
105
|
+
if (!head.present) return { kind: "deleted", commit };
|
|
106
|
+
const status = git(paper, ["status", "--porcelain=v1", "--", file]);
|
|
107
|
+
if ("why" in status) return { kind: "unknown", why: status.why };
|
|
108
|
+
return status.out.trim() === ""
|
|
109
|
+
? { kind: "committed", commit, date }
|
|
110
|
+
: { kind: "changed", commit };
|
|
111
|
+
},
|
|
112
|
+
order: (studyDir, decision) => orderIn(git, studyDir, decision),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type Git = ReturnType<typeof gitIn>;
|
|
117
|
+
|
|
118
|
+
/** Whether `file` is in HEAD's tree (`git ls-tree`, which takes an absolute path in the worktree). */
|
|
119
|
+
const inHead = (
|
|
120
|
+
git: Git,
|
|
121
|
+
cwd: AbsolutePath,
|
|
122
|
+
file: AbsolutePath,
|
|
123
|
+
): { readonly present: boolean } | { readonly why: string } => {
|
|
124
|
+
const t = git(cwd, ["ls-tree", "--name-only", "HEAD", "--", file]);
|
|
125
|
+
return "why" in t ? t : { present: t.out.trim() !== "" };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** The decision's first commit (null: never committed), and whether HEAD's tree still holds it. */
|
|
129
|
+
const decisionIn = (
|
|
130
|
+
git: Git,
|
|
131
|
+
studyDir: AbsolutePath,
|
|
132
|
+
decision: AbsolutePath,
|
|
133
|
+
):
|
|
134
|
+
| { readonly at: CommitAt | null; readonly deleted: boolean }
|
|
135
|
+
| { readonly why: string } => {
|
|
136
|
+
const d = git(studyDir, ["log", "--format=%H%x09%cI", "--", decision]);
|
|
137
|
+
if ("why" in d) return d;
|
|
138
|
+
const lines = d.out.trim().split("\n");
|
|
139
|
+
const first = commitAt(nth(lines, lines.length - 1));
|
|
140
|
+
if (first.commit === "") return { at: null, deleted: false };
|
|
141
|
+
const head = inHead(git, studyDir, decision);
|
|
142
|
+
return "why" in head ? head : { at: first, deleted: !head.present };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The order facts of the study in `studyDir`, decided by `decision`. */
|
|
146
|
+
function orderIn(
|
|
147
|
+
git: Git,
|
|
148
|
+
studyDir: AbsolutePath,
|
|
149
|
+
decision: AbsolutePath,
|
|
150
|
+
): OrderFacts {
|
|
151
|
+
const shallow = git(studyDir, ["rev-parse", "--is-shallow-repository"]);
|
|
152
|
+
if ("why" in shallow) return { kind: "unknown", why: shallow.why };
|
|
153
|
+
if (shallow.out.trim() === "true") return { kind: "shallow" };
|
|
154
|
+
const d = decisionIn(git, studyDir, decision);
|
|
155
|
+
if ("why" in d) return { kind: "unknown", why: d.why };
|
|
156
|
+
const files = git(studyDir, [
|
|
157
|
+
"log",
|
|
158
|
+
"--reverse",
|
|
159
|
+
"--format=%x01%H%x09%cI",
|
|
160
|
+
"--name-only",
|
|
161
|
+
"--relative",
|
|
162
|
+
"--",
|
|
163
|
+
".",
|
|
164
|
+
]);
|
|
165
|
+
if ("why" in files) return { kind: "unknown", why: files.why };
|
|
166
|
+
const ordered = ordersOf(git, studyDir, d.at, firstCommitsOf(files.out));
|
|
167
|
+
return "why" in ordered
|
|
168
|
+
? { kind: "unknown", why: ordered.why }
|
|
169
|
+
: {
|
|
170
|
+
kind: "read",
|
|
171
|
+
decision: d.at,
|
|
172
|
+
decisionDeleted: d.deleted,
|
|
173
|
+
files: ordered.files,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** git's answer for one commit: its order, or why git could not say. */
|
|
178
|
+
type Answer =
|
|
179
|
+
| { readonly commit: string; readonly order: Order }
|
|
180
|
+
| { readonly commit: string; readonly why: string };
|
|
181
|
+
const isFailed = (
|
|
182
|
+
a: Answer,
|
|
183
|
+
): a is { readonly commit: string; readonly why: string } => "why" in a;
|
|
184
|
+
const isOrdered = (
|
|
185
|
+
a: Answer,
|
|
186
|
+
): a is { readonly commit: string; readonly order: Order } => "order" in a;
|
|
187
|
+
|
|
188
|
+
/** The order of `commit` against `decision`'s first commit. */
|
|
189
|
+
const answerFor = (
|
|
190
|
+
git: Git,
|
|
191
|
+
cwd: AbsolutePath,
|
|
192
|
+
decision: CommitAt | null,
|
|
193
|
+
commit: string,
|
|
194
|
+
): Answer => {
|
|
195
|
+
if (decision === null) return { commit, order: "not-after" };
|
|
196
|
+
if (commit === decision.commit) return { commit, order: "same" };
|
|
197
|
+
const a = git(
|
|
198
|
+
cwd,
|
|
199
|
+
["merge-base", "--is-ancestor", decision.commit, commit],
|
|
200
|
+
[0, 1],
|
|
201
|
+
);
|
|
202
|
+
if ("why" in a) return { commit, why: a.why };
|
|
203
|
+
return { commit, order: a.status === 0 ? "after" : "not-after" };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Each file's first commit with its order against `decision`'s; git asked once per distinct commit. */
|
|
207
|
+
function ordersOf(
|
|
208
|
+
git: Git,
|
|
209
|
+
cwd: AbsolutePath,
|
|
210
|
+
decision: CommitAt | null,
|
|
211
|
+
firsts: ReadonlyMap<string, CommitAt>,
|
|
212
|
+
): { readonly files: readonly FirstCommit[] } | { readonly why: string } {
|
|
213
|
+
const answers = [...new Set([...firsts.values()].map((c) => c.commit))].map(
|
|
214
|
+
(c) => answerFor(git, cwd, decision, c),
|
|
215
|
+
);
|
|
216
|
+
const failed = answers.find(isFailed);
|
|
217
|
+
if (failed !== undefined) return { why: failed.why };
|
|
218
|
+
return {
|
|
219
|
+
files: answers
|
|
220
|
+
.filter(isOrdered)
|
|
221
|
+
.flatMap(({ commit, order }) =>
|
|
222
|
+
[...firsts]
|
|
223
|
+
.filter(([, c]) => c.commit === commit)
|
|
224
|
+
.map(([path, c]) => ({ path, ...c, order })),
|
|
225
|
+
),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/** The in-memory test doubles keep the contracts of the ports they stand in for. */
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { test } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import { absolutePath } from "../../domain/paths.ts";
|
|
5
|
+
import {
|
|
6
|
+
fixedDownload,
|
|
7
|
+
memoryFiles,
|
|
8
|
+
memoryListEntries,
|
|
9
|
+
memoryWorkspace,
|
|
10
|
+
} from "./index.ts";
|
|
5
11
|
|
|
6
12
|
test("memoryWorkspace: a `within` that throws is recorded as `threw`, and the error goes on", () => {
|
|
7
13
|
const ws = memoryWorkspace();
|
|
@@ -23,3 +29,11 @@ test("fixedDownload: bytes are handed back as they are, and every URL is recorde
|
|
|
23
29
|
assert.deepEqual(d.fetch("https://a", 1), { ok: true, value: bytes });
|
|
24
30
|
assert.deepEqual(d.urls, ["https://a"]);
|
|
25
31
|
});
|
|
32
|
+
|
|
33
|
+
test("memoryListEntries: each name directly under a directory, a file or a directory", () => {
|
|
34
|
+
const files = memoryFiles({ "/s/a.md": "", "/s/out/b.csv": "" });
|
|
35
|
+
assert.deepEqual(memoryListEntries(files)(absolutePath("/s")), [
|
|
36
|
+
{ name: "a.md", kind: "file" },
|
|
37
|
+
{ name: "out", kind: "dir" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
@@ -6,6 +6,7 @@ import { absolutePath } from "../../domain/paths.ts";
|
|
|
6
6
|
import type { Download } from "../../ports/download.ts";
|
|
7
7
|
import type { Files } from "../../ports/files.ts";
|
|
8
8
|
import type { ListDir } from "../../ports/talk-media.ts";
|
|
9
|
+
import type { ListEntries } from "../../ports/list-entries.ts";
|
|
9
10
|
import type { Command, ProcessExit, RunProcess } from "../../ports/process.ts";
|
|
10
11
|
import type { NotAPromise, Scratch, Workspace } from "../../ports/workspace.ts";
|
|
11
12
|
import { err, ok } from "../../domain/result.ts";
|
|
@@ -72,6 +73,18 @@ export const memoryListDir =
|
|
|
72
73
|
),
|
|
73
74
|
];
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* `ListEntries` over `memoryFiles`: each name directly under a directory, a file when it is one, else a
|
|
78
|
+
* directory. Memory holds no links: the walk's refusal to follow one is the node adapter's to show.
|
|
79
|
+
*/
|
|
80
|
+
export const memoryListEntries =
|
|
81
|
+
(files: MemoryFiles): ListEntries =>
|
|
82
|
+
(dir) =>
|
|
83
|
+
memoryListDir(files)(dir).map((name) => ({
|
|
84
|
+
name,
|
|
85
|
+
kind: files.isFile(absolutePath(`${dir}/${name}`)) ? "file" : "dir",
|
|
86
|
+
}));
|
|
87
|
+
|
|
75
88
|
export interface MemoryWorkspace extends Workspace {
|
|
76
89
|
/** Every file written into a scratch directory, in order. */
|
|
77
90
|
readonly written: {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** `ListEntries` over `node:fs`: `readdir` with its file types, which `lstat` each entry, never following a link. */
|
|
2
|
+
import { readdirSync, type Dirent } from "node:fs";
|
|
3
|
+
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
4
|
+
import { codeOf } from "../../domain/text.ts";
|
|
5
|
+
import type { DirEntry, ListEntries } from "../../ports/list-entries.ts";
|
|
6
|
+
|
|
7
|
+
/** What a directory entry is, its link never followed. */
|
|
8
|
+
const kindOf = (d: Readonly<Dirent>): DirEntry["kind"] => {
|
|
9
|
+
if (d.isSymbolicLink()) return "symlink";
|
|
10
|
+
return d.isDirectory() ? "dir" : "file";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const nodeListEntries: ListEntries = (
|
|
14
|
+
dir: AbsolutePath,
|
|
15
|
+
): readonly DirEntry[] => {
|
|
16
|
+
try {
|
|
17
|
+
return readdirSync(dir, { withFileTypes: true }).map((d) => ({
|
|
18
|
+
name: d.name,
|
|
19
|
+
kind: kindOf(d),
|
|
20
|
+
}));
|
|
21
|
+
} catch (e) {
|
|
22
|
+
const code = codeOf(e);
|
|
23
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nodeListEntries`: a directory's entries with their kinds, a symbolic link reported as one and never
|
|
3
|
+
* followed; none for a missing directory or a file; other errors thrown.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { absolutePath } from "../../domain/paths.ts";
|
|
9
|
+
import { useTempDir } from "../../../test/support.ts";
|
|
10
|
+
import { nodeListEntries } from "./list-entries.io.ts";
|
|
11
|
+
|
|
12
|
+
const root = useTempDir("paperlint-list-entries-");
|
|
13
|
+
|
|
14
|
+
const sorted = (dir: string) =>
|
|
15
|
+
[...nodeListEntries(absolutePath(dir))].sort((a, b) =>
|
|
16
|
+
a.name.localeCompare(b.name),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("nodeListEntries", () => {
|
|
20
|
+
it("names each entry's kind, a link to a directory as a link", () => {
|
|
21
|
+
const d = join(root, "study");
|
|
22
|
+
mkdirSync(join(d, "out"), { recursive: true });
|
|
23
|
+
writeFileSync(join(d, "a.md"), "");
|
|
24
|
+
symlinkSync(".", join(d, "loop"));
|
|
25
|
+
expect(sorted(d)).toEqual([
|
|
26
|
+
{ name: "a.md", kind: "file" },
|
|
27
|
+
{ name: "loop", kind: "symlink" },
|
|
28
|
+
{ name: "out", kind: "dir" },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("is empty for a directory that does not exist, and for a file", () => {
|
|
33
|
+
writeFileSync(join(root, "f"), "");
|
|
34
|
+
expect([
|
|
35
|
+
nodeListEntries(absolutePath(join(root, "none"))),
|
|
36
|
+
nodeListEntries(absolutePath(join(root, "f"))),
|
|
37
|
+
]).toEqual([[], []]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throws any other error", () => {
|
|
41
|
+
expect(() => nodeListEntries(absolutePath(`${root}/a\0b`))).toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|