@avi2dg/checks 0.21.0 → 0.23.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 (57) hide show
  1. package/CHANGELOG.md +66 -40
  2. package/CONTRIBUTING.md +14 -10
  3. package/README.md +15 -24
  4. package/bunfig.toml +1 -1
  5. package/dist/effect-channel/index.js +122 -0
  6. package/dist/{index.js → readability/index.js} +8 -120
  7. package/docs/configs/commit-messages.md +5 -1
  8. package/docs/configs/dependency-rules.md +5 -2
  9. package/docs/configs/effect-rules.md +32 -33
  10. package/docs/configs/native-settings.md +74 -0
  11. package/docs/configs/typescript-rules.md +4 -0
  12. package/docs/design.md +26 -27
  13. package/docs/gates/checks-backtest.md +4 -0
  14. package/docs/gates/checks-ci-wiring.md +30 -93
  15. package/docs/gates/checks-comment-gate.md +4 -0
  16. package/docs/gates/checks-commit-identity.md +23 -27
  17. package/docs/gates/checks-docs.md +23 -21
  18. package/docs/gates/checks-flake.md +4 -10
  19. package/docs/gates/checks-lint-coverage.md +5 -1
  20. package/docs/gates/checks-lint.md +22 -104
  21. package/docs/gates/checks-mutation-compare.md +4 -0
  22. package/docs/gates/checks-quarantine-clock.md +4 -0
  23. package/docs/gates/checks-repetition.md +27 -41
  24. package/docs/gates/checks-subsumed-tests.md +4 -0
  25. package/docs/gates/checks-suppressions-ratchet.md +4 -0
  26. package/docs/gates/checks-test-layout.md +16 -8
  27. package/docs/gates/checks-test.md +68 -36
  28. package/docs/gates/checks-vendor.md +20 -13
  29. package/oxlintrc.json +1 -1
  30. package/package.json +10 -21
  31. package/scripts/ci-wiring.ts +28 -97
  32. package/scripts/commit-identity.ts +32 -4
  33. package/scripts/doc-rules.ts +26 -11
  34. package/scripts/doc-templates.ts +2 -1
  35. package/scripts/docs.ts +4 -7
  36. package/scripts/gates.ts +0 -29
  37. package/scripts/git.ts +24 -1
  38. package/scripts/lint.ts +15 -34
  39. package/scripts/range-gate.ts +1 -2
  40. package/scripts/repetition.ts +51 -40
  41. package/scripts/shell-command.ts +7 -1
  42. package/scripts/swc.ts +46 -0
  43. package/scripts/test-layout.ts +40 -56
  44. package/scripts/test-skips.ts +180 -0
  45. package/scripts/test.ts +33 -38
  46. package/scripts/vendor.ts +55 -11
  47. package/dist/feature-rules.js +0 -354
  48. package/docs/configs/quality-file.md +0 -103
  49. package/docs/gates/checks-feature-owners.md +0 -113
  50. package/docs/gates/checks-quality.md +0 -111
  51. package/docs/gates/checks-size-budget.md +0 -107
  52. package/quality.schema.json +0 -514
  53. package/scripts/feature-owners.ts +0 -139
  54. package/scripts/quality-file.ts +0 -353
  55. package/scripts/quality.ts +0 -363
  56. package/scripts/size-budget.ts +0 -285
  57. package/scripts/size-rules.ts +0 -126
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import { git } from "./git.ts";
3
+ import { defaultBranch, git } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
- import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
6
- import { readQuality, type Quality } from "./quality-file.ts";
5
+ import { ENTRY_POINT, KIT_GATES, type KitGate } from "./gates.ts";
7
6
  import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
8
7
 
9
8
  export type { Command };
