@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.
- package/CHANGELOG.md +66 -40
- package/CONTRIBUTING.md +14 -10
- package/README.md +15 -24
- package/bunfig.toml +1 -1
- package/dist/effect-channel/index.js +122 -0
- package/dist/{index.js → readability/index.js} +8 -120
- package/docs/configs/commit-messages.md +5 -1
- package/docs/configs/dependency-rules.md +5 -2
- package/docs/configs/effect-rules.md +32 -33
- package/docs/configs/native-settings.md +74 -0
- package/docs/configs/typescript-rules.md +4 -0
- package/docs/design.md +26 -27
- package/docs/gates/checks-backtest.md +4 -0
- package/docs/gates/checks-ci-wiring.md +30 -93
- package/docs/gates/checks-comment-gate.md +4 -0
- package/docs/gates/checks-commit-identity.md +23 -27
- package/docs/gates/checks-docs.md +23 -21
- package/docs/gates/checks-flake.md +4 -10
- package/docs/gates/checks-lint-coverage.md +5 -1
- package/docs/gates/checks-lint.md +22 -104
- package/docs/gates/checks-mutation-compare.md +4 -0
- package/docs/gates/checks-quarantine-clock.md +4 -0
- package/docs/gates/checks-repetition.md +27 -41
- package/docs/gates/checks-subsumed-tests.md +4 -0
- package/docs/gates/checks-suppressions-ratchet.md +4 -0
- package/docs/gates/checks-test-layout.md +16 -8
- package/docs/gates/checks-test.md +68 -36
- package/docs/gates/checks-vendor.md +20 -13
- package/oxlintrc.json +1 -1
- package/package.json +10 -21
- package/scripts/ci-wiring.ts +28 -97
- package/scripts/commit-identity.ts +32 -4
- package/scripts/doc-rules.ts +26 -11
- package/scripts/doc-templates.ts +2 -1
- package/scripts/docs.ts +4 -7
- package/scripts/gates.ts +0 -29
- package/scripts/git.ts +24 -1
- package/scripts/lint.ts +15 -34
- package/scripts/range-gate.ts +1 -2
- package/scripts/repetition.ts +51 -40
- package/scripts/shell-command.ts +7 -1
- package/scripts/swc.ts +46 -0
- package/scripts/test-layout.ts +40 -56
- package/scripts/test-skips.ts +180 -0
- package/scripts/test.ts +33 -38
- package/scripts/vendor.ts +55 -11
- package/dist/feature-rules.js +0 -354
- package/docs/configs/quality-file.md +0 -103
- package/docs/gates/checks-feature-owners.md +0 -113
- package/docs/gates/checks-quality.md +0 -111
- package/docs/gates/checks-size-budget.md +0 -107
- package/quality.schema.json +0 -514
- package/scripts/feature-owners.ts +0 -139
- package/scripts/quality-file.ts +0 -353
- package/scripts/quality.ts +0 -363
- package/scripts/size-budget.ts +0 -285
- package/scripts/size-rules.ts +0 -126
package/scripts/ci-wiring.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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:
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
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[]) {
|
package/scripts/doc-rules.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
46
|
-
const
|
|
47
|
-
if (mode !== undefined) return
|
|
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
|
|
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;
|
|
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
|
}
|
package/scripts/doc-templates.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { slotLabel, type FixedSlot, type HeadingRule, type OpenSlot, type Presence, type Slot } from "./doc-outline.ts";
|
|
2
|
-
|
|
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([...
|
|
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
|
|
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 {
|
|
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
|
|
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*
|
|
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[]
|
|
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
|
|
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[]
|
|
151
|
-
const ends = yield* endsOf(args
|
|
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
|
|
188
|
-
const range = yield* resolveRange(process.argv.slice(2)
|
|
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 =
|
|
191
|
-
|
|
192
|
-
|
|
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) {
|
package/scripts/range-gate.ts
CHANGED
|
@@ -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
|
|
7
|
+
return { refs, root };
|
|
9
8
|
});
|