@avi2dg/checks 0.18.0 → 0.20.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 +23 -0
- package/README.md +4 -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 +1 -0
- package/docs/configs/quality-file.md +1 -0
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +20 -11
- package/docs/gates/checks-quality.md +1 -1
- package/docs/gates/checks-quarantine-clock.md +54 -0
- package/docs/gates/checks-subsumed-tests.md +68 -0
- package/docs/gates/checks-test-layout.md +9 -3
- package/docs/gates/checks-vendor.md +97 -0
- package/package.json +12 -2
- package/quality.schema.json +56 -4
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +10 -0
- package/scripts/lint.ts +75 -35
- package/scripts/mutation-compare.ts +218 -75
- package/scripts/quality-file.ts +30 -3
- package/scripts/quality.ts +19 -4
- package/scripts/quarantine-clock.ts +153 -0
- package/scripts/repetition.ts +40 -19
- package/scripts/size-budget.ts +46 -20
- package/scripts/subsumed-tests.ts +208 -0
- package/scripts/suppressions-ratchet.ts +1 -5
- package/scripts/test-layout.ts +28 -17
- package/scripts/vendor.ts +337 -0
package/quality.schema.json
CHANGED
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"checks-quality",
|
|
49
49
|
"checks-size-budget",
|
|
50
50
|
"checks-repetition",
|
|
51
|
-
"checks-feature-owners"
|
|
51
|
+
"checks-feature-owners",
|
|
52
|
+
"checks-quarantine-clock"
|
|
52
53
|
]
|
|
53
54
|
},
|
|
54
55
|
"allOf": [
|
|
@@ -76,6 +77,11 @@
|
|
|
76
77
|
"contains": {
|
|
77
78
|
"const": "checks-docs"
|
|
78
79
|
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"contains": {
|
|
83
|
+
"const": "checks-quarantine-clock"
|
|
84
|
+
}
|
|
79
85
|
}
|
|
80
86
|
]
|
|
81
87
|
}
|
|
@@ -114,6 +120,12 @@
|
|
|
114
120
|
},
|
|
115
121
|
"description": "The source the repository ships, as against tests and tooling"
|
|
116
122
|
},
|
|
123
|
+
"libraries": {
|
|
124
|
+
"type": "array",
|
|
125
|
+
"items": {
|
|
126
|
+
"$ref": "#/$defs/Library"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
117
129
|
"effect": {
|
|
118
130
|
"type": "object",
|
|
119
131
|
"properties": {
|
|
@@ -237,12 +249,12 @@
|
|
|
237
249
|
"type": "array",
|
|
238
250
|
"prefixItems": [
|
|
239
251
|
{
|
|
240
|
-
"$ref": "#/$defs/
|
|
252
|
+
"$ref": "#/$defs/FilePath_1"
|
|
241
253
|
}
|
|
242
254
|
],
|
|
243
255
|
"minItems": 1,
|
|
244
256
|
"items": {
|
|
245
|
-
"$ref": "#/$defs/
|
|
257
|
+
"$ref": "#/$defs/FilePath_1"
|
|
246
258
|
},
|
|
247
259
|
"description": "The files under root that code outside it imports the feature through"
|
|
248
260
|
},
|
|
@@ -417,12 +429,52 @@
|
|
|
417
429
|
"pattern": "^(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)(?:\\/(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+))*\\/(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
|
|
418
430
|
"description": "A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./"
|
|
419
431
|
},
|
|
432
|
+
"FilePath": {
|
|
433
|
+
"type": "string",
|
|
434
|
+
"pattern": "^(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]*\\.\\w+$",
|
|
435
|
+
"description": "The manifest inside the clone holding the version; package.json when absent"
|
|
436
|
+
},
|
|
437
|
+
"Library": {
|
|
438
|
+
"type": "object",
|
|
439
|
+
"properties": {
|
|
440
|
+
"name": {
|
|
441
|
+
"type": "string",
|
|
442
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
443
|
+
"description": "The link checks-vendor manages under repos/"
|
|
444
|
+
},
|
|
445
|
+
"package": {
|
|
446
|
+
"type": "string",
|
|
447
|
+
"minLength": 1,
|
|
448
|
+
"description": "The npm package whose installed version picks the tag"
|
|
449
|
+
},
|
|
450
|
+
"repository": {
|
|
451
|
+
"type": "string",
|
|
452
|
+
"minLength": 1,
|
|
453
|
+
"description": "The git remote checks-vendor clones the tag from"
|
|
454
|
+
},
|
|
455
|
+
"tag": {
|
|
456
|
+
"type": "string",
|
|
457
|
+
"pattern": "\\{version\\}",
|
|
458
|
+
"description": "The tag template, with {version} for the installed version"
|
|
459
|
+
},
|
|
460
|
+
"path": {
|
|
461
|
+
"$ref": "#/$defs/FilePath"
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
"required": [
|
|
465
|
+
"name",
|
|
466
|
+
"package",
|
|
467
|
+
"repository",
|
|
468
|
+
"tag"
|
|
469
|
+
],
|
|
470
|
+
"additionalProperties": false
|
|
471
|
+
},
|
|
420
472
|
"DirectoryPath": {
|
|
421
473
|
"type": "string",
|
|
422
474
|
"pattern": "^(?!\\.\\.?(?:\\/|$))[\\w.@+-]+(?:\\/(?!\\.\\.?(?:\\/|$))[\\w.@+-]+)*$",
|
|
423
475
|
"description": "The directory the feature owns"
|
|
424
476
|
},
|
|
425
|
-
"
|
|
477
|
+
"FilePath_1": {
|
|
426
478
|
"type": "string",
|
|
427
479
|
"pattern": "^(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]*\\.\\w+$"
|
|
428
480
|
},
|
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
|
@@ -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) {
|
|
@@ -172,6 +178,10 @@ export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonl
|
|
|
172
178
|
return yield* rangeEnds(first, second, cwd);
|
|
173
179
|
});
|
|
174
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
|
+
|
|
175
185
|
// A scratch index leaves the repository's own index and working tree untouched.
|
|
176
186
|
export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
|
|
177
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,56 @@
|
|
|
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 killedBy?: readonly string[];
|
|
15
|
+
readonly location: Location;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ReportFile = {
|
|
19
|
+
readonly source: string;
|
|
20
|
+
readonly mutants: readonly Mutant[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TestFile = {
|
|
24
|
+
readonly tests: readonly { readonly id: string; readonly name: string }[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type KillRun = {
|
|
28
|
+
readonly files: ReadonlyMap<string, ReportFile>;
|
|
29
|
+
readonly testFiles: ReadonlyMap<string, TestFile>;
|
|
30
|
+
readonly bail: "off" | "on" | "unrecorded";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MutantChange = {
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly location: Location;
|
|
36
|
+
readonly mutatorName: string;
|
|
37
|
+
readonly replacement: string;
|
|
38
|
+
readonly from: string;
|
|
39
|
+
readonly to: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type UnmatchedMutant = {
|
|
11
43
|
readonly path: string;
|
|
12
|
-
readonly
|
|
13
|
-
readonly
|
|
44
|
+
readonly location: Location;
|
|
45
|
+
readonly mutatorName: string;
|
|
46
|
+
readonly replacement: string;
|
|
47
|
+
readonly status: string;
|
|
14
48
|
};
|
|
15
49
|
|
|
16
50
|
export type Comparison = {
|
|
17
|
-
readonly
|
|
18
|
-
readonly
|
|
19
|
-
readonly
|
|
51
|
+
readonly regressions: readonly MutantChange[];
|
|
52
|
+
readonly moves: readonly MutantChange[];
|
|
53
|
+
readonly baseOnly: readonly UnmatchedMutant[];
|
|
54
|
+
readonly headOnly: readonly UnmatchedMutant[];
|
|
20
55
|
readonly regression: boolean;
|
|
21
56
|
};
|
|
22
57
|
|
|
@@ -32,98 +67,206 @@ export class ReportError extends Schema.TaggedError<ReportError>()("ReportError"
|
|
|
32
67
|
|
|
33
68
|
const DETECTED = new Set(["Killed", "Timeout"]);
|
|
34
69
|
const UNDETECTED = new Set(["Survived", "NoCoverage"]);
|
|
70
|
+
const LEAVES_SCORE = new Set(["CompileError", "RuntimeError", "Ignored", "Pending"]);
|
|
35
71
|
const USAGE = "usage: mutation-compare.ts [--advisory] <base-report> <head-report>";
|
|
36
72
|
|
|
37
|
-
const
|
|
73
|
+
const Files = Schema.Record(
|
|
74
|
+
Schema.String,
|
|
38
75
|
Schema.Struct({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Schema.Struct({
|
|
76
|
+
source: Schema.String,
|
|
77
|
+
mutants: Schema.Array(
|
|
78
|
+
Schema.Struct({
|
|
79
|
+
status: Schema.String,
|
|
80
|
+
mutatorName: Schema.String,
|
|
81
|
+
replacement: Schema.String,
|
|
82
|
+
killedBy: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
83
|
+
location: Schema.Struct({
|
|
84
|
+
start: Schema.Struct({ line: Schema.Finite, column: Schema.Finite }),
|
|
85
|
+
end: Schema.Struct({ line: Schema.Finite, column: Schema.Finite }),
|
|
86
|
+
}),
|
|
87
|
+
}),
|
|
42
88
|
),
|
|
43
89
|
}),
|
|
44
90
|
);
|
|
45
|
-
const decodeReport = Schema.decodeUnknownEffect(
|
|
91
|
+
const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ files: Files })));
|
|
92
|
+
const decodeKillRun = Schema.decodeUnknownEffect(
|
|
93
|
+
Schema.fromJsonString(
|
|
94
|
+
Schema.Struct({
|
|
95
|
+
files: Files,
|
|
96
|
+
testFiles: Schema.Record(Schema.String, Schema.Struct({ tests: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })) })),
|
|
97
|
+
config: Schema.optionalKey(Schema.Struct({ disableBail: Schema.optionalKey(Schema.Boolean) })),
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const notAReport = (source: string) => (cause: { readonly message: string }) => new ReportError({ message: `${source} is not a Stryker mutation report: ${cause.message}` });
|
|
46
103
|
|
|
47
|
-
export const parseReport = (source: string, text: string): Effect.Effect<Map<string,
|
|
104
|
+
export const parseReport = (source: string, text: string): Effect.Effect<Map<string, ReportFile>, ReportError> =>
|
|
48
105
|
decodeReport(text).pipe(
|
|
49
|
-
Effect.map(
|
|
50
|
-
|
|
51
|
-
),
|
|
52
|
-
Effect.mapError((cause) => new ReportError({ message: `${source} is not a Stryker mutation report: ${cause.message}` })),
|
|
106
|
+
Effect.map(({ files }) => new Map(Object.entries(files))),
|
|
107
|
+
Effect.mapError(notAReport(source)),
|
|
53
108
|
);
|
|
54
109
|
|
|
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 };
|
|
110
|
+
function bailOf(config: { readonly disableBail?: boolean } | undefined): KillRun["bail"] {
|
|
111
|
+
if (config === undefined) return "unrecorded";
|
|
112
|
+
return config.disableBail === true ? "off" : "on";
|
|
63
113
|
}
|
|
64
114
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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) };
|
|
115
|
+
export const parseKillRun = (source: string, text: string): Effect.Effect<KillRun, ReportError> =>
|
|
116
|
+
decodeKillRun(text).pipe(
|
|
117
|
+
Effect.map(({ files, testFiles, config }) => ({
|
|
118
|
+
files: new Map(Object.entries(files)),
|
|
119
|
+
testFiles: new Map(Object.entries(testFiles)),
|
|
120
|
+
bail: bailOf(config),
|
|
121
|
+
})),
|
|
122
|
+
Effect.mapError(notAReport(source)),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
function byPosition(a: { readonly path: string; readonly location: Location }, b: { readonly path: string; readonly location: Location }): number {
|
|
126
|
+
if (a.path !== b.path) return a.path < b.path ? -1 : 1;
|
|
127
|
+
return a.location.start.line - b.location.start.line || a.location.start.column - b.location.start.column;
|
|
84
128
|
}
|
|
85
129
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
130
|
+
function sharedEnds(base: readonly string[], head: readonly string[]): { readonly prefix: number; readonly suffix: number } {
|
|
131
|
+
let prefix = 0;
|
|
132
|
+
while (prefix < base.length && prefix < head.length && base[prefix] === head[prefix]) prefix++;
|
|
133
|
+
let suffix = 0;
|
|
134
|
+
while (suffix < base.length - prefix && suffix < head.length - prefix && base[base.length - 1 - suffix] === head[head.length - 1 - suffix]) suffix++;
|
|
135
|
+
return { prefix, suffix };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function commonAfterTable(base: readonly string[], head: readonly string[], prefix: number, rows: number, width: number): (row: number, column: number) => number {
|
|
139
|
+
const common = new Uint32Array((rows + 1) * width);
|
|
140
|
+
const commonAfter = (row: number, column: number) => common[row * width + column] ?? 0;
|
|
141
|
+
for (let row = rows - 1; row >= 0; row--) {
|
|
142
|
+
for (let column = width - 2; column >= 0; column--) {
|
|
143
|
+
common[row * width + column] =
|
|
144
|
+
base[prefix + row] === head[prefix + column] ? commonAfter(row + 1, column + 1) + 1 : Math.max(commonAfter(row + 1, column), commonAfter(row, column + 1));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return commonAfter;
|
|
90
148
|
}
|
|
91
149
|
|
|
92
|
-
function
|
|
93
|
-
|
|
150
|
+
function unchangedLines(baseSource: string, headSource: string): Map<number, number> {
|
|
151
|
+
const base = baseSource.split("\n");
|
|
152
|
+
const head = headSource.split("\n");
|
|
153
|
+
const { prefix, suffix } = sharedEnds(base, head);
|
|
154
|
+
const rows = base.length - prefix - suffix;
|
|
155
|
+
const width = head.length - prefix - suffix + 1;
|
|
156
|
+
const commonAfter = commonAfterTable(base, head, prefix, rows, width);
|
|
157
|
+
const lines = new Map<number, number>();
|
|
158
|
+
for (let line = 1; line <= prefix; line++) lines.set(line, line);
|
|
159
|
+
for (let row = 0, column = 0; row < rows && column < width - 1; ) {
|
|
160
|
+
if (base[prefix + row] === head[prefix + column]) lines.set(prefix + ++row, prefix + ++column);
|
|
161
|
+
else if (commonAfter(row + 1, column) >= commonAfter(row, column + 1)) row++;
|
|
162
|
+
else column++;
|
|
163
|
+
}
|
|
164
|
+
for (let line = 1; line <= suffix; line++) lines.set(base.length - suffix + line, head.length - suffix + line);
|
|
165
|
+
return lines;
|
|
94
166
|
}
|
|
95
167
|
|
|
96
|
-
function
|
|
97
|
-
|
|
168
|
+
function mapLocation(location: Location, lines: ReadonlyMap<number, number>): Location | undefined {
|
|
169
|
+
for (let line = location.start.line; line <= location.end.line; line++) if (!lines.has(line)) return undefined;
|
|
170
|
+
const start = lines.get(location.start.line);
|
|
171
|
+
const end = lines.get(location.end.line);
|
|
172
|
+
if (start === undefined || end === undefined || end - start !== location.end.line - location.start.line) return undefined;
|
|
173
|
+
return { start: { line: start, column: location.start.column }, end: { line: end, column: location.end.column } };
|
|
98
174
|
}
|
|
99
175
|
|
|
100
|
-
function
|
|
101
|
-
|
|
176
|
+
function keyMutants(
|
|
177
|
+
path: string,
|
|
178
|
+
mutants: readonly Mutant[],
|
|
179
|
+
place: (location: Location) => Location | undefined,
|
|
180
|
+
): { readonly keyed: Map<string, UnmatchedMutant>; readonly unplaced: readonly UnmatchedMutant[] } {
|
|
181
|
+
const keyed = new Map<string, UnmatchedMutant>();
|
|
182
|
+
const unplaced: UnmatchedMutant[] = [];
|
|
183
|
+
const seen = new Map<string, number>();
|
|
184
|
+
for (const mutant of mutants) {
|
|
185
|
+
const location = place(mutant.location);
|
|
186
|
+
if (location === undefined) {
|
|
187
|
+
unplaced.push({ path, ...mutant });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const signature = JSON.stringify([location.start, location.end, mutant.mutatorName, mutant.replacement]);
|
|
191
|
+
const occurrence = seen.get(signature) ?? 0;
|
|
192
|
+
seen.set(signature, occurrence + 1);
|
|
193
|
+
keyed.set(`${signature}#${occurrence}`, { path, ...mutant });
|
|
194
|
+
}
|
|
195
|
+
return { keyed, unplaced };
|
|
102
196
|
}
|
|
103
197
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
198
|
+
type Findings = {
|
|
199
|
+
readonly regressions: MutantChange[];
|
|
200
|
+
readonly moves: MutantChange[];
|
|
201
|
+
readonly baseOnly: UnmatchedMutant[];
|
|
202
|
+
readonly headOnly: UnmatchedMutant[];
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function judgeMatch(path: string, mutant: UnmatchedMutant, counterpart: UnmatchedMutant, findings: Findings): void {
|
|
206
|
+
const change: MutantChange = {
|
|
207
|
+
path,
|
|
208
|
+
location: counterpart.location,
|
|
209
|
+
mutatorName: mutant.mutatorName,
|
|
210
|
+
replacement: mutant.replacement,
|
|
211
|
+
from: mutant.status,
|
|
212
|
+
to: counterpart.status,
|
|
213
|
+
};
|
|
214
|
+
if (mutant.status !== counterpart.status && (LEAVES_SCORE.has(mutant.status) || LEAVES_SCORE.has(counterpart.status))) {
|
|
215
|
+
findings.moves.push(change);
|
|
216
|
+
} else if (DETECTED.has(mutant.status) && UNDETECTED.has(counterpart.status)) {
|
|
217
|
+
findings.regressions.push(change);
|
|
218
|
+
}
|
|
107
219
|
}
|
|
108
220
|
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
lines.push(` ${file.path}: ${describe(file.base)} -> ${describe(file.head)}`);
|
|
221
|
+
function compareFile(path: string, baseFile: ReportFile | undefined, headFile: ReportFile | undefined, findings: Findings): void {
|
|
222
|
+
const lines = baseFile !== undefined && headFile !== undefined ? unchangedLines(baseFile.source, headFile.source) : new Map<number, number>();
|
|
223
|
+
const base = keyMutants(path, baseFile?.mutants ?? [], (location) => mapLocation(location, lines));
|
|
224
|
+
const head = keyMutants(path, headFile?.mutants ?? [], (location) => location);
|
|
225
|
+
findings.baseOnly.push(...base.unplaced);
|
|
226
|
+
for (const [key, mutant] of base.keyed) {
|
|
227
|
+
const counterpart = head.keyed.get(key);
|
|
228
|
+
if (counterpart === undefined) findings.baseOnly.push(mutant);
|
|
229
|
+
else judgeMatch(path, mutant, counterpart, findings);
|
|
119
230
|
}
|
|
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)" : ""}`);
|
|
231
|
+
for (const [key, mutant] of head.keyed) {
|
|
232
|
+
if (!base.keyed.has(key)) findings.headOnly.push(mutant);
|
|
125
233
|
}
|
|
126
|
-
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function compareReports(baseFiles: ReadonlyMap<string, ReportFile>, headFiles: ReadonlyMap<string, ReportFile>): Comparison {
|
|
237
|
+
const findings: Findings = { regressions: [], moves: [], baseOnly: [], headOnly: [] };
|
|
238
|
+
for (const path of new Set([...baseFiles.keys(), ...headFiles.keys()])) compareFile(path, baseFiles.get(path), headFiles.get(path), findings);
|
|
239
|
+
|
|
240
|
+
findings.regressions.sort(byPosition);
|
|
241
|
+
findings.moves.sort(byPosition);
|
|
242
|
+
findings.baseOnly.sort(byPosition);
|
|
243
|
+
findings.headOnly.sort(byPosition);
|
|
244
|
+
|
|
245
|
+
return { ...findings, regression: findings.regressions.length > 0 };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function locate(mutant: { readonly path: string; readonly location: Location }): string {
|
|
249
|
+
return `${mutant.path}:${mutant.location.start.line}:${mutant.location.start.column}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function describeChange(change: MutantChange): string {
|
|
253
|
+
return `${locate(change)} ${change.mutatorName} ${JSON.stringify(change.replacement)}: ${change.from} -> ${change.to}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function describeUnmatched(mutant: UnmatchedMutant): string {
|
|
257
|
+
return `${locate(mutant)} ${mutant.mutatorName} ${JSON.stringify(mutant.replacement)}: ${mutant.status}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function formatComparison(comparison: Comparison, advisory: boolean): string {
|
|
261
|
+
const lines: string[] = [];
|
|
262
|
+
for (const change of comparison.regressions) lines.push(` regression ${describeChange(change)}`);
|
|
263
|
+
for (const change of comparison.moves) lines.push(` moved ${describeChange(change)}`);
|
|
264
|
+
for (const mutant of comparison.baseOnly) lines.push(` base only ${describeUnmatched(mutant)}`);
|
|
265
|
+
for (const mutant of comparison.headOnly) lines.push(` head only ${describeUnmatched(mutant)}`);
|
|
266
|
+
const verdict = comparison.regression
|
|
267
|
+
? `mutation-compare: REGRESSION (${comparison.regressions.length} mutant(s))${advisory ? " in advisory mode, exit 0" : ""}`
|
|
268
|
+
: `mutation-compare: no regression${advisory ? " (advisory mode, exit 0)" : ""}`;
|
|
269
|
+
return [verdict, ...lines].join("\n");
|
|
127
270
|
}
|
|
128
271
|
|
|
129
272
|
export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
|