@avi2dg/checks 0.50.1 → 0.52.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 (45) hide show
  1. package/.bun-version +1 -0
  2. package/CHANGELOG.md +27 -0
  3. package/README.md +24 -249
  4. package/commitlint.config.ts +0 -1
  5. package/dist/mutation/unit-test-order.js +1 -1
  6. package/dist/presets/knip.d.ts +1 -1
  7. package/dist/presets/knip.js +1 -1
  8. package/dist/templates/how-to.md +1 -1
  9. package/dist/templates/readme.md +1 -1
  10. package/dist/templates/tutorial.md +1 -1
  11. package/docs/bins.md +67 -0
  12. package/docs/gates/checks-advisories.md +2 -2
  13. package/docs/gates/checks-browser.md +5 -1
  14. package/docs/gates/checks-ci-wiring.md +18 -9
  15. package/docs/gates/checks-commit-identity.md +6 -3
  16. package/docs/gates/checks-copy.md +4 -0
  17. package/docs/gates/checks-docs.md +24 -3
  18. package/docs/gates/checks-exports.md +1 -1
  19. package/docs/gates/checks-frontend-syntax.md +5 -1
  20. package/docs/gates/checks-lint.md +2 -2
  21. package/docs/gates/checks-mutation-baseline.md +36 -7
  22. package/docs/gates/checks-mutation-compare.md +3 -3
  23. package/docs/gates/checks-mutation-scope.md +1 -1
  24. package/docs/gates/checks-mutation.md +26 -9
  25. package/docs/gates/checks-night-run.md +1 -1
  26. package/docs/gates/checks-secrets.md +2 -2
  27. package/docs/gates/checks-unused.md +1 -1
  28. package/docs/how-to/set-up-a-repository.md +142 -0
  29. package/docs/how-to/turn-on-the-css-families.md +35 -0
  30. package/docs/how-to/upgrade-the-kit.md +42 -0
  31. package/docs/shipped-files.md +36 -0
  32. package/package.json +13 -1
  33. package/src/delivery/bun-pin.ts +19 -5
  34. package/src/delivery/ci-wiring.ts +11 -77
  35. package/src/delivery/commit-identity.ts +14 -7
  36. package/src/delivery/runner-policy.ts +385 -0
  37. package/src/delivery/workflow-references.ts +1 -1
  38. package/src/docs/doc-templates.ts +1 -1
  39. package/src/docs/doc-versions.ts +137 -0
  40. package/src/docs/docs.ts +7 -0
  41. package/src/testing/mutation-baseline-full.ts +68 -0
  42. package/src/testing/mutation-baseline-saved.ts +52 -0
  43. package/src/testing/mutation-baseline.ts +27 -23
  44. package/src/testing/mutation-scope-graph.ts +89 -0
  45. package/src/testing/mutation-scope.ts +48 -93
