@avi2dg/checks 0.21.0 → 0.22.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 (54) hide show
  1. package/CHANGELOG.md +58 -40
  2. package/CONTRIBUTING.md +11 -8
  3. package/README.md +15 -24
  4. package/bunfig.toml +1 -1
  5. package/docs/configs/commit-messages.md +5 -1
  6. package/docs/configs/dependency-rules.md +5 -2
  7. package/docs/configs/effect-rules.md +32 -33
  8. package/docs/configs/native-settings.md +74 -0
  9. package/docs/configs/typescript-rules.md +4 -0
  10. package/docs/design.md +24 -25
  11. package/docs/gates/checks-backtest.md +4 -0
  12. package/docs/gates/checks-ci-wiring.md +30 -93
  13. package/docs/gates/checks-comment-gate.md +4 -0
  14. package/docs/gates/checks-commit-identity.md +23 -27
  15. package/docs/gates/checks-docs.md +23 -21
  16. package/docs/gates/checks-flake.md +4 -10
  17. package/docs/gates/checks-lint-coverage.md +5 -1
  18. package/docs/gates/checks-lint.md +22 -104
  19. package/docs/gates/checks-mutation-compare.md +4 -0
  20. package/docs/gates/checks-quarantine-clock.md +4 -0
  21. package/docs/gates/checks-repetition.md +26 -41
  22. package/docs/gates/checks-subsumed-tests.md +4 -0
  23. package/docs/gates/checks-suppressions-ratchet.md +4 -0
  24. package/docs/gates/checks-test-layout.md +16 -8
  25. package/docs/gates/checks-test.md +68 -36
  26. package/docs/gates/checks-vendor.md +20 -13
  27. package/package.json +8 -21
  28. package/scripts/ci-wiring.ts +28 -97
  29. package/scripts/commit-identity.ts +32 -4
  30. package/scripts/doc-rules.ts +26 -11
  31. package/scripts/doc-templates.ts +2 -1
  32. package/scripts/docs.ts +4 -7
  33. package/scripts/gates.ts +0 -29
  34. package/scripts/git.ts +24 -1
  35. package/scripts/lint.ts +15 -34
  36. package/scripts/range-gate.ts +1 -2
  37. package/scripts/repetition.ts +40 -40
  38. package/scripts/shell-command.ts +7 -1
  39. package/scripts/swc.ts +46 -0
  40. package/scripts/test-layout.ts +40 -56
  41. package/scripts/test-skips.ts +180 -0
  42. package/scripts/test.ts +33 -38
  43. package/scripts/vendor.ts +55 -11
  44. package/dist/feature-rules.js +0 -354
  45. package/docs/configs/quality-file.md +0 -103
  46. package/docs/gates/checks-feature-owners.md +0 -113
  47. package/docs/gates/checks-quality.md +0 -111
  48. package/docs/gates/checks-size-budget.md +0 -107
  49. package/quality.schema.json +0 -514
  50. package/scripts/feature-owners.ts +0 -139
  51. package/scripts/quality-file.ts +0 -353
  52. package/scripts/quality.ts +0 -363
  53. package/scripts/size-budget.ts +0 -285
  54. package/scripts/size-rules.ts +0 -126
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,8 @@
27
27
  "scripts/lint.ts",
28
28
  "scripts/test-layout.ts",
29
29
  "scripts/test.ts",
30
+ "scripts/test-skips.ts",
31
+ "scripts/swc.ts",
30
32
  "scripts/test-report.ts",
31
33
  "scripts/flake.ts",
32
34
  "scripts/commit-identity.ts",
@@ -40,13 +42,8 @@
40
42
  "scripts/comment-gate.ts",
41
43
  "scripts/suppressions-ratchet.ts",
42
44
  "scripts/backtest.ts",
43
- "scripts/quality-file.ts",
44
- "scripts/quality.ts",
45
- "scripts/size-budget.ts",
46
45
  "scripts/range-gate.ts",
47
- "scripts/size-rules.ts",
48
46
  "scripts/repetition.ts",
49
- "scripts/feature-owners.ts",
50
47
  "scripts/quarantine-clock.ts",
51
48
  "scripts/docs.ts",
