@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.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem } from "effect";
3
+ import { runMain, Usage } from "./main.ts";
4
+ import { type KillRun, parseKillRun, ReportError } from "./mutation-compare.ts";
5
+
6
+ export type KillSets = ReadonlyMap<string, ReadonlySet<string>>;
7
+
8
+ export type Subsumed = {
9
+ readonly test: string;
10
+ readonly kills: number;
11
+ readonly subsumedBy: string;
12
+ readonly subsumerKills: number;
13
+ };
14
+
15
+ export type IdenticalGroup = {
16
+ readonly tests: readonly string[];
17
+ readonly kills: number;
18
+ };
19
+
20
+ export type Cover = {
21
+ readonly members: readonly string[];
22
+ readonly tests: number;
23
+ readonly kills: number;
24
+ };
25
+
26
+ export type Report = {
27
+ readonly files: readonly string[];
28
+ readonly subsumed: readonly Subsumed[];
29
+ readonly identical: readonly IdenticalGroup[];
30
+ readonly cover: Cover;
31
+ };
32
+
33
+ export type Options = {
34
+ readonly reportPath: string;
35
+ };
36
+
37
+ const USAGE = "usage: subsumed-tests.ts <mutation-report>";
38
+
39
+ export function killSetsOf(run: KillRun): KillSets {
40
+ const kills = new Map<string, Set<string>>();
41
+ for (const file of run.testFiles.values()) for (const test of file.tests) kills.set(test.id, new Set<string>());
42
+ for (const [path, file] of run.files) {
43
+ file.mutants.forEach((mutant, index) => {
44
+ for (const test of mutant.killedBy ?? []) {
45
+ let set = kills.get(test);
46
+ if (set === undefined) {
47
+ set = new Set<string>();
48
+ kills.set(test, set);
49
+ }
50
+ set.add(`${path}#${index}`);
51
+ }
52
+ });
53
+ }
54
+ return kills;
55
+ }
56
+
57
+ function isSuperset(candidate: ReadonlySet<string>, other: ReadonlySet<string>): boolean {
58
+ if (candidate.size <= other.size) return false;
59
+ for (const kill of other) if (!candidate.has(kill)) return false;
60
+ return true;
61
+ }
62
+
63
+ function subsumerOf(test: string, kills: ReadonlySet<string>, sets: KillSets): { readonly name: string; readonly size: number } | undefined {
64
+ let best: { readonly name: string; readonly size: number } | undefined;
65
+ for (const [other, otherKills] of sets) {
66
+ if (other !== test && isSuperset(otherKills, kills) && (best === undefined || otherKills.size > best.size || (otherKills.size === best.size && other < best.name))) best = { name: other, size: otherKills.size };
67
+ }
68
+ return best;
69
+ }
70
+
71
+ export function findSubsumed(sets: KillSets): readonly Subsumed[] {
72
+ const subsumed: Subsumed[] = [];
73
+ for (const [test, kills] of sets) {
74
+ if (kills.size === 0) continue;
75
+ const subsumer = subsumerOf(test, kills, sets);
76
+ if (subsumer !== undefined) subsumed.push({ test, kills: kills.size, subsumedBy: subsumer.name, subsumerKills: subsumer.size });
77
+ }
78
+ return subsumed;
79
+ }
80
+
81
+ export function findIdentical(sets: KillSets): readonly IdenticalGroup[] {
82
+ const groups = new Map<string, string[]>();
83
+ for (const [test, kills] of sets) {
84
+ if (kills.size === 0) continue;
85
+ const key = [...kills].toSorted().join("\n");
86
+ const group = groups.get(key);
87
+ if (group === undefined) groups.set(key, [test]);
88
+ else group.push(test);
89
+ }
90
+ return [...groups.values()].filter((tests) => tests.length > 1).map((tests) => ({ tests, kills: sets.get(tests[0] ?? "")?.size ?? 0 }));
91
+ }
92
+
93
+ function uncoveredCount(kills: ReadonlySet<string>, uncovered: ReadonlySet<string>): number {
94
+ let count = 0;
95
+ for (const kill of kills) if (uncovered.has(kill)) count++;
96
+ return count;
97
+ }
98
+
99
+ function bestAddition(candidates: readonly (readonly [string, ReadonlySet<string>])[], members: readonly string[], uncovered: ReadonlySet<string>): readonly [string, ReadonlySet<string>] | undefined {
100
+ let best: readonly [string, ReadonlySet<string>] | undefined;
101
+ let bestCount = 0;
102
+ for (const candidate of candidates) {
103
+ if (members.includes(candidate[0])) continue;
104
+ const count = uncoveredCount(candidate[1], uncovered);
105
+ if (count > bestCount) {
106
+ best = candidate;
107
+ bestCount = count;
108
+ }
109
+ }
110
+ return best;
111
+ }
112
+
113
+ export function greedyCover(sets: KillSets): Cover {
114
+ const uncovered = new Set<string>();
115
+ for (const kills of sets.values()) for (const kill of kills) uncovered.add(kill);
116
+ const total = uncovered.size;
117
+ const members: string[] = [];
118
+ const candidates = [...sets].toSorted(([a], [b]) => (a < b ? -1 : 1));
119
+ while (uncovered.size > 0) {
120
+ const best = bestAddition(candidates, members, uncovered);
121
+ if (best === undefined) break;
122
+ members.push(best[0]);
123
+ for (const kill of best[1]) uncovered.delete(kill);
124
+ }
125
+ return { members, tests: sets.size, kills: total };
126
+ }
127
+
128
+ function testNames(run: KillRun): ReadonlyMap<string, string> {
129
+ const names = new Map<string, string>();
130
+ for (const [path, file] of run.testFiles) for (const test of file.tests) names.set(test.id, qualified(path, test.name));
131
+ return names;
132
+ }
133
+
134
+ // The bun runner writes names that already open with their file.
135
+ function qualified(path: string, name: string): string {
136
+ return path === "" || name.startsWith(`${path} > `) ? name : `${path} > ${name}`;
137
+ }
138
+
139
+ const byName = (a: string, b: string) => (a < b ? -1 : 1);
140
+
141
+ export function analyze(run: KillRun): Report {
142
+ const sets = killSetsOf(run);
143
+ const names = testNames(run);
144
+ const name = (id: string) => names.get(id) ?? id;
145
+ const cover = greedyCover(sets);
146
+ return {
147
+ files: [...run.files.keys()].toSorted(),
148
+ subsumed: findSubsumed(sets)
149
+ .map((one) => ({ ...one, test: name(one.test), subsumedBy: name(one.subsumedBy) }))
150
+ .toSorted((a, b) => byName(a.test, b.test)),
151
+ identical: findIdentical(sets)
152
+ .map((group) => ({ ...group, tests: group.tests.map(name).toSorted(byName) }))
153
+ .toSorted((a, b) => byName(a.tests[0] ?? "", b.tests[0] ?? "")),
154
+ cover: { ...cover, members: cover.members.map(name) },
155
+ };
156
+ }
157
+
158
+ function describeTest(test: string, kills: number): string {
159
+ return `${JSON.stringify(test)} (${kills} kill${kills === 1 ? "" : "s"})`;
160
+ }
161
+
162
+ export function formatReport(report: Report): string {
163
+ const lines = [`subsumed-tests: ${report.files.length} file(s) mutated`];
164
+ for (const file of report.files) lines.push(` mutated ${file}`);
165
+ lines.push(`subsumed tests (${report.subsumed.length}):`);
166
+ for (const one of report.subsumed) lines.push(` ${describeTest(one.test, one.kills)} subsumed by ${describeTest(one.subsumedBy, one.subsumerKills)}`);
167
+ lines.push(`identical kill sets (${report.identical.length} group(s)):`);
168
+ for (const group of report.identical) lines.push(` ${group.tests.map((test) => describeTest(test, group.kills)).join(" = ")}`);
169
+ lines.push(`greedy cover: ${report.cover.members.length} of ${report.cover.tests} test(s) keep all ${report.cover.kills} kill(s)`);
170
+ for (const member of report.cover.members) lines.push(` cover ${JSON.stringify(member)}`);
171
+ return lines.join("\n");
172
+ }
173
+
174
+ export const bailWarning = (source: string, run: KillRun): Effect.Effect<string | undefined, ReportError> => {
175
+ if (run.bail === "on")
176
+ return Effect.fail(
177
+ new ReportError({
178
+ message: `${source} was built with bail on, since its config.disableBail is not true, so each mutant records only its first killer: rerun Stryker with \`bunx stryker run --disableBail\` and pass the report it writes`,
179
+ }),
180
+ );
181
+ if (run.bail === "unrecorded") return Effect.succeed(`${source} records no config, so nothing shows whether bail was off: build it with \`bunx stryker run --disableBail\``);
182
+ return Effect.succeed(undefined);
183
+ };
184
+
185
+ export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
186
+ const [reportPath, ...extra] = argv.filter((arg) => !arg.startsWith("--"));
187
+ const flag = argv.find((arg) => arg.startsWith("--"));
188
+ if (flag !== undefined) return yield* new Usage({ message: `${USAGE}: unknown flag ${flag}` });
189
+ if (reportPath === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
190
+ return { reportPath };
191
+ });
192
+
193
+ const load = Effect.fn("load")(function* (path: string) {
194
+ const fs = yield* FileSystem.FileSystem;
195
+ const text = yield* fs.readFileString(path).pipe(Effect.mapError(() => new ReportError({ message: `cannot read ${path}` })));
196
+ const run = yield* parseKillRun(path, text);
197
+ const warning = yield* bailWarning(path, run);
198
+ if (warning !== undefined) yield* Console.error(`subsumed-tests: warning: ${warning}`);
199
+ return run;
200
+ });
201
+
202
+ const report = Effect.gen(function* () {
203
+ const options = yield* parseArgs(process.argv.slice(2));
204
+ yield* Console.log(formatReport(analyze(yield* load(options.reportPath))));
205
+ return true;
206
+ });
207
+
208
+ if (import.meta.main) runMain("subsumed-tests", report);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { git, parentOrEmptyTree, refArgs } from "./git.ts";
3
+ import { git, commitOf, parentOrEmptyTree, refArgs } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
5
 
