@saadnvd1/agent-os 0.3.462 → 0.3.464

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A workspace's standing rules: what its orchestrator must keep knowing
3
3
  * across fresh starts ("never merge #111", "briefs must not name
4
- * competitors"). The orchestrator writes them with rule_add and rule_remove,
4
+ * third-party products"). The orchestrator writes them with rule_add and rule_remove,
5
5
  * Saad in Settings → Workspaces, and every start reads them into its brief.
6
6
  * They restrict and inform; none can approve anything. A removed rule stays
7
7
  * in the table, marked.
@@ -73,7 +73,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
73
73
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
74
74
  note: "Add a line to this workspace's decision log; it shows in your chat.",
75
75
  rule_add:
76
- 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
76
+ 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
77
77
  rule_remove:
78
78
  "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
79
79
  review:
@@ -1,4 +1,5 @@
1
- import { execFileSync } from "child_process";
1
+ import { execFile } from "child_process";
2
+ import { run } from "./tasks/gh";
2
3
  import { UNTRUSTED_RULE, untrusted } from "./orchestrator/untrusted";
3
4
 
4
5
  export interface GeneratedPRContent {
@@ -15,7 +16,7 @@ export async function generatePRContent(
15
16
  ): Promise<GeneratedPRContent> {
16
17
  try {
17
18
  // Get git context
18
- const { diff, commits, changedFiles } = getGitContext(
19
+ const { diff, commits, changedFiles } = await getGitContext(
19
20
  workingDir,
20
21
  baseBranch
21
22
  );
@@ -45,23 +46,17 @@ export async function generatePRContent(
45
46
  /**
46
47
  * Get git context for PR generation
47
48
  */
48
- function getGitContext(
49
+ async function getGitContext(
49
50
  workingDir: string,
50
51
  baseBranch: string
51
- ): { diff: string; commits: string[]; changedFiles: string[] } {
52
+ ): Promise<{ diff: string; commits: string[]; changedFiles: string[] }> {
52
53
  let diff = "";
53
54
  let commits: string[] = [];
54
55
  let changedFiles: string[] = [];
55
56
 
56
57
  // Names and text here come from the repository; every one is an
57
58
  // argument to git, never shell text or an option.
58
- const git = (args: string[]) =>
59
- execFileSync("git", args, {
60
- cwd: workingDir,
61
- encoding: "utf-8",
62
- stdio: ["ignore", "pipe", "pipe"],
63
- maxBuffer: 10 * 1024 * 1024,
64
- });
59
+ const git = (args: string[]) => run("git", args, workingDir, 10_000);
65
60
  const lines = (out: string) =>
66
61
  out
67
62
  .split("\n")
@@ -72,7 +67,7 @@ function getGitContext(
72
67
  // Try to get the remote base branch reference
73
68
  let baseBranchRef = baseBranch;
74
69
  try {
75
- git([
70
+ await git([
76
71
  "rev-parse",
77
72
  "--verify",
78
73
  "--end-of-options",
@@ -82,7 +77,7 @@ function getGitContext(
82
77
  } catch {
83
78
  // Fall back to local branch
84
79
  try {
85
- git(["rev-parse", "--verify", "--end-of-options", baseBranch]);
80
+ await git(["rev-parse", "--verify", "--end-of-options", baseBranch]);
86
81
  } catch {
87
82
  // Base branch doesn't exist
88
83
  return { diff, commits, changedFiles };
@@ -91,18 +86,18 @@ function getGitContext(
91
86
  const range = `${baseBranchRef}...HEAD`;
92
87
 
93
88
  try {
94
- diff = git(["diff", "--stat", "--end-of-options", range, "--"]);
89
+ diff = await git(["diff", "--stat", "--end-of-options", range, "--"]);
95
90
  } catch {}
96
91
 
97
92
  try {
98
93
  changedFiles = lines(
99
- git(["diff", "--name-only", "--end-of-options", range, "--"])
94
+ await git(["diff", "--name-only", "--end-of-options", range, "--"])
100
95
  );
101
96
  } catch {}
102
97
 
103
98
  try {
104
99
  commits = lines(
105
- git([
100
+ await git([
106
101
  "log",
107
102
  "--pretty=format:%s",
108
103
  "--end-of-options",
@@ -114,11 +109,11 @@ function getGitContext(
114
109
 
115
110
  // Also include uncommitted changes
116
111
  try {
117
- const workingDiff = git(["diff", "--stat"]);
112
+ const workingDiff = await git(["diff", "--stat"]);
118
113
  if (workingDiff) {
119
114
  diff = diff ? `${diff}\n${workingDiff}` : workingDiff;
120
115
  }
121
- const uncommittedFiles = lines(git(["diff", "--name-only"]));
116
+ const uncommittedFiles = lines(await git(["diff", "--name-only"]));
122
117
  changedFiles = [...new Set([...changedFiles, ...uncommittedFiles])];
123
118
  } catch {}
124
119
  } catch (error) {
@@ -138,7 +133,7 @@ async function generateWithClaude(
138
133
  ): Promise<GeneratedPRContent | null> {
139
134
  // Check if Claude CLI is available
140
135
  try {
141
- execFileSync("claude", ["--version"], { stdio: "pipe", timeout: 5000 });
136
+ await run("claude", ["--version"], workingDir, 5000);
142
137
  } catch {
143
138
  return null;
144
139
  }
@@ -148,14 +143,7 @@ async function generateWithClaude(
148
143
  try {
149
144
  // Use claude CLI with --print flag for non-interactive output
150
145
  // The prompt goes on stdin: never argv, never a shell.
151
- const output = execFileSync("claude", ["--print"], {
152
- cwd: workingDir,
153
- input: prompt,
154
- encoding: "utf-8",
155
- timeout: 30000,
156
- maxBuffer: 1024 * 1024,
157
- });
158
-
146
+ const output = await claudePrint(workingDir, prompt);
159
147
  return parseClaudeResponse(output);
160
148
  } catch (error) {
161
149
  console.debug("Claude CLI invocation failed", error);
@@ -163,6 +151,28 @@ async function generateWithClaude(
163
151
  }
164
152
  }
165
153
 
154
+ // `claude --print` with the prompt on stdin, off the event loop and killed
155
+ // at the ceiling.
156
+ function claudePrint(cwd: string, prompt: string): Promise<string> {
157
+ return new Promise((resolve, reject) => {
158
+ const child = execFile(
159
+ "claude",
160
+ ["--print"],
161
+ {
162
+ cwd,
163
+ encoding: "utf-8",
164
+ timeout: 30_000,
165
+ killSignal: "SIGKILL",
166
+ maxBuffer: 1024 * 1024,
167
+ },
168
+ (error, stdout) => (error ? reject(error) : resolve(stdout))
169
+ );
170
+ // claude exiting before it read the prompt is its failure, not a crash.
171
+ child.stdin?.on("error", () => {});
172
+ child.stdin?.end(prompt);
173
+ });
174
+ }
175
+
166
176
  /**
167
177
  * Build prompt for PR generation
168
178
  */
package/lib/pr.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { execFileSync } from "child_process";
2
+ import { run } from "./tasks/gh";
3
+ import { GhBackoffError, isRateLimit } from "./tasks/gh-limit";
2
4
 
3
5
  /** The remote's default branch, or "main" when it has none. */
4
6
  export function remoteDefaultBranch(workingDir: string): string {
@@ -32,14 +34,19 @@ export interface CommitInfo {
32
34
  body: string;
33
35
  }
34
36
 
37
+ // Every call here waits on git, gh or the network: all of it runs off the
38
+ // event loop, so a slow GitHub never stalls the server's other requests.
39
+
35
40
  /**
36
- * Check if gh CLI is installed and authenticated
41
+ * Check if gh CLI is installed and authenticated. Throws GhBackoffError while
42
+ * every gh call waits out GitHub's rate limit: that isn't a missing login.
37
43
  */
38
- export function checkGhCli(): boolean {
44
+ export async function checkGhCli(): Promise<boolean> {
39
45
  try {
40
- execFileSync("gh", ["auth", "status"], { timeout: 5000, stdio: "pipe" });
46
+ await run("gh", ["auth", "status"], process.cwd(), 5000);
41
47
  return true;
42
- } catch {
48
+ } catch (error) {
49
+ if (error instanceof GhBackoffError) throw error;
43
50
  return false;
44
51
  }
45
52
  }
@@ -47,22 +54,25 @@ export function checkGhCli(): boolean {
47
54
  /**
48
55
  * Get commits between current branch and base branch
49
56
  */
50
- export function getCommitsSinceBase(
57
+ export async function getCommitsSinceBase(
51
58
  workingDir: string,
52
59
  baseBranch = "main"
53
- ): CommitInfo[] {
60
+ ): Promise<CommitInfo[]> {
54
61
  try {
55
62
  // Get the merge base
56
63
  // Names come from the repository (its remote's default branch): they
57
64
  // are arguments, never shell text or options.
58
- const mergeBase = execFileSync(
59
- "git",
60
- ["merge-base", "--end-of-options", baseBranch, "HEAD"],
61
- { cwd: workingDir, encoding: "utf-8" }
65
+ const mergeBase = (
66
+ await run(
67
+ "git",
68
+ ["merge-base", "--end-of-options", baseBranch, "HEAD"],
69
+ workingDir,
70
+ 10_000
71
+ )
62
72
  ).trim();
63
73
 
64
74
  // Get commits since merge base
65
- const output = execFileSync(
75
+ const output = await run(
66
76
  "git",
67
77
  [
68
78
  "log",
@@ -70,7 +80,8 @@ export function getCommitsSinceBase(
70
80
  "--format=COMMIT_START%n%H%n%s%n%b%nCOMMIT_END",
71
81
  "--",
72
82
  ],
73
- { cwd: workingDir, encoding: "utf-8" }
83
+ workingDir,
84
+ 10_000
74
85
  );
75
86
 
76
87
  const commits: CommitInfo[] = [];
@@ -160,12 +171,12 @@ export function generatePRBody(commits: CommitInfo[]): string {
160
171
  /**
161
172
  * Get PR for a branch
162
173
  */
163
- export function getPRForBranch(
174
+ export async function getPRForBranch(
164
175
  workingDir: string,
165
176
  branchName: string
166
- ): PRInfo | null {
177
+ ): Promise<PRInfo | null> {
167
178
  try {
168
- const output = execFileSync(
179
+ const output = await run(
169
180
  "gh",
170
181
  [
171
182
  "pr",
@@ -177,11 +188,16 @@ export function getPRForBranch(
177
188
  "--limit",
178
189
  "1",
179
190
  ],
180
- { cwd: workingDir, encoding: "utf-8", timeout: 10000 }
191
+ workingDir,
192
+ 10_000
181
193
  );
182
194
  const prs = JSON.parse(output);
183
195
  return prs.length > 0 ? prs[0] : null;
184
- } catch {
196
+ } catch (error) {
197
+ // GitHub's rate limit says nothing about whether a PR exists.
198
+ if (error instanceof GhBackoffError) throw error;
199
+ if (isRateLimit(error))
200
+ throw new GhBackoffError("GitHub's rate limit is spent; try again later");
185
201
  return null;
186
202
  }
187
203
  }
@@ -189,30 +205,32 @@ export function getPRForBranch(
189
205
  /**
190
206
  * Create a new PR
191
207
  */
192
- export function createPR(
208
+ export async function createPR(
193
209
  workingDir: string,
194
210
  branchName: string,
195
211
  baseBranch: string,
196
212
  title: string,
197
213
  body: string
198
- ): PRInfo {
214
+ ): Promise<PRInfo> {
199
215
  // First ensure branch is pushed
200
216
  try {
201
- execFileSync("git", ["push", "-u", "origin", "--", branchName], {
202
- cwd: workingDir,
203
- timeout: 30000,
204
- stdio: "pipe",
205
- });
217
+ await run(
218
+ "git",
219
+ ["push", "-u", "origin", "--", branchName],
220
+ workingDir,
221
+ 30_000
222
+ );
206
223
  } catch {
207
224
  // Branch might already be pushed
208
225
  }
209
226
 
210
227
  // Create PR using gh CLI
211
228
  // gh pr create outputs the PR URL on success
212
- const output = execFileSync(
229
+ const output = await run(
213
230
  "gh",
214
231
  ["pr", "create", "--title", title, "--base", baseBranch, "--body", body],
215
- { cwd: workingDir, encoding: "utf-8", timeout: 30000 }
232
+ workingDir,
233
+ 30_000
216
234
  );
217
235
 
218
236
  // Parse URL from output (gh pr create prints the URL)
@@ -235,20 +253,24 @@ export function createPR(
235
253
  /**
236
254
  * Get current branch name
237
255
  */
238
- export function getCurrentBranch(workingDir: string): string {
239
- return execFileSync("git", ["branch", "--show-current"], {
240
- cwd: workingDir,
241
- encoding: "utf-8",
242
- }).trim();
256
+ export async function getCurrentBranch(workingDir: string): Promise<string> {
257
+ return (
258
+ await run("git", ["branch", "--show-current"], workingDir, 10_000)
259
+ ).trim();
243
260
  }
244
261
 
245
262
  /**
246
- * Get the default base branch (main or master)
263
+ * Get the default base branch: the remote's, or "main" when it has none.
247
264
  */
248
- export function getBaseBranch(workingDir: string): string {
265
+ export async function getBaseBranch(workingDir: string): Promise<string> {
249
266
  try {
250
- // Try to get from remote HEAD
251
- return remoteDefaultBranch(workingDir);
267
+ const ref = await run(
268
+ "git",
269
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
270
+ workingDir,
271
+ 10_000
272
+ );
273
+ return ref.trim().replace(/^origin\//, "");
252
274
  } catch {
253
275
  return "main";
254
276
  }
@@ -1,6 +1,6 @@
1
1
  // A linked machine's sessions are sessions here. One whose folder maps to
2
2
  // a project here sits with that project's sessions; the rest are listed
3
- // under their machine ("homelab · 3"), with the same rows. Client-safe.
3
+ // under their machine ("devbox · 3"), with the same rows. Client-safe.
4
4
  import type { Session } from "@/lib/db/types";
5
5
  import { buildShelves, type ShelfInput, type SidebarRow } from "./shelves";
6
6
 
@@ -662,6 +662,13 @@ class SessionStatusDetector {
662
662
  return [...this.cache.data.values()];
663
663
  }
664
664
 
665
+ // A new object whenever the listing or its errors change (the cache is
666
+ // replaced, never edited): readers derived from them can skip the work
667
+ // while it's the same one.
668
+ listingStamp(): object {
669
+ return this.cache;
670
+ }
671
+
665
672
  hostErrors(): Record<string, string> {
666
673
  return Object.fromEntries(this.cache.hostErrors);
667
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.462",
3
+ "version": "0.3.464",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -18,7 +18,6 @@
18
18
  "mcp/",
19
19
  "public/",
20
20
  "scripts/",
21
- "!scripts/competitor-audit/**",
22
21
  "stores/",
23
22
  "styles/",
24
23
  "next.config.ts",
package/scripts/check CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # The full guard, the same run locally and in CI:
3
- # scripts/check typecheck, lint, format, tests
3
+ # scripts/check banned terms, typecheck, lint, format, tests
4
4
  # scripts/check --build ...and a production build, held to its JS budget
5
5
  set -euo pipefail
6
6
  cd "$(dirname "$0")/.."
7
7
 
8
8
  step() { printf '\n▸ %s\n' "$1"; }
9
9
 
10
+ step "terms"; npx tsx scripts/hooks/banned-terms.ts --tree
10
11
  step "typecheck"; npx tsc --noEmit
11
12
  step "lint"; npx eslint . --max-warnings=40
12
13
  step "format"; npx prettier --check . --log-level warn
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { execFileSync, spawnSync } from "child_process";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { GENERIC_TERMS, loadTerms, matcher, scanTree } from "./banned-terms";
7
+
8
+ const generic = GENERIC_TERMS[0];
9
+
10
+ function tempRepo() {
11
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "banned-terms-"));
12
+ const git = (...args: string[]) =>
13
+ execFileSync("git", args, { cwd: repo, stdio: "pipe" });
14
+ git("init", "-q");
15
+ return { repo, git };
16
+ }
17
+
18
+ describe("loadTerms", () => {
19
+ it("is the generic list alone when the private file is absent", () => {
20
+ const terms = loadTerms(path.join(os.tmpdir(), "no-such-banned-names"));
21
+ expect(terms.map((t) => t.source)).toEqual(GENERIC_TERMS);
22
+ });
23
+
24
+ it("adds the private regexes, skipping blanks and comments", () => {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "banned-names-"));
26
+ const file = path.join(dir, "banned-names");
27
+ fs.writeFileSync(file, "# names\nacme[ -]?widgets\n\n zorp \n");
28
+ try {
29
+ const check = matcher(loadTerms(file));
30
+ expect(check("We copied Acme-Widgets here")).toBe('"Acme-Widgets"');
31
+ expect(check("ZORP")).toBe('"ZORP"');
32
+ expect(check(`the ${generic.toUpperCase()} list`)).not.toBeNull();
33
+ expect(check("nothing to see")).toBeNull();
34
+ } finally {
35
+ fs.rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("scanTree", () => {
41
+ it("names the file and line of each hit, and a hit in a path", () => {
42
+ const { repo, git } = tempRepo();
43
+ try {
44
+ fs.mkdirSync(path.join(repo, `${generic}-x`));
45
+ fs.writeFileSync(path.join(repo, `${generic}-x`, "a.md"), "fine\n");
46
+ fs.writeFileSync(path.join(repo, "b.ts"), `ok\n// a ${generic}\n`);
47
+ fs.writeFileSync(path.join(repo, "c.bin"), `\0${generic}`);
48
+ git("add", ".");
49
+ expect(scanTree(repo, loadTerms("/nonexistent"))).toEqual([
50
+ { file: "b.ts", line: 2, what: `"${generic}"` },
51
+ { file: `${generic}-x/a.md`, line: 0, what: `"${generic}"` },
52
+ ]);
53
+ } finally {
54
+ fs.rmSync(repo, { recursive: true, force: true });
55
+ }
56
+ });
57
+ });
58
+
59
+ describe("the pre-commit check", () => {
60
+ it("fails on a staged private name, and passes once it's gone", () => {
61
+ const { repo, git } = tempRepo();
62
+ const list = path.join(repo, "..", `${path.basename(repo)}-names`);
63
+ fs.writeFileSync(list, "acme\n");
64
+ const scan = () =>
65
+ spawnSync(
66
+ path.resolve("node_modules/.bin/tsx"),
67
+ [path.resolve("scripts/hooks/banned-terms.ts")],
68
+ {
69
+ cwd: repo,
70
+ encoding: "utf-8",
71
+ env: { ...process.env, AGENTOS_BANNED_NAMES: list },
72
+ }
73
+ );
74
+ try {
75
+ fs.writeFileSync(path.join(repo, "x.md"), "ok\nlike Acme does\n");
76
+ git("add", "x.md");
77
+ const bad = scan();
78
+ expect(bad.status).toBe(1);
79
+ expect(bad.stderr).toContain("x.md:2");
80
+ fs.writeFileSync(path.join(repo, "x.md"), "ok\nlike others do\n");
81
+ git("add", "x.md");
82
+ expect(scan().status).toBe(0);
83
+ fs.writeFileSync(path.join(repo, "acme-notes.md"), "clean\n");
84
+ git("add", "acme-notes.md");
85
+ const named = scan();
86
+ expect(named.status).toBe(1);
87
+ expect(named.stderr).toContain("acme-notes.md:0");
88
+ } finally {
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ fs.rmSync(list, { force: true });
91
+ }
92
+ });
93
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Words that must never land in this public repo: one generic term, always,
3
+ * plus a private denylist kept outside the repo (the list itself would be a
4
+ * mention), one case-insensitive regex per line, skipped when it's absent.
5
+ *
6
+ * tsx scripts/hooks/banned-terms.ts the staged diff's added lines (pre-commit)
7
+ * tsx scripts/hooks/banned-terms.ts --tree every tracked file (scripts/check, CI)
8
+ */
9
+
10
+ import { execFileSync } from "child_process";
11
+ import fs from "fs";
12
+ import os from "os";
13
+ import path from "path";
14
+ import { scanDiff, stagedDiff, type SecretHit } from "./staged-secrets";
15
+
16
+ // Assembled so this file isn't a hit itself.
17
+ export const GENERIC_TERMS = [["compet", "itor"].join("")];
18
+
19
+ export const bannedFile = () =>
20
+ process.env.AGENTOS_BANNED_NAMES ||
21
+ path.join(os.homedir(), ".config", "agent-os", "banned-names");
22
+
23
+ export function loadTerms(file = bannedFile()): RegExp[] {
24
+ const generic = GENERIC_TERMS.map((t) => new RegExp(t, "i"));
25
+ let raw = "";
26
+ try {
27
+ raw = fs.readFileSync(file, "utf8");
28
+ } catch {
29
+ return generic;
30
+ }
31
+ const own = raw
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l && !l.startsWith("#"))
35
+ .map((l) => new RegExp(l, "i"));
36
+ return [...generic, ...own];
37
+ }
38
+
39
+ // What a line matched, without echoing the private pattern that caught it.
40
+ export const matcher = (terms: RegExp[]) => (line: string) => {
41
+ for (const t of terms) {
42
+ const m = line.match(t);
43
+ if (m) return `"${m[0]}"`;
44
+ }
45
+ return null;
46
+ };
47
+
48
+ export function scanTree(cwd: string, terms: RegExp[]): SecretHit[] {
49
+ const check = matcher(terms);
50
+ const files = execFileSync("git", ["ls-files", "-z"], {
51
+ cwd,
52
+ encoding: "utf8",
53
+ maxBuffer: 64 * 1024 * 1024,
54
+ })
55
+ .split("\0")
56
+ .filter(Boolean);
57
+ const hits: SecretHit[] = [];
58
+ for (const file of files) {
59
+ let text: string;
60
+ try {
61
+ text = fs.readFileSync(path.join(cwd, file), "utf8");
62
+ } catch {
63
+ continue; // deleted in the working tree, or a submodule
64
+ }
65
+ if (text.includes("\0")) continue;
66
+ text.split("\n").forEach((l, i) => {
67
+ const what = check(l);
68
+ if (what) hits.push({ file, line: i + 1, what });
69
+ });
70
+ }
71
+ // A path can name something too.
72
+ for (const file of files) {
73
+ const what = check(file);
74
+ if (what) hits.push({ file, line: 0, what });
75
+ }
76
+ return hits;
77
+ }
78
+
79
+ function stagedHits(terms: RegExp[]): SecretHit[] {
80
+ const check = matcher(terms);
81
+ const names = execFileSync(
82
+ "git",
83
+ ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
84
+ { encoding: "utf8" }
85
+ )
86
+ .split("\0")
87
+ .filter(Boolean)
88
+ .flatMap((file) => {
89
+ const what = check(file);
90
+ return what ? [{ file, line: 0, what }] : [];
91
+ });
92
+ return [...scanDiff(stagedDiff(), check), ...names];
93
+ }
94
+
95
+ if (process.argv[1]?.endsWith("banned-terms.ts")) {
96
+ const terms = loadTerms();
97
+ const hits = process.argv.includes("--tree")
98
+ ? scanTree(process.cwd(), terms)
99
+ : stagedHits(terms);
100
+ for (const h of hits)
101
+ console.error(`✗ ${h.file}:${h.line}: names ${h.what}; reword it`);
102
+ if (hits.length) process.exit(1);
103
+ }
@@ -13,10 +13,13 @@ export interface SecretHit {
13
13
  what: string;
14
14
  }
15
15
 
16
- // Each added line of a `git diff -U0` with the file and new line number it
17
- // lands on. Headers are only read before a file's first hunk, so an added
18
- // line that itself starts with "++" isn't taken for one.
19
- export function scanDiff(diff: string): SecretHit[] {
16
+ // Each added line of a `git diff -U0` that `check` flags, with the file and
17
+ // new line number it lands on. Headers are only read before a file's first
18
+ // hunk, so an added line that itself starts with "++" isn't taken for one.
19
+ export function scanDiff(
20
+ diff: string,
21
+ check: (line: string) => string | null = secretsIn
22
+ ): SecretHit[] {
20
23
  const hits: SecretHit[] = [];
21
24
  let file = "";
22
25
  let line = 0;
@@ -30,7 +33,7 @@ export function scanDiff(diff: string): SecretHit[] {
30
33
  header = false;
31
34
  line = Number(l.match(/\+(\d+)/)?.[1] ?? 0);
32
35
  } else if (!header && l.startsWith("+")) {
33
- const what = secretsIn(l.slice(1));
36
+ const what = check(l.slice(1));
34
37
  if (what) hits.push({ file, line, what });
35
38
  line++;
36
39
  }
@@ -38,8 +41,8 @@ export function scanDiff(diff: string): SecretHit[] {
38
41
  return hits;
39
42
  }
40
43
 
41
- if (process.argv[1]?.endsWith("staged-secrets.ts")) {
42
- const diff = execFileSync(
44
+ export const stagedDiff = () =>
45
+ execFileSync(
43
46
  "git",
44
47
  [
45
48
  "diff",
@@ -51,7 +54,9 @@ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
51
54
  ],
52
55
  { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }
53
56
  );
54
- const hits = scanDiff(diff);
57
+
58
+ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
59
+ const hits = scanDiff(stagedDiff());
55
60
  for (const h of hits)
56
61
  console.error(`✗ ${h.file}:${h.line}: looks like a secret (${h.what})`);
57
62
  if (hits.length) {