@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
package/oxlintrc.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "plugins": ["typescript", "oxc", "eslint", "import"],
3
- "jsPlugins": ["./dist/effect-channel/index.js", "./dist/readability/index.js"],
3
+ "jsPlugins": ["./dist/effect-channel/index.js", "./dist/readability/index.js", "./dist/data-shape/index.js"],
4
4
  "categories": { "correctness": "error", "suspicious": "error" },
5
5
  "rules": {
6
6
  "effect-channel/no-error-channel-escape": "error",
@@ -18,5 +18,25 @@
18
18
  "typescript/no-non-null-assertion": "error",
19
19
  "typescript/no-deprecated": "error",
20
20
  "typescript/consistent-return": "off"
21
- }
21
+ },
22
+ "overrides": [
23
+ {
24
+ "files": ["**/*.ts", "**/*.tsx"],
25
+ "rules": {
26
+ "data-shape/readonly-collection-param": "error"
27
+ }
28
+ },
29
+ {
30
+ "files": ["**/*.ts", "**/*.tsx"],
31
+ "excludeFiles": ["tests/**"],
32
+ "rules": {
33
+ "data-shape/schema-twin": "error",
34
+ "typescript/no-unsafe-assignment": "error",
35
+ "typescript/no-unsafe-member-access": "error",
36
+ "typescript/no-unsafe-argument": "error",
37
+ "typescript/no-unsafe-return": "error",
38
+ "typescript/no-unsafe-call": "error"
39
+ }
40
+ }
41
+ ]
22
42
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.26.0",
4
- "description": "Deterministic checks shared across the captain's TypeScript repos",
3
+ "version": "0.28.0",
4
+ "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -53,19 +53,26 @@
53
53
  "src/docs/docs.ts",
54
54
  "src/dependencies/vendor.ts",
55
55
  "src/dependencies/vendor-args.ts",
56
+ "src/dependencies/cache-root.ts",
57
+ "src/dependencies/advisories.ts",
58
+ "src/dependencies/advisory-rules.ts",
59
+ "src/dependencies/osv-scanner.ts",
56
60
  "src/docs/doc-outline.ts",
57
61
  "src/docs/doc-rules.ts",
58
62
  "src/docs/doc-references.ts",
59
63
  "src/docs/doc-snapshot.ts",
60
64
  "src/docs/doc-templates.ts",
65
+ "src/docs/doc-names.ts",
61
66
  "dist/templates/",
62
67
  "src/quality/presets/effect.oxlint.json",
63
68
  "src/quality/presets/effect.language-service.json",
64
69
  "oxlintrc.json",
65
70
  "stryker.preset.js",
66
71
  "tsconfig.effect.json",
72
+ "ts-reset.d.ts",
67
73
  "dist/effect-channel/index.js",
68
- "dist/readability/index.js"
74
+ "dist/readability/index.js",
75
+ "dist/data-shape/index.js"
69
76
  ],
70
77
  "exports": {
71
78
  "./dependency-cruiser.config.js": "./dependency-cruiser.config.js",
@@ -97,11 +104,12 @@
97
104
  "checks-exports": "src/complexity/exports.ts",
98
105
  "checks-quarantine-clock": "src/testing/quarantine-clock.ts",
99
106
  "checks-docs": "src/docs/docs.ts",
100
- "checks-vendor": "src/dependencies/vendor.ts"
107
+ "checks-vendor": "src/dependencies/vendor.ts",
108
+ "checks-advisories": "src/dependencies/advisories.ts"
101
109
  },
102
110
  "scripts": {
103
111
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
104
- "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
112
+ "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
105
113
  "lint": "oxlint --type-aware && bun src/core/lint.ts && depcruise --config .dependency-cruiser.cjs .",
106
114
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
107
115
  "test": "bun src/testing/test.ts"
@@ -113,7 +121,8 @@
113
121
  "effect": "4.0.0-rc.115",
114
122
  "jscpd": "5.3.2",
115
123
  "oxlint": "1.83.0",
116
- "oxlint-tsgolint": "7.0.2002"
124
+ "oxlint-tsgolint": "7.0.2002",
125
+ "typescript": "7.0.2"
117
126
  },