6
6
  export const SUPPRESSIONS = "oxlint-suppressions.json";
@@ -87,10 +87,6 @@ export function report({ counted, lowered, rises }: Ratchet): string {
87
87
  ].join("\n");
88
88
  }
89
89
 
90
- const commitOf = Effect.fn("commitOf")(function* (rev: string) {
91
- return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`])).trim();
92
- });
93
-
94
90
  const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string) {
95
91
  const commit = yield* git(["merge-base", base, head]).pipe(
96
92
  Effect.mapError(() => new SuppressionsError({ message: `${base} and ${head} share no commit` })),
@@ -3,7 +3,8 @@ import { parse } from "@swc/core";
3
3
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
4
4
  import { git } from "./git.ts";
5
5
  import { runMain } from "./main.ts";
6
- import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
6
+ import { ENTRY_POINT, QUALITY_FILE, TEST_ENTRY_POINT } from "./gates.ts";
7
+ import { readQuality } from "./quality-file.ts";
7
8
 
8
9
  export type Violation = {
9
10
  readonly file: string;
@@ -16,7 +17,7 @@ const E2E = "tests/e2e/";
16
17
  const HELPER_DIRS = ["tests/lib/", "tests/fixtures/"] as const;
17
18
  const DATA_DIR = "tests/fixtures/";
18
19
 
19
- const TEST_FILE = /(?:[.](?:test|spec)|_test)[.](tsx?)$/;
20
+ export const TEST_FILE = /(?:[.](?:test|spec)|_test)[.](tsx?)$/;
20
21
  const TARGET_FILE = /^tests\/(?:[^/]+\/)*[^/]+[.]test[.]tsx?$/;
21
22
  const TYPESCRIPT = /[.]tsx?$/;
22
23
 
@@ -39,9 +40,14 @@ const BANNED_BUN_NAMES: readonly string[] = [
39
40
  ];
40
41
  const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
41
42
 
42
- const REQUIRED_TEST_TABLE: Record<string, unknown> = {
43
- pathIgnorePatterns: ["**/tests/quarantine/**"],
44
- };
43
+ const IGNORES_KEY = "pathIgnorePatterns";
44
+ const QUARANTINE = "**/tests/quarantine/**";
45
+ const VENDORED = "repos/**";
46
+ const PRESET_IGNORES: readonly string[] = [QUARANTINE, VENDORED];
47
+
48
+ function acceptedIgnores(vendors: boolean): readonly (readonly string[])[] {
49
+ return vendors ? [PRESET_IGNORES] : [PRESET_IGNORES, [QUARANTINE]];
50
+ }
45
51
  export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
46
52
  export const LAYOUT_CHECK_BIN = "checks-test-layout";
47
53
  const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
@@ -245,7 +251,7 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
245
251
  return violations;
246
252
  }
247
253
 
248
- export function bunfigViolations(consumer: unknown, preset: unknown): readonly Violation[] {
254
+ export function bunfigViolations(consumer: unknown, preset: unknown, vendors: boolean): readonly Violation[] {
249
255
  const file = "bunfig.toml";
250
256
  const copy = "bun has no bunfig extends, so copy node_modules/@avi2dg/checks/bunfig.toml";
251
257
  if (consumer === undefined) {
@@ -255,18 +261,21 @@ export function bunfigViolations(consumer: unknown, preset: unknown): readonly V
255
261
  if (!isRecord(presetTest)) {
256
262
  return [{ file, line: undefined, message: "the shipped bunfig preset has no [test] table" }];
257
263
  }
258
- const expected = { ...presetTest, ...REQUIRED_TEST_TABLE };
259
264
  const consumerTest = isRecord(consumer) ? consumer["test"] : undefined;
265
+ const found = (key: string): unknown => (isRecord(consumerTest) ? consumerTest[key] : undefined);
266
+ const drifted = (key: string, wanted: string, fix: string): Violation => ({
267
+ file,
268
+ line: undefined,
269
+ message: `[test].${key} must be ${wanted}, found ${JSON.stringify(found(key) ?? null)}; ${fix}`,
270
+ });
260
271
  const violations: Violation[] = [];
261
- for (const [key, value] of Object.entries(expected)) {
262
- const found = isRecord(consumerTest) ? consumerTest[key] : undefined;
263
- if (!Bun.deepEquals(found, value)) {
264
- violations.push({
265
- file,
266
- line: undefined,
267
- message: `[test].${key} must be ${JSON.stringify(value)}, found ${JSON.stringify(found ?? null)}; ${copy}`,
268
- });
269
- }
272
+ for (const [key, value] of Object.entries(presetTest)) {
273
+ if (key !== IGNORES_KEY && !Bun.deepEquals(found(key), value)) violations.push(drifted(key, JSON.stringify(value), copy));
274
+ }
275
+ const accepted = acceptedIgnores(vendors);
276
+ if (!accepted.some((ignores) => Bun.deepEquals(found(IGNORES_KEY), ignores))) {
277
+ const wanted = accepted.map((ignores) => JSON.stringify(ignores)).join(" or ");
278
+ violations.push(drifted(IGNORES_KEY, wanted, `the check pins it, requiring ${VENDORED} where ${QUALITY_FILE} declares sources.libraries`));
270
279
  }
271
280
  return violations;
272
281
  }
@@ -321,7 +330,9 @@ export const run = Effect.fn("run")(function* (root: string, presetPath: string)
321
330
 
322
331
  const bunfig = path.join(root, "bunfig.toml");
323
332
  const consumerBunfig = (yield* fs.exists(bunfig)) ? yield* parsedToml(bunfig) : undefined;
324
- violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath)));
333
+ const { quality } = yield* readQuality(root);
334
+ const vendors = (quality.sources?.libraries ?? []).length > 0;
335
+ violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath), vendors));
325
336
 
326
337
  return { files: files.length, violations };
327
338
  });
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
+ import { QUALITY_FILE } from "./gates.ts";
4
+ import { git } from "./git.ts";
5
+ import { runMain, Usage } from "./main.ts";
6
+ import { readQuality, type Library } from "./quality-file.ts";
7
+
8
+ const NAME = "checks-vendor";
9
+ const USAGE = `usage: ${NAME} takes no arguments, since quality.json names the libraries`;
10
+ const CACHE_HOME = ".cache/avi2dg-checks";
11
+ const RECORD_SUFFIX = ".commit";
12
+ const LINKS = "repos";
13
+ const VERSION_TOKEN = "{version}";
14
+ // bun runs prepare under umask 0, so a mode left to the umask lets any local user swap the shared tree.
15
+ const DIRECTORY_MODE = 0o755;
16
+ const RECORD_MODE = 0o644;
17
+
18
+ export class VendorError extends Schema.TaggedError<VendorError>()("VendorError", {
19
+ message: Schema.String,
20
+ }) {}
21
+
22
+ export class Unreachable extends Schema.TaggedError<Unreachable>()("Unreachable", {
23
+ message: Schema.String,
24
+ }) {}
25
+
26
+ export function tagFor(template: string, version: string): string {
27
+ return template.replaceAll(VERSION_TOKEN, version);
28
+ }
29
+
30
+ const SCHEMED = /^([a-z][a-z0-9+.-]*):\/\/(?:[^/@]+@)?([^/:]+)(?::\d+)?\/(.+)$/i;
31
+ const SCP_LIKE = /^(?:[^@/:]+@)?([^:/]+):(.+)$/;
32
+
33
+ function urlSegments(host: string, rest: string): readonly string[] {
34
+ const parts = rest.split("/").filter((part) => part !== "");
35
+ return [host, ...parts.slice(0, -1), (parts[parts.length - 1] ?? "").replace(/\.git$/, "")];
36
+ }
37
+
38
+ export function remoteSegments(remote: string): readonly string[] {
39
+ const cleaned = remote.replace(/\/+$/, "");
40
+ const schemed = SCHEMED.exec(cleaned);
41
+ if (schemed !== null) {
42
+ const [, , host, rest] = schemed;
43
+ if (host !== undefined && rest !== undefined) return urlSegments(host, rest);
44
+ }
45
+ const scp = SCP_LIKE.exec(cleaned);
46
+ if (scp !== null) {
47
+ const [, host, rest] = scp;
48
+ if (host !== undefined && rest !== undefined) return urlSegments(host, rest);
49
+ }
50
+ const escaped = cleaned
51
+ .replaceAll("_", "__")
52
+ .replaceAll("/", "_")
53
+ .replaceAll(":", "_")
54
+ .replace(/^\.+/, (dots) => "_".repeat(dots.length));
55
+ return ["local", escaped];
56
+ }
57
+
58
+ export function listsTag(output: string, tag: string): boolean {
59
+ return output.split("\n").some((line) => line.split("\t")[1] === `refs/tags/${tag}`);
60
+ }
61
+
62
+ const PackageManifest = Schema.Struct({ version: Schema.NonEmptyString });
63
+ const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest));
64
+
65
+ const manifestVersion = Effect.fn("manifestVersion")(function* (file: string, missing: string) {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ if (!(yield* fs.exists(file))) return yield* new VendorError({ message: missing });
68
+ const { version } = yield* fs.readFileString(file).pipe(
69
+ Effect.flatMap(decodeManifest),
70
+ Effect.mapError((cause) => new VendorError({ message: `${file} carries no version: ${cause.message}` })),
71
+ );
72
+ return version;
73
+ });
74
+
75
+ const headOf = Effect.fn("headOf")(function* (dir: string) {
76
+ return (yield* git(["rev-parse", "HEAD"], dir).pipe(
77
+ Effect.mapError((cause) => new VendorError({ message: `${dir} names no commit: ${cause.message}` })),
78
+ )).trim();
79
+ });
80
+
81
+ const confirmTag = Effect.fn("confirmTag")(function* (remote: string, tag: string) {
82
+ const output = yield* git(["ls-remote", remote, `refs/tags/${tag}*`]).pipe(
83
+ Effect.mapError((cause) => new Unreachable({ message: `cannot list ${tag} on ${remote}: ${cause.message}` })),
84
+ );
85
+ if (!listsTag(output, tag)) {
86
+ return yield* new VendorError({ message: `${remote} holds no tag ${tag}, so the installed version points nowhere` });
87
+ }
88
+ });
89
+
90
+ const isLink = Effect.fn("isLink")(function* (entry: string) {
91
+ const fs = yield* FileSystem.FileSystem;
92
+ return Option.isSome(yield* fs.readLink(entry).pipe(Effect.option));
93
+ });
94
+
95
+ type EntryMode = {
96
+ readonly entry: string;
97
+ readonly mode: number;
98
+ };
99
+
100
+ const modes = Effect.fn("modes")(function* (dir: string) {
101
+ const fs = yield* FileSystem.FileSystem;
102
+ const path = yield* Path.Path;
103
+ const found: EntryMode[] = [];
104
+ const pending = [dir];
105
+ for (const entry of pending) {
106
+ if (yield* isLink(entry)) continue;
107
+ const info = yield* fs.stat(entry).pipe(
108
+ Effect.mapError((cause) => new VendorError({ message: `cannot stat ${entry}: ${cause.message}` })),
109
+ );
110
+ found.push({ entry, mode: info.mode });
111
+ if (info.type !== "Directory") continue;
112
+ const children = yield* fs.readDirectory(entry).pipe(
113
+ Effect.mapError((cause) => new VendorError({ message: `cannot list ${entry}: ${cause.message}` })),
114
+ );
115
+ pending.push(...children.map((child) => path.join(entry, child)));
116
+ }
117
+ return found;
118
+ });
119
+
120
+ const WRITE_BITS = 0o222;
121
+ const OWNER_WRITE = 0o200;
122
+
123
+ function clearing(dir: string): string {
124
+ return `clear it with \`chmod -R u+w ${dir} && rm -rf ${dir}\` and rerun ${NAME}`;
125
+ }
126
+
127
+ const freeze = Effect.fn("freeze")(function* (dir: string) {
128
+ const fs = yield* FileSystem.FileSystem;
129
+ for (const { entry, mode } of yield* modes(dir)) {
130
+ yield* fs.chmod(entry, mode & ~WRITE_BITS).pipe(
131
+ Effect.mapError((cause) => new VendorError({ message: `cannot freeze ${entry}: ${cause.message}` })),
132
+ );
133
+ }
134
+ });
135
+
136
+ function discard(dir: string) {
137
+ return Effect.gen(function* () {
138
+ const fs = yield* FileSystem.FileSystem;
139
+ if (!(yield* fs.exists(dir))) return;
140
+ for (const { entry, mode } of yield* modes(dir)) yield* fs.chmod(entry, mode | OWNER_WRITE);
141
+ yield* fs.remove(dir, { recursive: true });
142
+ }).pipe(Effect.catch((cause) => Console.error(`${NAME}: cannot clear the staging tree ${dir}: ${cause.message}`)));
143
+ }
144
+
145
+ const checkVersion = Effect.fn("checkVersion")(function* (dir: string, library: Library, installed: string, tag: string) {
146
+ const path = yield* Path.Path;
147
+ const manifest = path.join(dir, library.path ?? "package.json");
148
+ const landed = yield* manifestVersion(manifest, `${manifest} is missing, so ${tag} cannot prove it holds ${installed}`);
149
+ if (landed !== installed) {
150
+ return yield* new VendorError({ message: `${tag} holds version ${landed}, not the installed ${installed}` });
151
+ }
152
+ });
153
+
154
+ function recordOf(dir: string): string {
155
+ return `${dir}${RECORD_SUFFIX}`;
156
+ }
157
+
158
+ const recorded = Effect.fn("recorded")(function* (dir: string) {
159
+ const fs = yield* FileSystem.FileSystem;
160
+ const record = recordOf(dir);
161
+ if (!(yield* fs.exists(record))) return Option.none<string>();
162
+ const text = yield* fs.readFileString(record).pipe(
163
+ Effect.mapError((cause) => new VendorError({ message: `cannot read ${record}: ${cause.message}` })),
164
+ );
165
+ return Option.some(text.trim());
166
+ });
167
+
168
+ const verify = Effect.fn("verify")(function* (dir: string, library: Library, installed: string, tag: string) {
169
+ const held = yield* recorded(dir);
170
+ if (Option.isNone(held)) {
171
+ return yield* new VendorError({ message: `${recordOf(dir)} is missing, so ${dir} has no commit to hold to; ${clearing(dir)}` });
172
+ }
173
+ const record = held.value;
174
+ const head = yield* headOf(dir);
175
+ if (head !== record) {
176
+ return yield* new VendorError({ message: `${dir} sits on ${head}, not the recorded ${record}; ${clearing(dir)}` });
177
+ }
178
+ yield* checkVersion(dir, library, installed, tag);
179
+ const tampered = (yield* modes(dir)).filter(({ mode }) => (mode & WRITE_BITS) !== 0).map(({ entry }) => entry);
180
+ if (tampered.length > 0) {
181
+ const [first = dir] = tampered;
182
+ return yield* new VendorError({
183
+ message: `${dir} leaves ${tampered.length} paths writable, starting with ${first}; ${clearing(dir)}`,
184
+ });
185
+ }
186
+ const status = yield* git(["status", "--porcelain", "--ignored"], dir).pipe(
187
+ Effect.mapError((cause) => new VendorError({ message: `${dir} reports no status: ${cause.message}` })),
188
+ );
189
+ if (status.trim() !== "") {
190
+ const [first = ""] = status.trim().split("\n");
191
+ return yield* new VendorError({ message: `${dir} holds writes outside the recorded commit, starting with ${first}; ${clearing(dir)}` });
192
+ }
193
+ });
194
+
195
+ const ensureLink = Effect.fn("ensureLink")(function* (root: string, library: Library, dir: string) {
196
+ const fs = yield* FileSystem.FileSystem;
197
+ const path = yield* Path.Path;
198
+ const parent = path.join(root, LINKS);
199
+ yield* fs.makeDirectory(parent, { recursive: true, mode: DIRECTORY_MODE }).pipe(
200
+ Effect.mapError((cause) => new VendorError({ message: `cannot hold ${parent}: ${cause.message}` })),
201
+ );
202
+ const at = path.join(parent, library.name);
203
+ const current = yield* fs.readLink(at).pipe(Effect.option);
204
+ if (Option.isSome(current)) {
205
+ if (current.value === dir) return;
206
+ yield* fs.remove(at).pipe(
207
+ Effect.mapError((cause) => new VendorError({ message: `cannot move ${at} aside: ${cause.message}` })),
208
+ );
209
+ } else if (yield* fs.exists(at)) {
210
+ return yield* new VendorError({ message: `${at} is not a link, so ${NAME} leaves it alone; move it away and rerun` });
211
+ }
212
+ yield* fs.symlink(dir, at).pipe(
213
+ Effect.mapError((cause) => new VendorError({ message: `cannot link ${at} to ${dir}: ${cause.message}` })),
214
+ );
215
+ });
216
+
217
+ const unlink = Effect.fn("unlink")(function* (root: string, library: Library) {
218
+ const fs = yield* FileSystem.FileSystem;
219
+ const path = yield* Path.Path;
220
+ const at = path.join(root, LINKS, library.name);
221
+ if (Option.isNone(yield* fs.readLink(at).pipe(Effect.option))) return;
222
+ yield* fs.remove(at).pipe(
223
+ Effect.mapError((cause) => new VendorError({ message: `cannot drop ${at}: ${cause.message}` })),
224
+ );
225
+ yield* Console.error(`${NAME}: dropped ${LINKS}/${library.name}, so readers fall back to node_modules/${library.package}`);
226
+ });
227
+
228
+ const stage = Effect.fn("stage")(function* (staging: string, library: Library, installed: string, tag: string, dir: string) {
229
+ const fs = yield* FileSystem.FileSystem;
230
+ yield* git(["clone", "--no-local", "--branch", tag, "--depth", "1", "--", library.repository, staging]).pipe(
231
+ Effect.mapError((cause) => new Unreachable({ message: `cannot clone ${tag} from ${library.repository}: ${cause.message}` })),
232
+ );
233
+ const head = yield* headOf(staging);
234
+ const held = yield* recorded(dir);
235
+ if (Option.isSome(held) && held.value !== head) {
236
+ return yield* new VendorError({
237
+ message: `${tag} on ${library.repository} lands on ${head}, not the recorded ${held.value}; delete ${recordOf(dir)} to accept the move`,
238
+ });
239
+ }
240
+ yield* checkVersion(staging, library, installed, tag);
241
+ if (Option.isNone(held)) {
242
+ yield* fs.writeFileString(recordOf(dir), `${head}\n`, { mode: RECORD_MODE }).pipe(
243
+ Effect.mapError((cause) => new VendorError({ message: `cannot record the fetch beside ${dir}: ${cause.message}` })),
244
+ );
245
+ }
246
+ yield* freeze(staging);
247
+ return yield* fs.rename(staging, dir).pipe(
248
+ Effect.as(true),
249
+ Effect.catchTag("PlatformError", (cause) =>
250
+ Effect.flatMap(fs.exists(dir), (raced) =>
251
+ raced ? Effect.succeed(false) : Effect.fail(new VendorError({ message: `cannot move ${staging} into ${dir}: ${cause.message}` })),
252
+ ),
253
+ ),
254
+ );
255
+ });
256
+
257
+ const land = Effect.fn("land")(function* (library: Library, installed: string, tag: string, dir: string) {
258
+ const fs = yield* FileSystem.FileSystem;
259
+ const path = yield* Path.Path;
260
+ const parent = path.dirname(dir);
261
+ yield* confirmTag(library.repository, tag);
262
+ yield* fs.makeDirectory(parent, { recursive: true, mode: DIRECTORY_MODE }).pipe(
263
+ Effect.mapError((cause) => new VendorError({ message: `cannot hold ${dir}: ${cause.message}` })),
264
+ );
265
+ const staging = yield* fs.makeTempDirectory({ directory: parent, prefix: `.${path.basename(dir)}-` }).pipe(
266
+ Effect.mapError((cause) => new VendorError({ message: `cannot stage ${dir}: ${cause.message}` })),
267
+ );
268
+ return yield* stage(staging, library, installed, tag, dir).pipe(Effect.ensuring(discard(staging)));
269
+ });
270
+
271
+ const vend = Effect.fn("vend")(function* (root: string, cache: string, library: Library) {
272
+ const fs = yield* FileSystem.FileSystem;
273
+ const path = yield* Path.Path;
274
+ const installed = yield* manifestVersion(
275
+ path.join(root, "node_modules", ...library.package.split("/"), "package.json"),
276
+ `node_modules/${library.package}/package.json is missing, so the installed version of ${library.package} is unknown; install first`,
277
+ );
278
+ const tag = tagFor(library.tag, installed);
279
+ const dir = path.join(cache, LINKS, ...remoteSegments(library.repository), tag);
280
+ if (!(yield* fs.exists(dir)) && (yield* land(library, installed, tag, dir))) {
281
+ yield* Console.error(`${NAME}: cloned ${tag} from ${library.repository} and linked ${LINKS}/${library.name}`);
282
+ } else {
283
+ yield* verify(dir, library, installed, tag);
284
+ yield* Console.error(`${NAME}: ${LINKS}/${library.name} still holds ${tag}, verified against its recorded commit`);
285
+ }
286
+ yield* ensureLink(root, library, dir);
287
+ });
288
+
289
+ const cacheRoot = Effect.fn("cacheRoot")(function* () {
290
+ const path = yield* Path.Path;
291
+ const home = process.env.HOME;
292
+ if (home === undefined || home === "") {
293
+ return yield* new VendorError({ message: "HOME is missing, so the shared cache has no root" });
294
+ }
295
+ return path.join(home, CACHE_HOME);
296
+ });
297
+
298
+ const main = Effect.gen(function* () {
299
+ const [extra] = process.argv.slice(2);
300
+ if (extra !== undefined) return yield* new Usage({ message: USAGE });
301
+ const toplevel = yield* git(["rev-parse", "--show-toplevel"]).pipe(
302
+ Effect.map((output) => Option.some(output.trim())),
303
+ Effect.catchTag("GitFailure", (failure) =>
304
+ Console.error(`${NAME}: ${failure.message}, so no checkout holds ${LINKS}/ and readers stay on node_modules`).pipe(
305
+ Effect.as(Option.none<string>()),
306
+ ),
307
+ ),
308
+ );
309
+ if (Option.isNone(toplevel)) return true;
310
+ const root = toplevel.value;
311
+ const { quality } = yield* readQuality(root);
312
+ const libraries = quality.sources?.libraries ?? [];
313
+ if (libraries.length === 0) {
314
+ yield* Console.error(`${NAME}: ${QUALITY_FILE} declares no libraries, so nothing is pinned`);
315
+ return true;
316
+ }
317
+ const cache = yield* cacheRoot();
318
+ let passed = true;
319
+ for (const library of libraries) {
320
+ const vended = yield* vend(root, cache, library).pipe(
321
+ Effect.as(true),
322
+ Effect.catchTags({
323
+ VendorError: (failure) =>
324
+ Console.error(`${NAME}: ${failure.message}`).pipe(Effect.andThen(unlink(root, library)), Effect.as(false)),
325
+ Unreachable: (failure) =>
326
+ Console.error(`${NAME}: ${failure.message}, so ${LINKS}/${library.name} stays unlinked until a run can fetch it`).pipe(
327
+ Effect.andThen(unlink(root, library)),
328
+ Effect.as(true),
329
+ ),
330
+ }),
331
+ );
332
+ passed = passed && vended;
333
+ }
334
+ return passed;
335
+ });
336
+
337
+ if (import.meta.main) runMain(NAME, main);