@avi2dg/checks 0.17.0 → 0.19.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/CHANGELOG.md +22 -0
- package/README.md +3 -1
- package/bunfig.toml +1 -1
- package/dependency-cruiser.config.js +2 -0
- package/dist/feature-rules.js +22 -5
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/quality-file.md +3 -2
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +20 -11
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-quarantine-clock.md +54 -0
- package/docs/gates/checks-test-layout.md +9 -3
- package/docs/gates/checks-vendor.md +97 -0
- package/package.json +11 -2
- package/quality.schema.json +56 -4
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +54 -57
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +41 -26
- package/scripts/lint.ts +75 -35
- package/scripts/mutation-compare.ts +179 -70
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality-file.ts +30 -3
- package/scripts/quality.ts +157 -10
- package/scripts/quarantine-clock.ts +153 -0
- package/scripts/range-gate.ts +9 -0
- package/scripts/repetition.ts +40 -19
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +51 -28
- package/scripts/suppressions-ratchet.ts +22 -28
- package/scripts/test-layout.ts +65 -52
- package/scripts/test-report.ts +18 -13
- package/scripts/vendor.ts +337 -0
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { parse } from "@swc/core";
|
|
3
3
|
import { Console, Effect, Path, Schema } from "effect";
|
|
4
4
|
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
5
|
-
import { runMain
|
|
6
|
-
import { PROOF_DIRECTORY,
|
|
5
|
+
import { runMain } from "./main.ts";
|
|
6
|
+
import { PROOF_DIRECTORY, type Feature } from "./quality-file.ts";
|
|
7
|
+
import { rangeGateInputs } from "./range-gate.ts";
|
|
7
8
|
|
|
8
9
|
type Unproven = {
|
|
9
10
|
readonly feature: string;
|
|
@@ -120,17 +121,13 @@ function signalReport(touched: readonly Touch[]): string {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
const owners = Effect.gen(function* () {
|
|
123
|
-
const
|
|
124
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
125
|
-
|
|
126
|
-
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
127
|
-
const { source, quality } = yield* readQuality(root);
|
|
124
|
+
const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
|
|
128
125
|
const features = quality.features ?? [];
|
|
129
126
|
if (features.length === 0) {
|
|
130
127
|
yield* Console.log(`${NAME}: ${source} declares no feature`);
|
|
131
128
|
return true;
|
|
132
129
|
}
|
|
133
|
-
const { base, head } = yield* rangeEnds(first, second, root);
|
|
130
|
+
const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
|
|
134
131
|
const found = yield* unproven(root, head, features);
|
|
135
132
|
yield* Console.log(proofReport(features, found));
|
|
136
133
|
if (quality.changeSignal === "advisory") {
|
package/scripts/gates.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const KIT_GATES = [
|
|
|
42
42
|
{ bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
43
43
|
{ bin: "checks-repetition", script: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
44
44
|
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
45
|
+
{ bin: "checks-quarantine-clock", script: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
45
46
|
] as const satisfies readonly KitGate[];
|
|
46
47
|
|
|
47
48
|
const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
package/scripts/git.ts
CHANGED
|
@@ -87,37 +87,37 @@ function newPathOf(line: string): string | undefined {
|
|
|
87
87
|
|
|
88
88
|
const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
89
89
|
|
|
90
|
+
type HunkPosition = {
|
|
91
|
+
readonly line: number;
|
|
92
|
+
readonly oldLeft: number;
|
|
93
|
+
readonly newLeft: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function hunkStart(row: string): HunkPosition | undefined {
|
|
97
|
+
const hunk = HUNK.exec(row);
|
|
98
|
+
return hunk === null ? undefined : { oldLeft: Number(hunk[1] ?? 1), line: Number(hunk[2]), newLeft: Number(hunk[3] ?? 1) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pastHunkRow(row: string, { line, oldLeft, newLeft }: HunkPosition): HunkPosition {
|
|
102
|
+
if (row.startsWith("+")) return { line: line + 1, oldLeft, newLeft: newLeft - 1 };
|
|
103
|
+
if (row.startsWith("-")) return { line, oldLeft: oldLeft - 1, newLeft };
|
|
104
|
+
if (row.startsWith(" ")) return { line: line + 1, oldLeft: oldLeft - 1, newLeft: newLeft - 1 };
|
|
105
|
+
return { line, oldLeft, newLeft };
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
// A hunk's header counts its lines, so an added line reading `++ x` is not taken for the next file's `+++` header.
|
|
91
109
|
export function parseAddedLines(diff: string): Map<string, Set<number>> {
|
|
92
110
|
const added = new Map<string, Set<number>>();
|
|
93
111
|
let path: string | undefined;
|
|
94
|
-
let
|
|
95
|
-
let oldLeft = 0;
|
|
96
|
-
let newLeft = 0;
|
|
112
|
+
let at: HunkPosition = { line: 0, oldLeft: 0, newLeft: 0 };
|
|
97
113
|
for (const row of diff.split("\n")) {
|
|
98
|
-
if (oldLeft > 0 || newLeft > 0) {
|
|
99
|
-
if (row.startsWith("+"))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
newLeft -= 1;
|
|
103
|
-
} else if (row.startsWith("-")) {
|
|
104
|
-
oldLeft -= 1;
|
|
105
|
-
} else if (row.startsWith(" ")) {
|
|
106
|
-
line += 1;
|
|
107
|
-
oldLeft -= 1;
|
|
108
|
-
newLeft -= 1;
|
|
109
|
-
}
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (row.startsWith("+++ ")) {
|
|
114
|
+
if (at.oldLeft > 0 || at.newLeft > 0) {
|
|
115
|
+
if (row.startsWith("+") && path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(at.line));
|
|
116
|
+
at = pastHunkRow(row, at);
|
|
117
|
+
} else if (row.startsWith("+++ ")) {
|
|
113
118
|
path = newPathOf(row);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const hunk = HUNK.exec(row);
|
|
117
|
-
if (hunk !== null) {
|
|
118
|
-
oldLeft = Number(hunk[1] ?? 1);
|
|
119
|
-
line = Number(hunk[2]);
|
|
120
|
-
newLeft = Number(hunk[3] ?? 1);
|
|
119
|
+
} else {
|
|
120
|
+
at = hunkStart(row) ?? at;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
return added;
|
|
@@ -148,6 +148,12 @@ function namesAParent(commitObject: string): boolean {
|
|
|
148
148
|
return headers.split("\n").some((header) => header.startsWith("parent "));
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export const isShallowBoundary = Effect.fn("isShallowBoundary")(function* (rev: string, cwd?: string) {
|
|
152
|
+
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd))) return false;
|
|
153
|
+
const [, ...parents] = (yield* git(["rev-list", "--parents", "-n", "1", rev], cwd)).trim().split(" ");
|
|
154
|
+
return parents.length === 0;
|
|
155
|
+
});
|
|
156
|
+
|
|
151
157
|
// A shallow clone's boundary commit reads as parentless to rev-parse and log, and judged against
|
|
152
158
|
// the empty tree it would carry the whole repository; only the commit object still names its parents.
|
|
153
159
|
export const parentOrEmptyTree = Effect.fn("parentOrEmptyTree")(function* (rev: string, cwd?: string) {
|
|
@@ -161,12 +167,21 @@ export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second
|
|
|
161
167
|
return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
|
|
162
168
|
});
|
|
163
169
|
|
|
164
|
-
export const
|
|
170
|
+
export const refArgs = Effect.fn("refArgs")(function* (args: readonly string[], usage: string) {
|
|
165
171
|
const [first, second, ...extra] = args;
|
|
166
172
|
if (first === undefined || extra.length > 0) return yield* new Usage({ message: usage });
|
|
173
|
+
return { first, second };
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
|
|
177
|
+
const { first, second } = yield* refArgs(args, usage);
|
|
167
178
|
return yield* rangeEnds(first, second, cwd);
|
|
168
179
|
});
|
|
169
180
|
|
|
181
|
+
export const commitOf = Effect.fn("commitOf")(function* (rev: string, cwd?: string) {
|
|
182
|
+
return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`], cwd)).trim();
|
|
183
|
+
});
|
|
184
|
+
|
|
170
185
|
// A scratch index leaves the repository's own index and working tree untouched.
|
|
171
186
|
export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
|
|
172
187
|
const path = yield* Path.Path;
|
package/scripts/lint.ts
CHANGED
|
@@ -26,18 +26,25 @@ const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
|
|
|
26
26
|
const PULL_REQUEST_EVENT = "pull_request";
|
|
27
27
|
const SHALLOW_HINT = "a CI checkout needs actions/checkout fetch-depth: 0";
|
|
28
28
|
|
|
29
|
-
const
|
|
30
|
-
Schema.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}),
|
|
38
|
-
),
|
|
29
|
+
const PullRequest = Schema.Struct({
|
|
30
|
+
number: Schema.Int,
|
|
31
|
+
base: Schema.Struct({ ref: Schema.String }),
|
|
32
|
+
head: Schema.Struct({ sha: Schema.String }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const decodePullRequestEvent = Schema.decodeUnknownEffect(
|
|
36
|
+
Schema.fromJsonString(Schema.Struct({ pull_request: PullRequest })),
|
|
39
37
|
);
|
|
40
38
|
|
|
39
|
+
// Decides the range ends an already-decoded pull request event describes.
|
|
40
|
+
export function pullRequestEndsOf(pullRequest: typeof PullRequest.Type): { base: string; head: string; source: string } {
|
|
41
|
+
return {
|
|
42
|
+
base: `origin/${pullRequest.base.ref}`,
|
|
43
|
+
head: pullRequest.head.sha,
|
|
44
|
+
source: `pull request #${pullRequest.number} into ${pullRequest.base.ref}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: string) {
|
|
42
49
|
const fs = yield* FileSystem.FileSystem;
|
|
43
50
|
const { pull_request: pullRequest } = yield* fs.readFileString(eventPath).pipe(
|
|
@@ -46,11 +53,7 @@ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: strin
|
|
|
46
53
|
(cause) => new RangeUnresolved({ message: `cannot read the pull request from ${eventPath}: ${cause.message}` }),
|
|
47
54
|
),
|
|
48
55
|
);
|
|
49
|
-
return
|
|
50
|
-
base: `origin/${pullRequest.base.ref}`,
|
|
51
|
-
head: pullRequest.head.sha,
|
|
52
|
-
source: `pull request #${pullRequest.number} into ${pullRequest.base.ref}`,
|
|
53
|
-
};
|
|
56
|
+
return pullRequestEndsOf(pullRequest);
|
|
54
57
|
});
|
|
55
58
|
|
|
56
59
|
const readWiring = Effect.gen(function* () {
|
|
@@ -58,43 +61,74 @@ const readWiring = Effect.gen(function* () {
|
|
|
58
61
|
return { source, defaultBranch: quality.defaultBranch ?? DEFAULT_BRANCH, lintGates: quality.gates?.lint };
|
|
59
62
|
});
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
// Decides the base ref `git symbolic-ref` already resolved, or the repository's declared
|
|
65
|
+
// default branch when that ref is absent, such as a checkout with no remote HEAD symlink.
|
|
66
|
+
export function originRefOf(symbolicRef: string | undefined, defaultBranch: string): string {
|
|
67
|
+
return symbolicRef ?? `origin/${defaultBranch}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
|
|
71
|
+
// where judging HEAD alone would pass every commit before it unchecked. No origin ref means no remote-tracking ref at all.
|
|
72
|
+
export function localEndsOf(originRef: string | undefined): { base: string; head: string; source: string } {
|
|
73
|
+
if (originRef === undefined) return { base: "HEAD", head: "HEAD", source: "HEAD alone, as the clone has no remote-tracking refs" };
|
|
74
|
+
return { base: originRef, head: "HEAD", source: `HEAD against ${originRef}` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const originRef = (defaultBranch: string) =>
|
|
62
78
|
git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
|
|
63
79
|
Effect.map((ref) => ref.trim()),
|
|
64
|
-
Effect.catchTag("GitFailure", () => Effect.succeed(
|
|
65
|
-
Effect.map((
|
|
80
|
+
Effect.catchTag("GitFailure", () => Effect.succeed(undefined)),
|
|
81
|
+
Effect.map((symbolicRef) => originRefOf(symbolicRef, defaultBranch)),
|
|
66
82
|
);
|
|
67
83
|
|
|
68
|
-
// A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
|
|
69
|
-
// where judging HEAD alone would pass every commit before it unchecked.
|
|
70
84
|
const localEnds = Effect.fn("localEnds")(function* (defaultBranch: string) {
|
|
71
85
|
const remoteTracking = yield* git(["for-each-ref", "--count=1", "refs/remotes/"]).pipe(
|
|
72
86
|
Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })),
|
|
73
87
|
);
|
|
74
|
-
|
|
75
|
-
return { base: "HEAD", head: "HEAD", source: "HEAD alone, as the clone has no remote-tracking refs" };
|
|
76
|
-
}
|
|
77
|
-
return yield* originEnds(defaultBranch);
|
|
88
|
+
return localEndsOf(remoteTracking.trim() === "" ? undefined : yield* originRef(defaultBranch));
|
|
78
89
|
});
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
// Decides which source of range ends the arguments and the pull request env vars select,
|
|
92
|
+
// with no I/O: a caller resolves the selected kind against git or the event file.
|
|
93
|
+
export type EndsSelection =
|
|
94
|
+
| { readonly kind: "explicit"; readonly base: string; readonly head: string }
|
|
95
|
+
| { readonly kind: "usage" }
|
|
96
|
+
| { readonly kind: "pull-request"; readonly path: string }
|
|
97
|
+
| { readonly kind: "pull-request-unresolved" }
|
|
98
|
+
| { readonly kind: "local" };
|
|
99
|
+
|
|
100
|
+
export function selectEnds(args: readonly string[], eventName: string | undefined, eventPath: string | undefined): EndsSelection {
|
|
81
101
|
const [base, head, ...extra] = args;
|
|
82
|
-
if (base !== undefined && head !== undefined && extra.length === 0) {
|
|
83
|
-
|
|
102
|
+
if (base !== undefined && head !== undefined && extra.length === 0) return { kind: "explicit", base, head };
|
|
103
|
+
if (base !== undefined) return { kind: "usage" };
|
|
104
|
+
if (eventName === PULL_REQUEST_EVENT) {
|
|
105
|
+
return eventPath === undefined ? { kind: "pull-request-unresolved" } : { kind: "pull-request", path: eventPath };
|
|
84
106
|
}
|
|
85
|
-
|
|
107
|
+
return { kind: "local" };
|
|
108
|
+
}
|
|
86
109
|
|
|
110
|
+
const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
|
|
87
111
|
const event = yield* Config.all({
|
|
88
112
|
name: Config.option(Config.String("GITHUB_EVENT_NAME")),
|
|
89
113
|
path: Config.option(Config.String("GITHUB_EVENT_PATH")),
|
|
90
114
|
}).pipe(Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })));
|
|
91
|
-
|
|
92
|
-
|
|
115
|
+
const selection = selectEnds(
|
|
116
|
+
args,
|
|
117
|
+
Option.getOrUndefined(event.name),
|
|
118
|
+
Option.getOrUndefined(event.path),
|
|
119
|
+
);
|
|
120
|
+
switch (selection.kind) {
|
|
121
|
+
case "explicit":
|
|
122
|
+
return { base: selection.base, head: selection.head, source: `${selection.head} against ${selection.base}` };
|
|
123
|
+
case "usage":
|
|
124
|
+
return yield* new Usage({ message: USAGE });
|
|
125
|
+
case "pull-request":
|
|
126
|
+
return yield* pullRequestEnds(selection.path);
|
|
127
|
+
case "pull-request-unresolved":
|
|
93
128
|
return yield* new RangeUnresolved({ message: `${PULL_REQUEST_EVENT} sets no GITHUB_EVENT_PATH` });
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
case "local":
|
|
130
|
+
return yield* localEnds(defaultBranch);
|
|
96
131
|
}
|
|
97
|
-
return yield* localEnds(defaultBranch);
|
|
98
132
|
});
|
|
99
133
|
|
|
100
134
|
const commitOf = (ref: string) =>
|
|
@@ -105,6 +139,12 @@ const commitOf = (ref: string) =>
|
|
|
105
139
|
),
|
|
106
140
|
);
|
|
107
141
|
|
|
142
|
+
// Decides the range git merge-base already resolved: the commits between base and head, or a
|
|
143
|
+
// lone tip when they coincide, as every range gate would otherwise charge the head with nothing.
|
|
144
|
+
export function rangeOf(base: string, head: string): Range["refs"] {
|
|
145
|
+
return base === head ? [head] : [base, head];
|
|
146
|
+
}
|
|
147
|
+
|
|
108
148
|
// From the base branch's tip, every range gate would charge the head with the commits the base
|
|
109
149
|
// branch gained after the head branched off.
|
|
110
150
|
const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[], defaultBranch: string) {
|
|
@@ -116,10 +156,10 @@ const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[
|
|
|
116
156
|
() => new RangeUnresolved({ message: `${ends.base} and ${ends.head} share no commit in this clone; ${SHALLOW_HINT}` }),
|
|
117
157
|
),
|
|
118
158
|
);
|
|
119
|
-
return { refs: base
|
|
159
|
+
return { refs: rangeOf(base, head), source: ends.source } satisfies Range;
|
|
120
160
|
});
|
|
121
161
|
|
|
122
|
-
function describe({ refs }: Range): string {
|
|
162
|
+
export function describe({ refs }: Range): string {
|
|
123
163
|
return refs.length === 1 ? `tip ${refs[0]}` : `range ${refs[0]}..${refs[1]}`;
|
|
124
164
|
}
|
|
125
165
|
|
|
@@ -2,21 +2,45 @@
|
|
|
2
2
|
import { Console, Effect, FileSystem, Schema } from "effect";
|
|
3
3
|
import { runMain, Usage } from "./main.ts";
|
|
4
4
|
|
|
5
|
-
export type
|
|
6
|
-
readonly
|
|
7
|
-
readonly
|
|
5
|
+
export type Location = {
|
|
6
|
+
readonly start: { readonly line: number; readonly column: number };
|
|
7
|
+
readonly end: { readonly line: number; readonly column: number };
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
export type
|
|
10
|
+
export type Mutant = {
|
|
11
|
+
readonly status: string;
|
|
12
|
+
readonly mutatorName: string;
|
|
13
|
+
readonly replacement: string;
|
|
14
|
+
readonly location: Location;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ReportFile = {
|
|
18
|
+
readonly source: string;
|
|
19
|
+
readonly mutants: readonly Mutant[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MutantChange = {
|
|
11
23
|
readonly path: string;
|
|
12
|
-
readonly
|
|
13
|
-
readonly
|
|
24
|
+
readonly location: Location;
|
|
25
|
+
readonly mutatorName: string;
|
|
26
|
+
readonly replacement: string;
|
|
27
|
+
readonly from: string;
|
|
28
|
+
readonly to: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type UnmatchedMutant = {
|
|
32
|
+
readonly path: string;
|
|
33
|
+
readonly location: Location;
|
|
34
|
+
readonly mutatorName: string;
|
|
35
|
+
readonly replacement: string;
|
|
36
|
+
readonly status: string;
|
|
14
37
|
};
|
|
15
38
|
|
|
16
39
|
export type Comparison = {
|
|
17
|
-
readonly
|
|
18
|
-
readonly
|
|
19
|
-
readonly
|
|
40
|
+
readonly regressions: readonly MutantChange[];
|
|
41
|
+
readonly moves: readonly MutantChange[];
|
|
42
|
+
readonly baseOnly: readonly UnmatchedMutant[];
|
|
43
|
+
readonly headOnly: readonly UnmatchedMutant[];
|
|
20
44
|
readonly regression: boolean;
|
|
21
45
|
};
|
|
22
46
|
|
|
@@ -32,98 +56,183 @@ export class ReportError extends Schema.TaggedError<ReportError>()("ReportError"
|
|
|
32
56
|
|
|
33
57
|
const DETECTED = new Set(["Killed", "Timeout"]);
|
|
34
58
|
const UNDETECTED = new Set(["Survived", "NoCoverage"]);
|
|
59
|
+
const LEAVES_SCORE = new Set(["CompileError", "RuntimeError", "Ignored", "Pending"]);
|
|
35
60
|
const USAGE = "usage: mutation-compare.ts [--advisory] <base-report> <head-report>";
|
|
36
61
|
|
|
37
62
|
const Report = Schema.fromJsonString(
|
|
38
63
|
Schema.Struct({
|
|
39
64
|
files: Schema.Record(
|
|
40
65
|
Schema.String,
|
|
41
|
-
Schema.Struct({
|
|
66
|
+
Schema.Struct({
|
|
67
|
+
source: Schema.String,
|
|
68
|
+
mutants: Schema.Array(
|
|
69
|
+
Schema.Struct({
|
|
70
|
+
status: Schema.String,
|
|
71
|
+
mutatorName: Schema.String,
|
|
72
|
+
replacement: Schema.String,
|
|
73
|
+
location: Schema.Struct({
|
|
74
|
+
start: Schema.Struct({ line: Schema.Finite, column: Schema.Finite }),
|
|
75
|
+
end: Schema.Struct({ line: Schema.Finite, column: Schema.Finite }),
|
|
76
|
+
}),
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
}),
|
|
42
80
|
),
|
|
43
81
|
}),
|
|
44
82
|
);
|
|
45
83
|
const decodeReport = Schema.decodeUnknownEffect(Report);
|
|
46
84
|
|
|
47
|
-
export const parseReport = (source: string, text: string): Effect.Effect<Map<string,
|
|
85
|
+
export const parseReport = (source: string, text: string): Effect.Effect<Map<string, ReportFile>, ReportError> =>
|
|
48
86
|
decodeReport(text).pipe(
|
|
49
|
-
Effect.map(
|
|
50
|
-
({ files }) => new Map(Object.entries(files).map(([path, { mutants }]) => [path, mutants.map(({ status }) => status)])),
|
|
51
|
-
),
|
|
87
|
+
Effect.map(({ files }) => new Map(Object.entries(files))),
|
|
52
88
|
Effect.mapError((cause) => new ReportError({ message: `${source} is not a Stryker mutation report: ${cause.message}` })),
|
|
53
89
|
);
|
|
54
90
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const status of statuses) {
|
|
59
|
-
if (DETECTED.has(status)) killed += 1;
|
|
60
|
-
if (DETECTED.has(status) || UNDETECTED.has(status)) total += 1;
|
|
61
|
-
}
|
|
62
|
-
return { killed, total };
|
|
91
|
+
function byPosition(a: { readonly path: string; readonly location: Location }, b: { readonly path: string; readonly location: Location }): number {
|
|
92
|
+
if (a.path !== b.path) return a.path < b.path ? -1 : 1;
|
|
93
|
+
return a.location.start.line - b.location.start.line || a.location.start.column - b.location.start.column;
|
|
63
94
|
}
|
|
64
95
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const paths = [...new Set([...baseFiles.keys(), ...headFiles.keys()])].sort();
|
|
72
|
-
const files = paths.map((path) => {
|
|
73
|
-
const baseStatuses = baseFiles.get(path);
|
|
74
|
-
const headStatuses = headFiles.get(path);
|
|
75
|
-
return {
|
|
76
|
-
path,
|
|
77
|
-
base: baseStatuses === undefined ? EMPTY : tally(baseStatuses),
|
|
78
|
-
head: headStatuses === undefined ? EMPTY : tally(headStatuses),
|
|
79
|
-
};
|
|
80
|
-
});
|
|
81
|
-
const base = tally([...baseFiles.values()].flat());
|
|
82
|
-
const head = tally([...headFiles.values()].flat());
|
|
83
|
-
return { base, head, files, regression: regressed(base, head) };
|
|
96
|
+
function sharedEnds(base: readonly string[], head: readonly string[]): { readonly prefix: number; readonly suffix: number } {
|
|
97
|
+
let prefix = 0;
|
|
98
|
+
while (prefix < base.length && prefix < head.length && base[prefix] === head[prefix]) prefix++;
|
|
99
|
+
let suffix = 0;
|
|
100
|
+
while (suffix < base.length - prefix && suffix < head.length - prefix && base[base.length - 1 - suffix] === head[head.length - 1 - suffix]) suffix++;
|
|
101
|
+
return { prefix, suffix };
|
|
84
102
|
}
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
function commonAfterTable(base: readonly string[], head: readonly string[], prefix: number, rows: number, width: number): (row: number, column: number) => number {
|
|
105
|
+
const common = new Uint32Array((rows + 1) * width);
|
|
106
|
+
const commonAfter = (row: number, column: number) => common[row * width + column] ?? 0;
|
|
107
|
+
for (let row = rows - 1; row >= 0; row--) {
|
|
108
|
+
for (let column = width - 2; column >= 0; column--) {
|
|
109
|
+
common[row * width + column] =
|
|
110
|
+
base[prefix + row] === head[prefix + column] ? commonAfter(row + 1, column + 1) + 1 : Math.max(commonAfter(row + 1, column), commonAfter(row, column + 1));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return commonAfter;
|
|
90
114
|
}
|
|
91
115
|
|
|
92
|
-
function
|
|
93
|
-
|
|
116
|
+
function unchangedLines(baseSource: string, headSource: string): Map<number, number> {
|
|
117
|
+
const base = baseSource.split("\n");
|
|
118
|
+
const head = headSource.split("\n");
|
|
119
|
+
const { prefix, suffix } = sharedEnds(base, head);
|
|
120
|
+
const rows = base.length - prefix - suffix;
|
|
121
|
+
const width = head.length - prefix - suffix + 1;
|
|
122
|
+
const commonAfter = commonAfterTable(base, head, prefix, rows, width);
|
|
123
|
+
const lines = new Map<number, number>();
|
|
124
|
+
for (let line = 1; line <= prefix; line++) lines.set(line, line);
|
|
125
|
+
for (let row = 0, column = 0; row < rows && column < width - 1; ) {
|
|
126
|
+
if (base[prefix + row] === head[prefix + column]) lines.set(prefix + ++row, prefix + ++column);
|
|
127
|
+
else if (commonAfter(row + 1, column) >= commonAfter(row, column + 1)) row++;
|
|
128
|
+
else column++;
|
|
129
|
+
}
|
|
130
|
+
for (let line = 1; line <= suffix; line++) lines.set(base.length - suffix + line, head.length - suffix + line);
|
|
131
|
+
return lines;
|
|
94
132
|
}
|
|
95
133
|
|
|
96
|
-
function
|
|
97
|
-
|
|
134
|
+
function mapLocation(location: Location, lines: ReadonlyMap<number, number>): Location | undefined {
|
|
135
|
+
for (let line = location.start.line; line <= location.end.line; line++) if (!lines.has(line)) return undefined;
|
|
136
|
+
const start = lines.get(location.start.line);
|
|
137
|
+
const end = lines.get(location.end.line);
|
|
138
|
+
if (start === undefined || end === undefined || end - start !== location.end.line - location.start.line) return undefined;
|
|
139
|
+
return { start: { line: start, column: location.start.column }, end: { line: end, column: location.end.column } };
|
|
98
140
|
}
|
|
99
141
|
|
|
100
|
-
function
|
|
101
|
-
|
|
142
|
+
function keyMutants(
|
|
143
|
+
path: string,
|
|
144
|
+
mutants: readonly Mutant[],
|
|
145
|
+
place: (location: Location) => Location | undefined,
|
|
146
|
+
): { readonly keyed: Map<string, UnmatchedMutant>; readonly unplaced: readonly UnmatchedMutant[] } {
|
|
147
|
+
const keyed = new Map<string, UnmatchedMutant>();
|
|
148
|
+
const unplaced: UnmatchedMutant[] = [];
|
|
149
|
+
const seen = new Map<string, number>();
|
|
150
|
+
for (const mutant of mutants) {
|
|
151
|
+
const location = place(mutant.location);
|
|
152
|
+
if (location === undefined) {
|
|
153
|
+
unplaced.push({ path, ...mutant });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const signature = JSON.stringify([location.start, location.end, mutant.mutatorName, mutant.replacement]);
|
|
157
|
+
const occurrence = seen.get(signature) ?? 0;
|
|
158
|
+
seen.set(signature, occurrence + 1);
|
|
159
|
+
keyed.set(`${signature}#${occurrence}`, { path, ...mutant });
|
|
160
|
+
}
|
|
161
|
+
return { keyed, unplaced };
|
|
102
162
|
}
|
|
103
163
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
164
|
+
type Findings = {
|
|
165
|
+
readonly regressions: MutantChange[];
|
|
166
|
+
readonly moves: MutantChange[];
|
|
167
|
+
readonly baseOnly: UnmatchedMutant[];
|
|
168
|
+
readonly headOnly: UnmatchedMutant[];
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function judgeMatch(path: string, mutant: UnmatchedMutant, counterpart: UnmatchedMutant, findings: Findings): void {
|
|
172
|
+
const change: MutantChange = {
|
|
173
|
+
path,
|
|
174
|
+
location: counterpart.location,
|
|
175
|
+
mutatorName: mutant.mutatorName,
|
|
176
|
+
replacement: mutant.replacement,
|
|
177
|
+
from: mutant.status,
|
|
178
|
+
to: counterpart.status,
|
|
179
|
+
};
|
|
180
|
+
if (mutant.status !== counterpart.status && (LEAVES_SCORE.has(mutant.status) || LEAVES_SCORE.has(counterpart.status))) {
|
|
181
|
+
findings.moves.push(change);
|
|
182
|
+
} else if (DETECTED.has(mutant.status) && UNDETECTED.has(counterpart.status)) {
|
|
183
|
+
findings.regressions.push(change);
|
|
184
|
+
}
|
|
107
185
|
}
|
|
108
186
|
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
lines.push(` ${file.path}: ${describe(file.base)} -> ${describe(file.head)}`);
|
|
187
|
+
function compareFile(path: string, baseFile: ReportFile | undefined, headFile: ReportFile | undefined, findings: Findings): void {
|
|
188
|
+
const lines = baseFile !== undefined && headFile !== undefined ? unchangedLines(baseFile.source, headFile.source) : new Map<number, number>();
|
|
189
|
+
const base = keyMutants(path, baseFile?.mutants ?? [], (location) => mapLocation(location, lines));
|
|
190
|
+
const head = keyMutants(path, headFile?.mutants ?? [], (location) => location);
|
|
191
|
+
findings.baseOnly.push(...base.unplaced);
|
|
192
|
+
for (const [key, mutant] of base.keyed) {
|
|
193
|
+
const counterpart = head.keyed.get(key);
|
|
194
|
+
if (counterpart === undefined) findings.baseOnly.push(mutant);
|
|
195
|
+
else judgeMatch(path, mutant, counterpart, findings);
|
|
119
196
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
lines.push(`mutation-compare: REGRESSION (${deltaPoints(comparison.base, comparison.head)})${advisory ? " in advisory mode, exit 0" : ""}`);
|
|
123
|
-
} else {
|
|
124
|
-
lines.push(`mutation-compare: no regression${advisory ? " (advisory mode, exit 0)" : ""}`);
|
|
197
|
+
for (const [key, mutant] of head.keyed) {
|
|
198
|
+
if (!base.keyed.has(key)) findings.headOnly.push(mutant);
|
|
125
199
|
}
|
|
126
|
-
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function compareReports(baseFiles: ReadonlyMap<string, ReportFile>, headFiles: ReadonlyMap<string, ReportFile>): Comparison {
|
|
203
|
+
const findings: Findings = { regressions: [], moves: [], baseOnly: [], headOnly: [] };
|
|
204
|
+
for (const path of new Set([...baseFiles.keys(), ...headFiles.keys()])) compareFile(path, baseFiles.get(path), headFiles.get(path), findings);
|
|
205
|
+
|
|
206
|
+
findings.regressions.sort(byPosition);
|
|
207
|
+
findings.moves.sort(byPosition);
|
|
208
|
+
findings.baseOnly.sort(byPosition);
|
|
209
|
+
findings.headOnly.sort(byPosition);
|
|
210
|
+
|
|
211
|
+
return { ...findings, regression: findings.regressions.length > 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function locate(mutant: { readonly path: string; readonly location: Location }): string {
|
|
215
|
+
return `${mutant.path}:${mutant.location.start.line}:${mutant.location.start.column}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function describeChange(change: MutantChange): string {
|
|
219
|
+
return `${locate(change)} ${change.mutatorName} ${JSON.stringify(change.replacement)}: ${change.from} -> ${change.to}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function describeUnmatched(mutant: UnmatchedMutant): string {
|
|
223
|
+
return `${locate(mutant)} ${mutant.mutatorName} ${JSON.stringify(mutant.replacement)}: ${mutant.status}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function formatComparison(comparison: Comparison, advisory: boolean): string {
|
|
227
|
+
const lines: string[] = [];
|
|
228
|
+
for (const change of comparison.regressions) lines.push(` regression ${describeChange(change)}`);
|
|
229
|
+
for (const change of comparison.moves) lines.push(` moved ${describeChange(change)}`);
|
|
230
|
+
for (const mutant of comparison.baseOnly) lines.push(` base only ${describeUnmatched(mutant)}`);
|
|
231
|
+
for (const mutant of comparison.headOnly) lines.push(` head only ${describeUnmatched(mutant)}`);
|
|
232
|
+
const verdict = comparison.regression
|
|
233
|
+
? `mutation-compare: REGRESSION (${comparison.regressions.length} mutant(s))${advisory ? " in advisory mode, exit 0" : ""}`
|
|
234
|
+
: `mutation-compare: no regression${advisory ? " (advisory mode, exit 0)" : ""}`;
|
|
235
|
+
return [verdict, ...lines].join("\n");
|
|
127
236
|
}
|
|
128
237
|
|
|
129
238
|
export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
|