@avi2dg/checks 0.14.0 → 0.16.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.
@@ -0,0 +1,70 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { anchoredTargets, anchorsOf, PACKAGE_MANIFEST, packagesOf, snapshotOf, type Unresolved } from "./doc-references.ts";
3
+ import { git, pathsAt } from "./git.ts";
4
+
5
+ export class ManifestUnreadable extends Schema.TaggedError<ManifestUnreadable>()("ManifestUnreadable", {
6
+ message: Schema.String,
7
+ }) {}
8
+
9
+ const Scripts = Schema.fromJsonString(Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) }));
10
+ const decodeScripts = Schema.decodeUnknownEffect(Scripts);
11
+
12
+ export const readTexts = Effect.fn("readTexts")(function* (root: string, rev: string, paths: readonly string[]) {
13
+ const texts = yield* Effect.forEach(paths, (path) => git(["show", `${rev}:${path}`], root), { concurrency: 8 });
14
+ return new Map(paths.map((path, index) => [path, texts[index] ?? ""]));
15
+ });
16
+
17
+ function manifestIn(directory: string): string {
18
+ return directory === "" ? PACKAGE_MANIFEST : `${directory}/${PACKAGE_MANIFEST}`;
19
+ }
20
+
21
+ const scriptsAt = Effect.fn("scriptsAt")(function* (root: string, rev: string, directory: string) {
22
+ const manifest = manifestIn(directory);
23
+ const { scripts = {} } = yield* git(["show", `${rev}:${manifest}`], root).pipe(
24
+ Effect.flatMap(decodeScripts),
25
+ Effect.mapError((cause) => new ManifestUnreadable({ message: `${manifest} at ${rev}: ${cause.message}` })),
26
+ );
27
+ return new Set(Object.keys(scripts));
28
+ });
29
+
30
+ export const snapshotAt = Effect.fn("snapshotAt")(function* (
31
+ root: string,
32
+ rev: string,
33
+ docs: ReadonlyMap<string, string>,
34
+ alsoRoots: readonly string[],
35
+ ) {
36
+ const files = yield* pathsAt(rev, [], root);
37
+ const tracked = snapshotOf(files, new Map(), new Map());
38
+ const targets = [...new Set([...docs].flatMap(([doc, text]) => anchoredTargets(doc, text)))].filter((path) => tracked.files.has(path));
39
+ const read = yield* readTexts(root, rev, targets.filter((path) => !docs.has(path)));
40
+ const anchors = new Map(targets.map((path) => [path, anchorsOf(docs.get(path) ?? read.get(path) ?? "")]));
41
+ const packages = packagesOf([...docs.keys()], tracked);
42
+ const scripts = yield* Effect.forEach(packages, (directory) => scriptsAt(root, rev, directory), { concurrency: 8 });
43
+ return snapshotOf(files, anchors, new Map(packages.map((directory, index) => [directory, scripts[index] ?? new Set<string>()])), alsoRoots);
44
+ });
45
+
46
+ const ignored = (root: string, path: string) =>
47
+ git(["check-ignore", "-q", "--no-index", "--", path], root).pipe(
48
+ Effect.as(true),
49
+ Effect.catchTag("GitFailure", () => Effect.succeed(false)),
50
+ );
51
+
52
+ // A script bun runs from an installed dependency's bin names no entry in package.json.
53
+ const installedBin = Effect.fn("installedBin")(function* (root: string, directory: string, name: string) {
54
+ const fs = yield* FileSystem.FileSystem;
55
+ const path = yield* Path.Path;
56
+ const bins = [...new Set([directory, ""])].map((from) => path.join(root, from, "node_modules", ".bin", name));
57
+ const found = yield* Effect.forEach(bins, (bin) => fs.exists(bin).pipe(Effect.orElseSucceed(() => false)));
58
+ return found.some(Boolean);
59
+ });
60
+
61
+ // A path git ignores is generated or fetched, so a clean checkout lacks it by design.
62
+ const stillBroken = Effect.fn("stillBroken")(function* (root: string, { missing }: Unresolved) {
63
+ if (missing.type === "file") return !(yield* ignored(root, missing.path));
64
+ if (missing.type === "script") return !(yield* installedBin(root, missing.packageDirectory, missing.name));
65
+ return true;
66
+ });
67
+
68
+ export const stillMissing = Effect.fn("stillMissing")(function* (root: string, unresolved: readonly Unresolved[]) {
69
+ return yield* Effect.forEach(unresolved, (one) => stillBroken(root, one), { concurrency: 8 });
70
+ });
package/scripts/docs.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect } from "effect";
3
+ import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
3
4
  import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