@@ -0,0 +1,385 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { runsMutation, within, type Run } from "./mutation-job.ts";
3
+ import { isRecord, names, workflowJobs, workflowTitle, type Workflow } from "./workflow-references.ts";
4
+
5
+ export type RunnerConfig = {
6
+ readonly mutation: readonly string[] | undefined;
7
+ readonly selfHosted: readonly (readonly string[])[];
8
+ };
9
+
10
+ export type RunnerPolicy = {
11
+ readonly visibility: "private" | "public" | "unknown";
12
+ readonly runners: RunnerConfig;
13
+ readonly scripts: ReadonlyMap<string, string>;
14
+ readonly files?: ReadonlyMap<string, string>;
15
+ };
16
+
17
+ export type RunnerFault = {
18
+ readonly location: string;
19
+ readonly fault: string;
20
+ };
21
+
22
+ export class RunnerPolicyError extends Schema.TaggedError<RunnerPolicyError>()("RunnerPolicyError", {
23
+ message: Schema.String,
24
+ }) {}
25
+
26
+ const Runners = Schema.Struct({
27
+ mutationRunner: Schema.optional(Schema.NonEmptyArray(Schema.NonEmptyString)),
28
+ selfHostedRunners: Schema.optional(Schema.Array(Schema.NonEmptyArray(Schema.NonEmptyString))),
29
+ });
30
+ const Manifest = Schema.Struct({
31
+ checks: Schema.optional(Runners),
32
+ });
33
+ // GitHub expressions match context and property names without regard to case.
34
+ const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
35
+ const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
36
+ const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
37
+ const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
38
+
39
+ function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
40
+ const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
41
+ return named?.length === labels.length && labels.every((label) => named.includes(label));
42
+ }
43
+
44
+ function readsOverride(runsOn: unknown): boolean {
45
+ if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
46
+ const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
47
+ return values.some(readsOverride);
48
+ }
49
+
50
+ function forkReachable(workflow: Workflow): boolean {
51
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
52
+ const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
53
+ return events.some((event) => {
54
+ const name = event.toLowerCase();
55
+ return name === "pull_request" || name === "pull_request_target" || name === "pull_request_review" || name === "pull_request_review_comment" || name === "issue_comment";
56
+ });
57
+ }
58
+
59
+ // An exact match would let a differently cased label slip past the refusal.
60
+ function namesSelfHosted(runsOn: unknown): boolean {
61
+ if (typeof runsOn === "string") return runsOn.trim().toLowerCase() === "self-hosted";
62
+ if (Array.isArray(runsOn)) return runsOn.some((label) => typeof label === "string" && label.toLowerCase() === "self-hosted");
63
+ if (isRecord(runsOn)) {
64
+ const labels = runsOn["labels"];
65
+ return Array.isArray(labels) && labels.some((label) => typeof label === "string" && label.toLowerCase() === "self-hosted");
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function mutationFault(runsOn: unknown, labels: readonly string[] | undefined, visibility: RunnerPolicy["visibility"], reachable: boolean): string | undefined {
71
+ const pin = labels === undefined ? undefined : `[${labels.join(", ")}]`;
72
+ if (readsOverride(runsOn)) {
73
+ if (visibility === "public" && reachable) {
74
+ return "a mutation job reads CI_RUNS_ON, so an override moves its mutation runs; set runs-on: ubuntu-latest";
75
+ }
76
+ return pin === undefined
77
+ ? "a mutation job reads CI_RUNS_ON, so an override moves its mutation runs; declare the mutation runner labels in package.json checks.mutationRunner"
78
+ : `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off ${pin}; set runs-on: ${pin}`;
79
+ }
80
+ if (visibility !== "private") return undefined;
81
+ if (labels === undefined) {
82
+ return "a mutation job in a private repository names no checks.mutationRunner in package.json; declare the mutation runner labels there";
83
+ }
84
+ return sameLabels(runsOn, labels) ? undefined : `a mutation job in a private repository runs off ${pin}; set runs-on: ${pin}`;
85
+ }
86
+
87
+ function otherFault(runsOn: unknown, runners: RunnerConfig, visibility: RunnerPolicy["visibility"]): string | undefined {
88
+ const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
89
+ if (hosted || runners.selfHosted.some((allowed) => sameLabels(runsOn, allowed))) return undefined;
90
+ if (visibility !== "private" && !readsOverride(runsOn)) return undefined;
91
+ return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
92
+ }
93
+
94
+ function declaredLabels(labels: readonly string[], runners: RunnerConfig): boolean {
95
+ const declared = runners.mutation === undefined ? runners.selfHosted : [runners.mutation, ...runners.selfHosted];
96
+ const wanted = labels.map((label) => label.toLowerCase());
97
+ return wanted.length > 0 && declared.some((allowed) => wanted.every((label) => allowed.some((candidate) => candidate.toLowerCase() === label)));
98
+ }
99
+
100
+ function matrixKey(expression: string): string | undefined {
101
+ const match = /^\$\{\{\s*matrix\s*\.\s*([\w-]+)\s*\}\}$/i.exec(expression.trim());
102
+ return match?.[1]?.toLowerCase();
103
+ }
104
+
105
+ type Scalar = string | number | boolean;
106
+
107
+ type Combo = Map<string, unknown>;
108
+
109
+ type Terms = readonly (readonly [string, unknown])[];
110
+
111
+ function scalarList(value: unknown): readonly Scalar[] | undefined {
112
+ if (!Array.isArray(value)) return undefined;
113
+ const kept = value.filter((entry): entry is Scalar => typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean");
114
+ return kept.length === value.length ? kept : undefined;
115
+ }
116
+
117
+ function matrixAxes(matrix: Readonly<Record<string, unknown>>): readonly (readonly [string, readonly Scalar[]])[] | undefined {
118
+ const axes: (readonly [string, readonly Scalar[]])[] = [];
119
+ for (const [name, axis] of Object.entries(matrix)) {
120
+ const lowered = name.toLowerCase();
121
+ if (lowered === "include" || lowered === "exclude") continue;
122
+ const values = scalarList(axis);
123
+ if (values === undefined) return undefined;
124
+ axes.push([lowered, values]);
125
+ }
126
+ return axes;
127
+ }
128
+
129
+ function expandCombos(axes: readonly (readonly [string, readonly Scalar[]])[]): Combo[] | undefined {
130
+ let combos: Combo[] = [new Map()];
131
+ for (const [name, axis] of axes) {
132
+ const next: Combo[] = [];
133
+ for (const combo of combos) {
134
+ for (const value of axis) next.push(new Map(combo).set(name, value));
135
+ }
136
+ combos = next;
137
+ if (combos.length > 512) return undefined;
138
+ }
139
+ return combos;
140
+ }
141
+
142
+ function exclusionBlocks(excluded: unknown): readonly Terms[] | undefined {
143
+ if (excluded === undefined) return [];
144
+ if (!Array.isArray(excluded)) return undefined;
145
+ const blocks: Terms[] = [];
146
+ for (const candidate of excluded) {
147
+ if (!isRecord(candidate)) return undefined;
148
+ const terms: (readonly [string, unknown])[] = [];
149
+ let usable = true;
150
+ for (const [name, value] of Object.entries(candidate)) {
151
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
152
+ usable = false;
153
+ break;
154
+ }
155
+ terms.push([name.toLowerCase(), value]);
156
+ }
157
+ if (usable) blocks.push(terms);
158
+ }
159
+ return blocks;
160
+ }
161
+
162
+ function withoutExcluded(combos: readonly Combo[], blocks: readonly Terms[]): Combo[] {
163
+ return combos.filter((combo) => !blocks.some((terms) => terms.every(([name, value]) => combo.get(name) === value)));
164
+ }
165
+
166
+ function includeEntries(candidate: Readonly<Record<string, unknown>>): Terms | undefined {
167
+ const entries: (readonly [string, unknown])[] = [];
168
+ for (const [name, value] of Object.entries(candidate)) {
169
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined;
170
+ entries.push([name.toLowerCase(), value]);
171
+ }
172
+ return entries;
173
+ }
174
+
175
+ function mergeEntries(originals: readonly Combo[], axisNames: ReadonlySet<string>, entries: Terms, appended: Combo[]): void {
176
+ const matches = originals.filter((combo) => entries.every(([name, value]) => !axisNames.has(name) || combo.get(name) === value));
177
+ if (matches.length === 0) appended.push(new Map(entries));
178
+ else for (const combo of matches) for (const [name, value] of entries) combo.set(name, value);
179
+ }
180
+
181
+ function applyInclude(originals: readonly Combo[], axisNames: ReadonlySet<string>, extra: unknown): readonly Combo[] | undefined {
182
+ const appended: Combo[] = [];
183
+ if (!Array.isArray(extra)) return appended;
184
+ for (const candidate of extra) {
185
+ if (!isRecord(candidate)) return undefined;
186
+ const entries = includeEntries(candidate);
187
+ if (entries === undefined) return undefined;
188
+ mergeEntries(originals, axisNames, entries, appended);
189
+ }
190
+ return appended;
191
+ }
192
+
193
+ function runnerValues(combos: readonly Combo[], appended: readonly Combo[], key: string): readonly string[] | undefined {
194
+ const used = new Set<string>();
195
+ for (const combo of [...combos, ...appended]) {
196
+ const value = combo.get(key);
197
+ if (value === undefined) continue;
198
+ if (typeof value !== "string") return undefined;
199
+ used.add(value);
200
+ }
201
+ return used.size > 0 ? [...used] : undefined;
202
+ }
203
+
204
+ function matrixValues(job: Readonly<Record<string, unknown>>, key: string): readonly string[] | undefined {
205
+ const strategy = job["strategy"];
206
+ if (!isRecord(strategy)) return undefined;
207
+ const matrix = strategy["matrix"];
208
+ if (!isRecord(matrix)) return undefined;
209
+ if (key === "include" || key === "exclude") return undefined;
210
+ const axes = matrixAxes(matrix);
211
+ if (axes === undefined) return undefined;
212
+ const combos = expandCombos(axes);
213
+ if (combos === undefined) return undefined;
214
+ const blocks = exclusionBlocks(matrix["exclude"]);
215
+ if (blocks === undefined) return undefined;
216
+ const kept = withoutExcluded(combos, blocks);
217
+ const appended = applyInclude(axes.length === 0 ? [] : kept, new Set(axes.map(([name]) => name)), matrix["include"]);
218
+ if (appended === undefined) return undefined;
219
+ return runnerValues(kept, appended, key);
220
+ }
221
+
222
+ function hasInterpolation(value: string): boolean {
223
+ return value.includes("${{");
224
+ }
225
+
226
+ function mentionedSelfHosted(value: unknown): boolean {
227
+ if (typeof value === "string") return /self-hosted/i.test(value);
228
+ if (Array.isArray(value)) return value.some(mentionedSelfHosted);
229
+ if (isRecord(value)) return Object.values(value).some(mentionedSelfHosted);
230
+ return false;
231
+ }
232
+
233
+ function plainLabels(value: unknown): readonly string[] | undefined {
234
+ if (typeof value === "string") return hasInterpolation(value) ? undefined : [value];
235
+ if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string")) return undefined;
236
+ return value.some(hasInterpolation) ? undefined : value;
237
+ }
238
+
239
+ function matrixRefusal(
240
+ job: Readonly<Record<string, unknown>>,
241
+ shown: string,
242
+ key: string,
243
+ runners: RunnerConfig,
244
+ selfHosted: string,
245
+ unresolvable: (shown: string) => string,
246
+ ): string | undefined {
247
+ const values = matrixValues(job, key);
248
+ if (values === undefined) return unresolvable(shown);
249
+ for (const value of values) {
250
+ if (hasInterpolation(value)) return mentionedSelfHosted(value) ? selfHosted : unresolvable(shown);
251
+ if (value.toLowerCase() === "self-hosted" || declaredLabels([value], runners)) return selfHosted;
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ function stringFault(
257
+ job: Readonly<Record<string, unknown>>,
258
+ runsOn: string,
259
+ runners: RunnerConfig,
260
+ selfHosted: string,
261
+ unresolvable: (shown: string) => string,
262
+ ): string | undefined {
263
+ const trimmed = runsOn.trim();
264
+ const key = matrixKey(trimmed);
265
+ if (key !== undefined) return matrixRefusal(job, trimmed, key, runners, selfHosted, unresolvable);
266
+ if (hasInterpolation(trimmed)) return mentionedSelfHosted(trimmed) ? selfHosted : unresolvable(trimmed);
267
+ return declaredLabels([trimmed], runners) ? selfHosted : undefined;
268
+ }
269
+
270
+ function arrayFault(runsOn: unknown, runners: RunnerConfig, selfHosted: string, unresolvable: (shown: string) => string): string | undefined {
271
+ const labels = plainLabels(runsOn);
272
+ if (labels === undefined) return mentionedSelfHosted(runsOn) ? selfHosted : unresolvable("its runs-on expression");
273
+ return declaredLabels(labels, runners) ? selfHosted : undefined;
274
+ }
275
+
276
+ function scalarLabelsFault(runsOn: unknown, runners: RunnerConfig, selfHosted: string, unresolvable: (shown: string) => string): string | undefined {
277
+ const labels = plainLabels(runsOn);
278
+ if (labels === undefined) return unresolvable("its runs-on expression");
279
+ return labels.some((label) => label.toLowerCase() === "self-hosted") || declaredLabels(labels, runners) ? selfHosted : undefined;
280
+ }
281
+
282
+ function recordFault(
283
+ runsOn: Readonly<Record<string, unknown>>,
284
+ runners: RunnerConfig,
285
+ selfHosted: string,
286
+ unresolvable: (shown: string) => string,
287
+ ): string | undefined {
288
+ if (runsOn["group"] === undefined) return scalarLabelsFault(runsOn["labels"], runners, selfHosted, unresolvable);
289
+ const labels = plainLabels(runsOn["labels"]);
290
+ if (labels !== undefined && (labels.some((label) => label.toLowerCase() === "self-hosted") || declaredLabels(labels, runners))) return selfHosted;
291
+ const group = typeof runsOn["group"] === "string" ? runsOn["group"].trim() : "a runner group";
292
+ return "a public repository runs a fork-reachable job on runner group " + group + ", which cannot be shown to be hosted before the run; set runs-on: ubuntu-latest";
293
+ }
294
+
295
+ function publicFault(job: Readonly<Record<string, unknown>>, runsOn: unknown, runners: RunnerConfig): string | undefined {
296
+ const selfHosted = "a public repository runs a fork-reachable job on self-hosted labels, so a fork pull request would run its code there; set runs-on: ubuntu-latest";
297
+ const unresolvable = (shown: string) =>
298
+ "a public repository runs a fork-reachable job on " + shown + ", which cannot be shown to be a hosted runner before the run; set runs-on: ubuntu-latest";
299
+ if (namesSelfHosted(runsOn)) return selfHosted;
300
+ if (typeof runsOn === "string") return stringFault(job, runsOn, runners, selfHosted, unresolvable);
301
+ if (Array.isArray(runsOn)) return arrayFault(runsOn, runners, selfHosted, unresolvable);
302
+ if (isRecord(runsOn)) return recordFault(runsOn, runners, selfHosted, unresolvable);
303
+ return unresolvable("its runs-on expression");
304
+ }
305
+
306
+ function calledWorkflow(job: Readonly<Record<string, unknown>>): string | undefined {
307
+ const uses = job["uses"];
308
+ return typeof uses === "string" && uses.startsWith("./.github/workflows/") ? uses.slice(2) : undefined;
309
+ }
310
+
311
+ function watchedWorkflows(workflow: Workflow): readonly string[] {
312
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
313
+ const watched = isRecord(on) ? on["workflow_run"] : undefined;
314
+ return isRecord(watched) ? (names(watched["workflows"]) ?? []) : [];
315
+ }
316
+
317
+ function runnerFault(job: Readonly<Record<string, unknown>>, runsOn: unknown, mutation: boolean, policy: RunnerPolicy, reachable: boolean): string | undefined {
318
+ if (policy.visibility === "public" && reachable) {
319
+ const fault = publicFault(job, runsOn, policy.runners);
320
+ if (fault !== undefined) return fault;
321
+ }
322
+ return mutation ? mutationFault(runsOn, policy.runners.mutation, policy.visibility, reachable) : otherFault(runsOn, policy.runners, policy.visibility);
323
+ }
324
+
325
+ function stepDirectory(value: unknown, inherited: string | undefined): string | undefined {
326
+ if (value === undefined) return inherited;
327
+ return typeof value === "string" ? within("", value) : undefined;
328
+ }
329
+
330
+ function defaultDirectory(node: unknown, inherited: string | undefined): string | undefined {
331
+ const defaults = isRecord(node) ? node["defaults"] : undefined;
332
+ const run = isRecord(defaults) ? defaults["run"] : undefined;
333
+ return stepDirectory(isRecord(run) ? run["working-directory"] : undefined, inherited);
334
+ }
335
+
336
+ export function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): readonly Run[] {
337
+ const dir = defaultDirectory(job, defaultDirectory(workflow.document, ""));
338
+ const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
339
+ return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
340
+ }
341
+
342
+ export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
343
+ const reachable = new Map(workflows.map((workflow) => [workflow.path, forkReachable(workflow)] as const));
344
+ const titleOf = new Map(workflows.map((workflow) => [workflow.path, workflowTitle(workflow.document) ?? workflow.path] as const));
345
+ let changed = true;
346
+ while (changed) {
347
+ changed = false;
348
+ for (const workflow of workflows) {
349
+ if (reachable.get(workflow.path) === true) continue;
350
+ const called = workflows.some(
351
+ (other) => reachable.get(other.path) === true && workflowJobs(other).some(([, job]) => calledWorkflow(job) === workflow.path),
352
+ );
353
+ const watched = watchedWorkflows(workflow).some((parent) =>
354
+ workflows.some((other) => reachable.get(other.path) === true && titleOf.get(other.path) === parent),
355
+ );
356
+ if (called || watched) {
357
+ reachable.set(workflow.path, true);
358
+ changed = true;
359
+ }
360
+ }
361
+ }
362
+ return workflows.flatMap((workflow) =>
363
+ workflowJobs(workflow).flatMap(([id, job]) => {
364
+ if (job["runs-on"] === undefined) return [];
365
+ const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
366
+ const fault = runnerFault(job, job["runs-on"], mutation, policy, reachable.get(workflow.path) ?? forkReachable(workflow));
367
+ return fault === undefined ? [] : [{ location: workflow.path + " job " + id, fault }];
368
+ }),
369
+ );
370
+ }
371
+
372
+ export function formatRunnerReport(faults: readonly RunnerFault[]): string {
373
+ return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
374
+ }
375
+
376
+ export const readRunnerConfig = Effect.fn("readRunnerConfig")(function* (root: string) {
377
+ const fs = yield* FileSystem.FileSystem;
378
+ const file = (yield* Path.Path).join(root, "package.json");
379
+ if (!(yield* fs.exists(file))) return { mutation: undefined, selfHosted: [] } satisfies RunnerConfig;
380
+ const { checks } = yield* fs.readFileString(file).pipe(
381
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))),
382
+ Effect.mapError((cause) => new RunnerPolicyError({ message: `cannot read ${file}: ${cause.message}` })),
383
+ );
384
+ return { mutation: checks?.mutationRunner, selfHosted: checks?.selfHostedRunners ?? [] } satisfies RunnerConfig;
385
+ });
@@ -232,7 +232,7 @@ export function locate(path: string, lines: readonly string[]): Locator {
232
232
  };
