@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.
@@ -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/FilePath"
252
+ "$ref": "#/$defs/FilePath_1"
241
253
  }
242
254
  ],
243
255
  "minItems": 1,
244
256
  "items": {
245
- "$ref": "#/$defs/FilePath"
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
- "FilePath": {
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 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,56 @@
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 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 base: Tally;
13
- readonly head: Tally;
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 base: Tally;
18
- readonly head: Tally;
19
- readonly files: readonly FileComparison[];
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 Report = Schema.fromJsonString(
73
+ const Files = Schema.Record(
74
+ Schema.String,
38
75
  Schema.Struct({
39
- files: Schema.Record(
40
- Schema.String,
41
- Schema.Struct({ mutants: Schema.Array(Schema.Struct({ status: Schema.String })) }),
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(Report);
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, readonly string[]>, ReportError> =>
104
+ export const parseReport = (source: string, text: string): Effect.Effect<Map<string, ReportFile>, ReportError> =>
48
105
  decodeReport(text).pipe(
49
- Effect.map(
50
- ({ files }) => new Map(Object.entries(files).map(([path, { mutants }]) => [path, mutants.map(({ status }) => status)])),
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 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 };
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 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) };
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
- 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;
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 points(count: Tally): number {
93
- return count.total === 0 ? 100 : (100 * count.killed) / count.total;
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 percent(count: Tally): string {
97
- return count.total === 0 ? "n/a" : `${points(count).toFixed(2)}%`;
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 describe(count: Tally): string {
101
- return `${percent(count)} (${count.killed}/${count.total})`;
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
- function deltaPoints(base: Tally, head: Tally): string {
105
- const delta = points(head) - points(base);
106
- return `${delta < 0 ? "-" : "+"}${Math.abs(delta).toFixed(2)}pp`;
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
- 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)}`);
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
- 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)" : ""}`);
231
+ for (const [key, mutant] of head.keyed) {
232
+ if (!base.keyed.has(key)) findings.headOnly.push(mutant);
125
233
  }
126
- return lines.join("\n");
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> {