@avi2dg/checks 0.57.0 → 0.58.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 CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.58.0
6
+
7
+ Released 2026-10-29.
8
+
9
+ ### Features
10
+
11
+ - **delivery:** scan commit messages for secrets and pack terms [#17](https://github.com/avi2d/checks/pull/17)
12
+
13
+ ## 0.57.1
14
+
15
+ Released 2026-10-29.
16
+
17
+ ### Fixes
18
+
19
+ - **complexity:** time out graphwalk and its dependency cruiser child [#18](https://github.com/avi2d/checks/pull/18)
20
+
5
21
  ## 0.57.0
6
22
 
7
23
  Released 2026-10-27.
@@ -1,21 +1,3 @@
1
- import configConventional from "@commitlint/config-conventional";
2
-
3
- const houseTypes: ReadonlyArray<string> = [
4
- "theme",
5
- "renamer",
6
- "shell",
7
- "pi",
8
- "home",
9
- "audit",
10
- "comments",
11
- "unslop",
12
- ];
13
-
14
- const conventionalEnum = configConventional.rules["type-enum"];
15
-
16
1
  export default {
17
2
  extends: ["@commitlint/config-conventional"],
18
- rules: {
19
- "type-enum": [conventionalEnum[0], conventionalEnum[1], [...conventionalEnum[2], ...houseTypes]],
20
- },
21
3
  };
@@ -8,7 +8,7 @@ The shared commitlint config holds each pull request title to the conventional c
8
8
 
9
9
  ## Config
10
10
 
11
- Commits follow `@commitlint/config-conventional` plus the house prefixes `commitlint.config.ts` lists, shared from `node_modules/@avi2dg/checks/commitlint.config.ts`.
11
+ Commits follow `@commitlint/config-conventional`, shared from `node_modules/@avi2dg/checks/commitlint.config.ts`.
12
12
  It arrives with the kit, since `@commitlint/cli` and `@commitlint/config-conventional` are dependencies, not peers.
13
13
 
14
14
  ## Workflow
@@ -28,8 +28,8 @@ import { defineConfig } from "@avi2dg/checks/oxlint";
28
28
  export default defineConfig({
29
29
  effect: {
30
30
  excludeFiles: [
31
- // Launchd runs the renamer, and it cannot import Effect v4.
32
- "home/.config/renamer/**",
31
+ // The host loads this entry outside the Effect runtime.
32
+ "src/host/**",
33
33
  ],
34
34
  },
35
35
  });
package/docs/design.md CHANGED
@@ -323,6 +323,8 @@ A finding location prints as it stands, such as a file name or a link.
323
323
  So a term inside a file name or a repository name shows there.
324
324
  That location is already public in the scanned commit or the scanned pull request.
325
325
 
326
+ A pushed branch has already sent each commit message to the forge.
327
+ So `checks-secrets` scans each message in the range with the same rules and the same pack.
326
328
  A pull request's title, body and comments reach the forge without a commit, so [checks-pr-text](gates/checks-pr-text.md) scans them with the same rules and the same pack.
327
329
 
328
330
  ## The frontend checks are opt-in
@@ -9,7 +9,7 @@ audience: consumers
9
9
  ## What it checks
10
10
 
11
11
  A change is docs-only when every file it touches is a `.md` file at the top level or under `docs/`, and it touches at least one file.
12
- Markdown anywhere else, such as a test fixture under `tests/` or a template under `dist/`, keeps the full run.
12
+ Markdown anywhere else, such as a test fixture under `tests/` or a template under `dist/`, keeps the typecheck and unit test steps running.
13
13
  A rename counts both sides.
14
14
  An empty change and a change with no range keep the full run.
15
15
 
@@ -41,10 +41,11 @@ docs-only=true
41
41
 
42
42
  ## When it runs
43
43
 
44
- Each of the `static`, `unit` and `e2e` jobs of the `ci` workflow appends the verdict to `GITHUB_OUTPUT` in a filter step.
44
+ The `static` and `unit` jobs of the `ci` workflow append the verdict to `GITHUB_OUTPUT` in a filter step.
45
45
  Their typecheck and test steps skip when the verdict reads `true`.
46
+ The `e2e` job does not read this verdict: `scripts/e2e-skip.ts` skips it for a change of only Markdown and the root `copy.json`, a wider set than this bin's.
46
47
  The build, its `git diff --exit-code` and `bun run lint` still run, so `checks-docs` and the generated blocks hold on a docs-only change.
47
- Every job still runs and reports, so every required check stays green on a docs-only change.
48
+ The `checks` job still runs and reports, so its required check stays green on a docs-only change.
48
49
 
49
50
  ## Related topics
50
51
 
@@ -55,6 +55,14 @@ It cruises against the first config it finds there:
55
55
  It runs dependency-cruiser from the installed kit under Bun, which loads a TypeScript config wherever it sits.
56
56
  It leaves out a tracked file the working tree no longer holds.
57
57
 
58
+ ## Time limits
59
+
60
+ A run that lasts past 300 seconds stops, prints `graphwalk: timed out after 300s; set CHECKS_GRAPHWALK_TIMEOUT_SECONDS to allow longer` and exits 2.
61
+ A dependency-cruiser run that lasts past 120 seconds stops, and the imports section prints `imports: dependency-cruiser timed out after 120s; set CHECKS_DEPCRUISE_TIMEOUT_SECONDS to allow longer` and cannot decide.
62
+ `CHECKS_GRAPHWALK_TIMEOUT_SECONDS` and `CHECKS_DEPCRUISE_TIMEOUT_SECONDS` each take a whole number of seconds above 0, and any other value leaves the run or the section undecided.
63
+ When a limit passes, the gate sends SIGTERM to the process group of each process it started.
64
+ It sends SIGKILL 2 seconds later to any process still in that group, so nothing it started outlives the gate.
65
+
58
66
  ## Arguments
59
67
 
60
68
  ```sh
@@ -81,9 +89,10 @@ A seed written after the range adds a dead export, or after it removes the last
81
89
  | --- | --- |
82
90
  | 0 | no file is unreferenced, no symbol is unlisted, added or stale, and no import breaks an `error` rule, or `--write` recorded the baseline |
83
91
  | 1 | a file is unreferenced, a symbol is unlisted, added or stale, an import breaks an `error` rule, or the repository holds no Knip configuration |
84
- | 2 | a ref does not resolve, a scanner cannot run, or a Knip report, a cruise report or the baseline does not parse |
92
+ | 2 | a ref does not resolve, a scanner cannot run, the run or dependency-cruiser passes its time limit, or a Knip report, a cruise report or the baseline does not parse |
85
93
 
86
94
  The gate exits 1 when any section reports a violation, and exits 2 only when no section reports one and at least one section cannot decide.
95
+ When the run passes its time limit the gate exits 2 even when a section already reported a violation.
87
96
 
88
97
  It also exits 2 when the repository tracks no `.ts`, `.tsx`, `.astro`, `.mts` or `.cts` file, or, with `--write`, when it tracks no TypeScript file.
89
98
  A repository that tracks `.astro` files but no `.ts`, `.tsx`, `.mts` or `.cts` file prints only the unreferenced-files section.
@@ -12,7 +12,10 @@ A private pack adds terms no public file may carry, such as a person's name, an
12
12
  It scans the lines each commit in the range adds, in every file the commit adds or modifies, with gitleaks.
13
13
  In a merge commit it scans the lines the merge's own resolution adds, such as a key written while resolving a conflict, and not what the merge brings in from the other parent.
14
14
  It exits 2 when a commit it scans is an octopus merge, one with three or more parents, because git gives no resolution diff for one.
15
+ It scans the message of each commit in the range with the rules and the private pack [checks-pr-text](checks-pr-text.md) runs on a pull request's text.
16
+ A merge commit's own message is scanned too, though the file scan skips what the merge brings in from the other parent.
15
17
  It fails on each secret the scan finds, and names its file, its line, the rule that matched and the commit that added it.
18
+ A finding in a message names `the message` and its line in place of a file, where the subject is line 1.
16
19
  A secret one commit adds and a later commit in the range removes still fails, because the first commit still holds it.
17
20
  It guards new changes only, and never scans a commit before the range, so a secret already in the history takes no part.
18
21
 
@@ -40,14 +43,15 @@ These rules do not catch:
40
43
  No setting accepts a finding.
41
44
  It ignores a repository's `.gitleaks.toml`, its `.gitleaksignore` and every `gitleaks:allow` comment.
42
45
  It skips the paths gitleaks' default config skips, such as lockfiles, images and `node_modules/`.
43
- It scans what a commit adds to its files, and not its message or its author, which [checks-commit-identity](checks-commit-identity.md) judges.
46
+ It scans what a commit adds to its files and its message, and not its author, which [checks-commit-identity](checks-commit-identity.md) judges.
44
47
 
45
48
  ## The private pack
46
49
 
47
50
  The private pack is a JSON file of terms that lives outside every public repository, and `CHECKS_SECRETS_PACK` names its path.
48
51
  The kit ships no pack and no term, so a public repository can run the pack without carrying what it hides.
49
52
  With `CHECKS_SECRETS_PACK` unset the gate runs the kit's rules alone, exactly as it does without a pack, and its last line says so.
50
- With it set the gate adds a rule for each kind of term the pack lists, and fails a range that adds any of them.
53
+ With it set the gate adds a rule for each kind of term the pack lists.
54
+ It fails a range that adds any of them to a file or a commit message.
51
55
 
52
56
  Each key is optional, and each holds a list of strings:
53
57
 
@@ -78,7 +82,7 @@ An IPv6 range has no key, so a pack lists its prefix under `hosts` as its addres
78
82
  The gitleaks default allowlist skips paths such as lockfiles, go.mod and go.sum, images and documents such as PDFs.
79
83
  It also skips node_modules and some Go and Ruby vendor paths, so the pack terms are not searched there.
80
84
  A lockfile's resolved URLs can carry a private registry host, so keep such a host out of lockfiles, go.mod and go.sum by other means.
81
- Commit messages are not scanned by the pack, and checks-commit-identity covers only the author, the committer and Co-authored-by trailers.
85
+ The pack scans each commit message, trailers included, and checks-commit-identity also judges the author, the committer and Co-authored-by trailers.
82
86
 
83
87
  The gate exits 2, and scans nothing, when `CHECKS_SECRETS_PACK` is set but the pack cannot serve:
84
88
 
@@ -119,11 +123,12 @@ Unlike [checks-pr-text](checks-pr-text.md), this gate has no skip: a job that wr
119
123
 
120
124
  ## What it reads
121
125
 
122
- It reads the commits of the range from git, not the working tree.
126
+ It reads the commits of the range and their messages from git, not the working tree.
123
127
  It needs git 2.36 or newer, which diffs a merge against the merge git would make on its own, and it exits 2 on an older git.
124
128
  It reads its rules from `src/delivery/gitleaks.toml` in the installed package.
125
129
  It reads the path of the private pack from `CHECKS_SECRETS_PACK`.
126
- It writes the kit's rules and the pack's into one config in a temporary directory it deletes when the scan ends.
130
+ It writes the kit's rules and the pack's into one config, and each commit message into a file of its own.
131
+ Each goes in a temporary directory it deletes when the scan ends.
127
132
 
128
133
  It runs the gitleaks release `src/delivery/gitleaks.ts` pins, by the SHA-256 of each platform's archive and of the binary inside it.
129
134
  On first use it downloads the archive from the scanner's GitHub release and unpacks the binary into `~/.cache/avi2dg-checks/gitleaks/<version>/`.
@@ -146,8 +151,8 @@ With one it scans that commit alone.
146
151
 
147
152
  | Code | When |
148
153
  | --- | --- |
149
- | 0 | no commit in the range adds a secret |
150
- | 1 | a commit in the range adds a secret |
154
+ | 0 | no commit in the range adds a secret, in its files or its message |
155
+ | 1 | a commit in the range adds a secret, in its files or its message |
151
156
  | 2 | a ref does not resolve, or a commit it scans is an octopus merge, or no verified scanner is at hand, or git is older than 2.36, or gitleaks fails, or `CHECKS_SECRETS_PACK` is set and the pack cannot serve |
152
157
 
153
158
  ## Sample output
@@ -163,8 +168,9 @@ secrets: CHECKS_SECRETS_PACK is unset, so the kit's rules ran without a private
163
168
  With a pack set, a finding of the pack names the kind of term and never the term:
164
169
 
165
170
  ```
166
- secrets: the range adds 1 secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:
171
+ secrets: the range adds 2 secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:
167
172
  docs/setup.md:12 private-domain in 4f1c09ab: A domain the private pack lists
173
+ the message:3 private-name in 4f1c09ab: A name form the private pack lists
168
174
  secrets: the kit's rules and the private pack CHECKS_SECRETS_PACK names ran
169
175
  ```
170
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { BunServices } from "@effect/platform-bun";
3
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { Config, Console, Duration, Effect, FileSystem, Path, Schema } from "effect";
4
4
  import { LINTED_SOURCE, TYPESCRIPT_SOURCE, type TrackedContent } from "../core/gates.ts";
5
5
  import { checkoutFiles, collect, commitOf, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
6
6
  import { PASSED, runExitMain, UNDECIDED, Usage, VIOLATED } from "../core/main.ts";
@@ -34,6 +34,10 @@ export const CRUISED = ["*.ts", "*.tsx", "*.mts", "*.cts"] as const;
34
34
 
35
35
  const KIT_DEFAULTS = "kit-defaults.ts";
36
36
 
37
+ type TimeLimit = { readonly variable: string; readonly seconds: number };
38
+ const RUN_LIMIT = { variable: "CHECKS_GRAPHWALK_TIMEOUT_SECONDS", seconds: 300 } satisfies TimeLimit;
39
+ const CRUISE_LIMIT = { variable: "CHECKS_DEPCRUISE_TIMEOUT_SECONDS", seconds: 120 } satisfies TimeLimit;
40
+
37
41
  export type Status = "passed" | "violated" | "undecided";
38
42
 
39
43
  class GraphwalkError extends Schema.TaggedError<GraphwalkError>()("GraphwalkError", {
@@ -75,6 +79,15 @@ function outside(symbols: readonly UnusedSymbol[], others: readonly UnusedSymbol
75
79
  return symbols.filter((symbol) => !known.has(keyOf(symbol))).toSorted(byPosition);
76
80
  }
77
81
 
82
+ const withinLimit = Effect.fn("withinLimit")(function* <A, E, R>(prefix: string, { variable, seconds: fallback }: TimeLimit, body: Effect.Effect<A, E, R>) {
83
+ const seconds = yield* Config.schema(Schema.Int.check(Schema.isGreaterThan(0)), variable).pipe(
84
+ Config.withDefault(fallback),
85
+ Effect.mapError(() => new GraphwalkError({ message: `${variable} must be a whole number of seconds above 0` })),
86
+ );
87
+ const message = `${prefix}timed out after ${seconds}s; set ${variable} to allow longer`;
88
+ return yield* body.pipe(Effect.timeoutOrElse({ duration: Duration.seconds(seconds), orElse: () => Effect.fail(new GraphwalkError({ message })) }));
89
+ });
90
+
78
91
  const describe = (symbol: UnusedSymbol): string => ` ${symbol.file}: ${symbol.name} (${symbol.kind})`;
79
92
 
80
93
  export const filesOf = Effect.fn("filesOf")(function* (stdout: string) {
@@ -267,9 +280,10 @@ const importsSection = (root: string) =>
267
280
  const files = yield* trackedInTree(root);
268
281
  if (files.length === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript to cruise" });
269
282
  const config = yield* configOf(root);
270
- const run = yield* collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
283
+ const cruise = collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
271
284
  Effect.mapError((cause) => new GraphwalkError({ message: `cannot run dependency-cruiser: ${cause.message}` })),
272
285
  );
286
+ const run = yield* withinLimit("dependency-cruiser ", CRUISE_LIMIT, cruise);
273
287
  const { summary } = yield* decodeCruise(run.stdout).pipe(
274
288
  Effect.mapError(
275
289
  () => new GraphwalkError({ message: `dependency-cruiser exits ${run.exitCode} with no report: ${(run.stdout + run.stderr).trim()}` }),
@@ -369,6 +383,10 @@ const gate = Effect.gen(function* () {
369
383
  ),
370
384
  );
371
385
 
386
+ const timedGate = withinLimit("", RUN_LIMIT, gate).pipe(
387
+ Effect.catch((cause) => Console.error(`${NAME}: ${cause.message}`).pipe(Effect.as(UNDECIDED))),
388
+ );
389
+
372
390
  if (import.meta.main) {
373
- runExitMain(gate);
391
+ runExitMain(timedGate);
374
392
  }
package/src/core/git.ts CHANGED
@@ -15,12 +15,15 @@ export type Feed = {
15
15
  readonly input?: string;
16
16
  };
17
17
 
18
+ // Without a SIGKILL deadline, an interrupted child that ignores SIGTERM holds its scope open forever.
19
+ const KILL_GRACE = "2 seconds";
20
+
18
21
  // Both pipes drain while the program runs: one left unread fills its buffer and stalls it.
19
22
  export const collect = Effect.fn("collect")(
20
23
  function* (program: string, args: readonly string[], cwd?: string, { env, input }: Feed = {}) {
21
24
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
22
25
  const stdin = input === undefined ? undefined : Stream.make(new TextEncoder().encode(input));
23
- const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin }));
26
+ const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin, forceKillAfter: KILL_GRACE }));
24
27
  const [stdout, stderr, exitCode] = yield* Effect.all([text(handle.stdout), text(handle.stderr), handle.exitCode], {
25
28
  concurrency: "unbounded",
26
29
  });
@@ -101,7 +101,7 @@ const land = Effect.fn("land")(function* (root: string, base: string, head: stri
101
101
  Effect.gen(function* () {
102
102
  const now = listed.split("\t")[0];
103
103
  if (now === release) return "landed";
104
- if (now === head) return yield* Effect.fail(failure);
104
+ if (now === head) return yield* failure;
105
105
  yield* git(["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${base}`], root);
