@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -1
  3. package/bunfig.toml +1 -1
  4. package/dependency-cruiser.config.js +2 -0
  5. package/dist/feature-rules.js +22 -5
  6. package/docs/configs/commit-messages.md +2 -18
  7. package/docs/configs/quality-file.md +3 -2
  8. package/docs/gates/checks-lint.md +2 -1
  9. package/docs/gates/checks-mutation-compare.md +20 -11
  10. package/docs/gates/checks-quality.md +23 -8
  11. package/docs/gates/checks-quarantine-clock.md +54 -0
  12. package/docs/gates/checks-test-layout.md +9 -3
  13. package/docs/gates/checks-vendor.md +97 -0
  14. package/package.json +11 -2
  15. package/quality.schema.json +56 -4
  16. package/scripts/backtest.ts +57 -41
  17. package/scripts/ci-wiring.ts +2 -63
  18. package/scripts/comment-gate.ts +3 -4
  19. package/scripts/comment-matchers.ts +54 -57
  20. package/scripts/commit-identity.ts +3 -4
  21. package/scripts/doc-outline.ts +34 -16
  22. package/scripts/doc-rules.ts +34 -21
  23. package/scripts/docs.ts +3 -4
  24. package/scripts/feature-owners.ts +5 -8
  25. package/scripts/gates.ts +1 -0
  26. package/scripts/git.ts +41 -26
  27. package/scripts/lint.ts +75 -35
  28. package/scripts/mutation-compare.ts +179 -70
  29. package/scripts/prose-matchers.ts +93 -83
  30. package/scripts/quality-file.ts +30 -3
  31. package/scripts/quality.ts +157 -10
  32. package/scripts/quarantine-clock.ts +153 -0
  33. package/scripts/range-gate.ts +9 -0
  34. package/scripts/repetition.ts +40 -19
  35. package/scripts/shell-command.ts +74 -0
  36. package/scripts/size-budget.ts +51 -28
  37. package/scripts/suppressions-ratchet.ts +22 -28
  38. package/scripts/test-layout.ts +65 -52
  39. package/scripts/test-report.ts +18 -13
  40. 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, Usage } from "./main.ts";
6
- import { PROOF_DIRECTORY, readQuality, type Feature } from "./quality-file.ts";
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 [first, second, ...extra] = process.argv.slice(2);
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 line = 0;
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
- if (path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(line));
101
- line += 1;
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
- continue;
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 rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
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 decodePullRequestEvent = Schema.decodeUnknownEffect(
30
- Schema.fromJsonString(
31
- Schema.Struct({
32
- pull_request: Schema.Struct({
33
- number: Schema.Int,
34
- base: Schema.Struct({ ref: Schema.String }),
35
- head: Schema.Struct({ sha: Schema.String }),
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
- const originEnds = (defaultBranch: string) =>
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(`origin/${defaultBranch}`)),
65
- Effect.map((base) => ({ base, head: "HEAD", source: `HEAD against ${base}` })),
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
- if (remoteTracking.trim() === "") {
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
- const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
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
- return { base, head, source: `${head} against ${base}` };
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
- if (base !== undefined) return yield* new Usage({ message: USAGE });
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
- if (Option.isSome(event.name) && event.name.value === PULL_REQUEST_EVENT) {
92
- if (Option.isNone(event.path)) {
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
- return yield* pullRequestEnds(event.path.value);
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 === head ? [head] : [base, head], source: ends.source } satisfies Range;
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 Tally = {
6
- readonly killed: number;
7
- readonly total: number;
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 FileComparison = {
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 base: Tally;
13
- readonly head: Tally;
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 base: Tally;
18
- readonly head: Tally;
19
- readonly files: readonly FileComparison[];
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({ mutants: Schema.Array(Schema.Struct({ status: Schema.String })) }),
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, readonly string[]>, ReportError> =>
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 tally(statuses: readonly string[]): Tally {
56
- let killed = 0;
57
- let total = 0;
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
- const EMPTY: Tally = { killed: 0, total: 0 };
66
-
67
- export function compareReports(
68
- baseFiles: ReadonlyMap<string, readonly string[]>,
69
- headFiles: ReadonlyMap<string, readonly string[]>,
70
- ): Comparison {
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
- export function regressed(base: Tally, head: Tally): boolean {
87
- if (base.total === 0) return head.total > 0 && head.killed < head.total;
88
- if (head.total === 0) return false;
89
- return head.killed * base.total < base.killed * head.total;
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 points(count: Tally): number {
93
- return count.total === 0 ? 100 : (100 * count.killed) / count.total;
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 percent(count: Tally): string {
97
- return count.total === 0 ? "n/a" : `${points(count).toFixed(2)}%`;
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 describe(count: Tally): string {
101
- return `${percent(count)} (${count.killed}/${count.total})`;
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
- function deltaPoints(base: Tally, head: Tally): string {
105
- const delta = points(head) - points(base);
106
- return `${delta < 0 ? "-" : "+"}${Math.abs(delta).toFixed(2)}pp`;
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
- export function formatComparison(comparison: Comparison, advisory: boolean): string {
110
- const changed = comparison.files.filter(
111
- (file) => file.base.killed !== file.head.killed || file.base.total !== file.head.total,
112
- );
113
- const unchanged = comparison.files.length - changed.length;
114
- const lines = [
115
- `mutation-compare: base ${describe(comparison.base)} head ${describe(comparison.head)} delta ${deltaPoints(comparison.base, comparison.head)}`,
116
- ];
117
- for (const file of changed) {
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
- if (unchanged > 0) lines.push(` ${unchanged} unchanged file(s)`);
121
- if (comparison.regression) {
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
- return lines.join("\n");
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> {