118
127
  "devDependencies": {
119
128
  "@effect/tsgo": "0.45.0",
@@ -132,6 +141,7 @@
132
141
  "@commitlint/config-conventional": "21.2.3",
133
142
  "@effect/platform-bun": "4.0.0-rc.115",
134
143
  "@effect/platform-node-shared": "4.0.0-rc.115",
144
+ "@total-typescript/ts-reset": "0.6.1",
135
145
  "knip": "6.38.0"
136
146
  }
137
147
  }
@@ -4,11 +4,13 @@ import { checkoutFiles, commitOf, git, pathsAt, rangeEnds, refArgs } from "../co
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { knipReport, scanTree } from "./knip.ts";
6
6
 
7
- export type UnusedSymbol = {
8
- readonly file: string;
9
- readonly kind: "export" | "type";
10
- readonly name: string;
11
- };
7
+ const UnusedSymbolSchema = Schema.Struct({
8
+ file: Schema.String,
9
+ kind: Schema.Literals(["export", "type"]),
10
+ name: Schema.String,
11
+ });
12
+
13
+ export type UnusedSymbol = typeof UnusedSymbolSchema.Type;
12
14
 
13
15
  export type Baseline = readonly UnusedSymbol[];
14
16
 
@@ -51,17 +53,7 @@ const decodeKnipReport = Schema.decodeUnknownEffect(
51
53
  ),
52
54
  );
53
55
 
54
- const decodeBaseline = Schema.decodeUnknownEffect(
55
- Schema.fromJsonString(
56
- Schema.Array(
57
- Schema.Struct({
58
- file: Schema.String,
59
- kind: Schema.Literals(["export", "type"]),
60
- name: Schema.String,
61
- }),
62
- ),
63
- ),
64
- );
56
+ const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(UnusedSymbolSchema)));
65
57
 
