@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +7 -6
  3. package/dist/data-shape/index.js +428 -0
  4. package/dist/templates/reference.md +1 -1
  5. package/docs/configs/commit-messages.md +1 -1
  6. package/docs/configs/dependency-rules.md +7 -1
  7. package/docs/configs/effect-rules.md +10 -0
  8. package/docs/configs/native-settings.md +8 -30
  9. package/docs/configs/typescript-rules.md +43 -1
  10. package/docs/design.md +232 -104
  11. package/docs/gates/checks-advisories.md +128 -0
  12. package/docs/gates/checks-changelog.md +8 -5
  13. package/docs/gates/checks-ci-wiring.md +2 -2
  14. package/docs/gates/checks-comment-gate.md +3 -4
  15. package/docs/gates/checks-commit-identity.md +3 -3
  16. package/docs/gates/checks-docs.md +56 -36
  17. package/docs/gates/checks-exports.md +4 -4
  18. package/docs/gates/checks-flake.md +3 -3
  19. package/docs/gates/checks-lint-coverage.md +17 -9
  20. package/docs/gates/checks-lint.md +9 -4
  21. package/docs/gates/checks-mutation-compare.md +3 -3
  22. package/docs/gates/checks-quarantine-clock.md +3 -5
  23. package/docs/gates/checks-release-notes.md +4 -4
  24. package/docs/gates/checks-release-report.md +3 -3
  25. package/docs/gates/checks-repetition.md +2 -1
  26. package/docs/gates/checks-subsumed-tests.md +3 -3
  27. package/docs/gates/checks-suppressions-ratchet.md +3 -4
  28. package/docs/gates/checks-test-layout.md +4 -4
  29. package/docs/gates/checks-test.md +3 -2
  30. package/docs/gates/checks-unused.md +4 -4
  31. package/docs/gates/checks-vendor.md +3 -3
  32. package/oxlintrc.json +22 -2
  33. package/package.json +16 -6
  34. package/src/complexity/exports.ts +8 -16
  35. package/src/complexity/knip.ts +1 -1
  36. package/src/core/gates.ts +3 -0
  37. package/src/core/git.ts +10 -0
  38. package/src/delivery/ci-wiring.ts +1 -1
  39. package/src/dependencies/advisories.ts +115 -0
  40. package/src/dependencies/advisory-rules.ts +206 -0
  41. package/src/dependencies/cache-root.ts +15 -0
  42. package/src/dependencies/osv-scanner.ts +176 -0
  43. package/src/dependencies/vendor.ts +1 -10
  44. package/src/docs/doc-names.ts +128 -0
  45. package/src/docs/doc-templates.ts +1 -1
  46. package/src/docs/docs.ts +17 -14
  47. package/src/docs/prose-matchers.ts +11 -1
  48. package/src/quality/lint-coverage.sh +35 -2
  49. package/src/quality/presets/effect.language-service.json +3 -1
  50. package/src/testing/mutation-compare.ts +51 -45
  51. package/src/testing/quarantine-clock.ts +2 -11
  52. package/src/testing/test-layout.ts +1 -1
  53. package/ts-reset.d.ts +2 -0
  54. 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, and when a reader looks it up.>"],
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 { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
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 living: number;
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: found.filter((one) => onChangedLines(one) || !before.has(keyOf(one))).map(finding),
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 references = yield* referenceFindings(
108
- { root, base, head, roots, changed, renamedFrom },
109
- new Map(living.map((path) => [path, text(path)])),
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
- living: living.length,
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, living, findings, advisory, brokenBefore }: Judged): string {
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 ${living} living doc(s) name`,
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
- : [`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs name were broken before the range:`, ...brokenBefore.map(describe)];
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: ${walked_count}/${expected_count} tracked .ts/.tsx files"
65
+ echo "lint-coverage: the program tsconfig.json builds holds the ts-reset rules is-array and json-parse"
66
+ exit "$status"
@@ -3,6 +3,8 @@
3
3
  "nodeBuiltinImport": "error",
4
4
  "asyncFunction": "error",
5
5
  "newPromise": "error",
6
- "extendsNativeError": "error"
6
+ "extendsNativeError": "error",
7
+ "processEnv": "error",
8
+ "processEnvInEffect": "error"
7
9
  }
8
10
  }