233
233
  }
234
234
 
235
- function workflowTitle(document: unknown): string | undefined {
235
+ export function workflowTitle(document: unknown): string | undefined {
236
236
  return isRecord(document) && typeof document["name"] === "string" ? document["name"] : undefined;
237
237
  }
238
238
 
@@ -53,7 +53,7 @@ const RELATED_TOPICS = fixed("Related topics", optional("there is no other page
53
53
  "- [<page title>](<path to the page>)",
54
54
  ]);
55
55
 
56
- const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequisite, with the version it is tested on>"]);
56
+ const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequisite, naming the file that pins its version rather than the version>"]);
57
57
 
58
58
  const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
59
59
 
@@ -0,0 +1,137 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import { git } from "../core/git.ts";
3
+ import { PACKAGE_MANIFEST } from "./doc-references.ts";
4
+ import { scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
5
+
6
+ type Pin = { readonly name: string | undefined; readonly version: string; readonly holder: string };
7
+
8
+ const BUN_VERSION = ".bun-version";
9
+
10
+ const Group = Schema.optionalKey(Schema.Record(Schema.String, Schema.String));
11
+ const Manifest = Schema.Struct({
12
+ version: Schema.optionalKey(Schema.String),
13
+ packageManager: Schema.optionalKey(Schema.String),
14
+ engines: Group,
15
+ dependencies: Group,
16
+ devDependencies: Group,
17
+ peerDependencies: Group,
18
+ optionalDependencies: Group,
19
+ });
20
+ const decodeManifest = Schema.decodeUnknownOption(Schema.fromJsonString(Manifest));
21
+ const NO_MANIFEST: typeof Manifest.Type = {};
22
+
23
+ const VERSION = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
24
+ const ONE_VERSION = new RegExp(String.raw`^[\^~=v]?(${VERSION.source})$`);
25
+ const VERSIONS = new RegExp(VERSION.source, "g");
26
+
27
+ function namedPins(group: Readonly<Record<string, string>> | undefined): readonly Pin[] {
28
+ return Object.entries(group ?? {}).flatMap(([name, range]) => {
29
+ const version = ONE_VERSION.exec(range.trim())?.[1];
30
+ return version === undefined ? [] : [{ name, version, holder: PACKAGE_MANIFEST }];
31
+ });
32
+ }
33
+
34
+ function barePins(value: string | undefined, holder: string): readonly Pin[] {
35
+ return [...(value ?? "").matchAll(VERSIONS)].map(([version]) => ({ name: undefined, version, holder }));
36
+ }
37
+
38
+ export function pinsOf(manifest: string, bunVersion: string): readonly Pin[] {
39
+ const { version, packageManager, engines = {}, dependencies, devDependencies, peerDependencies, optionalDependencies } = Option.getOrElse(
40
+ decodeManifest(manifest),
41
+ () => NO_MANIFEST,
42
+ );
43
+ const pins = [
44
+ ...[dependencies, devDependencies, peerDependencies, optionalDependencies].flatMap(namedPins),
45
+ ...[version, packageManager, ...Object.values(engines)].flatMap((value) => barePins(value, PACKAGE_MANIFEST)),
46
+ ...barePins(bunVersion, BUN_VERSION),
47
+ ];
48
+ return pins.filter((pin, index) => pins.findIndex((other) => other.name === pin.name && other.version === pin.version) === index);
49
+ }
50
+
51
+ function escaped(text: string): string {
52
+ return text.replaceAll(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`);
53
+ }
54
+
55
+ // A longer version that only opens with the pin, as 1.2.30 or 1.2.3-beta, is another version.
56
+ const VERSION_END = String.raw`(?![\w-]|\.\d)`;
57
+
58
+ function patternOf({ name, version }: Pin): RegExp {
59
+ const written =
60
+ name === undefined ? String.raw`(?<![\w.+-])v?${escaped(version)}` : String.raw`(?<![\w@/.-])${escaped(name)}@[\^~]?${escaped(version)}`;
61
+ return new RegExp(written + VERSION_END, "g");
62
+ }
63
+
64
+ const GENERATED_OPEN = /^\s*<!-- generated ([\w-]+):/;
65
+ const GENERATED_CLOSE = /^\s*<!-- end generated ([\w-]+) -->/;
66
+
67
+ function handWritten(lines: readonly MarkdownLine[]): readonly MarkdownLine[] {
68
+ const exempt = new Set<number>();
69
+ let at = 0;
70
+ while (at < lines.length) {
71
+ const opening = lines[at]?.kind === "code" ? null : GENERATED_OPEN.exec(lines[at]?.raw ?? "");
72
+ if (opening === null) {
73
+ at += 1;
74
+ continue;
75
+ }
76
+ const closeAt = lines.findIndex(
77
+ (line, index) => index > at && line.kind !== "code" && GENERATED_CLOSE.exec(line.raw)?.[1] === opening[1],
78
+ );
79
+ if (closeAt < 0) {
80
+ at += 1;
81
+ continue;
82
+ }
83
+ for (let index = at; index <= closeAt; index += 1) exempt.add(index);
84
+ at = closeAt + 1;
85
+ }
86
+ return lines.filter((_, index) => !exempt.has(index));
87
+ }
88
+
89
+ type Copy = { readonly at: number; readonly end: number; readonly pin: Pin; readonly written: string };
90
+
91
+ function namePresent(raw: string, name: string): boolean {
92
+ return new RegExp(String.raw`(?<![\w@/.-])${escaped(name)}(?![\w/.-])`, "i").test(raw);
93
+ }
94
+
95
+ function barePatternFor(version: string): RegExp {
96
+ return new RegExp(String.raw`(?<![\w.+-])v?${escaped(version)}` + VERSION_END, "g");
97
+ }
98
+
99
+ // A bare pin inside a named copy, as 1.2.3 in zod@1.2.3, is the same copy.
100
+ function copiesIn(raw: string, pins: readonly Pin[]): readonly Copy[] {
101
+ const found: Copy[] = [];
102
+ for (const pin of pins) {
103
+ for (const match of raw.matchAll(patternOf(pin))) {
104
+ const at = match.index;
105
+ found.push({ at, end: at + match[0].length, pin, written: match[0] });
106
+ }
107
+ if (pin.name !== undefined && namePresent(raw, pin.name)) {
108
+ for (const match of raw.matchAll(barePatternFor(pin.version))) {
109
+ const at = match.index;
110
+ found.push({ at, end: at + match[0].length, pin, written: match[0] });
111
+ }
112
+ }
113
+ }
114
+ const installed = found.filter((copy) => copy.written.includes("@"));
115
+ return found
116
+ .filter((copy) => copy.written.includes("@") || !installed.some(({ at, end }) => copy.at >= at && copy.end <= end))
117
+ .toSorted((a, b) => a.at - b.at);
118
+ }
119
+
120
+ export function versionFindings(text: string, pins: readonly Pin[], within?: ReadonlySet<number>): readonly { readonly line: number; readonly message: string }[] {
121
+ if (pins.length === 0) return [];
122
+ return handWritten(scanMarkdown(text))
123
+ .filter(({ line }) => within === undefined || within.has(line))
124
+ .flatMap(({ line, raw }) =>
125
+ copiesIn(raw, pins).map(({ written, pin }) => ({
126
+ line,
127
+ message: `carries \`${written}\`, a version ${pin.holder} pins. Name ${pin.holder} instead, or let a build write the line between generated markers`,
128
+ })),
129
+ );
130
+ }
131
+
132
+ const readAt = (root: string, head: string, path: string) =>
133
+ git(["show", `${head}:${path}`], root).pipe(Effect.catchTag("GitFailure", () => Effect.succeed("")));
134
+
135
+ export const pinsAt = Effect.fn("pinsAt")(function* (root: string, head: string) {
136
+ return pinsOf(yield* readAt(root, head, PACKAGE_MANIFEST), yield* readAt(root, head, BUN_VERSION));
137
+ });
package/src/docs/docs.ts CHANGED
@@ -4,6 +4,7 @@ import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "
4
4
  import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