52
49
  "scripts/vendor.ts",
@@ -58,12 +55,10 @@
58
55
  "templates/",
59
56
  "presets/effect.oxlint.json",
60
57
  "presets/effect.language-service.json",
61
- "quality.schema.json",
62
58
  "oxlintrc.json",
63
59
  "stryker.preset.js",
64
60
  "tsconfig.effect.json",
65
- "dist/index.js",
66
- "dist/feature-rules.js"
61
+ "dist/index.js"
67
62
  ],
68
63
  "exports": {
69
64
  "./bunfig.toml": "./bunfig.toml",
@@ -73,6 +68,7 @@
73
68
  "./scripts/lint.ts": "./scripts/lint.ts",
74
69
  "./scripts/test-layout.ts": "./scripts/test-layout.ts",
75
70
  "./scripts/test.ts": "./scripts/test.ts",
71
+ "./scripts/test-skips.ts": "./scripts/test-skips.ts",
76
72
  "./scripts/flake.ts": "./scripts/flake.ts",
77
73
  "./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
78
74
  "./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
@@ -84,11 +80,7 @@
84
80
  "./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
85
81
  "./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
86
82
  "./scripts/backtest.ts": "./scripts/backtest.ts",
87
- "./scripts/quality-file.ts": "./scripts/quality-file.ts",
88
- "./scripts/quality.ts": "./scripts/quality.ts",
89
- "./scripts/size-budget.ts": "./scripts/size-budget.ts",
90
83
  "./scripts/repetition.ts": "./scripts/repetition.ts",
91
- "./scripts/feature-owners.ts": "./scripts/feature-owners.ts",
92
84
  "./scripts/quarantine-clock.ts": "./scripts/quarantine-clock.ts",
93
85
  "./scripts/docs.ts": "./scripts/docs.ts",
94
86
  "./scripts/vendor.ts": "./scripts/vendor.ts",
@@ -103,12 +95,10 @@
103
95
  "./templates/explanation.md": "./templates/explanation.md",
104
96
  "./presets/effect.oxlint.json": "./presets/effect.oxlint.json",
105
97
  "./presets/effect.language-service.json": "./presets/effect.language-service.json",
106
- "./quality.schema.json": "./quality.schema.json",
107
98
  "./oxlintrc.json": "./oxlintrc.json",
108
99
  "./stryker.preset.js": "./stryker.preset.js",
109
100
  "./tsconfig.effect.json": "./tsconfig.effect.json",
110
- "./dist/index.js": "./dist/index.js",
111
- "./dist/feature-rules.js": "./dist/feature-rules.js"
101
+ "./dist/index.js": "./dist/index.js"
112
102
  },
113
103
  "bin": {
114
104
  "checks-lint": "scripts/lint.ts",
@@ -123,17 +113,14 @@
123
113
  "checks-comment-gate": "scripts/comment-gate.ts",
124
114
  "checks-suppressions-ratchet": "scripts/suppressions-ratchet.ts",
125
115
  "checks-backtest": "scripts/backtest.ts",
126
- "checks-quality": "scripts/quality.ts",
127
- "checks-size-budget": "scripts/size-budget.ts",
128
116
  "checks-repetition": "scripts/repetition.ts",
129
- "checks-feature-owners": "scripts/feature-owners.ts",
130
117
  "checks-quarantine-clock": "scripts/quarantine-clock.ts",
131
118
  "checks-docs": "scripts/docs.ts",
132
119
  "checks-vendor": "scripts/vendor.ts"
133
120
  },
134
121
  "scripts": {
135
- "prepare": "bun scripts/vendor.ts",
136
- "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun build scripts/feature-rules.ts --outdir dist --target node --format esm --packages external && bun scripts/quality-schema.ts && bun scripts/doc-templates-write.ts && bun scripts/changelog-write.ts && bun scripts/doc-blocks-write.ts",
122
+ "prepare": "bun scripts/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
123
+ "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun scripts/doc-templates-write.ts && bun scripts/changelog-write.ts && bun scripts/doc-blocks-write.ts",
137
124
  "lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
138
125
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
139
126
  "test": "bun scripts/test.ts"
@@ -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 };