4
- import { changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
5
+ import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
6
+ import { changedLines, changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
5
7
  import { runMain, Usage } from "./main.ts";
8
+ import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
6
9
  import { readQuality } from "./quality-file.ts";
7
10
 
8
11
  type Finding = {
@@ -13,46 +16,111 @@ type Finding = {
13
16
 
14
17
  type Judged = {
15
18
  readonly held: readonly string[];
19
+ readonly edited: { readonly docs: number; readonly lines: number };
20
+ readonly living: number;
16
21
  readonly findings: readonly Finding[];
17
22
  readonly advisory: ReadonlyMap<string, number>;
23
+ readonly brokenBefore: readonly Finding[];
18
24
  };
19
25
 
26
+ type Range = {
27
+ readonly root: string;
28
+ readonly base: string;
29
+ readonly head: string;
30
+ readonly roots: readonly string[];
31
+ readonly changed: ReadonlyMap<string, ReadonlySet<number>>;
32
+ readonly renamedFrom: ReadonlyMap<string, string>;
33
+ };
34
+
35
+ type Located = Unresolved & { readonly path: string };
36
+
20
37
  const NAME = "docs";
21
38
  const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
22
39
  const MARKDOWN = [":(glob)**/*.md"];
23
40
 
24
- const judgeFile = Effect.fn("judgeFile")(function* (
25
- root: string,
26
- head: string,
27
- path: string,
28
- placement: Placement,
29
- records: readonly string[],
30
- ) {
41
+ function templateFindings(path: string, text: string, placement: Placement, records: readonly string[]): readonly Finding[] {
31
42
  const misplaced = placementProblem(placement);
32
43
  if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
33
44
  if (placement.type !== "judged") return [];
34
- const text = yield* git(["show", `${head}:${path}`], root);
35
45
  return judge(placement.kind, { path, text }, records).map(({ line, message }) => ({ path, line, message }));
46
+ }
47
+
48
+ function inPathOrder(a: Finding, b: Finding): number {
49
+ return a.path === b.path ? (a.line ?? 0) - (b.line ?? 0) : a.path < b.path ? -1 : 1;
50
+ }
51
+
52
+ function locate(path: string, text: string, snapshot: Parameters<typeof unresolvedIn>[2], judging: Judging): readonly Located[] {
53
+ return unresolvedIn(path, text, snapshot, judging).map((unresolved) => ({ ...unresolved, path }));
54
+ }
55
+
56
+ const keyOf = ({ path, kind, named }: Located): string => `${path}\0${kind}\0${named}`;
57
+
58
+ // A reference on a line the range leaves alone fails only when the range broke it, as by deleting the file it names.
59
+ const brokenBeforeRange = Effect.fn("brokenBeforeRange")(function* (range: Range, found: readonly Located[], judging: (path: string) => Judging) {
60
+ const earlier = new Set(yield* pathsAt(range.base, MARKDOWN, range.root));
61
+ const docs = [...new Set(found.map(({ path }) => path))]
62
+ .map((path) => ({ path, from: range.renamedFrom.get(path) ?? path }))
63
+ .filter(({ from }) => earlier.has(from));
64
+ const texts = yield* readTexts(range.root, range.base, docs.map(({ from }) => from));
65
+ const snapshot = yield* snapshotAt(range.root, range.base, texts, range.roots);
66
+ return new Set(docs.flatMap(({ path, from }) => locate(from, texts.get(from) ?? "", snapshot, judging(path)).map((one) => keyOf({ ...one, path }))));
67
+ });
68
+
69
+ const referenceFindings = Effect.fn("referenceFindings")(function* (range: Range, texts: ReadonlyMap<string, string>, judging: (path: string) => Judging) {
70
+ const snapshot = yield* snapshotAt(range.root, range.head, texts, range.roots);
71
+ const unresolved = [...texts].flatMap(([path, text]) => locate(path, text, snapshot, judging(path)));
72
+ const missing = yield* stillMissing(range.root, unresolved);
73
+ const found = unresolved.filter((_, index) => missing[index] === true);
74
+ const onChangedLines = (one: Located): boolean => range.changed.get(one.path)?.has(one.line) === true;
75
+ const elsewhere = found.filter((one) => !onChangedLines(one));
76
+ const before = elsewhere.length === 0 ? new Set<string>() : yield* brokenBeforeRange(range, elsewhere, judging);
77
+ const finding = ({ path, line, message }: Located): Finding => ({ path, line, message });
78
+ return {
79
+ failing: found.filter((one) => onChangedLines(one) || !before.has(keyOf(one))).map(finding),
80
+ brokenBefore: elsewhere.filter((one) => before.has(keyOf(one))).map(finding),
81
+ };
36
82
  });
37
83
 
38
84
  const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head: string) {
39
85
  const { quality } = yield* readQuality(root);
40
- const touched = new Set(
41
- (yield* changedPaths(base, head, MARKDOWN, root)).flatMap((change) => (change.kind === "deleted" ? [] : [change.path])),
42
- );
86
+ const changes = yield* changedPaths(base, head, MARKDOWN, root);
87
+ const touched = new Set(changes.flatMap((change) => (change.kind === "deleted" ? [] : [change.path])));
88
+ const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
89
+ const changed = yield* changedLines(base, head, MARKDOWN, root);
43
90
  const present = yield* pathsAt(head, MARKDOWN, root);
44
91
  const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
45
- const placed = present.map((path) => ({ path, placement: placementOf(path, quality.docs) }));
46
- const judged = placed.filter(({ placement }) => placement.type !== "unjudged");
47
- const findings = (yield* Effect.forEach(judged, ({ path, placement }) => judgeFile(root, head, path, placement, records), {
48
- concurrency: 8,
49
- })).flat();
92
+ const judged = present.map((path) => ({ path, placement: placementOf(path, quality.docs) })).filter(({ placement }) => placement.type !== "unjudged");
93
+ const living = present.filter(isLivingDoc);
94
+ const proseDocs = present.flatMap((path) => {
95
+ const reader = readerOf(path);
96
+ return reader === undefined ? [] : [{ path, reader }];
97
+ });
98
+ const texts = yield* readTexts(root, head, [...new Set([...judged.map(({ path }) => path), ...proseDocs.map(({ path }) => path)])]);
99
+ const text = (path: string): string => texts.get(path) ?? "";
100
+
101
+ const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
102
+ const edited = proseDocs.filter(({ path }) => changed.has(path));
103
+ const prose = edited.flatMap(({ path, reader }) =>
104
+ proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
105
+ );
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)) });
108
+ // A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
109
+ const roots = rootsOf(yield* pathsAt(base, [], root));
110
+ const references = yield* referenceFindings(
111
+ { root, base, head, roots, changed, renamedFrom },
112
+ new Map(living.map((path) => [path, text(path)])),
113
+ judging,
114
+ );
50
115
  const advisory = new Map<string, number>();
51
- for (const { path } of findings.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
116
+ for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
52
117
  return {
53
118
  held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
54
- findings: findings.filter((finding) => touched.has(finding.path)),
119
+ edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
120
+ living: living.length,
121
+ findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing].toSorted(inPathOrder),
55
122
  advisory,
123
+ brokenBefore: references.brokenBefore.toSorted(inPathOrder),
56
124
  } satisfies Judged;
57
125
  });