5
5
  import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
6
6
  import { vanishedNames } from "./doc-names.ts";
7
+ import { pinsAt, versionFindings } from "./doc-versions.ts";
7
8
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
8
9
  import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
9
10
  import { runMain } from "../core/main.ts";
@@ -107,6 +108,10 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
107
108
  const prose = edited.flatMap(({ path, reader }) =>
108
109
  proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
109
110
  );
111
+ const pins = yield* pinsAt(root, head);
112
+ const versions = edited.flatMap(({ path }) =>
113
+ versionFindings(text(path), pins, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
114
+ );
110
115
  const sentenceAdvisory = edited
111
116
  .flatMap(({ path }) => longSentences(text(path), changed.get(path)).map(({ line, message }) => ({ path, line, message })))
112
117
  .toSorted(inPathOrder);
@@ -132,6 +137,7 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
132
137
  findings: [
133
138
  ...templated.filter((finding) => touched.has(finding.path)),
134
139
  ...prose,
140
+ ...versions,
135
141
  ...references.failing,
136
142
  ...vanished,
137
143
  ...shapes,
@@ -153,6 +159,7 @@ export function report({ held, edited, named, agents, findings, advisory, senten
153
159
  ? [
154
160
  `${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
155
161
  `${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
162
+ `${NAME}: no line the range adds or edits copies a version package.json or .bun-version pins`,
156
163
  `${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
157
164
  `${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
158
165
  ]
@@ -0,0 +1,68 @@
1
+ import { Clock, Config, Console, Effect, FileSystem, Option, Schema } from "effect";
2
+ import { ChildProcessSpawner } from "effect/process";
3
+ import { collect } from "../core/git.ts";
4
+ import { withoutStale } from "./mutation-baseline-saved.ts";
5
+ import { STRYKER_CONFIGS, stalenessSince } from "./mutation-scope.ts";
6
+
7
+ export class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFailure", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ export const gh = Effect.fn("gh")(function* (args: readonly string[]) {
12
+ const failed = (reason: string): BaselineFailure => new BaselineFailure({ message: `gh ${args.join(" ")}: ${reason.trim()}` });
13
+ const { stdout, stderr, exitCode } = yield* collect("gh", args).pipe(Effect.mapError((cause) => failed(cause.message)));
14
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
15
+ return stdout;
16
+ });
17
+
18
+ const FULL_CONFIGS = [...STRYKER_CONFIGS, "stryker.preset.js", "bun.lock", ".bun-version"];
19
+
20
+ const REFRESH_DAYS = 28;
21
+ const DAY_MS = 86_400_000;
22
+
23
+ const refreshWindow = (time: number): number => Math.floor(time / (REFRESH_DAYS * DAY_MS));
24
+
25
+ // Each full run reuses what the run before it restored, so a cap on the restored run's age alone caps nothing.
26
+ // Windows fixed from the epoch cap the whole chain: every state in a window was built from runs in that window.
27
+ export function inRefreshWindow(createdAt: string, now: number): boolean {
28
+ return refreshWindow(Date.parse(createdAt)) === refreshWindow(now);
29
+ }
30
+
31
+ export type FullRun = { readonly databaseId: number; readonly createdAt: string };
32
+
33
+ const decodeHeadSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ headSha: Schema.String })));
34
+
35
+ const viewedHead = Effect.fn("viewedHead")(function* (run: number) {
36
+ return (yield* gh(["run", "view", String(run), "--json", "headSha"]).pipe(Effect.flatMap(decodeHeadSha))).headSha;
37
+ });
38
+
39
+ export const pruneFullState = Effect.fn("pruneFullState")(function* (run: FullRun, state: string) {
40
+ const fs = yield* FileSystem.FileSystem;
41
+ if (!(yield* fs.exists(state))) return;
42
+ const override = yield* Config.Int("CHECKS_MUTATION_BASELINE_NOW_MS").pipe(Config.option);
43
+ let now = yield* Clock.currentTimeMillis;
44
+ if (Option.isSome(override)) now = override.value;
45
+ if (!inRefreshWindow(run.createdAt, now)) {
46
+ yield* fs.remove(state);
47
+ yield* Console.log(`mutation-baseline: dropped the restored full state, run ${run.databaseId} started before the current ${REFRESH_DAYS}-day refresh window`);
48
+ return;
49
+ }
50
+ const base = yield* viewedHead(run.databaseId);
51
+ const { paths, preload, stale, config } = yield* stalenessSince(base, "HEAD");
52
+ const changed = [...paths].filter((path) => FULL_CONFIGS.includes(path) || config.has(path)).toSorted();
53
+ if (changed.length > 0 || preload !== undefined) {
54
+ yield* fs.remove(state);
55
+ yield* Console.log(`mutation-baseline: dropped the restored full state, ${preload ?? changed.join(" ")} changed since run ${run.databaseId}`);
56
+ return;
57
+ }
58
+ const text = yield* fs.readFileString(state);
59
+ const stateJson: unknown = yield* Effect.try({
60
+ try: (): unknown => JSON.parse(text),
61
+ catch: () => new BaselineFailure({ message: `${state} is not a Stryker incremental state with its test files` }),
62
+ });
63
+ const stripped = withoutStale(stateJson, { tests: new Set(stale), sources: paths });
64
+ if (stripped.tests.length === 0 && stripped.sources.length === 0) return;
65
+ yield* fs.writeFileString(state, JSON.stringify(stripped.state));
66
+ const dropped = [...stripped.tests, ...stripped.sources.map((source) => `${source} results`)].join(" ");
67
+ yield* Console.log(`mutation-baseline: dropped ${dropped} from the restored full state`);
68
+ });