106
106
  const ancestor = yield* git(["merge-base", "--is-ancestor", release, "FETCH_HEAD"], root).pipe(
107
107
  Effect.as(true),
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Schema } from "effect";
3
- import { commitOf, git, refArgs } from "../core/git.ts";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { GitFailure, commitOf, git, refArgs } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { cacheRoot } from "../dependencies/cache-root.ts";
6
- import { pinnedGitleaks, scanCommits, type Leak } from "./gitleaks.ts";
6
+ import { pinnedGitleaks, scanCommits, scanDirectory, type DirectoryScan, type Leak } from "./gitleaks.ts";
7
7
  import { scanConfig } from "./secrets-pack.ts";
8
8
 
9
9
  const NAME = "secrets";
@@ -27,8 +27,7 @@ const revisionsOf = Effect.fn("revisionsOf")(function* (args: readonly string[],
27
27
 
28
28
  // Without --remerge-diff git log prints no diff for a merge, and a first-parent diff would rescan what the merge brings in.
29
29
  // git skips --remerge-diff for an octopus merge with only a warning, so the scan would pass a commit it never read.
30
- const logOptionsOf = Effect.fn("logOptionsOf")(function* (args: readonly string[], root: string) {
31
- const revisions = yield* revisionsOf(args, root);
30
+ const logOptionsOf = Effect.fn("logOptionsOf")(function* (revisions: readonly string[], root: string) {
32
31
  const [octopus] = (yield* git(["rev-list", `--min-parents=${OCTOPUS_PARENTS}`, ...revisions], root)).split("\n");
33
32
  if (octopus !== undefined && octopus !== "") {
34
33
  return yield* new OctopusMerge({
@@ -48,10 +47,42 @@ const atRepositoryPath = Effect.fn("atRepositoryPath")(function* (leak: Leak, ro
48
47
  return held ? leak : { ...leak, File: leak.File.slice(NEW_SIDE_PREFIX.length) };
49
48
  });
50
49
 
50
+ type Message = { readonly commit: string; readonly text: string };
51
+
52
+ // git refuses a NUL in a commit message, so the NUL -z writes after each record ends it.
53
+ const messagesOf = Effect.fn("messagesOf")(function* (revisions: readonly string[], root: string) {
54
+ const records = (yield* git(["log", "--no-show-signature", "-z", "--format=%H%n%B", ...revisions], root)).split("\0").filter((record) => record !== "");
55
+ return yield* Effect.forEach(records, (record) => {
56
+ const newline = record.indexOf("\n");
57
+ const commit = newline === -1 ? "" : record.slice(0, newline);
58
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
59
+ return Effect.fail(new GitFailure({ message: "git log: a record does not start with a commit id" }));
60
+ }
61
+ return Effect.succeed({ commit, text: record.slice(newline + 1) } satisfies Message);
62
+ });
63
+ });
64
+
65
+ const messageLeaksOf = Effect.fn("messageLeaksOf")(function* (messages: readonly Message[], scanner: Omit<DirectoryScan, "dir">) {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
68
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-messages-" });
69
+ yield* Effect.forEach(messages, ({ commit, text }) => fs.writeFileString(path.join(dir, commit), text));
70
+ const leaks = yield* scanDirectory({ ...scanner, dir });
71
+ return leaks.map((leak) => ({ ...leak, Commit: path.basename(leak.File) }));
72
+ });
73
+
51
74
  function leakLine({ File, StartLine, RuleID, Commit, Description }: Leak): string {
52
75
  return ` ${File}:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
53
76
  }
54
77
 
78
+ function messageLine({ StartLine, RuleID, Commit, Description }: Leak): string {
79
+ return ` the message:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
80
+ }
81
+
82
+ function byMessage(left: Leak, right: Leak): number {
83
+ return left.Commit.localeCompare(right.Commit) || left.StartLine - right.StartLine || left.RuleID.localeCompare(right.RuleID);
84
+ }
85
+
55
86
  function byPlace(left: Leak, right: Leak): number {
56
87
  return (
57
88
  left.File.localeCompare(right.File) ||
@@ -61,27 +92,31 @@ function byPlace(left: Leak, right: Leak): number {
61
92
  );
62
93
  }
63
94
 
64
- export function report(leaks: readonly Leak[]): string {
65
- if (leaks.length === 0) return `${NAME}: the range adds no secret`;
95
+ export function report(fileLeaks: readonly Leak[], messageLeaks: readonly Leak[]): string {
96
+ const count = fileLeaks.length + messageLeaks.length;
97
+ if (count === 0) return `${NAME}: the range adds no secret`;
66
98
  return [
67
- `${NAME}: the range adds ${leaks.length} secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:`,
68
- ...leaks.toSorted(byPlace).map(leakLine),
99
+ `${NAME}: the range adds ${count} secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:`,
100
+ ...fileLeaks.toSorted(byPlace).map(leakLine),
101
+ ...messageLeaks.toSorted(byMessage).map(messageLine),
69
102
  ].join("\n");
70
103
  }
71
104
 
72
105
  const secrets = Effect.gen(function* () {
73
106
  const fs = yield* FileSystem.FileSystem;
74
107
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
75
- const logOptions = yield* logOptionsOf(process.argv.slice(2), root);
108
+ const revisions = yield* revisionsOf(process.argv.slice(2), root);
109
+ const logOptions = yield* logOptionsOf(revisions, root);
76
110
  const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-" });
77
111
  const { config, notice } = yield* scanConfig(NAME, yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-pack-" }));
78
112
  const binary = yield* pinnedGitleaks(yield* cacheRoot());
79
113
  const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"], root)).trim();
80
114
  const reported = yield* scanCommits({ binary, gitDir, logOptions, config, ignoreDir });
81
- const leaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
82
- yield* Console.log(report(leaks));
115
+ const fileLeaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
116
+ const messageLeaks = yield* messageLeaksOf(yield* messagesOf(revisions, root), { binary, config, ignoreDir });
117
+ yield* Console.log(report(fileLeaks, messageLeaks));
83
118
  yield* Console.log(notice);
84
- return leaks.length === 0;
119
+ return fileLeaks.length + messageLeaks.length === 0;
85
120
  }).pipe(Effect.scoped);
86
121
 
87
122
  if (import.meta.main) runMain(NAME, secrets);