58
126
 
@@ -60,19 +128,27 @@ function describe({ path, line, message }: Finding): string {
60
128
  return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
61
129
  }
62
130
 
63
- export function report({ held, findings, advisory }: Judged): string {
131
+ export function report({ held, edited, living, findings, advisory, brokenBefore }: Judged): string {
64
132
  const verdict =
65
133
  findings.length === 0
66
- ? [`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`]
67
- : [`${NAME}: ${findings.length} violation(s) in the doc files the range touches:`, ...findings.map(describe)];
68
- const notice =
134
+ ? [
135
+ `${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
136
+ `${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
137
+ `${NAME}: the range breaks no path, link or command the ${living} living doc(s) name`,
138
+ ]
139
+ : [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
140
+ const unconformed =
69
141
  advisory.size === 0
70
142
  ? []
71
143
  : [
72
144
  `${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
73
145
  ...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
74
146
  ];
75
- return [...verdict, ...notice].join("\n");
147
+ const broken =
148
+ brokenBefore.length === 0
149
+ ? []
150
+ : [`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs name were broken before the range:`, ...brokenBefore.map(describe)];
151
+ return [...verdict, ...unconformed, ...broken].join("\n");
76
152
  }
77
153
 
78
154
  const docs = Effect.gen(function* () {
package/scripts/gates.ts CHANGED
@@ -40,6 +40,7 @@ export const KIT_GATES = [
40
40
  { bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
41
41
  { bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
42
42
  { bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
43
+ { bin: "checks-repetition", script: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
43
44
  { bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
44
45
  ] as const satisfies readonly KitGate[];
45
46
 
package/scripts/git.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { Effect, Schema, Stream } from "effect";
1
+ import { Effect, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { Usage } from "./main.ts";
3
4
 
4
5
  export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
5
6
  message: Schema.String,
@@ -71,6 +72,70 @@ export const changedPaths = Effect.fn("changedPaths")(function* (
71
72
  return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
72
73
  });
73
74
 
75
+ const ESCAPES: Readonly<Record<string, string>> = { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
76
+
77
+ // Git quotes a path holding a quote, a backslash or a control character, and ends one holding a space with a tab.
78
+ function newPathOf(line: string): string | undefined {
79
+ const named = line.slice("+++ ".length).replace(/\t$/, "");
80
+ const path = named.startsWith('"')
81
+ ? named.slice(1, -1).replace(/\\(?:([0-7]{3})|(.))/g, (_, octal: string | undefined, char: string) =>
82
+ octal === undefined ? (ESCAPES[char] ?? char) : String.fromCharCode(Number.parseInt(octal, 8)),
83
+ )
84
+ : named;
85
+ return path === "/dev/null" ? undefined : path;
86
+ }
87
+
88
+ const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
89
+
90
+ // A hunk's header counts its lines, so an added line reading `++ x` is not taken for the next file's `+++` header.
91
+ export function parseAddedLines(diff: string): Map<string, Set<number>> {
92
+ const added = new Map<string, Set<number>>();
93
+ let path: string | undefined;
94
+ let line = 0;
95
+ let oldLeft = 0;
96
+ let newLeft = 0;
97
+ for (const row of diff.split("\n")) {
98
+ if (oldLeft > 0 || newLeft > 0) {
99
+ if (row.startsWith("+")) {
100
+ if (path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(line));
101
+ line += 1;
102
+ newLeft -= 1;
103
+ } else if (row.startsWith("-")) {
104
+ oldLeft -= 1;
105
+ } else if (row.startsWith(" ")) {
106
+ line += 1;
107
+ oldLeft -= 1;
108
+ newLeft -= 1;
109
+ }
110
+ continue;
111
+ }
112
+ if (row.startsWith("+++ ")) {
113
+ path = newPathOf(row);
114
+ continue;
115
+ }
116
+ const hunk = HUNK.exec(row);
117
+ if (hunk !== null) {
118
+ oldLeft = Number(hunk[1] ?? 1);
119
+ line = Number(hunk[2]);
120
+ newLeft = Number(hunk[3] ?? 1);
121
+ }
122
+ }
123
+ return added;
124
+ }
125
+
126
+ export const changedLines = Effect.fn("changedLines")(function* (
127
+ base: string,
128
+ head: string,
129
+ pathspecs: readonly string[],
130
+ cwd?: string,
131
+ ) {
132
+ const diff = yield* git(
133
+ ["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", "-M", base, head, "--", ...pathspecs],
134
+ cwd,
135
+ );
136
+ return parseAddedLines(diff);
137
+ });
138
+
74
139
  const emptyTree = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
75
140
 
76
141
  export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
@@ -95,3 +160,20 @@ export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second
95
160
  if (second === undefined) return { base: yield* parentOrEmptyTree(first, cwd), head: first };
96
161
  return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
97
162
  });
163
+
164
+ export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
165
+ const [first, second, ...extra] = args;
166
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: usage });
167
+ return yield* rangeEnds(first, second, cwd);
168
+ });
169
+
170
+ // A scratch index leaves the repository's own index and working tree untouched.
171
+ export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
172
+ const path = yield* Path.Path;
173
+ const env = { GIT_INDEX_FILE: path.join(scratch, "index") };
174
+ const tree = path.join(scratch, "tree");
175
+ yield* git(["read-tree", rev], cwd, { env });
176
+ const listed = files.map((file) => `${file}\0`).join("");
177
+ yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}${path.sep}`], cwd, { env, input: listed });
178
+ return tree;
179
+ });