@@ -15,7 +14,6 @@ export type Gate = {
15
14
 
16
15
  export type Declaration = {
17
16
  readonly gates: readonly Gate[];
18
- readonly scheduled: readonly Gate[];
19
17
  readonly defaultBranch: string;
20
18
  readonly lintGates: readonly KitGate[];
21
19
  };
@@ -30,12 +28,6 @@ export type BlockedInvocation = {
30
28
  readonly blocker: string;
31
29
  };
32
30
 
33
- export type Omission = {
34
- readonly gate: string;
35
- readonly content: string;
36
- readonly files: readonly [string, ...string[]];
37
- };
38
-
39
31
  export type Gap = {
40
32
  readonly gate: string;
41
33
  readonly entryPoint?: string;
@@ -55,6 +47,10 @@ export class WiringError extends Schema.TaggedError<WiringError>()("WiringError"
55
47
  }) {}
56
48
 
57
49
  const WORKFLOWS = ".github/workflows";
50
+ const SCRIPTED = ["lint", "build", "typecheck", "test"];
51
+ const BUILT_TREE = "git diff --exit-code";
52
+ const TITLE_LINT = "./node_modules/.bin/commitlint";
53
+ const Manifest = Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) });
58
54
  // Without these a pull_request workflow never sees the commits a pull request pushes.
59
55
  const GATING_TYPES = ["opened", "synchronize"];
