@avi2dg/checks 0.11.0 → 0.13.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/README.md +334 -151
- package/dist/feature-rules.js +255 -0
- package/package.json +29 -5
- package/quality.schema.json +191 -1
- package/scripts/doc-outline.ts +215 -0
- package/scripts/doc-rules.ts +176 -0
- package/scripts/doc-templates.ts +203 -0
- package/scripts/docs.ts +90 -0
- package/scripts/feature-owners.ts +142 -0
- package/scripts/gates.ts +3 -0
- package/scripts/git.ts +70 -14
- package/scripts/quality-file.ts +135 -4
- package/scripts/quality.ts +10 -2
- package/scripts/size-budget.ts +158 -0
- package/templates/adr.md +29 -0
- package/templates/agents.md +17 -0
- package/templates/changelog.md +37 -0
- package/templates/claude.md +2 -0
- package/templates/explanation.md +15 -0
- package/templates/how-to.md +33 -0
- package/templates/readme.md +43 -0
- package/templates/reference.md +15 -0
- package/templates/tutorial.md +31 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parse } from "@swc/core";
|
|
3
|
+
import { Console, Effect, Path, Schema } from "effect";
|
|
4
|
+
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
5
|
+
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
import { PROOF_DIRECTORY, readQuality, type Feature } from "./quality-file.ts";
|
|
7
|
+
|
|
8
|
+
type Unproven = {
|
|
9
|
+
readonly feature: string;
|
|
10
|
+
readonly reason: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Touch = {
|
|
14
|
+
readonly feature: string;
|
|
15
|
+
readonly paths: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class ProofUnparsed extends Schema.TaggedError<ProofUnparsed>()("ProofUnparsed", {
|
|
19
|
+
message: Schema.String,
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
const NAME = "feature-owners";
|
|
23
|
+
const USAGE = "usage: feature-owners.ts <ref> | <base-ref> <head-ref>";
|
|
24
|
+
const RELATIVE = /^\.\.?\//;
|
|
25
|
+
const SCRIPT_EXTENSIONS = new Map([
|
|
26
|
+
[".js", [".ts", ".tsx"]],
|
|
27
|
+
[".jsx", [".tsx"]],
|
|
28
|
+
[".mjs", [".mts"]],
|
|
29
|
+
[".cjs", [".cts"]],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const runtimeSpecifiers = Effect.fn("runtimeSpecifiers")(function* (file: string, source: string) {
|
|
33
|
+
const module = yield* Effect.tryPromise({
|
|
34
|
+
try: () => parse(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" }),
|
|
35
|
+
catch: (error) => new ProofUnparsed({ message: `${file} does not parse: ${String(error)}` }),
|
|
36
|
+
});
|
|
37
|
+
return module.body.flatMap((item) => {
|
|
38
|
+
if (item.type === "ImportDeclaration" && !item.typeOnly) return [item.source.value];
|
|
39
|
+
// swc types source as optional, but a local export such as `export { x }` carries null.
|
|
40
|
+
if (item.type === "ExportNamedDeclaration" && !item.typeOnly && item.source) return [item.source.value];
|
|
41
|
+
if (item.type === "ExportAllDeclaration") return [item.source.value];
|
|
42
|
+
return [];
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const candidatesFor = Effect.fn("candidatesFor")(function* (importer: string, specifier: string) {
|
|
47
|
+
if (!RELATIVE.test(specifier)) return [];
|
|
48
|
+
const path = yield* Path.Path;
|
|
49
|
+
const target = path.join(path.dirname(importer), specifier);
|
|
50
|
+
const extension = path.extname(target);
|
|
51
|
+
const sources = SCRIPT_EXTENSIONS.get(extension);
|
|
52
|
+
if (sources !== undefined) return [target, ...sources.map((source) => `${target.slice(0, -extension.length)}${source}`)];
|
|
53
|
+
if (extension !== "") return [target];
|
|
54
|
+
return [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const proofImportsAnEntry = Effect.fn("proofImportsAnEntry")(function* (root: string, head: string, feature: Feature) {
|
|
58
|
+
const source = yield* git(["cat-file", "blob", `${head}:${feature.proof}`], root);
|
|
59
|
+
const specifiers = yield* runtimeSpecifiers(feature.proof, source);
|
|
60
|
+
const reached = yield* Effect.forEach(specifiers, (specifier) => candidatesFor(feature.proof, specifier));
|
|
61
|
+
return reached.flat().some((path) => feature.entries.includes(path));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const unproven = Effect.fn("unproven")(function* (root: string, head: string, features: readonly Feature[]) {
|
|
65
|
+
const declared = features.flatMap((feature) => [feature.proof, ...feature.entries]);
|
|
66
|
+
const present = new Set(yield* pathsAt(head, declared.map((path) => `:(literal)${path}`), root));
|
|
67
|
+
const found: Unproven[] = [];
|
|
68
|
+
for (const feature of features) {
|
|
69
|
+
const refuse = (reason: string) => found.push({ feature: feature.name, reason });
|
|
70
|
+
for (const entry of feature.entries.filter((path) => !present.has(path))) refuse(`entry ${entry} is not in the head commit`);
|
|
71
|
+
if (!present.has(feature.proof)) {
|
|
72
|
+
refuse(`proof ${feature.proof} is not in the head commit`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const reason = yield* proofImportsAnEntry(root, head, feature).pipe(
|
|
76
|
+
Effect.map((imports) => (imports ? undefined : `proof ${feature.proof} imports none of its entries, ${feature.entries.join(", ")}`)),
|
|
77
|
+
Effect.catchTag("ProofUnparsed", (error) => Effect.succeed(`proof ${error.message}`)),
|
|
78
|
+
);
|
|
79
|
+
if (reason !== undefined) refuse(reason);
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function ownerOf(features: readonly Feature[], path: string): string | undefined {
|
|
85
|
+
return features.find((feature) => path.startsWith(`${feature.root}/`) || path === feature.proof)?.name;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function touches(features: readonly Feature[], changes: readonly Change[]): readonly Touch[] {
|
|
89
|
+
const byOwner = new Map<string, string[]>();
|
|
90
|
+
for (const change of changes) {
|
|
91
|
+
for (const path of change.kind === "renamed" ? [change.from, change.path] : [change.path]) {
|
|
92
|
+
const owner = ownerOf(features, path);
|
|
93
|
+
if (owner === undefined) continue;
|
|
94
|
+
byOwner.set(owner, [...(byOwner.get(owner) ?? []), path]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return features.flatMap((feature) => {
|
|
98
|
+
const paths = byOwner.get(feature.name);
|
|
99
|
+
return paths === undefined ? [] : [{ feature: feature.name, paths }];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function proofReport(features: readonly Feature[], found: readonly Unproven[]): string {
|
|
104
|
+
if (found.length === 0) {
|
|
105
|
+
return `${NAME}: ${features.length} feature(s) keep a proof under ${PROOF_DIRECTORY} that imports an entry`;
|
|
106
|
+
}
|
|
107
|
+
return [
|
|
108
|
+
`${NAME}: ${found.length} problem(s) with the features' runnable proofs:`,
|
|
109
|
+
...found.map(({ feature, reason }) => ` ${feature}: ${reason}`),
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function signalReport(touched: readonly Touch[]): string {
|
|
114
|
+
if (touched.length === 0) return `${NAME}: advisory, the range touches no feature owner`;
|
|
115
|
+
const judge = touched.length > 1 ? "; a reviewer judges whether they make one slice" : "";
|
|
116
|
+
return [
|
|
117
|
+
`${NAME}: advisory, the range touches ${touched.length} feature owner(s)${judge}:`,
|
|
118
|
+
...touched.map(({ feature, paths }) => ` ${feature}: ${paths.join(", ")}`),
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const owners = Effect.gen(function* () {
|
|
123
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
124
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
125
|
+
|
|
126
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
127
|
+
const { source, quality } = yield* readQuality(root);
|
|
128
|
+
const features = quality.features ?? [];
|
|
129
|
+
if (features.length === 0) {
|
|
130
|
+
yield* Console.log(`${NAME}: ${source} declares no feature`);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const { base, head } = yield* rangeEnds(first, second, root);
|
|
134
|
+
const found = yield* unproven(root, head, features);
|
|
135
|
+
yield* Console.log(proofReport(features, found));
|
|
136
|
+
if (quality.changeSignal === "advisory") {
|
|
137
|
+
yield* Console.log(signalReport(touches(features, yield* changedPaths(base, head, [], root))));
|
|
138
|
+
}
|
|
139
|
+
return found.length === 0;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (import.meta.main) runMain(NAME, owners);
|
package/scripts/gates.ts
CHANGED
|
@@ -37,7 +37,10 @@ export const KIT_GATES = [
|
|
|
37
37
|
{ bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
38
38
|
{ bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
39
39
|
{ bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
40
|
+
{ bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
40
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
|
+
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
41
44
|
] as const satisfies readonly KitGate[];
|
|
42
45
|
|
|
43
46
|
const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
package/scripts/git.ts
CHANGED
|
@@ -8,24 +8,76 @@ export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
|
|
|
8
8
|
const text = <E>(bytes: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
|
|
9
9
|
bytes.pipe(Stream.decodeText(), Stream.mkString);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export type Feed = {
|
|
12
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
13
|
+
readonly input?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Both pipes drain while the program runs: one left unread fills its buffer and stalls it.
|
|
17
|
+
export const collect = Effect.fn("collect")(
|
|
18
|
+
function* (program: string, args: readonly string[], cwd?: string, { env, input }: Feed = {}) {
|
|
14
19
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const handle = yield* spawner
|
|
18
|
-
.spawn(ChildProcess.make("git", args, { cwd }))
|
|
19
|
-
.pipe(Effect.mapError((cause) => failed(cause.message)));
|
|
20
|
+
const stdin = input === undefined ? undefined : Stream.make(new TextEncoder().encode(input));
|
|
21
|
+
const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin }));
|
|
20
22
|
const [stdout, stderr, exitCode] = yield* Effect.all([text(handle.stdout), text(handle.stderr), handle.exitCode], {
|
|
21
23
|
concurrency: "unbounded",
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
return stdout;
|
|
24
|
+
});
|
|
25
|
+
return { stdout, stderr, exitCode };
|
|
25
26
|
},
|
|
26
27
|
Effect.scoped,
|
|
27
28
|
);
|
|
28
29
|
|
|
30
|
+
export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: string, feed?: Feed) {
|
|
31
|
+
const failed = (reason: string): GitFailure => new GitFailure({ message: `git ${args.join(" ")}: ${reason.trim()}` });
|
|
32
|
+
const { stdout, stderr, exitCode } = yield* collect("git", args, cwd, feed).pipe(
|
|
33
|
+
Effect.mapError((cause) => failed(cause.message)),
|
|
34
|
+
);
|
|
35
|
+
if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
|
|
36
|
+
return stdout;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type Change =
|
|
40
|
+
| { readonly kind: "written" | "deleted"; readonly path: string }
|
|
41
|
+
| { readonly kind: "renamed"; readonly from: string; readonly path: string; readonly edited: boolean };
|
|
42
|
+
|
|
43
|
+
const UNCHANGED_RENAME = "R100";
|
|
44
|
+
|
|
45
|
+
function parseNameStatus(output: string): readonly Change[] {
|
|
46
|
+
const fields = output.split("\0");
|
|
47
|
+
const changes: Change[] = [];
|
|
48
|
+
let index = 0;
|
|
49
|
+
while (index < fields.length - 1) {
|
|
50
|
+
const status = fields[index] ?? "";
|
|
51
|
+
if (status.startsWith("R")) {
|
|
52
|
+
changes.push({ kind: "renamed", from: fields[index + 1] ?? "", path: fields[index + 2] ?? "", edited: status !== UNCHANGED_RENAME });
|
|
53
|
+
index += 3;
|
|
54
|
+
} else if (status.startsWith("C")) {
|
|
55
|
+
changes.push({ kind: "written", path: fields[index + 2] ?? "" });
|
|
56
|
+
index += 3;
|
|
57
|
+
} else {
|
|
58
|
+
changes.push({ kind: status === "D" ? "deleted" : "written", path: fields[index + 1] ?? "" });
|
|
59
|
+
index += 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return changes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
66
|
+
base: string,
|
|
67
|
+
head: string,
|
|
68
|
+
pathspecs: readonly string[],
|
|
69
|
+
cwd?: string,
|
|
70
|
+
) {
|
|
71
|
+
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const emptyTree = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
|
|
75
|
+
|
|
76
|
+
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|
|
77
|
+
const listed = yield* git(["diff", "--name-only", "-z", "--no-renames", yield* emptyTree(cwd), rev, "--", ...pathspecs], cwd);
|
|
78
|
+
return listed.split("\0").filter((path) => path !== "");
|
|
79
|
+
});
|
|
80
|
+
|
|
29
81
|
function namesAParent(commitObject: string): boolean {
|
|
30
82
|
const [headers = ""] = commitObject.split("\n\n", 1);
|
|
31
83
|
return headers.split("\n").some((header) => header.startsWith("parent "));
|
|
@@ -34,8 +86,12 @@ function namesAParent(commitObject: string): boolean {
|
|
|
34
86
|
// A shallow clone's boundary commit reads as parentless to rev-parse and log, and judged against
|
|
35
87
|
// the empty tree it would carry the whole repository; only the commit object still names its parents.
|
|
36
88
|
export const parentOrEmptyTree = Effect.fn("parentOrEmptyTree")(function* (rev: string, cwd?: string) {
|
|
37
|
-
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd)))
|
|
38
|
-
return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
|
|
39
|
-
}
|
|
89
|
+
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd))) return yield* emptyTree(cwd);
|
|
40
90
|
return (yield* git(["rev-parse", "--verify", `${rev}^`], cwd)).trim();
|
|
41
91
|
});
|
|
92
|
+
|
|
93
|
+
// From the base branch's tip, a range would charge the head with what the base branch changed after it branched off.
|
|
94
|
+
export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second: string | undefined, cwd?: string) {
|
|
95
|
+
if (second === undefined) return { base: yield* parentOrEmptyTree(first, cwd), head: first };
|
|
96
|
+
return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
|
|
97
|
+
});
|
package/scripts/quality-file.ts
CHANGED
|
@@ -20,6 +20,28 @@ const PathGlob = Schema.String.check(
|
|
|
20
20
|
"A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./",
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
24
|
+
|
|
25
|
+
const DirectoryPath = Schema.String.check(
|
|
26
|
+
Schema.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
27
|
+
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash",
|
|
28
|
+
}),
|
|
29
|
+
).annotate({ identifier: "DirectoryPath" });
|
|
30
|
+
|
|
31
|
+
const FilePath = Schema.String.check(
|
|
32
|
+
Schema.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
|
|
33
|
+
expected: "a file from the repository root such as src/billing/index.ts, with no glob",
|
|
34
|
+
}),
|
|
35
|
+
).annotate({ identifier: "FilePath" });
|
|
36
|
+
|
|
37
|
+
export const PROOF_DIRECTORY = "tests/e2e/";
|
|
38
|
+
|
|
39
|
+
const ProofPath = Schema.String.check(
|
|
40
|
+
Schema.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
|
|
41
|
+
expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`,
|
|
42
|
+
}),
|
|
43
|
+
).annotate({ identifier: "ProofPath" });
|
|
44
|
+
|
|
23
45
|
const Command = Schema.NonEmptyString.annotate({ identifier: "Command" });
|
|
24
46
|
|
|
25
47
|
const RuleName = Schema.String.check(
|
|
@@ -65,6 +87,58 @@ const Sources = Schema.Struct({
|
|
|
65
87
|
effect: Schema.optionalKey(EffectSources),
|
|
66
88
|
});
|
|
67
89
|
|
|
90
|
+
const LineBudget = Schema.Int.check(Schema.isGreaterThan(0));
|
|
91
|
+
|
|
92
|
+
const Size = Schema.Struct({
|
|
93
|
+
fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
|
|
94
|
+
functionLines: LineBudget.annotate({
|
|
95
|
+
description: "The most lines a function may span, blank and comment lines counted",
|
|
96
|
+
}),
|
|
97
|
+
applies: Schema.Literals(["changed", "all"]).annotate({
|
|
98
|
+
description:
|
|
99
|
+
"Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory",
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const Feature = Schema.Struct({
|
|
104
|
+
name: Schema.String.check(
|
|
105
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
|
|
106
|
+
).annotate({ description: "The owner the dependency rule and the change signal name" }),
|
|
107
|
+
root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
|
|
108
|
+
entries: Schema.NonEmptyArray(FilePath).annotate({
|
|
109
|
+
description: "The files under root that code outside it imports the feature through",
|
|
110
|
+
}),
|
|
111
|
+
allowFrom: Schema.optionalKey(
|
|
112
|
+
Schema.Array(PathGlob).annotate({
|
|
113
|
+
description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may",
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" }),
|
|
117
|
+
}).check(
|
|
118
|
+
Schema.makeFilter(({ root, entries }) => {
|
|
119
|
+
const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
|
|
120
|
+
return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
export type Feature = typeof Feature.Type;
|
|
124
|
+
|
|
125
|
+
function nests(outer: string, inner: string): boolean {
|
|
126
|
+
return outer === inner || inner.startsWith(`${outer}/`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const Features = Schema.Array(Feature).check(
|
|
130
|
+
Schema.makeFilter((features) => {
|
|
131
|
+
const names = features.map((feature) => feature.name);
|
|
132
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
133
|
+
if (repeated.length > 0) return `names ${[...new Set(repeated)].join(", ")} more than once`;
|
|
134
|
+
for (const outer of features) {
|
|
135
|
+
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
136
|
+
if (inner !== undefined) return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
68
142
|
const AgentRules = Schema.Struct({
|
|
69
143
|
on: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
|
|
70
144
|
off: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" })),
|
|
@@ -75,6 +149,26 @@ const AgentRules = Schema.Struct({
|
|
|
75
149
|
}),
|
|
76
150
|
);
|
|
77
151
|
|
|
152
|
+
export const MODES = ["tutorial", "how-to", "reference", "explanation"] as const;
|
|
153
|
+
export type Mode = (typeof MODES)[number];
|
|
154
|
+
|
|
155
|
+
const pagesIn = (mode: string) =>
|
|
156
|
+
Schema.optionalKey(Schema.Array(PathGlob).annotate({ description: `The pages written as ${mode}` }));
|
|
157
|
+
|
|
158
|
+
const Docs = Schema.Struct({
|
|
159
|
+
pages: Schema.optionalKey(
|
|
160
|
+
Schema.Struct({
|
|
161
|
+
tutorial: pagesIn("a tutorial, which teaches by building one thing"),
|
|
162
|
+
"how-to": pagesIn("a how-to, which walks one task"),
|
|
163
|
+
reference: pagesIn("reference, which describes a thing to be looked up"),
|
|
164
|
+
explanation: pagesIn("an explanation, which says why"),
|
|
165
|
+
} satisfies Record<Mode, unknown>).annotate({
|
|
166
|
+
description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one",
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
});
|
|
170
|
+
export type Docs = typeof Docs.Type;
|
|
171
|
+
|
|
78
172
|
export const Quality = Schema.Struct({
|
|
79
173
|
$schema: Schema.optionalKey(Schema.String),
|
|
80
174
|
defaultBranch: Schema.optionalKey(
|
|
@@ -83,11 +177,48 @@ export const Quality = Schema.Struct({
|
|
|
83
177
|
gates: Schema.optionalKey(Gates),
|
|
84
178
|
commitIdentity: Schema.optionalKey(CommitIdentity),
|
|
85
179
|
sources: Schema.optionalKey(Sources),
|
|
180
|
+
size: Schema.optionalKey(
|
|
181
|
+
Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" }),
|
|
182
|
+
),
|
|
183
|
+
features: Schema.optionalKey(
|
|
184
|
+
Features.annotate({
|
|
185
|
+
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
|
|
186
|
+
}),
|
|
187
|
+
),
|
|
188
|
+
changeSignal: Schema.optionalKey(
|
|
189
|
+
Schema.Literal("advisory").annotate({
|
|
190
|
+
description: "Report which feature owners a change touches, without failing on it",
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
86
193
|
agentRules: Schema.optionalKey(AgentRules),
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
194
|
+
docs: Schema.optionalKey(Docs.annotate({ description: "What checks-docs reads to map a doc file to its template" })),
|
|
195
|
+
})
|
|
196
|
+
.annotate({
|
|
197
|
+
title: QUALITY_FILE,
|
|
198
|
+
description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
|
|
199
|
+
})
|
|
200
|
+
.check(
|
|
201
|
+
Schema.makeFilter(
|
|
202
|
+
({ size, sources }) =>
|
|
203
|
+
size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production",
|
|
204
|
+
{
|
|
205
|
+
toJsonSchema: () => ({
|
|
206
|
+
if: { required: ["size"] },
|
|
207
|
+
then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } },
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
),
|
|
211
|
+
Schema.makeFilter(
|
|
212
|
+
({ changeSignal, features = [] }) =>
|
|
213
|
+
changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features",
|
|
214
|
+
{
|
|
215
|
+
toJsonSchema: () => ({
|
|
216
|
+
if: { required: ["changeSignal"] },
|
|
217
|
+
then: { required: ["features"], properties: { features: { minItems: 1 } } },
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
),
|
|
221
|
+
);
|
|
91
222
|
export type Quality = typeof Quality.Type;
|
|
92
223
|
|
|
93
224
|
const LegacyManifest = Schema.Struct({
|
package/scripts/quality.ts
CHANGED
|
@@ -119,10 +119,18 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
|
|
122
|
+
const declared = [
|
|
123
|
+
...(quality.size === undefined ? [] : (quality.sources?.production ?? [])).map((glob) => ({
|
|
124
|
+
glob,
|
|
125
|
+
key: "sources.production",
|
|
126
|
+
holds: "no source to the size budget",
|
|
127
|
+
})),
|
|
128
|
+
...(quality.sources?.effect?.paths ?? []).map((glob) => ({ glob, key: "sources.effect.paths", holds: "nothing to the Effect rules" })),
|
|
129
|
+
];
|
|
122
130
|
const problems: string[] = [];
|
|
123
|
-
for (const glob
|
|
131
|
+
for (const { glob, key, holds } of declared) {
|
|
124
132
|
const matched = yield* git(["ls-files", "--cached", "--others", "--exclude-standard", "--", `:(glob)${glob}`], root);
|
|
125
|
-
if (matched.trim() === "") problems.push(
|
|
133
|
+
if (matched.trim() === "") problems.push(`${key} ${glob} matches no file, so it holds ${holds}`);
|
|
126
134
|
}
|
|
127
135
|
return problems;
|
|
128
136
|
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { changedPaths, collect, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
4
|
+
import { runMain, Usage } from "./main.ts";
|
|
5
|
+
import { readQuality, renderJson, type Quality } from "./quality-file.ts";
|
|
6
|
+
|
|
7
|
+
type Size = NonNullable<Quality["size"]>;
|
|
8
|
+
|
|
9
|
+
type Overrun = {
|
|
10
|
+
readonly file: string;
|
|
11
|
+
readonly line: number | undefined;
|
|
12
|
+
readonly message: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Measured = {
|
|
16
|
+
readonly scope: string;
|
|
17
|
+
readonly held: readonly string[];
|
|
18
|
+
readonly overruns: readonly Overrun[];
|
|
19
|
+
readonly advisory: readonly Overrun[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class OxlintUnreadable extends Schema.TaggedError<OxlintUnreadable>()("OxlintUnreadable", {
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
const NAME = "size-budget";
|
|
27
|
+
const USAGE = "usage: size-budget.ts <ref> | <base-ref> <head-ref>";
|
|
28
|
+
const DECLARATIONS = ":(exclude,glob)**/*.d.ts";
|
|
29
|
+
const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
|
|
30
|
+
const FILE_RULE = "eslint(max-lines)";
|
|
31
|
+
const FUNCTION_RULE = "eslint(max-lines-per-function)";
|
|
32
|
+
const OXLINT_FOUND_NOTHING = 0;
|
|
33
|
+
const OXLINT_FOUND_ERRORS = 1;
|
|
34
|
+
|
|
35
|
+
const decodeReport = Schema.decodeUnknownEffect(
|
|
36
|
+
Schema.fromJsonString(
|
|
37
|
+
Schema.Struct({
|
|
38
|
+
diagnostics: Schema.Array(
|
|
39
|
+
Schema.Struct({
|
|
40
|
+
code: Schema.String,
|
|
41
|
+
message: Schema.String,
|
|
42
|
+
filename: Schema.String,
|
|
43
|
+
labels: Schema.Array(Schema.Struct({ span: Schema.Struct({ line: Schema.Int }) })),
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
function sizeConfig({ fileLines, functionLines }: Size): unknown {
|
|
51
|
+
const counted = { skipBlankLines: false, skipComments: false };
|
|
52
|
+
return {
|
|
53
|
+
plugins: [],
|
|
54
|
+
categories: { correctness: "off" },
|
|
55
|
+
rules: {
|
|
56
|
+
"max-lines": ["error", { max: fileLines, ...counted }],
|
|
57
|
+
"max-lines-per-function": ["error", { max: functionLines, ...counted }],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function heldByChange(changes: readonly Change[]): readonly string[] {
|
|
63
|
+
return changes.flatMap((change) => {
|
|
64
|
+
if (change.kind === "written" || (change.kind === "renamed" && change.edited)) return [change.path];
|
|
65
|
+
return [];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const materialize = Effect.fn("materialize")(function* (root: string, head: string, files: readonly string[], scratch: string) {
|
|
70
|
+
const path = yield* Path.Path;
|
|
71
|
+
const env = { GIT_INDEX_FILE: path.join(scratch, "index") };
|
|
72
|
+
yield* git(["read-tree", head], root, { env });
|
|
73
|
+
const tree = `${path.join(scratch, "tree")}${path.sep}`;
|
|
74
|
+
yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}`], root, { env, input: files.map((file) => `${file}\0`).join("") });
|
|
75
|
+
return tree;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// oxlint reads files from disk, and the working tree need not hold the head: a merge checkout or an uncommitted edit.
|
|
79
|
+
const measure = Effect.fn("measure")(
|
|
80
|
+
function* (root: string, head: string, size: Size, files: readonly string[]) {
|
|
81
|
+
if (files.length === 0) return [];
|
|
82
|
+
const fs = yield* FileSystem.FileSystem;
|
|
83
|
+
const path = yield* Path.Path;
|
|
84
|
+
const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-size-budget-" });
|
|
85
|
+
const config = path.join(scratch, "size.oxlintrc.json");
|
|
86
|
+
yield* fs.writeFileString(config, renderJson(sizeConfig(size)));
|
|
87
|
+
const tree = yield* materialize(root, head, files, scratch);
|
|
88
|
+
|
|
89
|
+
const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", config, "-f", "json", "."], tree).pipe(
|
|
90
|
+
Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
|
|
91
|
+
);
|
|
92
|
+
if (exitCode !== OXLINT_FOUND_NOTHING && exitCode !== OXLINT_FOUND_ERRORS) {
|
|
93
|
+
return yield* new OxlintUnreadable({ message: `oxlint exited ${exitCode}: ${stderr.trim() || stdout.trim()}` });
|
|
94
|
+
}
|
|
95
|
+
const { diagnostics } = yield* decodeReport(stdout).pipe(
|
|
96
|
+
Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot read oxlint's report: ${cause.message}` })),
|
|
97
|
+
);
|
|
98
|
+
return diagnostics.flatMap(({ code, message, filename, labels }): Overrun[] => {
|
|
99
|
+
if (code === FILE_RULE) return [{ file: filename, line: undefined, message }];
|
|
100
|
+
if (code === FUNCTION_RULE) return [{ file: filename, line: labels[0]?.span.line, message }];
|
|
101
|
+
return [];
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
Effect.scoped,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const runBudget = Effect.fn("runBudget")(function* (root: string, size: Size, production: readonly string[], base: string, head: string) {
|
|
108
|
+
const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
|
|
109
|
+
const held =
|
|
110
|
+
size.applies === "all" ? yield* pathsAt(head, pathspecs, root) : heldByChange(yield* changedPaths(base, head, pathspecs, root));
|
|
111
|
+
const holds = new Set(held);
|
|
112
|
+
const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
|
|
113
|
+
const overruns = (yield* measure(root, head, size, [...held, ...others])).toSorted(
|
|
114
|
+
(a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0),
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
scope: size.applies === "all" ? "every production file" : "the production files the range adds or changes",
|
|
118
|
+
held,
|
|
119
|
+
overruns: overruns.filter((overrun) => holds.has(overrun.file)),
|
|
120
|
+
advisory: overruns.filter((overrun) => !holds.has(overrun.file)),
|
|
121
|
+
} satisfies Measured;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function describe({ file, line, message }: Overrun): string {
|
|
125
|
+
return ` ${file}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function report({ scope, held, overruns, advisory }: Measured, { fileLines, functionLines }: Size): string {
|
|
129
|
+
const budget = `${fileLines} lines per file and ${functionLines} per function`;
|
|
130
|
+
const verdict =
|
|
131
|
+
overruns.length === 0
|
|
132
|
+
? [`${NAME}: ${held.length} file(s), ${scope}, keep within ${budget}`]
|
|
133
|
+
: [`${NAME}: ${overruns.length} overrun(s) of ${budget} in ${scope}:`, ...overruns.map(describe)];
|
|
134
|
+
const notice =
|
|
135
|
+
advisory.length === 0
|
|
136
|
+
? []
|
|
137
|
+
: [`${NAME}: advisory, ${advisory.length} overrun(s) where the budget does not hold yet:`, ...advisory.map(describe)];
|
|
138
|
+
return [...verdict, ...notice].join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const budget = Effect.gen(function* () {
|
|
142
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
143
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
144
|
+
|
|
145
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
146
|
+
const { source, quality } = yield* readQuality(root);
|
|
147
|
+
if (quality.size === undefined) {
|
|
148
|
+
yield* Console.log(`${NAME}: ${source} declares no size budget`);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const { base, head } = yield* rangeEnds(first, second, root);
|
|
152
|
+
const measured = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
|
|
153
|
+
|
|
154
|
+
yield* Console.log(report(measured, quality.size));
|
|
155
|
+
return measured.overruns.length === 0;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (import.meta.main) runMain(NAME, budget);
|
package/templates/adr.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# <number>. <The decision, as a sentence>
|
|
2
|
+
|
|
3
|
+
Date: <YYYY-MM-DD, the day the record was written>
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
<Proposed, Accepted, Rejected, Deprecated, Superseded or Retired as its first word, then what it amends or what replaced it.>
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
<What forced a decision, and what was true when it was made.>
|
|
12
|
+
|
|
13
|
+
## <Another part of the record, such as What was considered and rejected>
|
|
14
|
+
|
|
15
|
+
<Leave this section out when the record needs no more.>
|
|
16
|
+
|
|
17
|
+
<A section like this may also follow any section below it.>
|
|
18
|
+
|
|
19
|
+
<Its text.>
|
|
20
|
+
|
|
21
|
+
## Decision
|
|
22
|
+
|
|
23
|
+
<What was decided, stated as what now holds.>
|
|
24
|
+
|
|
25
|
+
## Consequences
|
|
26
|
+
|
|
27
|
+
<Leave this section out when nothing follows from the decision but the decision.>
|
|
28
|
+
|
|
29
|
+
<What follows from the decision, its cost included.>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Project agent memory
|
|
2
|
+
|
|
3
|
+
<What this repository is, in one sentence, and that README.md holds what a person reads.>
|
|
4
|
+
|
|
5
|
+
## <A topic an agent needs>
|
|
6
|
+
|
|
7
|
+
<Leave this section out when the lead holds every constraint.>
|
|
8
|
+
|
|
9
|
+
- <A constraint an agent cannot infer from the code, and the file that holds its detail.>
|
|
10
|
+
|
|
11
|
+
## Maintaining this file
|
|
12
|
+
|
|
13
|
+
Keep this file for knowledge useful to almost every future agent session in this project.
|
|
14
|
+
Do not repeat what the codebase already shows.
|
|
15
|
+
Point to the authoritative file or command instead.
|
|
16
|
+
Prefer rewriting or pruning existing entries over appending new ones.
|
|
17
|
+
When updating this file, preserve this bar for all agents and keep entries concise.
|