@miroshnik/elves 0.0.0 → 2026.1018.1
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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -0
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* spec-diff — the spec diff: test names (folder path + describe + it) between the base branch and HEAD, by static
|
|
4
|
+
* parsing of the files at the two revisions (git cat-file, no runs and no checkouts). Prints a markdown section for the
|
|
5
|
+
* PR body: removed (first), changed (renamed in the same file), added, moved into the tree (a test vanished from a file
|
|
6
|
+
* outside tests/ and appeared in the tree under the same name — a summary by folders); plus test files changed outside
|
|
7
|
+
* the tests/ tree and the number of the PR's new names that aren't statements in the project language.
|
|
8
|
+
*
|
|
9
|
+
* bun spec-diff.ts [--base origin/main] [--head HEAD | --worktree] [--root DIR] [--no-merge-base] [--json]
|
|
10
|
+
* [--scenarios issue.md | -]
|
|
11
|
+
*
|
|
12
|
+
* --scenarios — the task body (markdown, `-` — stdin): the scenarios section ("## Scenarios" or its heading in any
|
|
13
|
+
* dictionary language) is matched against added and changed tests and against the describes of new harness checks — a
|
|
14
|
+
* scenario that became a test or a check, an undone scenario and tests beyond the scenarios.
|
|
15
|
+
*
|
|
16
|
+
* The section is in the root's project language (`language` in `.agents/elves.json`, the github skill's dictionary).
|
|
17
|
+
* The default base is the merge-base of the base branch and HEAD (like a PR diff on GitHub). Runs on Bun; needs git.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { pathToFileURL } from "node:url";
|
|
25
|
+
import { parseArgs } from "node:util";
|
|
26
|
+
|
|
27
|
+
import locale from "../../github/scripts/locale.ts";
|
|
28
|
+
import type { Names } from "../../github/scripts/locale.ts";
|
|
29
|
+
import { disablesIn } from "./harness.ts";
|
|
30
|
+
import * as L from "./speclib.ts";
|
|
31
|
+
import type { Test } from "./speclib.ts";
|
|
32
|
+
|
|
33
|
+
// the project language and its names: the PR section is in it; main sets it from the root
|
|
34
|
+
let LANG = locale.DEFAULT_LANGUAGE;
|
|
35
|
+
let N: Names = locale.names(LANG);
|
|
36
|
+
|
|
37
|
+
/** Below this similarity it's not a rename but a removed and a new requirement: let it be visible. */
|
|
38
|
+
export const RENAME_RATIO = 0.6;
|
|
39
|
+
|
|
40
|
+
function git(args: string[], cwd: string, input?: string): Buffer {
|
|
41
|
+
const r = spawnSync("git", args, { cwd, input, maxBuffer: 256 * 1024 * 1024 });
|
|
42
|
+
if (r.error) throw new Error(`git ${args.join(" ")}: ${r.error.message}`);
|
|
43
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString("utf8").trim()}`);
|
|
44
|
+
return r.stdout;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const gitText = (args: string[], cwd: string): string => git(args, cwd).toString("utf8");
|
|
48
|
+
|
|
49
|
+
function findRoot(root?: string): { top: string; root: string; prefix: string } {
|
|
50
|
+
const abs = path.resolve(root ?? process.cwd());
|
|
51
|
+
const top = gitText(["rev-parse", "--show-toplevel"], abs).trim();
|
|
52
|
+
const rel = path.relative(top, abs).replace(/\\/g, "/");
|
|
53
|
+
return { top, root: abs, prefix: rel === "" ? "" : rel + "/" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const wanted = (rel: string) => L.isTestFile(rel) && L.classify(rel)[0] !== "lib";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A copy of the Elves flow (`.agents/`, `.claude/` from the root) isn't project code: `install` puts it there, nobody
|
|
60
|
+
* edits it by hand, the project's linters don't see it. Its tests and lint disables aren't project decisions and don't
|
|
61
|
+
* go into the spec diff.
|
|
62
|
+
*/
|
|
63
|
+
const FLOW_COPY = /^\.(?:agents|claude)\//;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* {path relative to the root: source} for the test files of the tests/ tree at a revision (without lib).
|
|
67
|
+
* The contents come from one `git cat-file --batch`, not `git show` per file: in a repository with hundreds of test
|
|
68
|
+
* files that's two processes instead of hundreds.
|
|
69
|
+
*/
|
|
70
|
+
function testsAtRev(rev: string, top: string, prefix: string): Map<string, string> {
|
|
71
|
+
const list = gitText(["ls-tree", "-r", "--name-only", "-z", rev, "--", prefix + L.TESTS], top);
|
|
72
|
+
const paths = list.split("\0").filter((p) => p && p.startsWith(prefix) && wanted(p.slice(prefix.length)));
|
|
73
|
+
return filesAtRev(rev, top, prefix, paths);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** {path relative to the root: source} for paths from the git root at a revision; no file — no key either. */
|
|
77
|
+
function filesAtRev(rev: string, top: string, prefix: string, paths: string[]): Map<string, string> {
|
|
78
|
+
const files = new Map<string, string>();
|
|
79
|
+
if (!paths.length) return files;
|
|
80
|
+
const buf = git(["cat-file", "--batch"], top, paths.map((p) => `${rev}:${p}\n`).join(""));
|
|
81
|
+
// the reply per object: `<sha> <type> <size>\n<contents>\n`; no object — `<rev>:<path> missing\n`
|
|
82
|
+
let pos = 0;
|
|
83
|
+
for (const p of paths) {
|
|
84
|
+
const nl = buf.indexOf(0x0a, pos);
|
|
85
|
+
if (nl < 0) break;
|
|
86
|
+
const header = buf.toString("utf8", pos, nl);
|
|
87
|
+
pos = nl + 1;
|
|
88
|
+
if (header.endsWith(" missing")) continue;
|
|
89
|
+
const size = Number(header.split(" ")[2]);
|
|
90
|
+
files.set(p.slice(prefix.length), buf.toString("utf8", pos, pos + size));
|
|
91
|
+
pos += size + 1;
|
|
92
|
+
}
|
|
93
|
+
return files;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function testsInWorktree(root: string): Map<string, string> {
|
|
97
|
+
const files = new Map<string, string>();
|
|
98
|
+
const walk = (dir: string) => {
|
|
99
|
+
let names: string[];
|
|
100
|
+
try {
|
|
101
|
+
names = readdirSync(dir).sort();
|
|
102
|
+
} catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const fn of names) {
|
|
106
|
+
const full = path.join(dir, fn);
|
|
107
|
+
if (statSync(full).isDirectory()) {
|
|
108
|
+
walk(full);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const rel = path.relative(root, full).replace(/\\/g, "/");
|
|
112
|
+
if (wanted(rel)) files.set(rel, readFileSync(full, "utf8"));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
walk(path.join(root, L.TESTS));
|
|
116
|
+
return files;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// a name that can't be computed — a stderr line once: the same test is both in the base and in the head
|
|
120
|
+
const unnamedSeen = new Set<string>();
|
|
121
|
+
|
|
122
|
+
/** A harness check in a test file: the call from parsing and the file path. */
|
|
123
|
+
export type Check = L.HarnessCall & { path: string };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Tests and harness checks of files; `unnamed` — files with a test or describe whose name can't be computed statically
|
|
127
|
+
* (its tests only a run names). A file that can't be parsed and such a name — a line in stderr.
|
|
128
|
+
*/
|
|
129
|
+
function parseFiles(files: Map<string, string>, label: string): { tests: Test[]; checks: Check[]; unnamed: string[] } {
|
|
130
|
+
const tests: Test[] = [];
|
|
131
|
+
const checks: Check[] = [];
|
|
132
|
+
const unnamed: string[] = [];
|
|
133
|
+
for (const file of [...files.keys()].sort()) {
|
|
134
|
+
try {
|
|
135
|
+
const scan = L.scanJs(file, files.get(file)!);
|
|
136
|
+
tests.push(...scan.tests);
|
|
137
|
+
checks.push(...scan.checks.map((c) => ({ ...c, path: file })));
|
|
138
|
+
if (scan.unnamed.length) unnamed.push(file);
|
|
139
|
+
for (const u of scan.unnamed) {
|
|
140
|
+
const key = JSON.stringify([file, u.kind, u.name]);
|
|
141
|
+
if (unnamedSeen.has(key)) continue;
|
|
142
|
+
unnamedSeen.add(key);
|
|
143
|
+
console.error(`spec-diff: ${label}: ${file}:${u.line}: the ${u.kind} name can't be computed statically (${u.name}) — the test isn't in the lists`);
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error(`spec-diff: ${label}: ${file}: ${(e as Error).message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { tests, checks, unnamed };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Test files changed outside tests/ (including untracked ones with --worktree), except the flow copy. */
|
|
153
|
+
function outOfTreeFiles(base: string, head: string, top: string, prefix: string, worktree: boolean): string[] {
|
|
154
|
+
const spec = ["--", ".", `:(exclude)${prefix}${L.TESTS}`];
|
|
155
|
+
const changed = worktree
|
|
156
|
+
? gitText(["diff", "--name-only", "-z", base, ...spec], top) + gitText(["ls-files", "--others", "--exclude-standard", "-z", ...spec], top)
|
|
157
|
+
: gitText(["diff", "--name-only", "-z", base, head, ...spec], top);
|
|
158
|
+
const out = new Set<string>();
|
|
159
|
+
for (const p of changed.split("\0")) {
|
|
160
|
+
const rel = p.slice(prefix.length);
|
|
161
|
+
if (p && p.startsWith(prefix) && L.isTestFile(rel) && !FLOW_COPY.test(rel)) out.add(rel);
|
|
162
|
+
}
|
|
163
|
+
return [...out].sort();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A test outside the tree at the base that vanished from its file: a candidate for moved. */
|
|
167
|
+
export interface Moved {
|
|
168
|
+
test: Test; // the test in the tree, the path is new
|
|
169
|
+
from: string; // the file outside the tree at the base
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Tests of files outside the tree changed in the PR: they were there at the base, at HEAD they're gone from the same
|
|
174
|
+
* file (the file was deleted or the test was removed from it). Only they can count as moved: a test that stayed in
|
|
175
|
+
* place is the source of a copy, not of a move. `count` — how many tests the file had at the base.
|
|
176
|
+
*/
|
|
177
|
+
function outOfTreePool(
|
|
178
|
+
base: string,
|
|
179
|
+
head: string,
|
|
180
|
+
top: string,
|
|
181
|
+
root: string,
|
|
182
|
+
prefix: string,
|
|
183
|
+
outFiles: string[],
|
|
184
|
+
worktree: boolean,
|
|
185
|
+
): { pool: Test[]; count: Map<string, number>; gone: Set<string> } {
|
|
186
|
+
const pool: Test[] = [];
|
|
187
|
+
const count = new Map<string, number>();
|
|
188
|
+
const gone = new Set<string>();
|
|
189
|
+
if (!outFiles.length) return { pool, count, gone };
|
|
190
|
+
const baseFiles = filesAtRev(base, top, prefix, outFiles.map((f) => prefix + f));
|
|
191
|
+
let headFiles: Map<string, string>;
|
|
192
|
+
if (worktree) {
|
|
193
|
+
headFiles = new Map();
|
|
194
|
+
for (const f of outFiles) {
|
|
195
|
+
const full = path.join(root, f);
|
|
196
|
+
if (existsSync(full)) headFiles.set(f, readFileSync(full, "utf8"));
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
headFiles = filesAtRev(head, top, prefix, outFiles.map((f) => prefix + f));
|
|
200
|
+
}
|
|
201
|
+
const nameKey = (t: Test) => JSON.stringify([t.describes, t.name]);
|
|
202
|
+
for (const f of outFiles) {
|
|
203
|
+
const src = baseFiles.get(f);
|
|
204
|
+
if (src === undefined) continue;
|
|
205
|
+
const baseTests = parseFiles(new Map([[f, src]]), "base").tests;
|
|
206
|
+
count.set(f, baseTests.length);
|
|
207
|
+
if (!headFiles.has(f)) gone.add(f);
|
|
208
|
+
const left = new Map<string, number>();
|
|
209
|
+
for (const t of parseFiles(new Map([[f, headFiles.get(f) ?? ""]]), "HEAD").tests) left.set(nameKey(t), (left.get(nameKey(t)) ?? 0) + 1);
|
|
210
|
+
for (const t of baseTests) {
|
|
211
|
+
const k = nameKey(t);
|
|
212
|
+
const n = left.get(k) ?? 0;
|
|
213
|
+
if (n > 0) left.set(k, n - 1);
|
|
214
|
+
else pool.push(t);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { pool, count, gone };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** String similarity by Ratcliff/Obershelp (like difflib.SequenceMatcher.ratio without heuristics). */
|
|
221
|
+
export function ratio(a: string, b: string): number {
|
|
222
|
+
if (!a.length && !b.length) return 1;
|
|
223
|
+
return (2 * matching(a, b)) / (a.length + b.length);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function matching(a: string, b: string): number {
|
|
227
|
+
if (!a.length || !b.length) return 0;
|
|
228
|
+
let best = 0;
|
|
229
|
+
let bi = 0;
|
|
230
|
+
let bj = 0;
|
|
231
|
+
let prev = new Array<number>(b.length + 1).fill(0);
|
|
232
|
+
for (let i = 0; i < a.length; i++) {
|
|
233
|
+
const cur = new Array<number>(b.length + 1).fill(0);
|
|
234
|
+
for (let j = 0; j < b.length; j++) {
|
|
235
|
+
if (a[i] === b[j]) {
|
|
236
|
+
cur[j + 1] = prev[j]! + 1;
|
|
237
|
+
if (cur[j + 1]! > best) {
|
|
238
|
+
best = cur[j + 1]!;
|
|
239
|
+
bi = i + 1 - best;
|
|
240
|
+
bj = j + 1 - best;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
prev = cur;
|
|
245
|
+
}
|
|
246
|
+
if (!best) return 0;
|
|
247
|
+
return best + matching(a.slice(0, bi), b.slice(0, bj)) + matching(a.slice(bi + best), b.slice(bj + best));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Greedy matching by descending similarity; deterministic (ties are broken by order). */
|
|
251
|
+
function pair(
|
|
252
|
+
removed: Test[],
|
|
253
|
+
added: Test[],
|
|
254
|
+
cond: (r: Test, a: Test) => boolean,
|
|
255
|
+
score: (r: Test, a: Test) => number,
|
|
256
|
+
threshold: number,
|
|
257
|
+
): [Test, Test][] {
|
|
258
|
+
const cands: [number, number, number][] = [];
|
|
259
|
+
removed.forEach((r, i) => {
|
|
260
|
+
added.forEach((a, j) => {
|
|
261
|
+
if (!cond(r, a)) return;
|
|
262
|
+
const sc = score(r, a);
|
|
263
|
+
if (sc >= threshold) cands.push([-sc, i, j]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
cands.sort((x, y) => x[0] - y[0] || x[1] - y[1] || x[2] - y[2]);
|
|
267
|
+
const usedR = new Set<number>();
|
|
268
|
+
const usedA = new Set<number>();
|
|
269
|
+
const pairs: [Test, Test][] = [];
|
|
270
|
+
for (const [, i, j] of cands) {
|
|
271
|
+
if (usedR.has(i) || usedA.has(j)) continue;
|
|
272
|
+
usedR.add(i);
|
|
273
|
+
usedA.add(j);
|
|
274
|
+
pairs.push([removed[i]!, added[j]!]);
|
|
275
|
+
}
|
|
276
|
+
removed.splice(0, removed.length, ...removed.filter((_, i) => !usedR.has(i)));
|
|
277
|
+
added.splice(0, added.length, ...added.filter((_, j) => !usedA.has(j)));
|
|
278
|
+
return pairs;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A body with no check: `() => {}`, `async () => {}`, `function () {}` — without spaces, as in `Test.body`. */
|
|
282
|
+
const EMPTY_BODY = /^,(?:async)?(?:\([^)]*\)=>|function\w*\([^)]*\))\{\}$/;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The body for matching — without trailing commas before `)`, `]`, `}` and after the last argument: prettier adds them
|
|
286
|
+
* when it wraps a call with a long name, and in a body the wrap shifted to the right (#324). The check doesn't change
|
|
287
|
+
* because of them, while the exact body would split a rename into "removed" and "added".
|
|
288
|
+
*/
|
|
289
|
+
const plainBody = (body: string) => body.replace(/,(?=[)\]}]|$)/g, "");
|
|
290
|
+
|
|
291
|
+
const sameDescribes = (r: Test, a: Test) => r.describes.length === a.describes.length && r.describes.every((d, i) => d === a.describes[i]);
|
|
292
|
+
|
|
293
|
+
export function diff(
|
|
294
|
+
baseTests: Test[],
|
|
295
|
+
headTests: Test[],
|
|
296
|
+
pool: Test[] = [],
|
|
297
|
+
): { removed: Test[]; changed: [Test, Test][]; added: Test[]; moved: Moved[] } {
|
|
298
|
+
const b = new Map<string, Test>();
|
|
299
|
+
const h = new Map<string, Test>();
|
|
300
|
+
for (const t of baseTests) if (!b.has(L.keyOf(t))) b.set(L.keyOf(t), t);
|
|
301
|
+
for (const t of headTests) if (!h.has(L.keyOf(t))) h.set(L.keyOf(t), t);
|
|
302
|
+
const removed = [...b.entries()].filter(([k]) => !h.has(k)).map(([, t]) => t);
|
|
303
|
+
let added = [...h.entries()].filter(([k]) => !b.has(k)).map(([, t]) => t);
|
|
304
|
+
// 0) the same name and describe as a test that vanished outside the tree — moved into the tree, not new.
|
|
305
|
+
// An exact match, no similarity: a test renamed during the move is honestly "added".
|
|
306
|
+
const from = new Map<string, Test[]>();
|
|
307
|
+
for (const t of pool) {
|
|
308
|
+
const k = JSON.stringify([t.describes, t.name]);
|
|
309
|
+
from.set(k, [...(from.get(k) ?? []), t]);
|
|
310
|
+
}
|
|
311
|
+
// All HEAD tests go, not one per key: identical tests of two files moved into one folder are one requirement
|
|
312
|
+
// there, but both are moved, and the source files are moved whole.
|
|
313
|
+
const moved: Moved[] = [];
|
|
314
|
+
const movedKeys = new Set<string>();
|
|
315
|
+
for (const t of headTests) {
|
|
316
|
+
if (b.has(L.keyOf(t))) continue;
|
|
317
|
+
const src = from.get(JSON.stringify([t.describes, t.name]))?.shift();
|
|
318
|
+
if (!src) continue;
|
|
319
|
+
moved.push({ test: t, from: src.path });
|
|
320
|
+
movedKeys.add(L.keyOf(t));
|
|
321
|
+
}
|
|
322
|
+
added = added.filter((a) => !movedKeys.has(L.keyOf(a)));
|
|
323
|
+
// 1) the same file and the same body, unique in the file among the removed and among the new — the test is renamed,
|
|
324
|
+
// even if both the describe and the name changed (turning names into statements). An empty body isn't a sign: it
|
|
325
|
+
// has no check
|
|
326
|
+
const bodyKey = (t: Test) => {
|
|
327
|
+
const body = t.body && plainBody(t.body);
|
|
328
|
+
return body && !EMPTY_BODY.test(body) ? JSON.stringify([t.path, body]) : "";
|
|
329
|
+
};
|
|
330
|
+
const once = (list: Test[]) => {
|
|
331
|
+
const n = new Map<string, number>();
|
|
332
|
+
for (const t of list) if (bodyKey(t)) n.set(bodyKey(t), (n.get(bodyKey(t)) ?? 0) + 1);
|
|
333
|
+
return n;
|
|
334
|
+
};
|
|
335
|
+
const [inRemoved, inAdded] = [once(removed), once(added)];
|
|
336
|
+
const unique = (t: Test) => !!bodyKey(t) && inRemoved.get(bodyKey(t)) === 1 && inAdded.get(bodyKey(t)) === 1;
|
|
337
|
+
const changed = pair(removed, added, (r, a) => unique(r) && bodyKey(r) === bodyKey(a), () => 1, 1);
|
|
338
|
+
// 2) the same file and describe, a similar name — the test is renamed
|
|
339
|
+
changed.push(...pair(removed, added, (r, a) => r.path === a.path && sameDescribes(r, a), (r, a) => ratio(r.name, a.name), RENAME_RATIO));
|
|
340
|
+
// 3) the same folder and name, a different describe — the describe is renamed or the test moved to another section,
|
|
341
|
+
// including from another file of the folder: the requirement text is the same, the section changed
|
|
342
|
+
changed.push(
|
|
343
|
+
...pair(
|
|
344
|
+
removed,
|
|
345
|
+
added,
|
|
346
|
+
(r, a) => L.folderOf(r) === L.folderOf(a) && r.name === a.name,
|
|
347
|
+
(r, a) => ratio(r.describes.join(" › "), a.describes.join(" › ")),
|
|
348
|
+
0,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
return { removed, changed, added, moved };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Files outside the tree not moved whole: one moved whole (deleted, all its tests found a pair in the tree) is only a
|
|
356
|
+
* number in the summary; the rest are named: some of their tests found no pair, so something was renamed or lost in
|
|
357
|
+
* the move.
|
|
358
|
+
*/
|
|
359
|
+
export function unmovedFiles(outFiles: string[], moved: Moved[], count: Map<string, number>, gone: Set<string>): string[] {
|
|
360
|
+
const n = new Map<string, number>();
|
|
361
|
+
for (const m of moved) n.set(m.from, (n.get(m.from) ?? 0) + 1);
|
|
362
|
+
return outFiles.filter((f) => !(gone.has(f) && (count.get(f) ?? 0) > 0 && n.get(f) === count.get(f)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const outMark = (t: Test) => (L.classify(t.path)[0] === "out" ? ` ⚠️ ${N.spec.diff.outsideMark}` : "");
|
|
366
|
+
const entry = (t: Test) => `- \`${L.folderOf(t)}\` · ${L.titleOf(t)}${outMark(t)}`;
|
|
367
|
+
|
|
368
|
+
function changedEntry([o, n]: [Test, Test]): string {
|
|
369
|
+
const md = (parts: string[]) => parts.map(L.mdText).join(" › ");
|
|
370
|
+
if (sameDescribes(o, n)) {
|
|
371
|
+
const prefix = n.describes.length ? md(n.describes) + " › " : "";
|
|
372
|
+
return `- \`${L.folderOf(n)}\` · ${prefix}~~${L.mdText(o.name)}~~ → ${L.mdText(n.name)}${outMark(n)}`;
|
|
373
|
+
}
|
|
374
|
+
// both the describe and the name changed (or there was no describe) — the old name is struck through whole
|
|
375
|
+
if (o.name !== n.name || !o.describes.length) return `- \`${L.folderOf(n)}\` · ~~${L.titleOf(o)}~~ → ${L.titleOf(n)}${outMark(n)}`;
|
|
376
|
+
return `- \`${L.folderOf(n)}\` · ~~${md(o.describes)}~~ → ${md(n.describes)} › ${L.mdText(n.name)}${outMark(n)}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Move summary: per folder — how many tests and from how many files outside the tree; per test — only in --json. */
|
|
380
|
+
function movedLines(moved: Moved[]): string[] {
|
|
381
|
+
const byFolder = new Map<string, { tests: number; files: Set<string> }>();
|
|
382
|
+
for (const m of moved) {
|
|
383
|
+
const g = byFolder.get(L.folderOf(m.test)) ?? { tests: 0, files: new Set<string>() };
|
|
384
|
+
g.tests++;
|
|
385
|
+
g.files.add(m.from);
|
|
386
|
+
byFolder.set(L.folderOf(m.test), g);
|
|
387
|
+
}
|
|
388
|
+
const files = new Set(moved.map((m) => m.from)).size;
|
|
389
|
+
const fileWord = (n: number) => locale.count(LANG, N.spec.diff.files, n);
|
|
390
|
+
const folderLine = (f: string) => {
|
|
391
|
+
const g = byFolder.get(f)!;
|
|
392
|
+
return `- \`${f}\` — ${locale.fill(N.spec.diff.movedFolder, { tests: L.testsWord(LANG, g.tests), files: fileWord(g.files.size) })}`;
|
|
393
|
+
};
|
|
394
|
+
return [locale.fill(N.spec.diff.moved, { n: moved.length, tests: L.TESTS, files: fileWord(files) }), "", ...[...byFolder.keys()].sort().map(folderLine), ""];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------- decisions outside test names: the model, exceptions, harness checks ----------
|
|
398
|
+
|
|
399
|
+
/** Decisions of one kind at a revision — lines for a list; removed and added are the difference of lines. */
|
|
400
|
+
export interface Decisions {
|
|
401
|
+
model: string[];
|
|
402
|
+
exceptions: string[];
|
|
403
|
+
checks: string[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A project data module (model, exceptions) from text: .json — parsed, TS/JS — imported from a temporary file. */
|
|
407
|
+
async function loadData(text: string, file: string): Promise<unknown> {
|
|
408
|
+
if (file.endsWith(".json")) return JSON.parse(text);
|
|
409
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "spec-diff-"));
|
|
410
|
+
try {
|
|
411
|
+
const f = path.join(dir, "data" + path.extname(file));
|
|
412
|
+
writeFileSync(f, text);
|
|
413
|
+
return ((await import(pathToFileURL(f).href)) as { default?: unknown }).default;
|
|
414
|
+
} catch {
|
|
415
|
+
return undefined; // a model with imports beyond the file can't be read statically — the model's decisions aren't shown
|
|
416
|
+
} finally {
|
|
417
|
+
rmSync(dir, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
type ModelData = { modules?: Record<string, { dependsOn?: string[]; packages?: string[] }> };
|
|
422
|
+
|
|
423
|
+
/** Model → "module X", "dependency X → Y", "package P of module X": by kind, by name within a kind. */
|
|
424
|
+
export function modelFacts(m: ModelData | undefined): string[] {
|
|
425
|
+
const mods = Object.keys(m?.modules ?? {}).sort();
|
|
426
|
+
const at = (n: string) => m!.modules![n]!;
|
|
427
|
+
return [
|
|
428
|
+
...mods.map((n) => locale.fill(N.spec.diff.modelModule, { module: n })),
|
|
429
|
+
...mods.flatMap((n) => [...(at(n).dependsOn ?? [])].sort().map((d) => locale.fill(N.spec.diff.modelDependency, { from: n, to: d }))),
|
|
430
|
+
...mods.flatMap((n) => [...(at(n).packages ?? [])].sort().map((p) => locale.fill(N.spec.diff.modelPackage, { package: p, module: n }))),
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* What a harness call checks: `registry "mutations"`, `rule no-console`, `code without a consumer (production)`,
|
|
436
|
+
* `architecture model`; a registry or a rule that can't be computed without a run — only the kind.
|
|
437
|
+
*/
|
|
438
|
+
const checkLabel = (call: string, value: string | null): string => {
|
|
439
|
+
const named = (template: string) => locale.fill(template, { value: value! });
|
|
440
|
+
if (call === "invariant") return value === null ? N.spec.diff.registry : named(N.spec.diff.registryNamed);
|
|
441
|
+
if (call === "examples") return value === null ? N.spec.diff.rule : named(N.spec.diff.ruleNamed);
|
|
442
|
+
if (call === "deadCode") return value === null ? N.spec.diff.deadCode : named(N.spec.diff.deadCodeNamed);
|
|
443
|
+
return N.spec.diff.architecture;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// a check named by a call option: without a computable registry or rule it can't be named in the decisions list
|
|
447
|
+
const NAMED_BY_OPTION = new Set(["invariant", "examples"]);
|
|
448
|
+
|
|
449
|
+
/** Harness checks in test sources (file parsing — `parseFiles`): registries, rules, deadCode runs, the model. */
|
|
450
|
+
export function checkFacts(checks: Check[]): string[] {
|
|
451
|
+
const out = new Set<string>();
|
|
452
|
+
for (const c of checks) {
|
|
453
|
+
if (c.value === null && NAMED_BY_OPTION.has(c.call)) continue;
|
|
454
|
+
out.add(`\`${path.posix.dirname(c.path)}\` · ${checkLabel(c.call, c.value)}`);
|
|
455
|
+
}
|
|
456
|
+
return [...out].sort();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* A describe with harness checks — a scenario of the "registry + invariant" standard: the folder, the describe chain
|
|
461
|
+
* and what they check.
|
|
462
|
+
*/
|
|
463
|
+
export interface HarnessDescribe {
|
|
464
|
+
folder: string;
|
|
465
|
+
describes: string[];
|
|
466
|
+
checks: string[];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A describe with a harness check it didn't have at the base (the describe is new or renamed, the registry or the rule
|
|
471
|
+
* changed): like new and renamed tests, only it closes a scenario. A call outside a describe has nothing to name it
|
|
472
|
+
* by — it's skipped.
|
|
473
|
+
*/
|
|
474
|
+
export function freshChecks(base: Check[], head: Check[]): HarnessDescribe[] {
|
|
475
|
+
const group = (checks: Check[]) => {
|
|
476
|
+
const out = new Map<string, HarnessDescribe>();
|
|
477
|
+
for (const c of checks) {
|
|
478
|
+
if (!c.describes.length) continue;
|
|
479
|
+
const folder = path.posix.dirname(c.path);
|
|
480
|
+
const k = JSON.stringify([folder, c.describes]);
|
|
481
|
+
const g = out.get(k) ?? { folder, describes: c.describes, checks: [] };
|
|
482
|
+
if (!g.checks.includes(checkLabel(c.call, c.value))) g.checks.push(checkLabel(c.call, c.value));
|
|
483
|
+
out.set(k, g);
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
};
|
|
487
|
+
const was = group(base);
|
|
488
|
+
return [...group(head)].filter(([k, g]) => g.checks.some((c) => !was.get(k)?.checks.includes(c))).map(([, g]) => ({ ...g, checks: g.checks.sort() }));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Describes of new tests from the report with no harness call in the sources: the tests are written not by the runner
|
|
493
|
+
* but by a script after the run (the spec-claims report) — the check is visible only in the report, and these tests
|
|
494
|
+
* close the scenario with its name. A describe with a harness call in the sources is `freshChecks`' job: a new registry
|
|
495
|
+
* item in an old check doesn't close a scenario.
|
|
496
|
+
*/
|
|
497
|
+
export function reportedChecks(fresh: Test[], generated: Set<Test>, sourced: Check[]): HarnessDescribe[] {
|
|
498
|
+
const key = (folder: string, describes: string[]) => JSON.stringify([folder, describes]);
|
|
499
|
+
const inSource = new Set(sourced.map((c) => key(path.posix.dirname(c.path), c.describes)));
|
|
500
|
+
const out = new Map<string, HarnessDescribe>();
|
|
501
|
+
for (const t of fresh) {
|
|
502
|
+
const k = key(L.folderOf(t), t.describes);
|
|
503
|
+
if (!generated.has(t) || !t.describes.length || inSource.has(k)) continue;
|
|
504
|
+
out.set(k, { folder: L.folderOf(t), describes: t.describes, checks: [N.spec.diff.fromReport] });
|
|
505
|
+
}
|
|
506
|
+
return [...out.values()];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// the exceptions file — by the exceptionFile rule (speclib), as the harness reads it: exceptions/<item>.json — a harness
|
|
510
|
+
// exception; the former exceptions.* — an array (the base revision of the move is still on it); a misplaced file isn't
|
|
511
|
+
// read as an exception — we don't show it either
|
|
512
|
+
const readable = (rel: string) => {
|
|
513
|
+
const x = L.exceptionFile(rel);
|
|
514
|
+
return x && !x.error ? x : null;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/** exceptions.* entry: a harness registry item, `rule` — a convention. */
|
|
518
|
+
type ExceptionEntry = { item: string; rule?: string; issue: number; reason: string };
|
|
519
|
+
|
|
520
|
+
/** Exception line: what's excepted, the task and the reason — each entry has its own, identical ones aren't merged. */
|
|
521
|
+
function exceptionLine(folder: string, e: ExceptionEntry): string {
|
|
522
|
+
return `\`${folder}\` · ${e.item} (${e.rule ? `${e.rule}, ` : ""}#${e.issue}) — ${e.reason}`;
|
|
523
|
+
}
|
|
524
|
+
const CODE = /\.[cm]?[jt]sx?$/;
|
|
525
|
+
|
|
526
|
+
/** Files at a revision (null — the working tree) by paths from the git root: path without the prefix → text. */
|
|
527
|
+
function readFiles(rev: string | null, top: string, prefix: string, paths: string[]): Map<string, string> {
|
|
528
|
+
if (rev !== null) return filesAtRev(rev, top, prefix, paths);
|
|
529
|
+
const out = new Map<string, string>();
|
|
530
|
+
for (const p of paths) {
|
|
531
|
+
try {
|
|
532
|
+
out.set(p.slice(prefix.length), readFileSync(path.join(top, p), "utf8"));
|
|
533
|
+
} catch {
|
|
534
|
+
/* the file isn't in the working tree */
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Decisions at a revision: the model, exceptions (exceptions/ and exceptions.* in tests/, lint disables in changed
|
|
542
|
+
* files), checks.
|
|
543
|
+
*/
|
|
544
|
+
async function decisionsAt(rev: string | null, top: string, prefix: string, changedCode: string[], checks: Check[]): Promise<Decisions> {
|
|
545
|
+
const listed = rev !== null ? gitText(["ls-tree", "-r", "--name-only", "-z", rev, "--", prefix + L.TESTS], top).split("\0") : [];
|
|
546
|
+
const excPaths =
|
|
547
|
+
rev !== null
|
|
548
|
+
? listed.filter((p) => p.startsWith(prefix) && readable(p.slice(prefix.length)))
|
|
549
|
+
: walkTests(path.join(top, prefix, L.TESTS)).filter((p) => readable(p)).map((p) => prefix + p);
|
|
550
|
+
const modelPath = `${prefix}${L.TESTS}/architecture/model.ts`;
|
|
551
|
+
const files = readFiles(rev, top, prefix, [modelPath, ...excPaths, ...changedCode]);
|
|
552
|
+
const model = files.has(`${L.TESTS}/architecture/model.ts`) ? ((await loadData(files.get(`${L.TESTS}/architecture/model.ts`)!, modelPath)) as ModelData) : undefined;
|
|
553
|
+
const exceptions: string[] = [];
|
|
554
|
+
for (const p of excPaths) {
|
|
555
|
+
const rel = p.slice(prefix.length);
|
|
556
|
+
const { folder, legacy } = readable(rel)!;
|
|
557
|
+
if (!legacy) {
|
|
558
|
+
const data = await loadData(files.get(rel) ?? "null", rel).catch(() => null);
|
|
559
|
+
if (data && typeof data === "object") exceptions.push(exceptionLine(folder, data as ExceptionEntry));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const data = await loadData(files.get(rel) ?? "[]", rel);
|
|
563
|
+
for (const e of Array.isArray(data) ? (data as ExceptionEntry[]) : []) exceptions.push(exceptionLine(folder, e));
|
|
564
|
+
}
|
|
565
|
+
for (const p of changedCode) {
|
|
566
|
+
const rel = p.slice(prefix.length);
|
|
567
|
+
const text = files.get(rel);
|
|
568
|
+
if (!text || !text.includes("eslint-disable")) continue;
|
|
569
|
+
for (const d of disablesIn(rel, text)) exceptions.push(`\`${rel}\` · ${d.rules.join(", ") || N.spec.lint.allRules} — ${d.description || N.spec.lint.noReason}`);
|
|
570
|
+
}
|
|
571
|
+
return { model: modelFacts(model), exceptions: exceptions.sort(), checks: checkFacts(checks) };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function walkTests(dir: string, rel = L.TESTS): string[] {
|
|
575
|
+
const out: string[] = [];
|
|
576
|
+
let entries;
|
|
577
|
+
try {
|
|
578
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
579
|
+
} catch {
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
for (const e of entries) {
|
|
583
|
+
if (e.isDirectory()) out.push(...walkTests(path.join(dir, e.name), `${rel}/${e.name}`));
|
|
584
|
+
else out.push(`${rel}/${e.name}`);
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Difference with repeats: identical lines are separate entries (two identical lint disables); one removed — one in
|
|
591
|
+
* the difference.
|
|
592
|
+
*/
|
|
593
|
+
function minus(a: string[], b: string[]): string[] {
|
|
594
|
+
const left = new Map<string, number>();
|
|
595
|
+
for (const x of b) left.set(x, (left.get(x) ?? 0) + 1);
|
|
596
|
+
return a.filter((x) => {
|
|
597
|
+
const n = left.get(x) ?? 0;
|
|
598
|
+
left.set(x, n - 1);
|
|
599
|
+
return n <= 0;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Decision lines above the threshold — a count per folder or file (the line's first code span), for the model — per
|
|
605
|
+
* kind ("module", "package").
|
|
606
|
+
*/
|
|
607
|
+
function grouped(items: string[]): string[] {
|
|
608
|
+
const n = new Map<string, number>();
|
|
609
|
+
for (const x of items) {
|
|
610
|
+
const k = /^`[^`]+`/.exec(x)?.[0] ?? x.split(" ")[0]!;
|
|
611
|
+
n.set(k, (n.get(k) ?? 0) + 1);
|
|
612
|
+
}
|
|
613
|
+
return [...n.keys()].sort().map((k) => `- ${k} — ${n.get(k)}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Removed and added decisions by kind — sections for the PR body; no changes of a kind — no section; above the
|
|
618
|
+
* threshold — a summary.
|
|
619
|
+
*/
|
|
620
|
+
export function decisionLines(base: Decisions, head: Decisions, limit = Infinity): string[] {
|
|
621
|
+
const lines: string[] = [];
|
|
622
|
+
const kinds: [string, keyof Decisions][] = [
|
|
623
|
+
[N.spec.diff.modelTitle, "model"],
|
|
624
|
+
[N.spec.diff.exceptionsTitle, "exceptions"],
|
|
625
|
+
[N.spec.diff.checksTitle, "checks"],
|
|
626
|
+
];
|
|
627
|
+
const section = (title: string, items: string[]) => {
|
|
628
|
+
if (!items.length) return;
|
|
629
|
+
if (items.length > limit) lines.push(locale.fill(N.spec.diff.summarized, { title, n: items.length }), "", ...grouped(items), "");
|
|
630
|
+
else lines.push(`**${title} (${items.length}):**`, "", ...items.map((x) => `- ${L.mdText(x)}`), "");
|
|
631
|
+
};
|
|
632
|
+
for (const [title, k] of kinds) {
|
|
633
|
+
section(locale.fill(N.spec.diff.removedKind, { title }), minus(base[k], head[k]));
|
|
634
|
+
section(locale.fill(N.spec.diff.addedKind, { title }), minus(head[k], base[k]));
|
|
635
|
+
}
|
|
636
|
+
return lines;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Default summary threshold: with more tests in the lists (removed, changed, added) the PR body gets a summary by
|
|
641
|
+
* folders.
|
|
642
|
+
*/
|
|
643
|
+
export const SUMMARY_LIMIT = 100;
|
|
644
|
+
|
|
645
|
+
/** Summary by folders: the number removed, changed and added; with a runner report — how many the harness generated. */
|
|
646
|
+
function summaryLines(removed: Test[], changed: [Test, Test][], added: Test[], limit: number, harness?: Set<Test>): string[] {
|
|
647
|
+
type Row = { removed: number; changed: number; added: number; harness: number };
|
|
648
|
+
const rows = new Map<string, Row>();
|
|
649
|
+
const row = (t: Test) => {
|
|
650
|
+
const f = L.folderOf(t);
|
|
651
|
+
if (!rows.has(f)) rows.set(f, { removed: 0, changed: 0, added: 0, harness: 0 });
|
|
652
|
+
return rows.get(f)!;
|
|
653
|
+
};
|
|
654
|
+
const count = (ts: Test[], k: "removed" | "changed" | "added") => {
|
|
655
|
+
for (const t of ts) {
|
|
656
|
+
const r = row(t);
|
|
657
|
+
r[k]++;
|
|
658
|
+
if (harness?.has(t)) r.harness++;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
count(removed, "removed");
|
|
662
|
+
count(changed.map(([, n]) => n), "changed");
|
|
663
|
+
count(added, "added");
|
|
664
|
+
const total = removed.length + changed.length + added.length;
|
|
665
|
+
const cols = (r: Row) => [r.removed, r.changed, r.added, ...(harness ? [r.harness] : [])].join(" | ");
|
|
666
|
+
const sum = [...rows.values()].reduce((a, r) => ({ removed: a.removed + r.removed, changed: a.changed + r.changed, added: a.added + r.added, harness: a.harness + r.harness }));
|
|
667
|
+
return [
|
|
668
|
+
locale.fill(N.spec.diff.summary, { tests: L.testsWord(LANG, total), limit }),
|
|
669
|
+
"",
|
|
670
|
+
`| ${N.spec.diff.folder} | ${N.spec.diff.removed} | ${N.spec.diff.changed} | ${N.spec.diff.added} |${harness ? ` ${N.spec.diff.harnessColumn} |` : ""}`,
|
|
671
|
+
`|---|--:|--:|--:|${harness ? "--:|" : ""}`,
|
|
672
|
+
...[...rows.keys()].sort().map((f) => `| \`${f}\` | ${cols(rows.get(f)!)} |`),
|
|
673
|
+
`| **${N.spec.diff.total}** | ${cols(sum)} |`,
|
|
674
|
+
"",
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** More tests in the lists than the threshold — a summary by folders instead of the lists. */
|
|
679
|
+
const summarized = (removed: Test[], changed: [Test, Test][], added: Test[], limit: number) => removed.length + changed.length + added.length > limit;
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Names the PR brings in that aren't statements in the project language: `it` and `describe` names of new and renamed
|
|
683
|
+
* tests that the same file didn't have at the base — per file, without repeats. Not an error (#447): a number in the PR
|
|
684
|
+
* body, spec-doc names each one in a warning.
|
|
685
|
+
*/
|
|
686
|
+
export function newNotStatements(fresh: Test[], base: Test[], lang: string): number {
|
|
687
|
+
const key = (t: Test, n: string) => t.path + "\0" + n;
|
|
688
|
+
const had = new Set(base.flatMap((t) => [...t.describes, t.name].map((n) => key(t, n))));
|
|
689
|
+
const out = new Set<string>();
|
|
690
|
+
for (const t of fresh) for (const n of [...t.describes, t.name]) if (!had.has(key(t, n)) && !L.isStatement(n, lang)) out.add(key(t, n));
|
|
691
|
+
return out.size;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** The line about test files changed outside the `tests/` tree. */
|
|
695
|
+
const outsideFilesLine = (outFiles: string[]): string =>
|
|
696
|
+
locale.fill(N.spec.diff.outsideFiles, { tests: L.TESTS, files: outFiles.map((p) => `\`${p}\``).join(", ") });
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The section for the PR body; `note` — a line under the base (where the harness tests come from). A test not among the
|
|
700
|
+
* task's scenarios (`extra`) is a mark in the list: the test itself is already there, we don't print it a second time.
|
|
701
|
+
* `notStatements` — the number of the PR's new names that aren't statements: a line after the lists.
|
|
702
|
+
*/
|
|
703
|
+
export function render(
|
|
704
|
+
baseLabel: string,
|
|
705
|
+
removed: Test[],
|
|
706
|
+
changed: [Test, Test][],
|
|
707
|
+
added: Test[],
|
|
708
|
+
outFiles: string[],
|
|
709
|
+
moved: Moved[] = [],
|
|
710
|
+
opts: { limit?: number; harness?: Set<Test>; extra?: Set<Test>; note?: string; notStatements?: number } = {},
|
|
711
|
+
): string {
|
|
712
|
+
const lines = [N.prSpecSection, "", locale.fill(N.spec.diff.base, { base: baseLabel }), ""];
|
|
713
|
+
if (opts.note) lines.push(opts.note, "");
|
|
714
|
+
if (!removed.length && !changed.length && !added.length && !outFiles.length && !moved.length) {
|
|
715
|
+
lines.push(N.spec.diff.unchanged);
|
|
716
|
+
return lines.join("\n") + "\n";
|
|
717
|
+
}
|
|
718
|
+
const limit = opts.limit ?? SUMMARY_LIMIT;
|
|
719
|
+
const names = opts.notStatements ? [locale.fill(N.spec.diff.notStatements, { n: opts.notStatements }), ""] : [];
|
|
720
|
+
if (summarized(removed, changed, added, limit)) {
|
|
721
|
+
// a removed test is a removed requirement: named one by one in the PR body while there aren't more of them than
|
|
722
|
+
// the threshold
|
|
723
|
+
if (removed.length && removed.length <= limit) lines.push(`**${N.spec.diff.removed} (${removed.length}):**`, "", ...removed.map(entry), "");
|
|
724
|
+
lines.push(...summaryLines(removed, changed, added, limit, opts.harness));
|
|
725
|
+
if (moved.length) lines.push(...movedLines(moved));
|
|
726
|
+
if (outFiles.length) lines.push(outsideFilesLine(outFiles), "");
|
|
727
|
+
lines.push(...names);
|
|
728
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
729
|
+
}
|
|
730
|
+
const mark = (t: Test) => (opts.extra?.has(t) ? ` — ${N.spec.diff.extraMark}` : "");
|
|
731
|
+
const sections: [string, string[]][] = [
|
|
732
|
+
[N.spec.diff.removed, removed.map(entry)],
|
|
733
|
+
[N.spec.diff.changed, changed.map((p) => changedEntry(p) + mark(p[1]))],
|
|
734
|
+
[N.spec.diff.added, added.map((t) => entry(t) + mark(t))],
|
|
735
|
+
];
|
|
736
|
+
for (const [title, items] of sections) {
|
|
737
|
+
if (items.length) lines.push(`**${title} (${items.length}):**`, "", ...items, "");
|
|
738
|
+
else lines.push(locale.fill(N.spec.diff.empty, { title }), "");
|
|
739
|
+
}
|
|
740
|
+
if (moved.length) lines.push(...movedLines(moved));
|
|
741
|
+
if (outFiles.length) lines.push(outsideFilesLine(outFiles), "");
|
|
742
|
+
lines.push(...names);
|
|
743
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** `unreported` — base tests only a run names, of files a partial report didn't run: not compared (`unreportedTests`). */
|
|
747
|
+
function asJson(
|
|
748
|
+
baseLabel: string,
|
|
749
|
+
removed: Test[],
|
|
750
|
+
changed: [Test, Test][],
|
|
751
|
+
added: Test[],
|
|
752
|
+
outFiles: string[],
|
|
753
|
+
moved: Moved[],
|
|
754
|
+
notStatements: number,
|
|
755
|
+
unreported: Test[],
|
|
756
|
+
): string {
|
|
757
|
+
const d = (t: Test) => ({ file: t.path, folder: L.folderOf(t), describes: t.describes, name: t.name });
|
|
758
|
+
return (
|
|
759
|
+
JSON.stringify(
|
|
760
|
+
{
|
|
761
|
+
base: baseLabel,
|
|
762
|
+
removed: removed.map(d),
|
|
763
|
+
changed: changed.map(([o, n]) => ({ old: d(o), new: d(n) })),
|
|
764
|
+
added: added.map(d),
|
|
765
|
+
moved: moved.map((m) => ({ from: m.from, ...d(m.test) })),
|
|
766
|
+
out_of_tree_files: outFiles,
|
|
767
|
+
not_statements: notStatements,
|
|
768
|
+
unreported: unreported.map(d),
|
|
769
|
+
},
|
|
770
|
+
null,
|
|
771
|
+
2,
|
|
772
|
+
) + "\n"
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Task scenarios — the list items of the scenarios section (up to the next heading); checkboxes stripped; no section —
|
|
778
|
+
* null. The heading is in any dictionary language (`## Scenarios` or its translation): the task could have been written
|
|
779
|
+
* before the project language changed.
|
|
780
|
+
*/
|
|
781
|
+
export function parseScenarios(md: string): string[] | null {
|
|
782
|
+
const lines = md.split(/\r?\n/);
|
|
783
|
+
const heads = locale.every((n) => n.sections.scenarios).map((h) => new RegExp(`^${locale.escapeRe(h).replace(/ +/g, "\\s+")}\\s*$`));
|
|
784
|
+
const start = lines.findIndex((l) => heads.some((h) => h.test(l.trim())));
|
|
785
|
+
if (start < 0) return null;
|
|
786
|
+
const out: string[] = [];
|
|
787
|
+
for (const l of lines.slice(start + 1)) {
|
|
788
|
+
if (/^#{1,2}\s/.test(l)) break;
|
|
789
|
+
const m = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+?)\s*$/.exec(l);
|
|
790
|
+
if (m) out.push(m[1]!);
|
|
791
|
+
}
|
|
792
|
+
return out;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Quotes that quote a scenario whole: `«…»;` in a task is the same name as the test's. */
|
|
796
|
+
const QUOTES: [string, string][] = [["«", "»"], ['"', '"'], ["“", "”"], ["`", "`"]];
|
|
797
|
+
const TAIL = /[.。;,]+$/;
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* A name for matching: whitespace collapsed, case-insensitive, without outer quotes and a trailing mark — both before
|
|
801
|
+
* and after the quotes.
|
|
802
|
+
*/
|
|
803
|
+
const norm = (s: string): string => {
|
|
804
|
+
let t = s.replace(/\s+/g, " ").trim().replace(TAIL, "");
|
|
805
|
+
for (const [open, close] of QUOTES) {
|
|
806
|
+
const inner = t.slice(open.length, -close.length);
|
|
807
|
+
// "a" and "b" isn't a whole quote: its own closing quote is inside
|
|
808
|
+
if (t.length > open.length + close.length && t.startsWith(open) && t.endsWith(close) && !inner.includes(close)) t = inner.trim().replace(TAIL, "");
|
|
809
|
+
}
|
|
810
|
+
return t.toLowerCase();
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
/** A scenario and what it became: new tests, a harness check's describe, or nothing. */
|
|
814
|
+
export interface Found {
|
|
815
|
+
scenario: string;
|
|
816
|
+
tests: Test[];
|
|
817
|
+
harness: HarnessDescribe | null;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Scenario ↔ new tests: each one whose it name or "describe › it" chain matches it (case-insensitive, without outer
|
|
822
|
+
* quotes and a trailing mark) — one scenario is checked both on the server and in the UI; no test — the describe of a
|
|
823
|
+
* new harness check, its name or chain. Tests that check generated (`generated` — only from the report) are tests of
|
|
824
|
+
* its scenario, not beyond the scenarios.
|
|
825
|
+
*/
|
|
826
|
+
export function matchScenarios(
|
|
827
|
+
scenarios: string[],
|
|
828
|
+
fresh: Test[],
|
|
829
|
+
checks: HarnessDescribe[] = [],
|
|
830
|
+
generated: Set<Test> = new Set(),
|
|
831
|
+
): { found: Found[]; extra: Test[] } {
|
|
832
|
+
const used = new Set<Test>();
|
|
833
|
+
const usedChecks = new Set<HarnessDescribe>();
|
|
834
|
+
const found = scenarios.map((scenario): Found => {
|
|
835
|
+
const want = norm(scenario);
|
|
836
|
+
const tests = fresh.filter((x) => norm(x.name) === want || norm([...x.describes, x.name].join(" › ")) === want);
|
|
837
|
+
if (tests.length) {
|
|
838
|
+
for (const t of tests) used.add(t);
|
|
839
|
+
return { scenario, tests, harness: null };
|
|
840
|
+
}
|
|
841
|
+
const harness = checks.find((c) => !usedChecks.has(c) && (norm(c.describes.at(-1)!) === want || norm(c.describes.join(" › ")) === want)) ?? null;
|
|
842
|
+
if (harness) usedChecks.add(harness);
|
|
843
|
+
return { scenario, tests, harness };
|
|
844
|
+
});
|
|
845
|
+
const covered = (t: Test) =>
|
|
846
|
+
generated.has(t) && [...usedChecks].some((c) => c.folder === L.folderOf(t) && c.describes.length === t.describes.length && c.describes.every((d, i) => d === t.describes[i]));
|
|
847
|
+
return { found, extra: fresh.filter((t) => !used.has(t) && !covered(t)) };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const harnessEntry = (c: HarnessDescribe) =>
|
|
851
|
+
`\`${c.folder}\` · ${c.describes.map(L.mdText).join(" › ")} — ${N.spec.diff.harnessCheck}: ${c.checks.map(L.mdText).join(", ")}`;
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Scenario matching without repeats: a scenario that became a test is named the same as the test — while the tests are
|
|
855
|
+
* in the lists above (`listed`), it's only in the count, and a test beyond the scenarios is a mark there too; the
|
|
856
|
+
* folder summary has no lists — then it's the test's line. A harness check scenario is the check's line, an undone
|
|
857
|
+
* one — its own text.
|
|
858
|
+
*/
|
|
859
|
+
function scenarioLines(scenarios: string[] | null, match: { found: Found[]; extra: Test[] }, listed: boolean, limit = Infinity): string[] {
|
|
860
|
+
if (scenarios === null) return [`### ${N.spec.diff.scenarios}`, "", locale.fill(N.spec.diff.noScenarios, { section: N.sections.scenarios }), ""];
|
|
861
|
+
const { found, extra } = match;
|
|
862
|
+
const lines = [`### ${N.spec.diff.scenarios} (${scenarios.length})`, ""];
|
|
863
|
+
const done = found.filter((f) => f.tests.length).length;
|
|
864
|
+
if (listed && done) lines.push(locale.fill(N.spec.diff.becameTests, { n: done }), "");
|
|
865
|
+
const items = found.flatMap(({ scenario, tests, harness }) =>
|
|
866
|
+
tests.length
|
|
867
|
+
? listed
|
|
868
|
+
? []
|
|
869
|
+
: tests.map((t) => `- ✅ ${entry(t).slice(2)}`)
|
|
870
|
+
: [harness ? `- ✅ ${harnessEntry(harness)}` : `- ❌ ${L.mdText(scenario)} — ${N.spec.diff.noTest}`],
|
|
871
|
+
);
|
|
872
|
+
if (items.length) lines.push(...items, "");
|
|
873
|
+
if (!extra.length) return lines;
|
|
874
|
+
const title = `**${N.spec.diff.extraTitle} (${extra.length}):**`;
|
|
875
|
+
if (listed) lines.push(`${title} ${locale.fill(N.spec.diff.extraListed, { mark: N.spec.diff.extraMark })}`, "");
|
|
876
|
+
else if (extra.length > limit) lines.push(`${title} ${N.spec.diff.extraFull}`, "");
|
|
877
|
+
else lines.push(title, "", ...extra.map(entry), "");
|
|
878
|
+
return lines;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const USAGE =
|
|
882
|
+
"spec-diff [--base origin/main] [--head HEAD | --worktree] [--root DIR] [--no-merge-base] [--json] [--scenarios issue.md | -] [--report report … --spec-branch origin/spec] [--limit 100 | --full]";
|
|
883
|
+
/** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
|
|
884
|
+
export const HELP = `${USAGE}
|
|
885
|
+
|
|
886
|
+
Removed, changed and added test names between the base and the head — the PR body: the diff of checks is the diff of the
|
|
887
|
+
spec; --scenarios — the task's scenarios against the added tests.
|
|
888
|
+
|
|
889
|
+
Examples:
|
|
890
|
+
spec-diff
|
|
891
|
+
spec-diff --base origin/main --worktree --scenarios issue.md`;
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Tests without repeats: from the sources first, from the report — only those not there (generated by the harness, in
|
|
895
|
+
* `harness`).
|
|
896
|
+
*/
|
|
897
|
+
function union(a: Test[], b: Test[], harness: Set<Test>): Test[] {
|
|
898
|
+
const keys = new Set(a.map((t) => t.path + "\0" + L.keyOf(t)));
|
|
899
|
+
const extra = b.filter((t) => !keys.has(t.path + "\0" + L.keyOf(t)));
|
|
900
|
+
for (const t of extra) harness.add(t);
|
|
901
|
+
return [...a, ...extra];
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Base tests only a run names that a partial report can't confirm or deny: their file in the head has a harness call
|
|
906
|
+
* (#486) or a test whose name can't be computed statically — a template with a substitution in a loop (#498) — but no
|
|
907
|
+
* test in the report: the run skipped it (`bun test <one folder>`), the spec branch still has its tests, and without
|
|
908
|
+
* this they'd all be "removed". A file without such a call or test in the head — the check is removed, its tests are
|
|
909
|
+
* honestly removed; a test the head has anyway (in the sources) isn't removed either way and stays in the comparison.
|
|
910
|
+
* `runNamed` — the head's files whose tests only a run names; `files` — those of them absent from the report.
|
|
911
|
+
*/
|
|
912
|
+
export function unreportedTests(base: Test[], head: Test[], harness: Set<Test>, runNamed: string[], reported: Test[]): { files: string[]; tests: Test[] } {
|
|
913
|
+
const ran = new Set(reported.map((t) => t.path));
|
|
914
|
+
const files = [...new Set(runNamed)].filter((p) => !ran.has(p)).sort();
|
|
915
|
+
const unrun = new Set(files);
|
|
916
|
+
const inHead = new Set(head.map(L.keyOf));
|
|
917
|
+
return { files, tests: base.filter((t) => harness.has(t) && unrun.has(t.path) && !inHead.has(L.keyOf(t))) };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Base tests from the spec branch: `tests.json` of the commit whose `Source:` (the source SHA, spec-publish writes it)
|
|
922
|
+
* is the merge-base or the nearest ancestor; no branch or file — null.
|
|
923
|
+
*/
|
|
924
|
+
function specTestsAt(branch: string, base: string, top: string): { tests: Test[]; source: string } | null {
|
|
925
|
+
let log: string;
|
|
926
|
+
try {
|
|
927
|
+
log = gitText(["log", "--format=%H%x1f%B%x1e", branch, "--"], top);
|
|
928
|
+
} catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
for (const rec of log.split("\x1e")) {
|
|
932
|
+
const [sha, body = ""] = rec.trim().split("\x1f");
|
|
933
|
+
const source = L.sourceOf(body);
|
|
934
|
+
if (!sha || !source) continue;
|
|
935
|
+
if (source !== base && spawnSync("git", ["merge-base", "--is-ancestor", source, base], { cwd: top }).status !== 0) continue;
|
|
936
|
+
try {
|
|
937
|
+
const list = JSON.parse(gitText(["show", `${sha}:tests.json`], top)) as { path: string; describes: string[]; name: string }[];
|
|
938
|
+
return { tests: list.map((x) => L.makeTest(x.path, x.describes, x.name)), source };
|
|
939
|
+
} catch {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export async function main(argv: string[]): Promise<number> {
|
|
947
|
+
let opts;
|
|
948
|
+
try {
|
|
949
|
+
opts = parseArgs({
|
|
950
|
+
args: argv,
|
|
951
|
+
options: {
|
|
952
|
+
base: { type: "string", default: "origin/main" },
|
|
953
|
+
head: { type: "string", default: "HEAD" },
|
|
954
|
+
worktree: { type: "boolean", default: false },
|
|
955
|
+
root: { type: "string" },
|
|
956
|
+
"no-merge-base": { type: "boolean", default: false },
|
|
957
|
+
json: { type: "boolean", default: false },
|
|
958
|
+
scenarios: { type: "string" },
|
|
959
|
+
report: { type: "string", multiple: true },
|
|
960
|
+
"spec-branch": { type: "string", default: "origin/spec" },
|
|
961
|
+
full: { type: "boolean", default: false },
|
|
962
|
+
limit: { type: "string" },
|
|
963
|
+
help: { type: "boolean", short: "h", default: false },
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
} catch (e) {
|
|
967
|
+
console.error(`spec-diff: ${(e as Error).message}\n${USAGE}`);
|
|
968
|
+
return 2;
|
|
969
|
+
}
|
|
970
|
+
const v = opts.values;
|
|
971
|
+
if (v.help) return console.log(HELP), 0;
|
|
972
|
+
const limit = v.full ? Infinity : v.limit === undefined ? SUMMARY_LIMIT : Number(v.limit);
|
|
973
|
+
if (!(limit > 0) || (limit !== Infinity && !Number.isInteger(limit))) {
|
|
974
|
+
console.error(`spec-diff: --limit is a whole number of tests greater than zero, not «${v.limit}»\n${USAGE}`);
|
|
975
|
+
return 2;
|
|
976
|
+
}
|
|
977
|
+
// the root's project language, before parsing: base and head decision lines (model, harness checks) are compared in it
|
|
978
|
+
LANG = locale.projectLanguage(path.resolve(v.root ?? process.cwd()));
|
|
979
|
+
N = locale.names(LANG);
|
|
980
|
+
|
|
981
|
+
let removed: Test[], changed: [Test, Test][], added: Test[], moved: Moved[], outFiles: string[];
|
|
982
|
+
let decisions: string[];
|
|
983
|
+
let decisionData: { base: Decisions; head: Decisions };
|
|
984
|
+
let harnessNote = "";
|
|
985
|
+
let unreported: Test[] = []; // base harness tests of files the report didn't run — out of the comparison
|
|
986
|
+
const harness = new Set<Test>(); // tests only from the report — generated by the harness, not in the sources
|
|
987
|
+
let checks: HarnessDescribe[];
|
|
988
|
+
let sourced: Check[]; // harness calls in the head's sources: their describes aren't "report only"
|
|
989
|
+
let notStatements: number;
|
|
990
|
+
try {
|
|
991
|
+
const { top, root, prefix } = findRoot(v.root);
|
|
992
|
+
const base = v["no-merge-base"] ? v.base : gitText(["merge-base", v.base, v.worktree ? "HEAD" : v.head], top).trim();
|
|
993
|
+
const baseFiles = testsAtRev(base, top, prefix);
|
|
994
|
+
const baseScan = parseFiles(baseFiles, v.base);
|
|
995
|
+
let baseTests = baseScan.tests;
|
|
996
|
+
const headFiles = v.worktree ? testsInWorktree(root) : testsAtRev(v.head, top, prefix);
|
|
997
|
+
const headScan = parseFiles(headFiles, v.worktree ? "working tree" : v.head);
|
|
998
|
+
let headTests = headScan.tests;
|
|
999
|
+
// a standard scenario is a harness check's describe: from the sources, without the report and the spec branch
|
|
1000
|
+
checks = freshChecks(baseScan.checks, headScan.checks);
|
|
1001
|
+
sourced = headScan.checks;
|
|
1002
|
+
// harness tests (registries, examples) aren't named in the sources: the head — from the runner report, the base —
|
|
1003
|
+
// tests.json of the spec branch
|
|
1004
|
+
if (v.report?.length) {
|
|
1005
|
+
const branch = v["spec-branch"]!;
|
|
1006
|
+
const specBase = specTestsAt(branch, base, top);
|
|
1007
|
+
if (specBase) {
|
|
1008
|
+
// like the sources (wanted): tests/lib isn't spec — its tests (e2e setup projects) don't get into tests.json
|
|
1009
|
+
const inTree = (t: Test) => t.path.startsWith(L.TESTS + "/") && L.classify(t.path)[0] !== "lib";
|
|
1010
|
+
const reported = L.mergeTests(v.report.flatMap((r) => L.loadReport(r, root))).filter(inTree);
|
|
1011
|
+
baseTests = union(baseTests, specBase.tests, harness);
|
|
1012
|
+
headTests = union(headTests, reported, harness);
|
|
1013
|
+
// a skipped publish (spec-run: main's tree wasn't checked as a whole) — tests.json is older than the diff base:
|
|
1014
|
+
// report the gap
|
|
1015
|
+
const baseSha = gitText(["rev-parse", `${base}^{commit}`], top).trim();
|
|
1016
|
+
const gap = specBase.source === baseSha ? "" : locale.fill(N.spec.diff.harnessGap, { sha: baseSha.slice(0, 12) });
|
|
1017
|
+
harnessNote = locale.fill(N.spec.diff.harnessNote, { branch, source: specBase.source.slice(0, 12), gap });
|
|
1018
|
+
// a partial run's report: the files it skipped whose tests only a run names (harness calls, names not computable
|
|
1019
|
+
// statically) aren't compared — a line under the base, not "removed"
|
|
1020
|
+
const runNamed = [...headScan.checks.map((c) => c.path), ...headScan.unnamed];
|
|
1021
|
+
const partial = unreportedTests(baseTests, headTests, harness, runNamed, reported);
|
|
1022
|
+
if (partial.files.length) {
|
|
1023
|
+
const out = new Set(partial.tests);
|
|
1024
|
+
baseTests = baseTests.filter((t) => !out.has(t));
|
|
1025
|
+
unreported = partial.tests;
|
|
1026
|
+
const folders = [...new Set(partial.files.map((f) => path.posix.dirname(f)))].map((f) => `\`${f}\``).join(", ");
|
|
1027
|
+
const line = locale.fill(N.spec.diff.partialReport, { folders, n: partial.tests.length, branch, removed: N.spec.diff.removed });
|
|
1028
|
+
harnessNote += "\n\n" + line;
|
|
1029
|
+
}
|
|
1030
|
+
} else {
|
|
1031
|
+
harnessNote = locale.fill(N.spec.diff.noSpecBase, { branch });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const changedOut = outOfTreeFiles(base, v.head, top, prefix, v.worktree);
|
|
1035
|
+
const { pool, count, gone } = outOfTreePool(base, v.head, top, root, prefix, changedOut, v.worktree);
|
|
1036
|
+
({ removed, changed, added, moved } = diff(baseTests, headTests, pool));
|
|
1037
|
+
notStatements = newNotStatements([...added, ...changed.map(([, n]) => n)], baseTests, LANG);
|
|
1038
|
+
outFiles = unmovedFiles(changedOut, moved, count, gone);
|
|
1039
|
+
// lint disables — only in changed code files: a removed or added exception is always in the PR diff
|
|
1040
|
+
const changedAll = (
|
|
1041
|
+
v.worktree
|
|
1042
|
+
? gitText(["diff", "--name-only", "-z", base, "--", "."], top) + gitText(["ls-files", "--others", "--exclude-standard", "-z", "--", "."], top)
|
|
1043
|
+
: gitText(["diff", "--name-only", "-z", base, v.head, "--", "."], top)
|
|
1044
|
+
)
|
|
1045
|
+
.split("\0")
|
|
1046
|
+
.filter((f) => f.startsWith(prefix) && CODE.test(f) && !FLOW_COPY.test(f.slice(prefix.length)));
|
|
1047
|
+
decisionData = {
|
|
1048
|
+
base: await decisionsAt(base, top, prefix, changedAll, baseScan.checks),
|
|
1049
|
+
head: await decisionsAt(v.worktree ? null : v.head, top, prefix, changedAll, headScan.checks),
|
|
1050
|
+
};
|
|
1051
|
+
decisions = decisionLines(decisionData.base, decisionData.head, limit);
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
const msg = (e as Error).message;
|
|
1054
|
+
console.error(`spec-diff: ${msg}`);
|
|
1055
|
+
if (/merge-base|Not a valid|unknown revision|bad revision/.test(msg)) {
|
|
1056
|
+
console.error(`spec-diff: no revision ${v.base}? — git fetch origin or --base <branch>`);
|
|
1057
|
+
}
|
|
1058
|
+
return 2;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// scenarios before the lists: a test beyond the scenarios is marked right in them
|
|
1062
|
+
let scenarios: string[] | null = null;
|
|
1063
|
+
if (v.scenarios !== undefined) {
|
|
1064
|
+
try {
|
|
1065
|
+
scenarios = parseScenarios(readFileSync(v.scenarios === "-" ? 0 : v.scenarios, "utf8"));
|
|
1066
|
+
} catch (e) {
|
|
1067
|
+
console.error(`spec-diff: --scenarios: ${(e as Error).message}`);
|
|
1068
|
+
return 2;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const fresh = [...added, ...changed.map(([, n]) => n)];
|
|
1072
|
+
const match = scenarios ? matchScenarios(scenarios, fresh, [...checks, ...reportedChecks(fresh, harness, sourced)], harness) : { found: [], extra: fresh };
|
|
1073
|
+
|
|
1074
|
+
const label = v["no-merge-base"] ? v.base : `${v.base} (merge-base)`;
|
|
1075
|
+
const shown = { limit, harness: v.report?.length ? harness : undefined, extra: scenarios ? new Set(match.extra) : undefined, note: harnessNote, notStatements };
|
|
1076
|
+
let text = v.json ? asJson(label, removed, changed, added, outFiles, moved, notStatements, unreported) : render(label, removed, changed, added, outFiles, moved, shown);
|
|
1077
|
+
if (v.json) {
|
|
1078
|
+
const j = JSON.parse(text);
|
|
1079
|
+
j.decisions = Object.fromEntries(
|
|
1080
|
+
(["model", "exceptions", "checks"] as const).map((k) => [k, { removed: minus(decisionData.base[k], decisionData.head[k]), added: minus(decisionData.head[k], decisionData.base[k]) }]),
|
|
1081
|
+
);
|
|
1082
|
+
text = JSON.stringify(j, null, 2) + "\n";
|
|
1083
|
+
} else if (decisions.length) {
|
|
1084
|
+
text = text.trimEnd() + "\n\n" + decisions.join("\n").trimEnd() + "\n";
|
|
1085
|
+
}
|
|
1086
|
+
if (v.scenarios !== undefined) {
|
|
1087
|
+
if (v.json) {
|
|
1088
|
+
const j = JSON.parse(text);
|
|
1089
|
+
const d = (t: Test) => ({ folder: L.folderOf(t), describes: t.describes, name: t.name });
|
|
1090
|
+
j.scenarios =
|
|
1091
|
+
scenarios === null
|
|
1092
|
+
? null
|
|
1093
|
+
: match.found.map(({ scenario, tests, harness: h }) => ({
|
|
1094
|
+
scenario,
|
|
1095
|
+
tests: tests.map(d),
|
|
1096
|
+
harness: h ? { folder: h.folder, describes: h.describes, checks: h.checks } : null,
|
|
1097
|
+
}));
|
|
1098
|
+
j.extra_tests = match.extra.map(d);
|
|
1099
|
+
text = JSON.stringify(j, null, 2) + "\n";
|
|
1100
|
+
} else {
|
|
1101
|
+
const listed = !summarized(removed, changed, added, limit);
|
|
1102
|
+
text = text.trimEnd() + "\n\n" + scenarioLines(scenarios, match, listed, limit).join("\n").trimEnd() + "\n";
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
process.stdout.write(text);
|
|
1106
|
+
return 0;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (import.meta.main) {
|
|
1110
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1111
|
+
}
|