60
56
  const CONSTANTS = new Map([
@@ -120,13 +116,6 @@ const pullRequestTrigger = (branch: string): Trigger => (workflow) => {
120
116
  return undefined;
121
117
  };
122
118
 
123
- const scheduleTrigger: Trigger = (workflow) => {
124
- const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
125
- const schedule = isRecord(on) ? on["schedule"] : undefined;
126
- const crons = Array.isArray(schedule) ? schedule.filter((entry) => isRecord(entry) && typeof entry["cron"] === "string") : [];
127
- return crons.length > 0 ? undefined : `${workflow.path} does not trigger on a schedule`;
128
- };
129
-
130
119
  // GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
131
120
  function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
132
121
  const job = jobs[id];
@@ -219,10 +208,6 @@ export function findGaps(declaration: Declaration, workflows: readonly Workflow[
219
208
  return gapsAmong(declaration.gates, runSteps(workflows, pullRequestTrigger(declaration.defaultBranch)), declaration.lintGates);
220
209
  }
221
210
 
222
- export function findScheduledGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
223
- return gapsAmong(declaration.scheduled, runSteps(workflows, scheduleTrigger), declaration.lintGates);
224
- }
225
-
226
211
  function gapLines(gaps: readonly Gap[]): readonly string[] {
227
212
  const lines: string[] = [];
228
213
  for (const gap of gaps) {
@@ -241,73 +226,21 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
241
226
  return [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`, ...gapLines(gaps)].join("\n");
242
227
  }
243
228
 
244
- export function formatScheduledReport(declaration: Declaration, gaps: readonly Gap[]): string {
245
- const total = declaration.scheduled.length;
246
- if (gaps.length === 0) return `ci-wiring: ${total} scheduled command(s) run on a schedule`;
247
- return [`ci-wiring: ${gaps.length} of ${total} scheduled command(s) do not run on a schedule:`, ...gapLines(gaps)].join("\n");
229
+ export function requiredCommands(scripts: readonly string[]): readonly string[] {
230
+ return [
231
+ ...SCRIPTED.filter((name) => scripts.includes(name)).flatMap((name) =>
232
+ name === "build" ? [`bun run ${name}`, BUILT_TREE] : [`bun run ${name}`],
233
+ ),
234
+ TITLE_LINT,
235
+ ];
248
236
  }
249
237
 
250
- const plainCommands = Effect.fnUntraced(function* (
251
- listed: readonly string[],
252
- subject: string,
253
- ): Effect.fn.Return<readonly Gate[], WiringError> {
254
- const gates: Gate[] = [];
255
- for (const command of listed) {
256
- const words = plainCommand(command);
257
- if (words === undefined) {
258
- return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
259
- }
260
- gates.push({ command, words });
261
- }
262
- return gates;
263
- });
264
-
265
- export const parseDeclaration = Effect.fnUntraced(function* (
266
- { defaultBranch, gates: declared }: Quality,
267
- source: string,
268
- ): Effect.fn.Return<Declaration, WiringError> {
269
- if (declared?.ci === undefined) {
270
- return yield* new WiringError({ message: `${QUALITY_FILE} declares no gates.ci, a non-empty array of commands` });
271
- }
238
+ export function declarationFor(scripts: readonly string[], branch: string): Declaration {
272
239
  return {
273
- gates: yield* plainCommands(declared.ci, `${source} gate`),
274
- scheduled: yield* plainCommands(declared.scheduled ?? [], `${source} scheduled command`),
275
- defaultBranch: defaultBranch ?? DEFAULT_BRANCH,
276
- lintGates: selectedGates(declared.lint),
240
+ gates: requiredCommands(scripts).map((command) => ({ command, words: command.split(" ") })),
241
+ defaultBranch: branch,
242
+ lintGates: KIT_GATES,
277
243
  };
278
- });
279
-
280
- function omittedFrom(lintGates: readonly KitGate[]): readonly KitGate[] {
281
- return KIT_GATES.filter((gate) => !lintGates.includes(gate));
282
- }
283
-
284
- export const findOmissions = Effect.fn("findOmissions")(function* (root: string, lintGates: readonly KitGate[]) {
285
- const omissions: Omission[] = [];
286
- for (const gate of omittedFrom(lintGates)) {
287
- // LintGates refuses a selection that leaves out a gate every repository runs.
288
- if (gate.appliesTo === EVERY_REPOSITORY) continue;
289
- const { pathspecs, content } = gate.appliesTo;
290
- const [first, ...rest] = (yield* git(["ls-files", "-z", "--", ...pathspecs], root)).split("\0").filter(Boolean);
291
- if (first !== undefined) omissions.push({ gate: gate.bin, content, files: [first, ...rest] });
292
- }
293
- return omissions;
294
- });
295
-
296
- function sample([first, ...rest]: Omission["files"]): string {
297
- return rest.length === 0 ? first : `${first} and ${rest.length} more`;
298
- }
299
-
300
- export function formatOmissions(lintGates: readonly KitGate[], omissions: readonly Omission[]): string | undefined {
301
- const omitted = omittedFrom(lintGates);
302
- if (omitted.length === 0) return undefined;
303
- if (omissions.length === 0) {
304
- const bins = omitted.map((gate) => gate.bin).join(", ");
305
- return `ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${bins}, none of which this repository's contents make applicable`;
306
- }
307
- return [
308
- `ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
309
- ...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
310
- ].join("\n");
311
244
  }
312
245
 
313
246
  export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
@@ -317,8 +250,15 @@ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflo
317
250
  });
318
251
 
319
252
  export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
320
- const { source, quality } = yield* readQuality(root);
321
- return yield* parseDeclaration(quality, source);
253
+ const fs = yield* FileSystem.FileSystem;
254
+ const file = (yield* Path.Path).join(root, "package.json");
255
+ const { scripts = {} } = (yield* fs.exists(file))
256
+ ? yield* fs.readFileString(file).pipe(
257
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))),
258
+ Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
259
+ )
260
+ : Manifest.make({});
261
+ return declarationFor(Object.keys(scripts), yield* defaultBranch(root));
322
262
  });
323
263
 
324
264
  export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
@@ -338,21 +278,12 @@ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string)
338
278
 
339
279
  const wiring = Effect.gen(function* () {
340
280
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
341
- const declaration = yield* readDeclaration(root);
342
281
  const workflows = yield* readWorkflows(root);
282
+ const declaration = yield* readDeclaration(root);
343
283
  const gaps = findGaps(declaration, workflows);
344
- const omissions = yield* findOmissions(root, declaration.lintGates);
345
284
  const gapReport = formatReport(declaration, gaps);
346
285
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
347
- const omissionReport = formatOmissions(declaration.lintGates, omissions);
348
- if (omissionReport !== undefined) {
349
- yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
350
- }
351
- if (declaration.scheduled.length === 0) return gaps.length === 0 && omissions.length === 0;
352
- const scheduledGaps = findScheduledGaps(declaration, workflows);
353
- const scheduledReport = formatScheduledReport(declaration, scheduledGaps);
354
- yield* scheduledGaps.length > 0 ? Console.error(scheduledReport) : Console.log(scheduledReport);
355
- return gaps.length === 0 && omissions.length === 0 && scheduledGaps.length === 0;
286
+ return gaps.length === 0;
356
287
  });
357
288
 
358
289
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, Schema } from "effect";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { git, refArgs } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
- import { readQuality, type Identity } from "./quality-file.ts";
5
+
6
+ type Identity = { readonly name: string; readonly email: string };
6
7
 
7
8
  type Commit = {
8
9
  readonly sha: string;
@@ -38,9 +39,36 @@ function render(identity: Identity): string {
38
39
  return `${identity.name} <${identity.email}>`;
39
40
  }
40
41
 
42
+ const Person = Schema.Union([
43
+ Schema.String,
44
+ Schema.Struct({
45
+ name: Schema.String,
46
+ email: Schema.optionalKey(Schema.String),
47
+ url: Schema.optionalKey(Schema.String),
48
+ }),
49
+ ]);
50
+ const Authors = Schema.Struct({
51
+ author: Schema.optionalKey(Person),
52
+ contributors: Schema.optionalKey(Schema.Array(Person)),
53
+ });
54
+
55
+ function identityOf(person: typeof Person.Type): Identity | undefined {
56
+ const name = typeof person === "string" ? (/^[^(<]*/.exec(person)?.[0] ?? "").trim() : person.name;
57
+ const email = typeof person === "string" ? /<([^<>]+)>/.exec(person)?.[1] : person.email;
58
+ return name === "" || email === undefined || email === "" ? undefined : { name, email };
59
+ }
60
+
41
61
  const allowedAuthors = Effect.gen(function* () {
42
- const { quality } = yield* readQuality((yield* git(["rev-parse", "--show-toplevel"])).trim());
43
- return quality.commitIdentity?.authors ?? DEFAULT_AUTHORS;
62
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
63
+ const fs = yield* FileSystem.FileSystem;
64
+ const file = (yield* Path.Path).join(root, "package.json");
65
+ if (!(yield* fs.exists(file))) return DEFAULT_AUTHORS;
66
+ const manifest = yield* fs.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Authors))));
67
+ const listed = [manifest.author, ...(manifest.contributors ?? [])]
68
+ .filter((entry) => entry !== undefined)
69
+ .map(identityOf)
70
+ .filter((entry) => entry !== undefined);
71
+ return listed.length === 0 ? DEFAULT_AUTHORS : listed;
44
72
  });
45
73
 
46
74
  const readCommits = Effect.fn("readCommits")(function* (revisions: readonly string[]) {
@@ -1,3 +1,4 @@
1
+ import { Option, Result, Schema } from "effect";
1
2
  import {
2
3
  firstText,
3
4
  marked,
@@ -10,14 +11,12 @@ import {
10
11
  type Violation,
11
12
  VERSION,
12
13
  } from "./doc-outline.ts";
13
- import { ADR_STATUSES, TEMPLATES, templateFile, type Kind, type Title } from "./doc-templates.ts";
14
+ import { ADR_STATUSES, MODES, TEMPLATES, templateFile, type Kind, type Title } from "./doc-templates.ts";
14
15
  import { ADR_DIRECTORY, DOCS_DIRECTORY } from "./prose-matchers.ts";
15
- import { MODES, type Docs, type Mode } from "./quality-file.ts";
16
16
 
17
17
  export type Placement =
18
18
  | { readonly type: "judged"; readonly kind: Kind }
19
19
  | { readonly type: "undeclared" }
20
- | { readonly type: "ambiguous"; readonly modes: readonly Mode[] }
21
20
  | { readonly type: "unjudged" };
22
21
 
23
22
  export type Doc = {
@@ -37,14 +36,29 @@ export const ROOT_FILES: ReadonlyMap<string, Kind> = new Map<string, Kind>([
37
36
  ["CONTRIBUTING.md", "how-to"],
38
37
  ]);
39
38
 
40
- export function placementOf(path: string, docs: Docs | undefined): Placement {
39
+ const FRONT_MATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/;
40
+ const Fields = Schema.Struct({ kind: Schema.optionalKey(Schema.String), audience: Schema.optionalKey(Schema.String) });
41
+ const decodeFields = Schema.decodeUnknownOption(Fields);
42
+
43
+ function frontMatterOf(text: string): { readonly text: string; readonly fields: typeof Fields.Type } {
44
+ const match = FRONT_MATTER.exec(text);
45
+ if (match === null) return { text: "", fields: {} };
46
+ const parsed = Result.getOrUndefined(Result.try(() => Bun.YAML.parse(match[1] ?? "")));
47
+ return { text: match[0], fields: Option.getOrElse(decodeFields(parsed), () => ({})) };
48
+ }
49
+
50
+ export function speaksToConsumers(text: string): boolean {
51
+ return frontMatterOf(text).fields.audience === "consumers";
52
+ }
53
+
54
+ export function placementOf(path: string, text = ""): Placement {
41
55
  const root = ROOT_FILES.get(path);
42
56
  if (root !== undefined) return { type: "judged", kind: root };
43
57
  if (!path.endsWith(".md") || path === ADR_INDEX) return { type: "unjudged" };
44
58
  if (path.startsWith(ADR_DIRECTORY)) return { type: "judged", kind: "adr" };
45
- const modes = MODES.filter((mode) => (docs?.pages?.[mode] ?? []).some((glob) => new Bun.Glob(glob).match(path)));
46
- const [mode, ...others] = modes;
47
- if (mode !== undefined) return others.length === 0 ? { type: "judged", kind: mode } : { type: "ambiguous", modes };
59
+ const named = frontMatterOf(text).fields.kind;
60
+ const mode = MODES.find((known) => known === named);
61
+ if (mode !== undefined) return { type: "judged", kind: mode };
48
62
  return path.startsWith(DOCS_DIRECTORY) ? { type: "undeclared" } : { type: "unjudged" };
49
63
  }
50
64
 
@@ -171,20 +185,21 @@ function exactProblems(kind: Kind, expected: string, actual: string): readonly V
171
185
  export function judge(kind: Kind, doc: Doc, records: readonly string[]): readonly Violation[] {
172
186
  const template = TEMPLATES[kind];
173
187
  if (template.shape === "exact") return exactProblems(kind, template.text, doc.text);
174
- const outline = parseOutline(doc.text);
188
+ const frontMatter = frontMatterOf(doc.text).text;
189
+ const outline = parseOutline(doc.text.slice(frontMatter.length));
190
+ const lineOffset = frontMatter === "" ? 0 : frontMatter.split("\n").length - 1;
175
191
  const title = titleOf(outline);
176
192
  return [
177
193
  ...outlineProblems(outline),
178
194
  ...(title === undefined ? [] : titleProblems(template.title, title)),
179
195
  ...matchSections(outline.sections, template.sections, 2, title?.line ?? 1),
180
196
  ...kindProblems(kind, doc, outline, records),
181
- ].toSorted((a, b) => a.line - b.line);
197
+ ].map(({ line, message }) => ({ line: line + lineOffset, message })).toSorted((a, b) => a.line - b.line);
182
198
  }
183
199
 
184
200
  export function placementProblem(placement: Placement): string | undefined {
185
201
  if (placement.type === "undeclared") {
186
- return `is a page under ${DOCS_DIRECTORY} with no mode; declare it under docs.pages in quality.json as ${MODES.join(", ")}`;
202
+ return `is a page under ${DOCS_DIRECTORY} with no mode; add kind: ${MODES.join(", ")} in YAML front matter`;
187
203
  }
188
- if (placement.type === "ambiguous") return `is declared under docs.pages as ${placement.modes.join(" and ")}, and a page has one mode`;
189
204
  return undefined;
190
205
  }
@@ -1,5 +1,6 @@
1
1
  import { slotLabel, type FixedSlot, type HeadingRule, type OpenSlot, type Presence, type Slot } from "./doc-outline.ts";
2
- import { MODES } from "./quality-file.ts";
2
+
3
+ export const MODES = ["tutorial", "how-to", "reference", "explanation"] as const;
3
4
 
4
5
  export const KINDS = ["readme", "changelog", "adr", "agents", "claude", ...MODES] as const;
5
6
  export type Kind = (typeof KINDS)[number];
package/scripts/docs.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect } from "effect";
3
3
  import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
4
- import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
4
+ import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
5
5
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
6
6
  import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "./git.ts";
7
7
  import { runMain } from "./main.ts";
8
8
  import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
9
- import { readQuality } from "./quality-file.ts";
10
9
 
11
10
  type Finding = {
12
11
  readonly path: string;
@@ -82,29 +81,27 @@ const referenceFindings = Effect.fn("referenceFindings")(function* (range: Range
82
81
  });
83
82
 
84
83
  const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head: string) {
85
- const { quality } = yield* readQuality(root);
86
84
  const changes = yield* changedPaths(base, head, MARKDOWN, root);
87
85
  const touched = new Set(changes.flatMap((change) => (change.kind === "deleted" ? [] : [change.path])));
88
86
  const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
89
87
  const changed = yield* changedLines(base, head, MARKDOWN, root);
90
88
  const present = yield* pathsAt(head, MARKDOWN, root);
91
89
  const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
92
- const judged = present.map((path) => ({ path, placement: placementOf(path, quality.docs) })).filter(({ placement }) => placement.type !== "unjudged");
93
90
  const living = present.filter(isLivingDoc);
94
91
  const proseDocs = present.flatMap((path) => {
95
92
  const reader = readerOf(path);
96
93
  return reader === undefined ? [] : [{ path, reader }];
97
94
  });
98
- const texts = yield* readTexts(root, head, [...new Set([...judged.map(({ path }) => path), ...proseDocs.map(({ path }) => path)])]);
95
+ const texts = yield* readTexts(root, head, [...new Set([...present.filter((path) => path.endsWith(".md")), ...proseDocs.map(({ path }) => path)])]);
99
96
  const text = (path: string): string => texts.get(path) ?? "";
97
+ const judged = present.map((path) => ({ path, placement: placementOf(path, text(path)) })).filter(({ placement }) => placement.type !== "unjudged");
100
98
 
101
99
  const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
102
100
  const edited = proseDocs.filter(({ path }) => changed.has(path));
103
101
  const prose = edited.flatMap(({ path, reader }) =>
104
102
  proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
105
103
  );
106
- const forConsumers = (quality.docs?.forConsumers ?? []).map((glob) => new Bun.Glob(glob));
107
- const judging = (path: string): Judging => ({ commands: !forConsumers.some((glob) => glob.match(path)) });
104
+ const judging = (path: string): Judging => ({ commands: !speaksToConsumers(text(path)) });
108
105
  // A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
109
106
  const roots = rootsOf(yield* pathsAt(base, [], root));
110
107
  const references = yield* referenceFindings(
package/scripts/gates.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { Schema } from "effect";
2
-
3
1
  export type Program = {
4
2
  readonly bin: string;
5
3
  readonly script: string;
@@ -24,12 +22,8 @@ export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "test.ts"
24
22
 
25
23
  export const DEFAULT_BRANCH = "main";
26
24
 
27
- export const QUALITY_FILE = "quality.json";
28
-
29
25
  const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
30
26
 
31
- const QUALITY_DECLARATION: TrackedContent = { pathspecs: [QUALITY_FILE], content: `a ${QUALITY_FILE}` };
32
-
33
27
  export const KIT_GATES = [
34
28
  { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
35
29
  { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
@@ -38,29 +32,6 @@ export const KIT_GATES = [
38
32
  { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
39
33
  { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
40
34
  { bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
41
- { bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
42
- { bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
43
35
  { bin: "checks-repetition", script: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
44
- { bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
45
36
  { bin: "checks-quarantine-clock", script: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
46
37
  ] as const satisfies readonly KitGate[];
47
-
48
- const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
49
-
50
- // A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
51
- // is refused where checks-lint decodes the selection, not by ci-wiring.
52
- export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
53
- Schema.makeFilter(
54
- (selected) => {
55
- const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
56
- if (missing.length === 0) return true;
57
- const verb = missing.length === 1 ? "applies" : "apply";
58
- return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
59
- },
60
- { toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) },
61
- ),
62
- );
63
-
64
- export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
65
- return lintGates === undefined ? KIT_GATES : KIT_GATES.filter((gate) => lintGates.includes(gate.bin));
66
- }
package/scripts/git.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { Effect, Path, Schema, Stream } from "effect";
1
+ import { Config, Effect, FileSystem, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { DEFAULT_BRANCH } from "./gates.ts";
3
4
  import { Usage } from "./main.ts";
4
5
 
5
6
  export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
@@ -37,6 +38,28 @@ export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: st
37
38
  return stdout;
38
39
  });
39
40
 
41
+ const decodeEventRepository = Schema.decodeUnknownEffect(
42
+ Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ default_branch: Schema.NonEmptyString }) })),
43
+ );
44
+
45
+ // actions/checkout records no remote HEAD, so a push run in CI reads the branch from GitHub's event instead.
46
+ export const defaultBranch = Effect.fn("defaultBranch")(function* (cwd?: string) {
47
+ const base = yield* Config.String("GITHUB_BASE_REF").pipe(Config.withDefault(""));
48
+ if (base !== "") return base;
49
+ const recorded = yield* git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], cwd).pipe(
50
+ Effect.map((ref) => ref.trim().replace(/^origin\//, "")),
51
+ Effect.catchTag("GitFailure", () => Effect.succeed("")),
52
+ );
53
+ if (recorded !== "") return recorded;
54
+ const eventPath = yield* Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault(""));
55
+ if (eventPath === "") return DEFAULT_BRANCH;
56
+ return yield* (yield* FileSystem.FileSystem).readFileString(eventPath).pipe(
57
+ Effect.flatMap(decodeEventRepository),
58
+ Effect.map(({ repository }) => repository.default_branch),
59
+ Effect.orElseSucceed(() => DEFAULT_BRANCH),
60
+ );
61
+ });
62
+
40
63
  export type Change =
41
64
  | { readonly kind: "written" | "deleted"; readonly path: string }
42
65
  | { readonly kind: "renamed"; readonly from: string; readonly path: string; readonly edited: boolean };
package/scripts/lint.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
- import { DEFAULT_BRANCH, selectedGates, type KitGate } from "./gates.ts";
5
- import { git } from "./git.ts";
4
+ import { EVERY_REPOSITORY, KIT_GATES, type KitGate } from "./gates.ts";
5
+ import { defaultBranch, git } from "./git.ts";
6
6
  import { runMain, Usage } from "./main.ts";
7
- import { readQuality } from "./quality-file.ts";
8
7
 
9
8
  type Range = {
10
9
  readonly refs: readonly [tip: string] | readonly [base: string, head: string];
@@ -56,17 +55,6 @@ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: strin
56
55
  return pullRequestEndsOf(pullRequest);
57
56
  });
58
57
 
59
- const readWiring = Effect.gen(function* () {
60
- const { source, quality } = yield* readQuality((yield* git(["rev-parse", "--show-toplevel"])).trim());
61
- return { source, defaultBranch: quality.defaultBranch ?? DEFAULT_BRANCH, lintGates: quality.gates?.lint };
62
- });
63
-
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
58
  // A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
71
59
  // where judging HEAD alone would pass every commit before it unchecked. No origin ref means no remote-tracking ref at all.
72
60
  export function localEndsOf(originRef: string | undefined): { base: string; head: string; source: string } {
@@ -74,18 +62,11 @@ export function localEndsOf(originRef: string | undefined): { base: string; head
74
62
  return { base: originRef, head: "HEAD", source: `HEAD against ${originRef}` };
75
63
  }
76
64
 
77
- const originRef = (defaultBranch: string) =>
78
- git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
79
- Effect.map((ref) => ref.trim()),
80
- Effect.catchTag("GitFailure", () => Effect.succeed(undefined)),
81
- Effect.map((symbolicRef) => originRefOf(symbolicRef, defaultBranch)),
82
- );
83
-
84
- const localEnds = Effect.fn("localEnds")(function* (defaultBranch: string) {
65
+ const localEnds = Effect.gen(function* () {
85
66
  const remoteTracking = yield* git(["for-each-ref", "--count=1", "refs/remotes/"]).pipe(
86
67
  Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })),
87
68
  );
88
- return localEndsOf(remoteTracking.trim() === "" ? undefined : yield* originRef(defaultBranch));
69
+ return localEndsOf(remoteTracking.trim() === "" ? undefined : `origin/${yield* defaultBranch()}`);
89
70
  });
90
71
 
91
72
  // Decides which source of range ends the arguments and the pull request env vars select,
@@ -107,7 +88,7 @@ export function selectEnds(args: readonly string[], eventName: string | undefine
107
88
  return { kind: "local" };
108
89
  }
109
90
 
110
- const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
91
+ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
111
92
  const event = yield* Config.all({
112
93
  name: Config.option(Config.String("GITHUB_EVENT_NAME")),
113
94
  path: Config.option(Config.String("GITHUB_EVENT_PATH")),
@@ -127,7 +108,7 @@ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBr
127
108
  case "pull-request-unresolved":
128
109
  return yield* new RangeUnresolved({ message: `${PULL_REQUEST_EVENT} sets no GITHUB_EVENT_PATH` });
129
110
  case "local":
130
- return yield* localEnds(defaultBranch);
111
+ return yield* localEnds;
131
112
  }
132
113
  });
133
114
 
@@ -147,8 +128,8 @@ export function rangeOf(base: string, head: string): Range["refs"] {
147
128
 
148
129
  // From the base branch's tip, every range gate would charge the head with the commits the base
149
130
  // branch gained after the head branched off.
150
- const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[], defaultBranch: string) {
151
- const ends = yield* endsOf(args, defaultBranch);
131
+ const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[]) {
132
+ const ends = yield* endsOf(args);
152
133
  const head = yield* commitOf(ends.head);
153
134
  const base = yield* git(["merge-base", yield* commitOf(ends.base), head]).pipe(
154
135
  Effect.map((sha) => sha.trim()),
@@ -184,14 +165,14 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
184
165
  });
185
166
 
186
167
  const lint = Effect.gen(function* () {
187
- const { source, defaultBranch, lintGates } = yield* readWiring;
188
- const range = yield* resolveRange(process.argv.slice(2), defaultBranch);
168
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
169
+ const range = yield* resolveRange(process.argv.slice(2));
189
170
  yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
190
- const gates = selectedGates(lintGates);
191
- if (lintGates !== undefined) {
192
- yield* Console.log(`${NAME}: ${source} selects ${gates.map((gate) => gate.bin).join(", ")}`);
193
- }
194
-
171
+ const gates = yield* Effect.filter(KIT_GATES, (gate) =>
172
+ gate.appliesTo === EVERY_REPOSITORY
173
+ ? Effect.succeed(true)
174
+ : git(["ls-files", "--", ...gate.appliesTo.pathspecs], root).pipe(Effect.map((files) => files.trim() !== "")),
175
+ );
195
176
  const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
196
177
  const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
197
178
  if (failed.length === 0) {
@@ -1,9 +1,8 @@
1
1
  import { Effect } from "effect";
2
2
  import { git, refArgs } from "./git.ts";
3
- import { readQuality } from "./quality-file.ts";
4
3
 
5
4
  export const rangeGateInputs = Effect.fn("rangeGateInputs")(function* (usage: string) {
6
5
  const refs = yield* refArgs(process.argv.slice(2), usage);
7
6
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
8
- return { refs, root, ...(yield* readQuality(root)) };
7
+ return { refs, root };
9
8
  });