@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
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.50.1",
3
+ "version": "0.52.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
+ "author": {
6
+ "name": "avi2d",
7
+ "email": "avi2dg@gmail.com"
8
+ },
5
9
  "license": "MIT",
6
10
  "repository": {
7
11
  "type": "git",
@@ -13,10 +17,14 @@
13
17
  "type": "module",
14
18
  "files": [
15
19
  "CHANGELOG.md",
20
+ "docs/bins.md",
16
21
  "docs/configs/",
17
22
  "docs/design.md",
18
23
  "docs/gates/",
24
+ "docs/how-to/",
25
+ "docs/shipped-files.md",
19
26
  "bunfig.toml",
27
+ ".bun-version",
20
28
  "commitlint.config.ts",
21
29
  "dependency-cruiser.config.js",
22
30
  "knip-base.json",
@@ -48,7 +56,9 @@
48
56
  "src/testing/mutation-refusal.ts",
49
57
  "src/testing/mutation-compare.ts",
50
58
  "src/testing/mutation-scope.ts",
59
+ "src/testing/mutation-scope-graph.ts",
51
60
  "src/testing/mutation-baseline.ts",
61
+ "src/testing/mutation-baseline-full.ts",
52
62
  "src/testing/mutation-baseline-saved.ts",
53
63
  "src/testing/subsumed-tests.ts",
54
64
  "src/testing/night-run.ts",
@@ -58,6 +68,7 @@
58
68
  "src/delivery/workflow-references.ts",
59
69
  "src/delivery/bun-pin.ts",
60
70
  "src/delivery/mutation-job.ts",
71
+ "src/delivery/runner-policy.ts",
61
72
  "src/delivery/secrets.ts",
62
73
  "src/delivery/gitleaks.ts",
63
74
  "src/delivery/gitleaks.toml",
@@ -93,6 +104,7 @@
93
104
  "src/docs/doc-snapshot.ts",
94
105
  "src/docs/doc-templates.ts",
95
106
  "src/docs/doc-names.ts",
107
+ "src/docs/doc-versions.ts",
96
108
  "dist/templates/",
97
109
  "src/quality/presets/effect.language-service.json",
98
110
  "src/quality/presets/oxlint.ts",
@@ -2,12 +2,12 @@ import { Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { within } from "./mutation-job.ts";
3
3
  import { isRecord, locate, workflowJobs, type Workflow } from "./workflow-references.ts";
4
4
 
5
- export const FLEET_BUN_VERSION = "1.4.3";
6
5
  const ROOT_MANIFEST = "package.json";
7
6
 
8
7
  export type BunPinPolicy = {
9
8
  readonly files: ReadonlyMap<string, string>;
10
9
  readonly manifests: ReadonlyMap<string, unknown>;
10
+ readonly fleet: string;
11
11
  };
12
12
 
13
13
  export type BunPinFault = {
@@ -94,12 +94,12 @@ function bunPinRequest(policy: BunPinPolicy, step: Readonly<Record<string, unkno
94
94
  function bunPinFault(policy: BunPinPolicy, step: Readonly<Record<string, unknown>>): string | undefined {
95
95
  const request = bunPinRequest(policy, step);
96
96
  if (request.expression !== undefined) {
97
- return `requests bun ${request.expression} from ${request.source}, which cannot be shown equal to ${FLEET_BUN_VERSION} before the run`;
97
+ return `requests bun ${request.expression} from ${request.source}, which cannot be shown equal to ${policy.fleet} before the run`;
98
98
  }
99
99
  if (request.version === undefined) {
100
- return `pins no bun version from ${request.source}, so setup-bun installs latest, want ${FLEET_BUN_VERSION}`;
100
+ return `pins no bun version from ${request.source}, so setup-bun installs latest, want ${policy.fleet}`;
101
101
  }
102
- return request.version === FLEET_BUN_VERSION ? undefined : `requests bun ${request.version} from ${request.source}, want ${FLEET_BUN_VERSION}`;
102
+ return request.version === policy.fleet ? undefined : `requests bun ${request.version} from ${request.source}, want ${policy.fleet}`;
103
103
  }
104
104
 
105
105
  function unknownRefFault(ref: string): string {
@@ -156,6 +156,20 @@ const readJsonFile = Effect.fn("readJsonFile")(function* (file: string) {
156
156
  );
157
157
  });
158
158
 
159
+ export class BunPinError extends Schema.TaggedError<BunPinError>()("BunPinError", {
160
+ message: Schema.String,
161
+ }) {}
162
+
163
+ // The kit ships its own .bun-version, so the check reads the fleet pin from that file rather than a constant.
164
+ const kitBunVersion = Effect.gen(function* () {
165
+ const file = (yield* Path.Path).join(import.meta.dir, "..", "..", ".bun-version");
166
+ const version = (yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
167
+ Effect.mapError((cause) => new BunPinError({ message: `cannot read the kit .bun-version at ${file}: ${cause.message}` })),
168
+ )).trim();
169
+ if (version === "") return yield* new BunPinError({ message: `the kit .bun-version at ${file} pins no version` });
170
+ return version;
171
+ });
172
+
159
173
  export const readBunPinPolicy = Effect.fn("readBunPinPolicy")(function* (root: string, workflows: readonly Workflow[]) {
160
174
  const path = yield* Path.Path;
161
175
  const fs = yield* FileSystem.FileSystem;
@@ -170,5 +184,5 @@ export const readBunPinPolicy = Effect.fn("readBunPinPolicy")(function* (root: s
170
184
  if (text !== undefined) files.set(name, text);
171
185
  }
172
186
  }
173
- return { files, manifests };
187
+ return { files, manifests, fleet: yield* kitBunVersion };
174
188
  });
@@ -4,11 +4,13 @@ import { defaultBranch, git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
6
6
  import { findBunPinFaults, formatBunPinReport, readBunPinPolicy } from "./bun-pin.ts";
7
- import { readShellFiles, runsMutation, within, type Run } from "./mutation-job.ts";
7
+ import { readShellFiles } from "./mutation-job.ts";
8
+ import { findRunnerFaults, formatRunnerReport, jobRuns, readRunnerConfig } from "./runner-policy.ts";
8
9
  import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
9
10
  import { findMissingWorkflows, formatMissingReport, isRecord, names, workflowJobs, type Workflow } from "./workflow-references.ts";
10
11
 
11
- export { FLEET_BUN_VERSION, findBunPinFaults, formatBunPinReport } from "./bun-pin.ts";
12
+ export { BunPinError, findBunPinFaults, formatBunPinReport } from "./bun-pin.ts";
13
+ export { RunnerPolicyError } from "./runner-policy.ts";
12
14
  export type { BunPinFault, BunPinPolicy } from "./bun-pin.ts";
13
15
 
14
16
  export type { Command };
@@ -39,17 +41,6 @@ export type Gap = {
39
41
 
40
42
  export type Visibility = "private" | "public" | "unknown";
41
43
 
42
- export type RunnerPolicy = {
43
- readonly visibility: Visibility;
44
- readonly scripts: ReadonlyMap<string, string>;
45
- readonly files?: ReadonlyMap<string, string>;
46
- };
47
-
48
- export type RunnerFault = {
49
- readonly location: string;
50
- readonly fault: string;
51
- };
52
-
53
44
  type RunStep = {
54
45
  readonly location: string;
55
46
  readonly blocker: string | undefined;
@@ -71,7 +62,9 @@ const TITLE_LINT = "bunx checks-commit-title";
71
62
  // The gate holds until every consumer migrates, so the direct commitlint call still counts.
72
63
  // The kit calls its own file through bun, since bunx never resolves the working tree.
73
64
  const TITLE_CALLS = ["bun src/delivery/commit-title.ts", "./node_modules/.bin/commitlint"];
74
- const Manifest = Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) });
65
+ const ScriptsManifest = Schema.Struct({
66
+ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
67
+ });
75
68
  // Without these a pull_request workflow never sees the commits a pull request pushes.
76
69
  const GATING_TYPES = ["opened", "synchronize"];
77
70
  const CONSTANTS = new Map([
@@ -79,13 +72,6 @@ const CONSTANTS = new Map([
79
72
  ["false", false],
80
73
  ]);
81
74
  const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
82
- // GitHub expressions match context and property names without regard to case.
83
- const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
84
- const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
85
- const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
86
- const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
87
- const DEVICE_RUNNER = ["self-hosted", "macOS", "iphone"];
88
- const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
89
75
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
90
76
 
91
77
  function constant(value: unknown): boolean | undefined {
@@ -278,59 +264,6 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
278
264
  };
279
265
  }
280
266
 
281
- function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
282
- const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
283
- return named?.length === labels.length && labels.every((label) => named.includes(label));
284
- }
285
-
286
- function readsOverride(runsOn: unknown): boolean {
287
- if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
288
- const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
289
- return values.some(readsOverride);
290
- }
291
-
292
- function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
293
- const overridden = readsOverride(runsOn);
294
- const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
295
- if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off winbox; ${pin}`;
296
- if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
297
- const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
298
- if (hosted || sameLabels(runsOn, DEVICE_RUNNER) || (visibility !== "private" && !overridden)) return undefined;
299
- return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
300
- }
301
-
302
- function stepDirectory(value: unknown, inherited: string | undefined): string | undefined {
303
- if (value === undefined) return inherited;
304
- return typeof value === "string" ? within("", value) : undefined;
305
- }
306
-
307
- function defaultDirectory(node: unknown, inherited: string | undefined): string | undefined {
308
- const defaults = isRecord(node) ? node["defaults"] : undefined;
309
- const run = isRecord(defaults) ? defaults["run"] : undefined;
310
- return stepDirectory(isRecord(run) ? run["working-directory"] : undefined, inherited);
311
- }
312
-
313
- function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): readonly Run[] {
314
- const dir = defaultDirectory(job, defaultDirectory(workflow.document, ""));
315
- const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
316
- return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
317
- }
318
-
319
- export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
320
- return workflows.flatMap((workflow) =>
321
- workflowJobs(workflow).flatMap(([id, job]) => {
322
- if (job["runs-on"] === undefined) return [];
323
- const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
324
- const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
325
- return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
326
- }),
327
- );
328
- }
329
-
330
- export function formatRunnerReport(faults: readonly RunnerFault[]): string {
331
- return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
332
- }
333
-
334
267
  export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
335
268
  Effect.try({
336
269
  try: () => ({ path, document: Bun.YAML.parse(text), text }),
@@ -342,10 +275,10 @@ const readScripts = Effect.fn("readScripts")(function* (root: string) {
342
275
  const file = (yield* Path.Path).join(root, "package.json");
343
276
  const { scripts = {} } = (yield* fs.exists(file))
344
277
  ? yield* fs.readFileString(file).pipe(
345
- Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))),
278
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ScriptsManifest))),
346
279
  Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
347
280
  )
348
- : Manifest.make({});
281
+ : ScriptsManifest.make({});
349
282
  return new Map(Object.entries(scripts));
350
283
  });
351
284
 
@@ -390,7 +323,8 @@ const wiring = Effect.gen(function* () {
390
323
  if (missing.length > 0) yield* Console.error(formatMissingReport(missing));
391
324
  const scripts = yield* readScripts(root);
392
325
  const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
393
- const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs) }, workflows);
326
+ const runners = yield* readRunnerConfig(root);
327
+ const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs), runners }, workflows);
394
328
  if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
395
329
  const bunFaults = findBunPinFaults(yield* readBunPinPolicy(root, workflows), workflows);
396
330
  if (bunFaults.length > 0) yield* Console.error(formatBunPinReport(bunFaults));
@@ -16,8 +16,6 @@ type Commit = {
16
16
 
17
17
  type Offence = { readonly commit: Commit; readonly reasons: readonly string[] };
18
18
 
19
- const DEFAULT_AUTHORS: readonly Identity[] = [{ name: "avi2d", email: "avi2dg@gmail.com" }];
20
-
21
19
  // GitHub writes the squash commit, so it commits what the owner authored and never authors.
22
20
  const SQUASH_COMMITTER: Identity = { name: "GitHub", email: "noreply@github.com" };
23
21
 
@@ -74,13 +72,12 @@ const allowedAuthors = Effect.gen(function* () {
74
72
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
75
73
  const fs = yield* FileSystem.FileSystem;
76
74
  const file = (yield* Path.Path).join(root, "package.json");
77
- if (!(yield* fs.exists(file))) return DEFAULT_AUTHORS;
75
+ if (!(yield* fs.exists(file))) return [];
78
76
  const manifest = yield* fs.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Authors))));
79
- const listed = [manifest.author, ...(manifest.contributors ?? [])]
77
+ return [manifest.author, ...(manifest.contributors ?? [])]
80
78
  .filter((entry) => entry !== undefined)
81
79
  .map(identityOf)
82
80
  .filter((entry) => entry !== undefined);
83
- return listed.length === 0 ? DEFAULT_AUTHORS : listed;
84
81
  });
85
82
 
86
83
  const readCommits = Effect.fn("readCommits")(function* (revisions: readonly string[]) {
@@ -123,11 +120,17 @@ function allows(allowed: readonly Identity[], identity: Identity): boolean {
123
120
  );
124
121
  }
125
122
 
123
+ // GitHub authors a web merge under the account's display name, which the owner's git name need not match.
124
+ function allowsEmail(allowed: readonly Identity[], identity: Identity): boolean {
125
+ return allowed.some((entry) => entry.email.toLowerCase() === identity.email.toLowerCase());
126
+ }
127
+
126
128
  function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefined {
127
129
  const reasons: string[] = [];
128
130
  const release = releasedVersionOf(commit.subject) !== undefined;
129
131
  const authors = release ? [...allowed, RELEASE_AUTHOR] : allowed;
130
- if (!allows(authors, commit.author)) {
132
+ const madeByGitHub = allows([SQUASH_COMMITTER], commit.committer);
133
+ if (!(madeByGitHub ? allowsEmail(authors, commit.author) : allows(authors, commit.author))) {
131
134
  reasons.push(`author ${render(commit.author)}`);
132
135
  }
133
136
  if (!allows([...allowed, SQUASH_COMMITTER], commit.committer)) {
@@ -146,6 +149,10 @@ const check = Effect.gen(function* () {
146
149
  const revisions = second === undefined ? ["-1", range] : [range];
147
150
 
148
151
  const allowed = yield* allowedAuthors;
152
+ if (allowed.length === 0) {
153
+ yield* Console.error("commit-identity: package.json declares no author or contributors, so no identity is allowed; declare the owners there");
154
+ return false;
155
+ }
149
156
  const commits = yield* readCommits(revisions);
150
157
  const offences = commits
151
158
  .map((commit) => inspect(commit, allowed))
@@ -158,7 +165,7 @@ const check = Effect.gen(function* () {
158
165
  for (const reason of reasons) lines.push(` ${reason}`);
159
166
  }
160
167
  lines.push(` allowed: ${allowed.map(render).join(", ")}`);
161
- lines.push(` allowed as committer only: ${render(SQUASH_COMMITTER)}`);
168
+ lines.push(` allowed as committer only: ${render(SQUASH_COMMITTER)}, which also lets an allowed email author under any name`);
162
169
  lines.push(` allowed as release author only: ${render(RELEASE_AUTHOR)}`);
163
170
  yield* Console.error(lines.join("\n"));
164
171
  return false;