@avi2dg/checks 0.26.0 → 0.28.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 +25 -0
- package/README.md +7 -6
- package/dist/data-shape/index.js +428 -0
- package/dist/templates/reference.md +1 -1
- package/docs/configs/commit-messages.md +1 -1
- package/docs/configs/dependency-rules.md +7 -1
- package/docs/configs/effect-rules.md +10 -0
- package/docs/configs/native-settings.md +8 -30
- package/docs/configs/typescript-rules.md +43 -1
- package/docs/design.md +232 -104
- package/docs/gates/checks-advisories.md +128 -0
- package/docs/gates/checks-changelog.md +8 -5
- package/docs/gates/checks-ci-wiring.md +2 -2
- package/docs/gates/checks-comment-gate.md +3 -4
- package/docs/gates/checks-commit-identity.md +3 -3
- package/docs/gates/checks-docs.md +56 -36
- package/docs/gates/checks-exports.md +4 -4
- package/docs/gates/checks-flake.md +3 -3
- package/docs/gates/checks-lint-coverage.md +17 -9
- package/docs/gates/checks-lint.md +9 -4
- package/docs/gates/checks-mutation-compare.md +3 -3
- package/docs/gates/checks-quarantine-clock.md +3 -5
- package/docs/gates/checks-release-notes.md +4 -4
- package/docs/gates/checks-release-report.md +3 -3
- package/docs/gates/checks-repetition.md +2 -1
- package/docs/gates/checks-subsumed-tests.md +3 -3
- package/docs/gates/checks-suppressions-ratchet.md +3 -4
- package/docs/gates/checks-test-layout.md +4 -4
- package/docs/gates/checks-test.md +3 -2
- package/docs/gates/checks-unused.md +4 -4
- package/docs/gates/checks-vendor.md +3 -3
- package/oxlintrc.json +22 -2
- package/package.json +16 -6
- package/src/complexity/exports.ts +8 -16
- package/src/complexity/knip.ts +1 -1
- package/src/core/gates.ts +3 -0
- package/src/core/git.ts +10 -0
- package/src/delivery/ci-wiring.ts +1 -1
- package/src/dependencies/advisories.ts +115 -0
- package/src/dependencies/advisory-rules.ts +206 -0
- package/src/dependencies/cache-root.ts +15 -0
- package/src/dependencies/osv-scanner.ts +176 -0
- package/src/dependencies/vendor.ts +1 -10
- package/src/docs/doc-names.ts +128 -0
- package/src/docs/doc-templates.ts +1 -1
- package/src/docs/docs.ts +17 -14
- package/src/docs/prose-matchers.ts +11 -1
- package/src/quality/lint-coverage.sh +35 -2
- package/src/quality/presets/effect.language-service.json +3 -1
- package/src/testing/mutation-compare.ts +51 -45
- package/src/testing/quarantine-clock.ts +2 -11
- package/src/testing/test-layout.ts +1 -1
- package/ts-reset.d.ts +2 -0
- package/tsconfig.effect.json +3 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Clock, Context, Crypto, Effect, Encoding, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
|
|
3
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
4
|
+
import { collect } from "../core/git.ts";
|
|
5
|
+
|
|
6
|
+
export const OSV_SCANNER_VERSION = "2.6.0";
|
|
7
|
+
const RELEASES = `https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}`;
|
|
8
|
+
const EXECUTABLE_MODE = 0o755;
|
|
9
|
+
const HOUR_MS = 3_600_000;
|
|
10
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
11
|
+
export const REFRESH_HOURS = 24;
|
|
12
|
+
export const USABLE_DAYS = 7;
|
|
13
|
+
const FOUND_NOTHING = 0;
|
|
14
|
+
const FOUND_ADVISORIES = 1;
|
|
15
|
+
const FOUND_NO_PACKAGE = 128;
|
|
16
|
+
const NO_RESULTS = JSON.stringify({ results: [] });
|
|
17
|
+
|
|
18
|
+
type Build = { readonly asset: string; readonly sha256: string };
|
|
19
|
+
|
|
20
|
+
// The SHA-256 of each asset as osv-scanner_SHA256SUMS of the release lists it.
|
|
21
|
+
const BUILDS: Readonly<Record<string, Build>> = {
|
|
22
|
+
"darwin-arm64": { asset: "osv-scanner_darwin_arm64", sha256: "98c460dcd37de25819babd757d04542045b6243113e209edcd4d89fedb0256b4" },
|
|
23
|
+
"darwin-x64": { asset: "osv-scanner_darwin_amd64", sha256: "60c5296637e977b28eeda5c7f13573e447659a632922737f94d11fa7e30ad6ca" },
|
|
24
|
+
"linux-arm64": { asset: "osv-scanner_linux_arm64", sha256: "2c71403eb443d05891c4f268c3ad771cf4f16e5443463fd7851ef8f454d3c7e4" },
|
|
25
|
+
"linux-x64": { asset: "osv-scanner_linux_amd64", sha256: "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class OsvScannerError extends Schema.TaggedError<OsvScannerError>()("OsvScannerError", {
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
export function buildFor(platform: string, arch: string): Option.Option<Build> {
|
|
33
|
+
return Option.fromNullishOr(BUILDS[`${platform}-${arch}`]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sha256Of = Effect.fn("sha256Of")(function* (bytes: Uint8Array) {
|
|
37
|
+
const crypto = yield* Crypto.Crypto;
|
|
38
|
+
return Encoding.encodeHex(yield* crypto.digest("SHA-256", bytes));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const verified = Effect.fn("verified")(function* (binary: string, sha256: string) {
|
|
42
|
+
const fs = yield* FileSystem.FileSystem;
|
|
43
|
+
const found = yield* sha256Of(yield* fs.readFile(binary));
|
|
44
|
+
if (found !== sha256) {
|
|
45
|
+
return yield* new OsvScannerError({ message: `${binary} has SHA-256 ${found}, not the pinned ${sha256}; delete it and rerun` });
|
|
46
|
+
}
|
|
47
|
+
return binary;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const download = Effect.fn("download")(function* (url: string) {
|
|
51
|
+
const response = yield* HttpClient.get(url).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
|
|
52
|
+
return new Uint8Array(yield* response.arrayBuffer);
|
|
53
|
+
}, Effect.provide(FetchHttpClient.layer));
|
|
54
|
+
|
|
55
|
+
// The binary lands through a rename in its own directory, so a concurrent run sees it whole or not at all.
|
|
56
|
+
export const installPinned = Effect.fn("installPinned")(function* (url: string, sha256: string, binary: string) {
|
|
57
|
+
const fs = yield* FileSystem.FileSystem;
|
|
58
|
+
const path = yield* Path.Path;
|
|
59
|
+
if (yield* fs.exists(binary)) return yield* verified(binary, sha256);
|
|
60
|
+
const bytes = yield* download(url).pipe(
|
|
61
|
+
Effect.mapError((cause) => new OsvScannerError({ message: `cannot download ${url}: ${cause.message}` })),
|
|
62
|
+
);
|
|
63
|
+
const found = yield* sha256Of(bytes);
|
|
64
|
+
if (found !== sha256) {
|
|
65
|
+
return yield* new OsvScannerError({ message: `${url} has SHA-256 ${found}, not the pinned ${sha256}, so nothing was installed` });
|
|
66
|
+
}
|
|
67
|
+
yield* fs.makeDirectory(path.dirname(binary), { recursive: true });
|
|
68
|
+
const staging = yield* fs.makeTempDirectoryScoped({ directory: path.dirname(binary), prefix: `.${path.basename(binary)}-` });
|
|
69
|
+
const staged = path.join(staging, path.basename(binary));
|
|
70
|
+
yield* fs.writeFile(staged, bytes);
|
|
71
|
+
yield* fs.chmod(staged, EXECUTABLE_MODE);
|
|
72
|
+
yield* fs.rename(staged, binary);
|
|
73
|
+
return binary;
|
|
74
|
+
}, Effect.scoped);
|
|
75
|
+
|
|
76
|
+
const pinnedBinary = Effect.fn("pinnedBinary")(function* (cache: string) {
|
|
77
|
+
const build = buildFor(process.platform, process.arch);
|
|
78
|
+
if (Option.isNone(build)) {
|
|
79
|
+
return yield* new OsvScannerError({ message: `no pinned OSV-Scanner build runs on ${process.platform}-${process.arch}` });
|
|
80
|
+
}
|
|
81
|
+
const path = yield* Path.Path;
|
|
82
|
+
const { asset, sha256 } = build.value;
|
|
83
|
+
return yield* installPinned(`${RELEASES}/${asset}`, sha256, path.join(cache, "osv-scanner", OSV_SCANNER_VERSION, asset));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
type PinnedBinary = ReturnType<typeof pinnedBinary>;
|
|
87
|
+
|
|
88
|
+
export class Scanner extends Context.Service<
|
|
89
|
+
Scanner,
|
|
90
|
+
{ readonly binary: (cache: string) => Effect.Effect<string, Effect.Error<PinnedBinary>> }
|
|
91
|
+
>()("@avi2dg/checks/dependencies/Scanner") {
|
|
92
|
+
static readonly pinned = Layer.effect(
|
|
93
|
+
Scanner,
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const services = yield* Effect.context<Effect.Services<PinnedBinary>>();
|
|
96
|
+
return Scanner.of({ binary: (cache) => pinnedBinary(cache).pipe(Effect.provideContext(services)) });
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type RefreshPlan = "refresh" | "offline";
|
|
102
|
+
|
|
103
|
+
export function refreshPlan(refreshedAgo: Option.Option<number>): RefreshPlan {
|
|
104
|
+
return Option.isSome(refreshedAgo) && refreshedAgo.value <= REFRESH_HOURS * HOUR_MS ? "offline" : "refresh";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function usableWithoutRefresh(refreshedAgo: Option.Option<number>): boolean {
|
|
108
|
+
return Option.isSome(refreshedAgo) && refreshedAgo.value <= USABLE_DAYS * DAY_MS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Database = { readonly dir: string; readonly marker: string };
|
|
112
|
+
|
|
113
|
+
// An unparsable marker gives NaN and one dated ahead of the clock a negative age, and neither says how old the database is.
|
|
114
|
+
export function refreshAge(recorded: Option.Option<string>, now: number): Option.Option<number> {
|
|
115
|
+
return Option.filter(Option.map(recorded, (text) => now - Date.parse(text.trim())), (ago) => ago >= 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const refreshedAgo = Effect.fn("refreshedAgo")(function* ({ marker }: Database) {
|
|
119
|
+
const fs = yield* FileSystem.FileSystem;
|
|
120
|
+
const recorded = yield* fs.readFileString(marker).pipe(Effect.option);
|
|
121
|
+
return refreshAge(recorded, yield* Clock.currentTimeMillis);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const FLAGS: Readonly<Record<RefreshPlan, readonly string[]>> = {
|
|
125
|
+
refresh: ["--offline-vulnerabilities", "--download-offline-databases", "--no-resolve"],
|
|
126
|
+
offline: ["--offline"],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type Scanned =
|
|
130
|
+
| { readonly kind: "scanned"; readonly stdout: string }
|
|
131
|
+
| { readonly kind: "no-package" }
|
|
132
|
+
| { readonly kind: "failed"; readonly reason: string };
|
|
133
|
+
|
|
134
|
+
const scanOnce = Effect.fn("scanOnce")(function* (binary: string, database: Database, config: string, lockfiles: readonly string[], plan: RefreshPlan) {
|
|
135
|
+
const args = ["scan", "source", ...FLAGS[plan], "--format", "json", "--config", config, ...lockfiles.flatMap((file) => ["-L", file])];
|
|
136
|
+
const { stdout, stderr, exitCode } = yield* collect(binary, args, undefined, { env: { OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY: database.dir } }).pipe(
|
|
137
|
+
Effect.mapError((cause) => new OsvScannerError({ message: `cannot run ${binary}: ${cause.message}` })),
|
|
138
|
+
);
|
|
139
|
+
if (exitCode === FOUND_NOTHING || exitCode === FOUND_ADVISORIES) return { kind: "scanned", stdout } satisfies Scanned;
|
|
140
|
+
if (exitCode === FOUND_NO_PACKAGE) return { kind: "no-package" } satisfies Scanned;
|
|
141
|
+
const reason = stderr.trim().split("\n").at(-1) ?? "";
|
|
142
|
+
return { kind: "failed", reason: `${binary} exited ${exitCode}: ${reason}` } satisfies Scanned;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
type ScanResult = { readonly stdout: string; readonly note: Option.Option<string> };
|
|
146
|
+
|
|
147
|
+
// A failed refresh leaves the cached database in place, which still serves until USABLE_DAYS pass without a refresh.
|
|
148
|
+
export const scanLockfiles = Effect.fn("scanLockfiles")(function* (binary: string, cache: string, config: string, lockfiles: readonly string[]) {
|
|
149
|
+
const fs = yield* FileSystem.FileSystem;
|
|
150
|
+
const path = yield* Path.Path;
|
|
151
|
+
const dir = path.join(cache, "osv-scanner", "db");
|
|
152
|
+
const database = { dir, marker: path.join(dir, "refreshed") };
|
|
153
|
+
const ago = yield* refreshedAgo(database);
|
|
154
|
+
const plan = refreshPlan(ago);
|
|
155
|
+
const first = yield* scanOnce(binary, database, config, lockfiles, plan);
|
|
156
|
+
// A lockfile with no package stops the scanner before it downloads anything, so no refresh is recorded.
|
|
157
|
+
if (first.kind === "no-package") return { stdout: NO_RESULTS, note: Option.none() } satisfies ScanResult;
|
|
158
|
+
if (first.kind === "scanned") {
|
|
159
|
+
if (plan === "refresh") {
|
|
160
|
+
yield* fs.makeDirectory(dir, { recursive: true });
|
|
161
|
+
yield* fs.writeFileString(database.marker, `${new Date(yield* Clock.currentTimeMillis).toISOString()}\n`);
|
|
162
|
+
}
|
|
163
|
+
return { stdout: first.stdout, note: Option.none() } satisfies ScanResult;
|
|
164
|
+
}
|
|
165
|
+
if (plan === "offline") return yield* new OsvScannerError({ message: first.reason });
|
|
166
|
+
if (Option.isNone(ago) || !usableWithoutRefresh(ago)) {
|
|
167
|
+
return yield* new OsvScannerError({
|
|
168
|
+
message: `could not refresh the OSV database, and no copy was refreshed in the last ${USABLE_DAYS} days: ${first.reason}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const fallback = yield* scanOnce(binary, database, config, lockfiles, "offline");
|
|
172
|
+
if (fallback.kind === "failed") return yield* new OsvScannerError({ message: fallback.reason });
|
|
173
|
+
if (fallback.kind === "no-package") return { stdout: NO_RESULTS, note: Option.none() } satisfies ScanResult;
|
|
174
|
+
const days = Math.floor(ago.value / DAY_MS);
|
|
175
|
+
return { stdout: fallback.stdout, note: Option.some(`could not refresh the OSV database, so the scan read the copy refreshed ${days} day(s) ago: ${first.reason}`) } satisfies ScanResult;
|
|
176
|
+
});
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
import { git } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
|
+
import { cacheRoot } from "./cache-root.ts";
|
|
5
6
|
import { librariesFrom, NAME, OPENER, type Library } from "./vendor-args.ts";
|
|
6
7
|
|
|
7
|
-
const CACHE_HOME = ".cache/avi2dg-checks";
|
|
8
8
|
const RECORD_SUFFIX = ".commit";
|
|
9
9
|
const LINKS = "repos";
|
|
10
10
|
const VERSION_TOKEN = "{version}";
|
|
@@ -303,15 +303,6 @@ const vend = Effect.fn("vend")(function* (root: string, cache: string, library:
|
|
|
303
303
|
yield* ensureLink(root, library, dir);
|
|
304
304
|
});
|
|
305
305
|
|
|
306
|
-
const cacheRoot = Effect.fn("cacheRoot")(function* () {
|
|
307
|
-
const path = yield* Path.Path;
|
|
308
|
-
const home = process.env.HOME;
|
|
309
|
-
if (home === undefined || home === "") {
|
|
310
|
-
return yield* new VendorError({ message: "HOME is missing, so the shared cache has no root" });
|
|
311
|
-
}
|
|
312
|
-
return path.join(home, CACHE_HOME);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
306
|
const main = Effect.gen(function* () {
|
|
316
307
|
const libraries = yield* librariesFrom(process.argv.slice(2));
|
|
317
308
|
if (libraries.length === 0) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
|
+
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
3
|
+
import { collect, git, pathsAt } from "../core/git.ts";
|
|
4
|
+
import type { Unresolved } from "./doc-references.ts";
|
|
5
|
+
import { scanMarkdown } from "./prose-matchers.ts";
|
|
6
|
+
|
|
7
|
+
export type VanishedName = { readonly path: string; readonly line: number; readonly named: string; readonly message: string };
|
|
8
|
+
|
|
9
|
+
const OUTSIDE_DOCS = [".", ":(exclude)*.md"];
|
|
10
|
+
const NOT_ONE_NAME = /\s|<[^>]*>|^-/;
|
|
11
|
+
const HAS_LETTER = /[A-Za-z]/;
|
|
12
|
+
|
|
13
|
+
function nameOf(span: string, ownPackage: string | undefined): string | undefined {
|
|
14
|
+
if (span.length < 3 || !HAS_LETTER.test(span) || NOT_ONE_NAME.test(span)) return undefined;
|
|
15
|
+
let name = span
|
|
16
|
+
.replace(/^(["'])(.*)\1$/, "$2")
|
|
17
|
+
.replace(/^(?:\.|~)\//, "")
|
|
18
|
+
.replace(/^node_modules\//, "");
|
|
19
|
+
if (ownPackage !== undefined && name.startsWith(`${ownPackage}/`)) name = name.slice(ownPackage.length + 1);
|
|
20
|
+
return name.length < 3 ? undefined : name;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function namesIn(texts: ReadonlyMap<string, string>, ownPackage: string | undefined): readonly { path: string; line: number; name: string }[] {
|
|
24
|
+
return [...texts].flatMap(([path, text]) =>
|
|
25
|
+
scanMarkdown(text).flatMap((line) =>
|
|
26
|
+
line.kind === "code" || line.kind === "front-matter"
|
|
27
|
+
? []
|
|
28
|
+
: [...new Set(line.code)].flatMap((span) => {
|
|
29
|
+
const name = nameOf(span, ownPackage);
|
|
30
|
+
return name === undefined ? [] : [{ path, line: line.line, name }];
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class InstalledUnreadable extends Schema.TaggedError<InstalledUnreadable>()("InstalledUnreadable", {
|
|
37
|
+
message: Schema.String,
|
|
38
|
+
}) {}
|
|
39
|
+
|
|
40
|
+
const Group = Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown));
|
|
41
|
+
const Manifest = Schema.Struct({
|
|
42
|
+
name: Schema.optionalKey(Schema.String),
|
|
43
|
+
dependencies: Group,
|
|
44
|
+
devDependencies: Group,
|
|
45
|
+
peerDependencies: Group,
|
|
46
|
+
optionalDependencies: Group,
|
|
47
|
+
});
|
|
48
|
+
const decodeManifest = Schema.decodeUnknownOption(Schema.fromJsonString(Manifest));
|
|
49
|
+
const NO_MANIFEST: typeof Manifest.Type = {};
|
|
50
|
+
|
|
51
|
+
// git grep exits 1 when nothing matches, which is an answer rather than a failure.
|
|
52
|
+
const matchedAt = Effect.fn("matchedAt")(function* (root: string, rev: string, names: readonly string[]) {
|
|
53
|
+
if (names.length === 0) return new Set<string>();
|
|
54
|
+
const found = yield* git(["grep", "-I", "-F", "-o", "-h", "--no-color", "-f", "-", rev, "--", ...OUTSIDE_DOCS], root, {
|
|
55
|
+
input: `${names.join("\n")}\n`,
|
|
56
|
+
}).pipe(Effect.catchTag("GitFailure", () => Effect.succeed("")));
|
|
57
|
+
const paths = (yield* pathsAt(rev, [], root)).join("\n");
|
|
58
|
+
return new Set([...found.split("\n"), ...names.filter((name) => paths.includes(name))]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// -o reports one pattern per match, so a name inside a longer name's match is confirmed on its own.
|
|
62
|
+
const heldAt = Effect.fn("heldAt")(function* (root: string, rev: string, name: string) {
|
|
63
|
+
return yield* git(["grep", "-I", "-F", "-q", "-e", name, rev, "--", ...OUTSIDE_DOCS], root).pipe(
|
|
64
|
+
Effect.as(true),
|
|
65
|
+
Effect.catchTag("GitFailure", () => Effect.succeed(false)),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const manifestAt = Effect.fn("manifestAt")(function* (root: string, head: string) {
|
|
70
|
+
const manifest = yield* git(["show", `${head}:package.json`], root).pipe(Effect.catchTag("GitFailure", () => Effect.succeed("")));
|
|
71
|
+
const { name, dependencies, devDependencies, peerDependencies, optionalDependencies } = Option.getOrElse(decodeManifest(manifest), () => NO_MANIFEST);
|
|
72
|
+
const deps = [dependencies, devDependencies, peerDependencies, optionalDependencies].flatMap((group) => Object.keys(group ?? {}));
|
|
73
|
+
return { name, deps: [...new Set(deps)] };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const installedDirs = Effect.fn("installedDirs")(function* (root: string, deps: readonly string[]) {
|
|
77
|
+
const fs = yield* FileSystem.FileSystem;
|
|
78
|
+
const path = yield* Path.Path;
|
|
79
|
+
const dirs = deps.map((dep) => `node_modules/${dep}/`);
|
|
80
|
+
const present = yield* Effect.forEach(dirs, (dir) => fs.exists(path.join(root, dir)), { concurrency: 8 });
|
|
81
|
+
return dirs.filter((_, index) => present[index] === true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const installedHolds = Effect.fn("installedHolds")(function* (root: string, dirs: readonly string[], name: string) {
|
|
85
|
+
if (dirs.length === 0) return false;
|
|
86
|
+
const { stderr, exitCode } = yield* collect("grep", ["-r", "-I", "-F", "-q", "-e", name, "--", ...dirs], root);
|
|
87
|
+
if (exitCode === ChildProcessSpawner.ExitCode(0)) return true;
|
|
88
|
+
if (exitCode === ChildProcessSpawner.ExitCode(1)) return false;
|
|
89
|
+
return yield* new InstalledUnreadable({ message: `grep for \`${name}\` in ${dirs.join(", ")}: ${stderr.trim()}` });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const vanishedNames = Effect.fn("vanishedNames")(function* (
|
|
93
|
+
root: string,
|
|
94
|
+
base: string,
|
|
95
|
+
head: string,
|
|
96
|
+
texts: ReadonlyMap<string, string>,
|
|
97
|
+
failed: readonly (Unresolved & { readonly path: string })[],
|
|
98
|
+
) {
|
|
99
|
+
const manifest = yield* manifestAt(root, head);
|
|
100
|
+
const named = namesIn(texts, manifest.name);
|
|
101
|
+
const names = [...new Set(named.map(({ name }) => name))];
|
|
102
|
+
const atHead = yield* matchedAt(root, head, names);
|
|
103
|
+
const candidates = names.filter((name) => !atHead.has(name));
|
|
104
|
+
const atBase = yield* matchedAt(root, base, candidates);
|
|
105
|
+
const heldAtBase = yield* Effect.forEach(candidates, (name) => (atBase.has(name) ? Effect.succeed(true) : heldAt(root, base, name)), {
|
|
106
|
+
concurrency: 8,
|
|
107
|
+
});
|
|
108
|
+
const gone = candidates.filter((_, index) => heldAtBase[index] === true);
|
|
109
|
+
const confirmed = yield* Effect.forEach(gone, (name) => heldAt(root, head, name), { concurrency: 8 });
|
|
110
|
+
const unheld = gone.filter((_, index) => confirmed[index] === false);
|
|
111
|
+
const dirs = yield* installedDirs(root, manifest.deps);
|
|
112
|
+
const installed = yield* Effect.forEach(unheld, (name) => installedHolds(root, dirs, name), { concurrency: 8 });
|
|
113
|
+
const vanished = new Set(unheld.filter((_, index) => installed[index] === false));
|
|
114
|
+
const reported = new Set(
|
|
115
|
+
failed.flatMap((one) => (one.kind === "path" ? [`${one.path}:${one.line}:${nameOf(one.named, manifest.name) ?? one.named}`] : [])),
|
|
116
|
+
);
|
|
117
|
+
return named
|
|
118
|
+
.filter(({ name }) => vanished.has(name))
|
|
119
|
+
.filter(({ path, line, name }) => !reported.has(`${path}:${line}:${name}`))
|
|
120
|
+
.map(
|
|
121
|
+
({ path, line, name }): VanishedName => ({
|
|
122
|
+
path,
|
|
123
|
+
line,
|
|
124
|
+
named: name,
|
|
125
|
+
message: `names \`${name}\`, which the range removed from every file outside the docs. Say what holds now, or drop the line`,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
@@ -154,7 +154,7 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
|
154
154
|
reference: {
|
|
155
155
|
shape: "outline",
|
|
156
156
|
title: { type: "open", placeholder: "<The thing this page describes, as a noun>", rule: "any" },
|
|
157
|
-
lead: ["<What the thing is, in one sentence
|
|
157
|
+
lead: ["<What the thing is, in one sentence.>"],
|
|
158
158
|
sections: [
|
|
159
159
|
open("<One part of it, as a noun>", "any", optional("the lead and one table describe all of it"), [
|
|
160
160
|
"<A table, a list or a short description, with no steps and no opinion.>",
|
package/src/docs/docs.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
3
|
import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
4
4
|
import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
|
|
5
|
+
import { vanishedNames } from "./doc-names.ts";
|
|
5
6
|
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
6
7
|
import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
7
8
|
import { runMain } from "../core/main.ts";
|
|
8
|
-
import {
|
|
9
|
+
import { proseFindings, readerOf } from "./prose-matchers.ts";
|
|
9
10
|
|
|
10
11
|
type Finding = {
|
|
11
12
|
readonly path: string;
|
|
@@ -16,7 +17,7 @@ type Finding = {
|
|
|
16
17
|
type Judged = {
|
|
17
18
|
readonly held: readonly string[];
|
|
18
19
|
readonly edited: { readonly docs: number; readonly lines: number };
|
|
19
|
-
readonly
|
|
20
|
+
readonly named: number;
|
|
20
21
|
readonly findings: readonly Finding[];
|
|
21
22
|
readonly advisory: ReadonlyMap<string, number>;
|
|
22
23
|
readonly brokenBefore: readonly Finding[];
|
|
@@ -74,8 +75,10 @@ const referenceFindings = Effect.fn("referenceFindings")(function* (range: Range
|
|
|
74
75
|
const elsewhere = found.filter((one) => !onChangedLines(one));
|
|
75
76
|
const before = elsewhere.length === 0 ? new Set<string>() : yield* brokenBeforeRange(range, elsewhere, judging);
|
|
76
77
|
const finding = ({ path, line, message }: Located): Finding => ({ path, line, message });
|
|
78
|
+
const failed = found.filter((one) => onChangedLines(one) || !before.has(keyOf(one)));
|
|
77
79
|
return {
|
|
78
|
-
failing:
|
|
80
|
+
failing: failed.map(finding),
|
|
81
|
+
failed,
|
|
79
82
|
brokenBefore: elsewhere.filter((one) => before.has(keyOf(one))).map(finding),
|
|
80
83
|
};
|
|
81
84
|
});
|
|
@@ -87,7 +90,6 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
87
90
|
const changed = yield* changedLines(base, head, MARKDOWN, root);
|
|
88
91
|
const present = yield* pathsAt(head, MARKDOWN, root);
|
|
89
92
|
const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
|
|
90
|
-
const living = present.filter(isLivingDoc);
|
|
91
93
|
const proseDocs = present.flatMap((path) => {
|
|
92
94
|
const reader = readerOf(path);
|
|
93
95
|
return reader === undefined ? [] : [{ path, reader }];
|
|
@@ -104,18 +106,16 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
104
106
|
const judging = (path: string): Judging => ({ commands: !speaksToConsumers(text(path)) });
|
|
105
107
|
// A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
|
|
106
108
|
const roots = rootsOf(yield* pathsAt(base, [], root));
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
judging,
|
|
111
|
-
);
|
|
109
|
+
const referenced = new Map(proseDocs.map(({ path }) => [path, text(path)]));
|
|
110
|
+
const references = yield* referenceFindings({ root, base, head, roots, changed, renamedFrom }, referenced, judging);
|
|
111
|
+
const vanished = yield* vanishedNames(root, base, head, referenced, references.failed);
|
|
112
112
|
const advisory = new Map<string, number>();
|
|
113
113
|
for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
|
|
114
114
|
return {
|
|
115
115
|
held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
|
|
116
116
|
edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
|
|
117
|
-
|
|
118
|
-
findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing].toSorted(inPathOrder),
|
|
117
|
+
named: referenced.size,
|
|
118
|
+
findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing, ...vanished].toSorted(inPathOrder),
|
|
119
119
|
advisory,
|
|
120
120
|
brokenBefore: references.brokenBefore.toSorted(inPathOrder),
|
|
121
121
|
} satisfies Judged;
|
|
@@ -125,13 +125,13 @@ function describe({ path, line, message }: Finding): string {
|
|
|
125
125
|
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
export function report({ held, edited,
|
|
128
|
+
export function report({ held, edited, named, findings, advisory, brokenBefore }: Judged): string {
|
|
129
129
|
const verdict =
|
|
130
130
|
findings.length === 0
|
|
131
131
|
? [
|
|
132
132
|
`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
|
|
133
133
|
`${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`,
|
|
134
|
-
`${NAME}: the range breaks no path, link or command the ${
|
|
134
|
+
`${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
|
|
135
135
|
]
|
|
136
136
|
: [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
|
|
137
137
|
const unconformed =
|
|
@@ -144,7 +144,10 @@ export function report({ held, edited, living, findings, advisory, brokenBefore
|
|
|
144
144
|
const broken =
|
|
145
145
|
brokenBefore.length === 0
|
|
146
146
|
? []
|
|
147
|
-
: [
|
|
147
|
+
: [
|
|
148
|
+
`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs or agent files name were broken before the range:`,
|
|
149
|
+
...brokenBefore.map(describe),
|
|
150
|
+
];
|
|
148
151
|
return [...verdict, ...unconformed, ...broken].join("\n");
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -195,7 +195,7 @@ function unread(line: number, raw: string, kind: LineKind): MarkdownLine {
|
|
|
195
195
|
return { line, kind, raw, prose: BLANK.repeat(raw.length), code: [], links: [] };
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
function settext(lines: MarkdownLine[]): MarkdownLine[] {
|
|
198
|
+
function settext(lines: readonly MarkdownLine[]): MarkdownLine[] {
|
|
199
199
|
return lines.map((line, index) => {
|
|
200
200
|
const next = lines[index + 1];
|
|
201
201
|
return line.kind === "prose" && next?.kind === "break" && UNDERLINE.test(next.raw) ? { ...line, kind: "heading" } : line;
|
|
@@ -257,6 +257,12 @@ const PROMISES: readonly (readonly [shows: string, pattern: string])[] = [
|
|
|
257
257
|
["coming soon", "coming soon"],
|
|
258
258
|
["in a future release", "in a future (?:release|version)"],
|
|
259
259
|
];
|
|
260
|
+
const PAST: readonly (readonly [shows: string, pattern: string])[] = [
|
|
261
|
+
["formerly", String.raw`former(?:ly)?`], ["previously", "previously"], ["as before", "as before"],
|
|
262
|
+
["used to", String.raw`(?<!\b(?:is|are|was|were|be|been|being|get|gets|got|\w+['’](?:s|re))\s(?:(?:also|always|just|never|not|now|often|sometimes|still|then|\w+ly)\s)?)used to`],
|
|
263
|
+
["was replaced", String.raw`(?:was|were|has been|have been) (?:replaced|removed|renamed|dropped|deleted|retired|superseded)`],
|
|
264
|
+
["moved from", String.raw`(?:renamed|moved) from`], ["new owner", String.raw`new (?:home|owner)`],
|
|
265
|
+
];
|
|
260
266
|
|
|
261
267
|
const code = (text: string): string => `\`${text}\``;
|
|
262
268
|
const EVERY_READER: readonly Reader[] = ["people", "agents"];
|
|
@@ -287,6 +293,10 @@ const RULES: readonly MatchedRule[] = [
|
|
|
287
293
|
instead: "Say what is true now",
|
|
288
294
|
find: new RegExp(PROMISES.map(([, pattern]) => String.raw`\b${pattern}\b`).join("|"), "gi"),
|
|
289
295
|
},
|
|
296
|
+
{ readers: EVERY_READER, refuses: "a report about the past",
|
|
297
|
+
example: PAST.map(([shows]) => code(shows)).join(", "),
|
|
298
|
+
instead: "Say what is true now, and leave what changed to the changelog, a commit message or a decision record",
|
|
299
|
+
find: new RegExp(PAST.map(([, pattern]) => String.raw`(?<![\w-])${pattern}(?![\w-])`).join("|"), "gi") },
|
|
290
300
|
{
|
|
291
301
|
readers: PEOPLE,
|
|
292
302
|
refuses: "a sentence that opens by talking about the page",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# lint-coverage: fail when oxlint silently skips a tracked TypeScript source
|
|
2
|
+
# lint-coverage: fail when oxlint silently skips a tracked TypeScript source,
|
|
3
|
+
# or when tsconfig.json's program silently drops the ts-reset rules.
|
|
3
4
|
set -eu
|
|
4
5
|
|
|
5
6
|
tmp="$(mktemp -d)"
|
|
@@ -21,13 +22,45 @@ grep -E '\.tsx?$' "$tmp/walk" | LC_ALL=C sort > "$tmp/walked" || true
|
|
|
21
22
|
|
|
22
23
|
expected_count="$(wc -l < "$tmp/expected" | tr -d ' ')"
|
|
23
24
|
walked_count="$(grep -c . "$tmp/walked" || true)"
|
|
25
|
+
status=0
|
|
24
26
|
|
|
25
27
|
comm -23 "$tmp/expected" "$tmp/walked" > "$tmp/missing"
|
|
26
28
|
if [ -s "$tmp/missing" ]; then
|
|
27
29
|
missing_count="$(wc -l < "$tmp/missing" | tr -d ' ')"
|
|
28
30
|
echo "lint-coverage: oxlint skips ${missing_count}/${expected_count} tracked .ts/.tsx files; missing:"
|
|
29
31
|
cat "$tmp/missing"
|
|
32
|
+
status=1
|
|
33
|
+
else
|
|
34
|
+
echo "lint-coverage: ${walked_count}/${expected_count} tracked .ts/.tsx files"
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if ! [ -f tsconfig.json ]; then
|
|
38
|
+
echo "lint-coverage: no tsconfig.json, so no program to hold the ts-reset rules"
|
|
39
|
+
exit "$status"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
if ! tsc --listFilesOnly -p tsconfig.json > "$tmp/program" 2> "$tmp/program-error"; then
|
|
43
|
+
echo "lint-coverage: tsc could not list the program tsconfig.json builds:"
|
|
44
|
+
# tsc lists each file of the program as an absolute path beside its errors.
|
|
45
|
+
grep -v '^/' "$tmp/program" || true
|
|
46
|
+
cat "$tmp/program-error"
|
|
47
|
+
if [ "$status" -eq 1 ]; then
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
exit 2
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
dropped=""
|
|
54
|
+
for rule in is-array json-parse; do
|
|
55
|
+
if ! grep -q "/@total-typescript/ts-reset/dist/${rule}\.d\.ts\$" "$tmp/program"; then
|
|
56
|
+
dropped="${dropped} ${rule}"
|
|
57
|
+
fi
|
|
58
|
+
done
|
|
59
|
+
if [ -n "$dropped" ]; then
|
|
60
|
+
echo "lint-coverage: the program tsconfig.json builds drops the ts-reset rules:${dropped}"
|
|
61
|
+
echo " extend @avi2dg/checks/tsconfig.effect.json, and set files or include in tsconfig.json but not both"
|
|
30
62
|
exit 1
|
|
31
63
|
fi
|
|
32
64
|
|
|
33
|
-
echo "lint-coverage:
|
|
65
|
+
echo "lint-coverage: the program tsconfig.json builds holds the ts-reset rules is-array and json-parse"
|
|
66
|
+
exit "$status"
|