66
58
  function byPosition(a: UnusedSymbol, b: UnusedSymbol): number {
67
59
  return a.file.localeCompare(b.file) || a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
@@ -27,7 +27,7 @@ const hasKnipConfig = Effect.fn("hasKnipConfig")(function* (root: string) {
27
27
  const manifest: unknown = yield* fs.readFileString(path.join(root, "package.json")).pipe(
28
28
  Effect.flatMap((text) =>
29
29
  Effect.try({
30
- try: () => JSON.parse(text) as unknown,
30
+ try: () => JSON.parse(text),
31
31
  catch: () => new KnipError({ message: "package.json does not parse as JSON" }),
32
32
  })
33
33
  ),
package/src/core/gates.ts CHANGED
@@ -31,6 +31,8 @@ export const DEFAULT_BRANCH = "main";
31
31
 
32
32
  const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
33
33
 
34
+ const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
35
+
34
36
  export const KIT_GATES = [
35
37
  { bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
36
38
  { bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
@@ -43,4 +45,5 @@ export const KIT_GATES = [
43
45
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
44
46
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
45
47
  { bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
48
+ { bin: "checks-advisories", vector: "dependencies", file: "advisories.ts", reads: "range", appliesTo: BUN_LOCKFILE },
46
49
  ] as const satisfies readonly KitGate[];
package/src/core/git.ts CHANGED
@@ -205,6 +205,16 @@ export const commitOf = Effect.fn("commitOf")(function* (rev: string, cwd?: stri
205
205
  return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`], cwd)).trim();
206
206
  });
207
207
 
208
+ // The later of the author and committer dates, in seconds, so a rebase neither restarts nor stops a clock read from it.
209
+ export const writtenAt = Effect.fn("writtenAt")(function* (rev: string, cwd?: string) {
210
+ const shown = yield* git(["show", "-s", "--format=%at %ct", rev], cwd);
211
+ const dates = shown.trim().split(" ").map(Number);
212
+ if (dates.length !== 2 || !dates.every(Number.isInteger)) {
213
+ return yield* new GitFailure({ message: `cannot read when ${rev} was written` });
214
+ }
215
+ return Math.max(...dates);
216
+ });
217
+
208
218
  // A scratch index leaves the repository's own index and working tree untouched.
209
219
  export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
210
220
  const path = yield* Path.Path;
@@ -200,7 +200,7 @@ function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates:
200
200
  const blocked = invoking.flatMap(({ location, blocker }) =>
201
201
  blocker === undefined ? [] : [{ location, blocker }],
202
202
  );
203
- return [{ gate: gate.command, entryPoint: entryPoint?.join(" "), blocked }];
203
+ return [{ gate: gate.command, ...(entryPoint === undefined ? {} : { entryPoint: entryPoint.join(" ") }), blocked }];
204
204
  });
205
205
  }
206
206
 
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bun
2
+ import { Clock, Config, Console, Effect, FileSystem, type Layer, Option, Path, Schema } from "effect";
3
+ import { changedPaths, commitOf, git, pathsAt, rangeFromArgs, writtenAt } from "../core/git.ts";
4
+ import type { BunServices } from "@effect/platform-bun";
5
+ import { runMain, Usage } from "../core/main.ts";
6
+ import {
7
+ ACKNOWLEDGEMENTS,
8
+ clockOf,
9
+ decodeAcknowledgements,
10
+ decodeOsvReport,
11
+ findingsIn,
12
+ judge,
13
+ LOCKFILE,
14
+ passes,
15
+ report,
16
+ summaryOf,
17
+ type AcknowledgementClock,
18
+ type Outcome,
19
+ } from "./advisory-rules.ts";
20
+ import { cacheRoot } from "./cache-root.ts";
21
+ import { scanLockfiles, Scanner } from "./osv-scanner.ts";
22
+
23
+ const NAME = "advisories";
24
+ const ALL = "--all";
25
+ const USAGE = `usage: advisories.ts <ref> | <base-ref> <head-ref> | ${ALL}`;
26
+ const SIDES = ["base", "head"] as const;
27
+
28
+ class AdvisoriesError extends Schema.TaggedError<AdvisoriesError>()("AdvisoriesError", {
29
+ message: Schema.String,
30
+ }) {}
31
+
32
+ type Request = { readonly kind: "range"; readonly base: string; readonly head: string } | { readonly kind: "head"; readonly head: string };
33
+
34
+ const requestOf = Effect.fn("requestOf")(function* (args: readonly string[]) {
35
+ if (args[0] === ALL) {
36
+ if (args.length > 1) return yield* new Usage({ message: USAGE });
37
+ return { kind: "head", head: yield* commitOf("HEAD") } satisfies Request;
38
+ }
39
+ const { base, head } = yield* rangeFromArgs(args, USAGE);
40
+ return { kind: "range", base, head: yield* commitOf(head) } satisfies Request;
41
+ });
42
+
43
+ const fileAt = Effect.fn("fileAt")(function* (rev: string, file: string, root: string) {
44
+ if (!(yield* pathsAt(rev, [file], root)).includes(file)) return Option.none<string>();
45
+ return Option.some(yield* git(["show", `${rev}:${file}`], root));
46
+ });
47
+
48
+ const acknowledgementsAt = Effect.fn("acknowledgementsAt")(function* (head: string, root: string) {
49
+ const text = yield* fileAt(head, ACKNOWLEDGEMENTS, root);
50
+ if (Option.isNone(text)) return [];
51
+ return yield* decodeAcknowledgements(text.value).pipe(
52
+ Effect.mapError((cause) => new AdvisoriesError({ message: `${ACKNOWLEDGEMENTS} at ${head} does not decode: ${cause.message}` })),
53
+ );
54
+ });
55
+
56
+ type Lockfiles = Readonly<Record<(typeof SIDES)[number], Option.Option<string>>>;
57
+
58
+ // Scanning copies keeps the working tree and a repository's own osv-scanner.toml out of the verdict.
59
+ const findingsOf = Effect.fn("findingsOf")(function* (lockfiles: Lockfiles) {
60
+ const fs = yield* FileSystem.FileSystem;
61
+ const path = yield* Path.Path;
62
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-advisories-" });
63
+ const config = path.join(dir, "empty.toml");
64
+ yield* fs.writeFileString(config, "");
65
+ const written: string[] = [];
66
+ for (const side of SIDES) {
67
+ const text = lockfiles[side];
68
+ if (Option.isNone(text)) continue;
69
+ yield* fs.makeDirectory(path.join(dir, side));
70
+ yield* fs.writeFileString(path.join(dir, side, LOCKFILE), text.value);
71
+ written.push(path.join(dir, side, LOCKFILE));
72
+ }
73
+ const cache = yield* cacheRoot();
74
+ const { stdout, note } = yield* scanLockfiles(yield* (yield* Scanner).binary(cache), cache, config, written);
75
+ if (Option.isSome(note)) yield* Console.error(`${NAME}: ${note.value}`);
76
+ const scanned = yield* decodeOsvReport(stdout).pipe(
77
+ Effect.mapError((cause) => new AdvisoriesError({ message: `cannot read the report OSV-Scanner wrote: ${cause.message}` })),
78
+ );
79
+ const at = (side: string) => (source: string) => source.endsWith(`${path.sep}${side}${path.sep}${LOCKFILE}`);
80
+ return { base: findingsIn(scanned, at("base")), head: findingsIn(scanned, at("head")) };
81
+ }, Effect.scoped);
82
+
83
+ const outcomeOf = Effect.fn("outcomeOf")(function* (request: Request, root: string, clock: AcknowledgementClock) {
84
+ if (request.kind === "range" && (yield* changedPaths(request.base, request.head, [LOCKFILE], root)).length === 0) {
85
+ return { kind: "unchanged", problems: clock.problems } satisfies Outcome;
86
+ }
87
+ const head = yield* fileAt(request.head, LOCKFILE, root);
88
+ const base = request.kind === "range" ? yield* fileAt(request.base, LOCKFILE, root) : Option.none<string>();
89
+ const findings = Option.isNone(head) ? { base: [], head: [] } : yield* findingsOf({ base, head });
90
+ return { kind: "scanned", scope: { kind: request.kind }, judged: judge(findings.base, findings.head, clock) } satisfies Outcome;
91
+ });
92
+
93
+ const toStepSummary = Effect.fn("toStepSummary")(function* (text: string) {
94
+ const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
95
+ if (Option.isNone(stepSummary)) return;
96
+ yield* (yield* FileSystem.FileSystem).writeFileString(stepSummary.value, `${summaryOf(text)}\n`, { flag: "a" });
97
+ });
98
+
99
+ const advisories = Effect.gen(function* () {
100
+ const request = yield* requestOf(process.argv.slice(2));
101
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
102
+ const dates = { head: (yield* writtenAt(request.head, root)) * 1000, now: yield* Clock.currentTimeMillis };
103
+ const clock = clockOf(yield* acknowledgementsAt(request.head, root), { kind: request.kind }, dates);
104
+ const outcome = yield* outcomeOf(request, root, clock);
105
+ const text = report(outcome);
106
+ yield* Console.log(text);
107
+ if (request.kind === "head") yield* toStepSummary(text);
108
+ return passes(outcome);
109
+ });
110
+
111
+ export function main(scanner: Layer.Layer<Scanner, never, BunServices.BunServices>): void {
112
+ runMain(NAME, advisories.pipe(Effect.provide(scanner)));
113
+ }
114
+
115
+ if (import.meta.main) main(Scanner.pinned);
@@ -0,0 +1,206 @@
1
+ import { Schema } from "effect";
2
+
3
+ export const LOCKFILE = "bun.lock";
4
+ export const ACKNOWLEDGEMENTS = "advisory-acks.json";
5
+ const ACKNOWLEDGEMENT_DAYS = 30;
6
+ const DAY_MS = 86_400_000;
7
+ const UNRATED = "unrated";
8
+
9
+ // Date.parse rolls a day past the month's end, such as 2026-02-30, into the next month, so the round trip catches it.
10
+ function isCalendarDay(day: string): boolean {
11
+ const ms = Date.parse(`${day}T00:00:00Z`);
12
+ return !Number.isNaN(ms) && new Date(ms).toISOString().startsWith(day);
13
+ }
14
+
15
+ const Day = Schema.String.check(
16
+ Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/, { message: "is not a day such as 2026-10-20" }),
17
+ Schema.makeFilter((day: string) => isCalendarDay(day) || "is not a day of the calendar"),
18
+ );
19
+
20
+ const Acknowledgement = Schema.Struct({
21
+ package: Schema.NonEmptyString,
22
+ id: Schema.NonEmptyString,
23
+ until: Day,
24
+ reason: Schema.NonEmptyString,
25
+ });
26
+
27
+ export type Acknowledgement = typeof Acknowledgement.Type;
28
+
29
+ export const decodeAcknowledgements = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Acknowledgement)));
30
+
31
+ const Vulnerability = Schema.Struct({
32
+ id: Schema.String,
33
+ summary: Schema.optionalKey(Schema.String),
34
+ database_specific: Schema.optionalKey(Schema.Struct({ severity: Schema.optionalKey(Schema.String) })),
35
+ });
36
+
37
+ const OsvReport = Schema.Struct({
38
+ results: Schema.Array(
39
+ Schema.Struct({
40
+ source: Schema.Struct({ path: Schema.String }),
41
+ packages: Schema.Array(
42
+ Schema.Struct({
43
+ package: Schema.Struct({ name: Schema.String, version: Schema.String }),
44
+ vulnerabilities: Schema.optionalKey(Schema.Array(Vulnerability)),
45
+ }),
46
+ ),
47
+ }),
48
+ ),
49
+ });
50
+
51
+ type OsvReport = typeof OsvReport.Type;
52
+
53
+ export const decodeOsvReport = Schema.decodeUnknownEffect(Schema.fromJsonString(OsvReport));
54
+
55
+ export type Finding = {
56
+ readonly name: string;
57
+ readonly version: string;
58
+ readonly id: string;
59
+ readonly severity: string;
60
+ readonly summary: string;
61
+ };
62
+
63
+ // OSV-Scanner leaves a lockfile with no advisory out of its results, so an absent lockfile reads as clean.
64
+ export function findingsIn(scanned: OsvReport, lockfile: (path: string) => boolean): readonly Finding[] {
65
+ return scanned.results
66
+ .filter(({ source }) => lockfile(source.path))
67
+ .flatMap(({ packages }) => packages)
68
+ .flatMap(({ package: { name, version }, vulnerabilities = [] }) =>
69
+ vulnerabilities.map((vulnerability) => ({
70
+ name,
71
+ version,
72
+ id: vulnerability.id,
73
+ severity: vulnerability.database_specific?.severity?.toLowerCase() ?? UNRATED,
74
+ summary: vulnerability.summary ?? "",
75
+ })),
76
+ );
77
+ }
78
+
79
+ // Two live records can list each other as aliases, so only the id the scan reports names an advisory.
80
+ function keyOf({ name, id }: { readonly name: string; readonly id: string }): string {
81
+ return `${name} ${id}`;
82
+ }
83
+
84
+ // Keyed by name and id rather than version, so moving between two affected versions adds nothing.
85
+ export function introducedBy(base: readonly Finding[], head: readonly Finding[]): readonly Finding[] {
86
+ const known = new Set(base.map(keyOf));
87
+ return head.filter((finding) => !known.has(keyOf(finding)));
88
+ }
89
+
90
+ type AcknowledgementProblem =
91
+ | { readonly kind: "expired"; readonly acknowledgement: Acknowledgement }
92
+ | { readonly kind: "too-far"; readonly acknowledgement: Acknowledgement; readonly latest: string }
93
+ | { readonly kind: "unmatched"; readonly acknowledgement: Acknowledgement };
94
+
95
+ export type AcknowledgementClock = {
96
+ readonly live: readonly Acknowledgement[];
97
+ readonly problems: readonly AcknowledgementProblem[];
98
+ };
99
+
100
+ type Scope = { readonly kind: "range" } | { readonly kind: "head" };
101
+
102
+ export type Dates = { readonly head: number; readonly now: number };
103
+
104
+ // A range reads the head's date so a commit gets one verdict, and --all reads today so an entry expires in an idle repository.
105
+ function measuredFrom(scope: Scope, { head, now }: Dates): number {
106
+ return scope.kind === "head" ? now : head;
107
+ }
108
+
109
+ function dayOf(ms: number): string {
110
+ return new Date(ms).toISOString().slice(0, "YYYY-MM-DD".length);
111
+ }
112
+
113
+ export function clockOf(acknowledgements: readonly Acknowledgement[], scope: Scope, dates: Dates): AcknowledgementClock {
114
+ const from = measuredFrom(scope, dates);
115
+ const limit = from + ACKNOWLEDGEMENT_DAYS * DAY_MS;
116
+ const live: Acknowledgement[] = [];
117
+ const problems: AcknowledgementProblem[] = [];
118
+ for (const acknowledgement of acknowledgements) {
119
+ const until = Date.parse(`${acknowledgement.until}T00:00:00Z`);
120
+ if (until <= from) problems.push({ kind: "expired", acknowledgement });
121
+ else if (until > limit) problems.push({ kind: "too-far", acknowledgement, latest: dayOf(limit) });
122
+ else live.push(acknowledgement);
123
+ }
124
+ return { live, problems };
125
+ }
126
+
127
+ function covers(acknowledgement: Acknowledgement, finding: Finding): boolean {
128
+ return acknowledgement.package === finding.name && acknowledgement.id === finding.id;
129
+ }
130
+
131
+ type Judged = {
132
+ readonly failing: readonly Finding[];
133
+ readonly acknowledged: number;
134
+ readonly predating: number;
135
+ readonly problems: readonly AcknowledgementProblem[];
136
+ };
137
+
138
+ export function judge(base: readonly Finding[], head: readonly Finding[], clock: AcknowledgementClock): Judged {
139
+ const added = introducedBy(base, head);
140
+ const failing = added.filter((finding) => !clock.live.some((acknowledgement) => covers(acknowledgement, finding)));
141
+ const unmatched = clock.live
142
+ .filter((acknowledgement) => !head.some((finding) => covers(acknowledgement, finding)))
143
+ .map((acknowledgement): AcknowledgementProblem => ({ kind: "unmatched", acknowledgement }));
144
+ return {
145
+ failing,
146
+ acknowledged: added.length - failing.length,
147
+ predating: head.length - added.length,
148
+ problems: [...clock.problems, ...unmatched],
149
+ };
150
+ }
151
+
152
+ export type Outcome =
153
+ | { readonly kind: "unchanged"; readonly problems: readonly AcknowledgementProblem[] }
154
+ | { readonly kind: "scanned"; readonly scope: Scope; readonly judged: Judged };
155
+
156
+ export function passes(outcome: Outcome): boolean {
157
+ if (outcome.kind === "unchanged") return outcome.problems.length === 0;
158
+ return outcome.judged.failing.length === 0 && outcome.judged.problems.length === 0;
159
+ }
160
+
161
+ function problemLine(problem: AcknowledgementProblem): string {
162
+ const { package: name, id, until } = problem.acknowledgement;
163
+ switch (problem.kind) {
164
+ case "expired":
165
+ return ` ${name} ${id} expired on ${until}; upgrade the package and delete the entry, or renew it with a later day`;
166
+ case "too-far":
167
+ return ` ${name} ${id} runs until ${until}, more than ${ACKNOWLEDGEMENT_DAYS} days out; name a day no later than ${problem.latest}`;
168
+ case "unmatched":
169
+ return ` ${name} ${id} matches nothing in ${LOCKFILE} at the head; delete it`;
170
+ }
171
+ }
172
+
173
+ function problemLines(problems: readonly AcknowledgementProblem[]): readonly string[] {
174
+ if (problems.length === 0) return [];
175
+ return [`advisories: ${problems.length} acknowledgement(s) in ${ACKNOWLEDGEMENTS} do not hold:`, ...problems.map(problemLine)];
176
+ }
177
+
178
+ function findingLine({ name, version, id, severity, summary }: Finding): string {
179
+ return ` ${name}@${version} ${id} ${severity}${summary === "" ? "" : `: ${summary}`}`;
180
+ }
181
+
182
+ function scannedLines(scope: Scope, { failing, acknowledged, predating }: Judged): readonly string[] {
183
+ const fix = `upgrade each package, or acknowledge its advisory in ${ACKNOWLEDGEMENTS}:`;
184
+ if (scope.kind === "head") {
185
+ if (failing.length === 0) return [`advisories: ${LOCKFILE} at the head holds no unacknowledged advisory (${acknowledged} acknowledged)`];
186
+ return [`advisories: ${LOCKFILE} at the head holds ${failing.length} unacknowledged advisory(ies); ${fix}`, ...failing.map(findingLine)];
187
+ }
188
+ const counts = `${predating} at the head predate the range, ${acknowledged} acknowledged`;
189
+ if (failing.length === 0) return [`advisories: the range adds no advisory to ${LOCKFILE} (${counts})`];
190
+ return [`advisories: the range adds ${failing.length} advisory(ies) to ${LOCKFILE} (${counts}); ${fix}`, ...failing.map(findingLine)];
191
+ }
192
+
193
+ export function report(outcome: Outcome): string {
194
+ if (outcome.kind === "unchanged") {
195
+ return [`advisories: ${LOCKFILE} is unchanged in the range, so nothing was scanned`, ...problemLines(outcome.problems)].join("\n");
196
+ }
197
+ return [...scannedLines(outcome.scope, outcome.judged), ...problemLines(outcome.judged.problems)].join("\n");
198
+ }
199
+
200
+ // The job summary is Markdown, where an indented line would merge into the paragraph above it.
201
+ export function summaryOf(text: string): string {
202
+ return text
203
+ .split("\n")
204
+ .map((line) => (line.startsWith(" ") ? `- ${line.trim()}` : line))
205
+ .join("\n");
206
+ }
@@ -0,0 +1,15 @@
1
+ import { Config, Effect, Path, Schema } from "effect";
2
+
3
+ const CACHE_HOME = ".cache/avi2dg-checks";
4
+
5
+ class CacheUnrooted extends Schema.TaggedError<CacheUnrooted>()("CacheUnrooted", {
6
+ message: Schema.String,
7
+ }) {}
8
+
9
+ export const cacheRoot = Effect.fn("cacheRoot")(function* () {
10
+ const path = yield* Path.Path;
11
+ const home = yield* Config.NonEmptyString("HOME").pipe(
12
+ Effect.mapError(() => new CacheUnrooted({ message: "HOME is missing, so the shared cache has no root" })),
13
+ );
14
+ return path.join(home, CACHE_HOME);
15
+ });