@redential/cli 0.1.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 (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +217 -0
  3. package/dist/build-bundle.js +37 -0
  4. package/dist/categorize.js +25 -0
  5. package/dist/churn-exclusions.js +51 -0
  6. package/dist/cli.js +87 -0
  7. package/dist/config.js +13 -0
  8. package/dist/credentials.js +26 -0
  9. package/dist/errors.js +20 -0
  10. package/dist/git.js +141 -0
  11. package/dist/hash.js +4 -0
  12. package/dist/http-client.js +129 -0
  13. package/dist/import-detect.js +281 -0
  14. package/dist/login.js +133 -0
  15. package/dist/logout.js +6 -0
  16. package/dist/merkle.js +20 -0
  17. package/dist/prompt.js +70 -0
  18. package/dist/public-remote.js +43 -0
  19. package/dist/salt.js +20 -0
  20. package/dist/scan-command.js +24 -0
  21. package/dist/scan.js +145 -0
  22. package/dist/secret-scan.js +37 -0
  23. package/dist/skill-detect.js +234 -0
  24. package/dist/submit-command.js +49 -0
  25. package/dist/submit.js +71 -0
  26. package/dist/summary.js +148 -0
  27. package/dist/types.js +12 -0
  28. package/dist/version-check.js +62 -0
  29. package/package.json +48 -0
  30. package/signatures/ai/vector-search.json +39 -0
  31. package/signatures/ai/whisper.json +36 -0
  32. package/signatures/auth/firebase-auth.json +43 -0
  33. package/signatures/auth/oauth-oidc.json +41 -0
  34. package/signatures/auth/supabase-auth.json +29 -0
  35. package/signatures/backend/axum.json +24 -0
  36. package/signatures/db/activerecord.json +21 -0
  37. package/signatures/db/eloquent.json +22 -0
  38. package/signatures/db/supabase.json +31 -0
  39. package/signatures/frontend/tailwind.json +28 -0
  40. package/signatures/infra/docker.json +20 -0
  41. package/signatures/infra/github-actions.json +19 -0
  42. package/signatures/infra/kubernetes.json +21 -0
  43. package/signatures/infra/terraform.json +22 -0
  44. package/signatures/package-map.json +483 -0
  45. package/signatures/testing/jest.json +25 -0
  46. package/taxonomy.json +119 -0
package/dist/login.js ADDED
@@ -0,0 +1,133 @@
1
+ import { spawn } from "node:child_process";
2
+ import { AuthError } from "./errors.js";
3
+ import { getSiteUrl } from "./config.js";
4
+ import { saveCredentials } from "./credentials.js";
5
+ import { postJson, pollJson } from "./http-client.js";
6
+ import { checkForUpdate } from "./version-check.js";
7
+ function requestDeviceAuthorization(siteUrl) {
8
+ return postJson(`${siteUrl}/api/cli/device/authorize`, {});
9
+ }
10
+ function pollDeviceToken(siteUrl, deviceCode) {
11
+ // Not postJson: this endpoint returns HTTP 400 for authorization_pending/
12
+ // slow_down too (RFC 8628 shape), which postJson would treat as a fatal
13
+ // NetworkError before ever reading the body.
14
+ return pollJson(`${siteUrl}/api/cli/device/token`, { device_code: deviceCode });
15
+ }
16
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
+ /**
18
+ * Best-effort auto-open of verification_uri in the default browser — never
19
+ * fatal, never blocks login: the printed URL/code are the fallback for any
20
+ * failure here (headless box, SSH session, unknown platform, no browser).
21
+ * `verification_uri` comes from the server's JSON response, so it's treated
22
+ * as untrusted input: only http/https is ever handed to a native opener
23
+ * (never file://, an app-custom scheme, etc.), and the re-serialized
24
+ * `URL#href` is passed — never the raw string — so it can't carry stray
25
+ * whitespace/quotes into the child process's argv.
26
+ *
27
+ * Uses `spawn`, never `exec`/a shell string: the URL is always its own argv
28
+ * element, so a URL crafted with shell metacharacters can't inject a second
29
+ * command. Windows is the one platform where even that isn't enough — `cmd
30
+ * /c start` re-parses the command line, and a legal URL character like `&`
31
+ * would split it into a second command — so win32 shells out to
32
+ * `rundll32 url.dll,FileProtocolHandler` instead, which never touches a
33
+ * shell at all. `detached` + `unref` + `stdio: "ignore"` keep the opener
34
+ * from holding the CLI's event loop open or inheriting its file
35
+ * descriptors; the `error` listener is required because `spawn` emits
36
+ * `error` asynchronously on ENOENT (missing `open`/`xdg-open`/`rundll32`) —
37
+ * without a listener that's an unhandled `error` event that crashes the
38
+ * whole process.
39
+ */
40
+ function openBrowser(url) {
41
+ let target;
42
+ try {
43
+ const parsed = new URL(url);
44
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
45
+ return;
46
+ target = parsed.href;
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ let command;
52
+ let args;
53
+ if (process.platform === "darwin") {
54
+ command = "open";
55
+ args = [target];
56
+ }
57
+ else if (process.platform === "win32") {
58
+ command = "rundll32";
59
+ args = ["url.dll,FileProtocolHandler", target];
60
+ }
61
+ else {
62
+ command = "xdg-open";
63
+ args = [target];
64
+ }
65
+ try {
66
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
67
+ child.on("error", () => { });
68
+ child.unref();
69
+ }
70
+ catch {
71
+ // Synchronous spawn failures (e.g. EACCES) are equally non-fatal.
72
+ }
73
+ }
74
+ /**
75
+ * RFC 8628-shaped device authorization grant. Nothing on the server side of
76
+ * this exists yet (redence has no /api/cli/* routes at the time of writing —
77
+ * this repo defines the contract; redence implements to match, see
78
+ * docs/login-submit.md). Never sends anything except the device code itself.
79
+ */
80
+ export async function runLogin(opts = {}) {
81
+ const log = opts.log ?? console.log;
82
+ const sleep = opts.sleepFn ?? defaultSleep;
83
+ const open = opts.openFn ?? openBrowser;
84
+ const siteUrl = getSiteUrl();
85
+ const auth = await requestDeviceAuthorization(siteUrl);
86
+ log(`First, go to: ${auth.verification_uri}`);
87
+ log(`Then enter this code: ${auth.user_code}`);
88
+ // Printed above first, so the URL/code are on screen even if the browser
89
+ // steals focus or auto-open silently does nothing. Guarded here too, not
90
+ // just inside the default openBrowser: an injected openFn (test or
91
+ // future caller) must never be able to fail login either — including one
92
+ // that returns a rejecting promise, which a synchronous try/catch alone
93
+ // wouldn't catch (openFn's type is synchronous, but nothing stops a
94
+ // caller from assigning an async function to it).
95
+ try {
96
+ void Promise.resolve(open(auth.verification_uri)).catch(() => { });
97
+ }
98
+ catch {
99
+ // Auto-open is a convenience, never load-bearing.
100
+ }
101
+ log("Waiting for confirmation...");
102
+ let intervalMs = Math.max(auth.interval, 1) * 1000;
103
+ const deadline = Date.now() + auth.expires_in * 1000;
104
+ for (;;) {
105
+ if (Date.now() > deadline) {
106
+ throw new AuthError("Login timed out before the code was confirmed. Run `redential login` again.");
107
+ }
108
+ await sleep(intervalMs);
109
+ const result = await pollDeviceToken(siteUrl, auth.device_code);
110
+ if (result.access_token) {
111
+ saveCredentials({ access_token: result.access_token, site_url: siteUrl, obtained_at: new Date().toISOString() }, opts.configDir);
112
+ log("Logged in.");
113
+ // Best-effort only, after the login itself has already fully
114
+ // succeeded — never allowed to turn a successful login into a
115
+ // failure (checkForUpdate never throws by contract).
116
+ await (opts.checkForUpdateFn ?? (() => checkForUpdate({ log })))();
117
+ return;
118
+ }
119
+ switch (result.error) {
120
+ case "authorization_pending":
121
+ continue;
122
+ case "slow_down":
123
+ intervalMs += 5000;
124
+ continue;
125
+ case "access_denied":
126
+ throw new AuthError("Login was denied.");
127
+ case "expired_token":
128
+ throw new AuthError("The login code expired before it was confirmed. Run `redential login` again.");
129
+ default:
130
+ throw new AuthError("Unexpected response from the login server.");
131
+ }
132
+ }
133
+ }
package/dist/logout.js ADDED
@@ -0,0 +1,6 @@
1
+ import { deleteCredentials } from "./credentials.js";
2
+ export function runLogout(opts = {}) {
3
+ const log = opts.log ?? console.log;
4
+ const deleted = deleteCredentials(opts.configDir);
5
+ log(deleted ? "Logged out." : "Not logged in — nothing to do.");
6
+ }
package/dist/merkle.js ADDED
@@ -0,0 +1,20 @@
1
+ import { createHash } from "node:crypto";
2
+ function sha256hex(value) {
3
+ return createHash("sha256").update(value).digest("hex");
4
+ }
5
+ /** Plain pairwise-sha256 Merkle root over the user's commit shas. */
6
+ export function merkleRoot(leaves) {
7
+ let level = leaves.map(sha256hex);
8
+ if (level.length === 0)
9
+ return sha256hex("");
10
+ while (level.length > 1) {
11
+ const next = [];
12
+ for (let i = 0; i < level.length; i += 2) {
13
+ const left = level[i];
14
+ const right = i + 1 < level.length ? level[i + 1] : left;
15
+ next.push(sha256hex(left + right));
16
+ }
17
+ level = next;
18
+ }
19
+ return level[0];
20
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,70 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { ScanError } from "./errors.js";
3
+ const DEFAULT_STREAMS = { input: process.stdin, output: process.stdout };
4
+ /**
5
+ * `rl.question()` never settles if the input stream hits EOF before an
6
+ * answer arrives (e.g. closed/piped stdin in a script or CI) — the process
7
+ * would then idle with nothing keeping the event loop alive and exit 0
8
+ * without ever producing a bundle. Racing against the interface's own
9
+ * "close" event turns that silent non-answer into an explicit failure.
10
+ */
11
+ function questionOrThrowOnClose(rl, prompt, closeMessage) {
12
+ const closed = new Promise((_, reject) => {
13
+ rl.once("close", () => reject(new ScanError(closeMessage)));
14
+ });
15
+ return Promise.race([rl.question(prompt), closed]);
16
+ }
17
+ function formatCandidate(c) {
18
+ return `${c.email} (${c.count} commit${c.count === 1 ? "" : "s"})`;
19
+ }
20
+ export async function promptAuthors(candidates, streams = DEFAULT_STREAMS) {
21
+ const rl = createInterface(streams);
22
+ try {
23
+ // A single candidate is almost always "you" — a Y/n confirmation (Y
24
+ // default, so pressing Enter accepts) is faster than making the user
25
+ // type "1" for the only option. 2+ candidates keep the numbered list:
26
+ // there's no single obvious default to pick for them.
27
+ if (candidates.length === 1) {
28
+ const [only] = candidates;
29
+ const answer = await questionOrThrowOnClose(rl, `Found 1 identity: ${formatCandidate(only)}. Is this you? (Y/n) `, "Input closed before an author identity was selected.");
30
+ const trimmed = answer.trim().toLowerCase();
31
+ return trimmed === "" || trimmed.startsWith("y") ? [only.email] : [];
32
+ }
33
+ console.log("Which of these author identities are yours?");
34
+ candidates.forEach((c, i) => {
35
+ console.log(` ${i + 1}. ${formatCandidate(c)}`);
36
+ });
37
+ const answer = await questionOrThrowOnClose(rl, "Enter the numbers, comma-separated (e.g. 1,3): ", "Input closed before an author identity was selected.");
38
+ const indices = answer
39
+ .split(",")
40
+ .map((s) => parseInt(s.trim(), 10) - 1)
41
+ .filter((i) => i >= 0 && i < candidates.length);
42
+ return [...new Set(indices.map((i) => candidates[i].email))];
43
+ }
44
+ finally {
45
+ rl.close();
46
+ }
47
+ }
48
+ const ATTESTATION_TEXT = "I am authorized to analyze this repository.";
49
+ export async function promptConfirmAttestation(streams = DEFAULT_STREAMS) {
50
+ const rl = createInterface(streams);
51
+ try {
52
+ const answer = await questionOrThrowOnClose(rl, `${ATTESTATION_TEXT} Confirm? (y/n) `, "Input closed before authorization was confirmed.");
53
+ return answer.trim().toLowerCase().startsWith("y");
54
+ }
55
+ finally {
56
+ rl.close();
57
+ }
58
+ }
59
+ /** Separate confirmation from promptConfirmAttestation — "I'm authorized to
60
+ * scan" and "upload this specific bundle" are different questions. */
61
+ export async function promptConfirmUpload(streams = DEFAULT_STREAMS) {
62
+ const rl = createInterface(streams);
63
+ try {
64
+ const answer = await questionOrThrowOnClose(rl, "Upload this bundle? (y/n) ", "Input closed before the upload was confirmed.");
65
+ return answer.trim().toLowerCase().startsWith("y");
66
+ }
67
+ finally {
68
+ rl.close();
69
+ }
70
+ }
@@ -0,0 +1,43 @@
1
+ const KNOWN_PUBLIC_HOSTS = [/github\.com/, /gitlab\.com/, /bitbucket\.org/];
2
+ /**
3
+ * Heuristic only — NOT a network-verified "is this actually publicly
4
+ * fetchable" check (that would require a request, and `scan` never makes
5
+ * one). True accessibility depends on the repo's own visibility setting on
6
+ * that host, which only the host itself knows. This only recognizes
7
+ * well-known public-hosting domains and rules out URLs carrying embedded
8
+ * credentials (a strong signal of gated, non-public access).
9
+ *
10
+ * Known host != publicly accessible: the CLI's PRIMARY use case is a
11
+ * private employer repo hosted on github.com, so this must never block
12
+ * scanning — see publicHostWarning below and docs/privacy-tests.md.
13
+ *
14
+ * The real, network-backed check lives in submit.ts's checkVisibilityGate:
15
+ * an anonymous HEAD request made directly to the remote URL itself (never
16
+ * to Redential's servers), gated on isKnownPublicHost being true here
17
+ * first. `scan` never calls it — only `submit`, which already makes
18
+ * network calls, may.
19
+ */
20
+ export function isKnownPublicHost(remoteUrl) {
21
+ if (!remoteUrl)
22
+ return false;
23
+ if (/:\/\/[^/@]+:[^/@]+@/.test(remoteUrl))
24
+ return false; // embedded user:pass or token-as-password
25
+ if (/[?&](?:token|access_token)=/i.test(remoteUrl))
26
+ return false; // token in the URL itself
27
+ return KNOWN_PUBLIC_HOSTS.some((host) => host.test(remoteUrl));
28
+ }
29
+ /**
30
+ * Informational only — returns a message to print, or null. Never a
31
+ * reason to skip scanning: this heuristic can say "this MIGHT be
32
+ * connectable", never "this IS public", so blocking on it would break the
33
+ * CLI's main use case (a private employer repo that happens to be hosted
34
+ * on github.com). The user decides; `scan` always proceeds.
35
+ */
36
+ export function publicHostWarning(remoteUrl) {
37
+ if (!isKnownPublicHost(remoteUrl))
38
+ return null;
39
+ return ("Note: this repository's remote looks like it's hosted on GitHub, GitLab, or Bitbucket. " +
40
+ "If it's your own project and you can connect it directly, the GitHub App reads the actual " +
41
+ "code and grants a stronger tier than a local metadata scan. If this is a private/employer " +
42
+ "repo you can't connect that way, scanning normally (as below) is the right call — continuing.");
43
+ }
package/dist/salt.js ADDED
@@ -0,0 +1,20 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { DEFAULT_CONFIG_DIR } from "./config.js";
5
+ /**
6
+ * Device-local salt, persisted once per machine (same 0600 pattern as
7
+ * credentials.json). Its only job is to prevent rainbow-table lookups on
8
+ * repo_fingerprint / author_identity_hashes — not to anchor identity to an
9
+ * account, which is `login`'s job in a later milestone.
10
+ */
11
+ export function getOrCreateSalt(configDir = DEFAULT_CONFIG_DIR) {
12
+ const saltPath = join(configDir, "salt");
13
+ if (existsSync(saltPath)) {
14
+ return readFileSync(saltPath, "utf8").trim();
15
+ }
16
+ mkdirSync(configDir, { recursive: true });
17
+ const salt = randomBytes(32).toString("hex");
18
+ writeFileSync(saltPath, salt, { mode: 0o600 });
19
+ return salt;
20
+ }
@@ -0,0 +1,24 @@
1
+ import { buildBundleInteractively } from "./build-bundle.js";
2
+ import { formatSummary } from "./summary.js";
3
+ /**
4
+ * The `scan` command's actual behavior, independent of commander wiring —
5
+ * exists mainly so the public-host warning ("warn, never block") is
6
+ * testable without spawning the built CLI.
7
+ *
8
+ * Output contract: piped/redirected stdout (or `--json`) always gets ONLY
9
+ * the raw bundle JSON, byte-identical to before the summary existed, so
10
+ * `scan | jq` keeps working. A real TTY (and no `--json`) gets the same
11
+ * JSON printed first, then the human-readable "wrapped" summary below it
12
+ * — JSON first so the summary is what's left on screen once the JSON has
13
+ * scrolled up. The summary is pure formatting over the bundle `runScan`
14
+ * already computed, not a second data source.
15
+ */
16
+ export async function executeScanCommand(opts) {
17
+ const log = opts.log ?? console.log;
18
+ const bundle = await buildBundleInteractively(opts);
19
+ const bundleJson = JSON.stringify(bundle, null, 2);
20
+ log(bundleJson);
21
+ if (opts.isTTY && !opts.json) {
22
+ log(formatSummary(bundle));
23
+ }
24
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,145 @@
1
+ import { extname } from "node:path";
2
+ import { getAllCommits, getRemoteHostType, getRootCommitSha } from "./git.js";
3
+ import { saltedHash } from "./hash.js";
4
+ import { getOrCreateSalt } from "./salt.js";
5
+ import { merkleRoot } from "./merkle.js";
6
+ import { categorize } from "./categorize.js";
7
+ import { isExcludedPath, heuristicallyGeneratedPaths } from "./churn-exclusions.js";
8
+ import { detectSkills } from "./skill-detect.js";
9
+ import { assertNoSecrets } from "./secret-scan.js";
10
+ export { ScanError } from "./errors.js";
11
+ import { ScanError } from "./errors.js";
12
+ export function listAuthors(repoPath) {
13
+ const counts = new Map();
14
+ for (const c of getAllCommits(repoPath)) {
15
+ counts.set(c.email, (counts.get(c.email) ?? 0) + 1);
16
+ }
17
+ return [...counts.entries()]
18
+ .map(([email, count]) => ({ email, count }))
19
+ .sort((a, b) => b.count - a.count);
20
+ }
21
+ const MS_PER_DAY = 86_400_000;
22
+ function normalizeExtension(filePath) {
23
+ const ext = extname(filePath).toLowerCase();
24
+ return /^\.[a-z0-9]+$/.test(ext) ? ext : null;
25
+ }
26
+ export function runScan(opts) {
27
+ if (!opts.confirmed) {
28
+ throw new ScanError("Authorization not confirmed. Re-run with --yes after confirming you are authorized to analyze this repository, or answer the interactive prompt.");
29
+ }
30
+ if (opts.authors.length === 0) {
31
+ throw new ScanError("No author identity selected. Pass --author <email> (repeatable) or select interactively.");
32
+ }
33
+ const allCommits = getAllCommits(opts.repoPath);
34
+ if (allCommits.length === 0) {
35
+ throw new ScanError("This repository has no commits yet — nothing to scan.");
36
+ }
37
+ const authorSet = new Set(opts.authors);
38
+ const userCommits = allCommits.filter((c) => authorSet.has(c.email));
39
+ if (userCommits.length === 0) {
40
+ throw new ScanError(`No commits found for the selected author(s): ${opts.authors.join(", ")}`);
41
+ }
42
+ const now = opts.now ?? new Date();
43
+ const distinctAuthors = new Set(allCommits.map((c) => c.email));
44
+ const otherContributorsCount = [...distinctAuthors].filter((e) => !authorSet.has(e)).length;
45
+ const salt = getOrCreateSalt(opts.configDir);
46
+ const authorHashes = opts.authors.map((e) => saltedHash(salt, e));
47
+ const rootSha = getRootCommitSha(opts.repoPath);
48
+ const repoFirstCommitDate = allCommits[0].authorDate;
49
+ const ageDays = Math.floor((now.getTime() - repoFirstCommitDate.getTime()) / MS_PER_DAY);
50
+ const hostType = getRemoteHostType(opts.repoPath);
51
+ const repoFingerprint = saltedHash(salt, rootSha);
52
+ const firstAt = userCommits[0].authorDate;
53
+ const lastAt = userCommits[userCommits.length - 1].authorDate;
54
+ const spanDays = Math.floor((lastAt.getTime() - firstAt.getTime()) / MS_PER_DAY);
55
+ const hourHistogram = new Array(24).fill(0);
56
+ const weekdayHistogram = new Array(7).fill(0);
57
+ for (const c of userCommits) {
58
+ hourHistogram[c.authorDate.getUTCHours()]++;
59
+ weekdayHistogram[c.authorDate.getUTCDay()]++;
60
+ }
61
+ const signedCount = userCommits.filter((c) => c.signed).length;
62
+ const { languages, categories } = computeChurnBreakdown(userCommits);
63
+ const detectedSkills = detectSkills(userCommits, opts.repoPath, opts.skillDetectOptions);
64
+ const bundle = {
65
+ schema_version: "1.0.0",
66
+ runner: "local",
67
+ tool_version: opts.toolVersion,
68
+ created_at: now.toISOString(),
69
+ repo: { host_type: hostType, age_days: ageDays, repo_fingerprint: repoFingerprint },
70
+ identity: {
71
+ author_identity_hashes: authorHashes,
72
+ other_contributors_count: otherContributorsCount,
73
+ },
74
+ commits: {
75
+ user_total: userCommits.length,
76
+ first_at: firstAt.toISOString(),
77
+ last_at: lastAt.toISOString(),
78
+ span_days: spanDays,
79
+ hour_histogram: hourHistogram,
80
+ weekday_histogram: weekdayHistogram,
81
+ },
82
+ signed: { count: signedCount, ratio: signedCount / userCommits.length },
83
+ languages,
84
+ categories,
85
+ detected_skills: detectedSkills,
86
+ ownership: { user_commit_ratio: userCommits.length / allCommits.length },
87
+ integrity: { merkle_root: merkleRoot(userCommits.map((c) => c.sha)), algorithm: "sha256" },
88
+ attestation: { authorized_confirmation: true, confirmed_at: now.toISOString() },
89
+ };
90
+ // Final gate before the bundle reaches any caller (scan's stdout, a
91
+ // future submit): the bundle's fields are all structurally bounded today
92
+ // and can't carry a secret, but this is the regression guard for the
93
+ // day a bug or a new field lets one through.
94
+ assertNoSecrets(JSON.stringify(bundle));
95
+ return bundle;
96
+ }
97
+ function computeChurnBreakdown(userCommits) {
98
+ const generatedPaths = heuristicallyGeneratedPaths(userCommits);
99
+ const churnByExt = new Map();
100
+ const churnByCategory = new Map();
101
+ const commitsByCategory = new Map();
102
+ let langChurnTotal = 0;
103
+ let categoryChurnTotal = 0;
104
+ for (const c of userCommits) {
105
+ const categoriesTouched = new Set();
106
+ for (const f of c.churn) {
107
+ const churn = f.added + f.deleted;
108
+ if (churn === 0)
109
+ continue;
110
+ // Lockfiles, minified bundles, build-output dirs, and single-commit
111
+ // generated dumps are checked-in artifacts, not authored work — see
112
+ // docs/schema.md's "What is excluded from churn".
113
+ if (isExcludedPath(f.path) || generatedPaths.has(f.path))
114
+ continue;
115
+ const ext = normalizeExtension(f.path);
116
+ if (ext) {
117
+ langChurnTotal += churn;
118
+ churnByExt.set(ext, (churnByExt.get(ext) ?? 0) + churn);
119
+ }
120
+ categoryChurnTotal += churn;
121
+ const category = categorize(f.path);
122
+ churnByCategory.set(category, (churnByCategory.get(category) ?? 0) + churn);
123
+ categoriesTouched.add(category);
124
+ }
125
+ for (const category of categoriesTouched) {
126
+ if (!commitsByCategory.has(category))
127
+ commitsByCategory.set(category, new Set());
128
+ commitsByCategory.get(category).add(c.sha);
129
+ }
130
+ }
131
+ const languages = langChurnTotal > 0
132
+ ? [...churnByExt.entries()].map(([extension, churn]) => ({
133
+ extension,
134
+ share: churn / langChurnTotal,
135
+ }))
136
+ : [];
137
+ const categories = categoryChurnTotal > 0
138
+ ? [...churnByCategory.entries()].map(([name, churn]) => ({
139
+ name,
140
+ commit_count: commitsByCategory.get(name).size,
141
+ churn_share: churn / categoryChurnTotal,
142
+ }))
143
+ : [];
144
+ return { languages, categories };
145
+ }
@@ -0,0 +1,37 @@
1
+ import { ScanError } from "./errors.js";
2
+ /**
3
+ * Deliberately narrow, low-noise patterns — this scans the FINAL serialized
4
+ * bundle (see docs/principles.md, "Bounded output"), not raw diff content.
5
+ * Every current bundle field is an enum, a hash, or a number, so none of
6
+ * these should ever fire on correctly-built output; this exists as a
7
+ * regression guard against a future bug or a careless new field, not as a
8
+ * general-purpose secret detector.
9
+ */
10
+ const SECRET_PATTERNS = [
11
+ { name: "AWS access key ID", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
12
+ {
13
+ name: "PEM private key",
14
+ pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----/,
15
+ },
16
+ {
17
+ name: "API key/secret/token/password assignment",
18
+ pattern: /\b(?:api[_-]?key|secret|token|password)\b\s*[:=]\s*["']?[A-Za-z0-9_\-]{16,}/i,
19
+ },
20
+ { name: ".env-style KEY=VALUE assignment", pattern: /^[A-Z][A-Z0-9_]{2,}=\S+/m },
21
+ ];
22
+ /** Names of the patterns that matched `payload` — never the matched text itself. */
23
+ export function findSecretPatterns(payload) {
24
+ return SECRET_PATTERNS.filter(({ pattern }) => pattern.test(payload)).map(({ name }) => name);
25
+ }
26
+ /**
27
+ * Throws rather than returning a boolean so callers can't accidentally
28
+ * ignore the result. Never includes the matched substring in the error —
29
+ * only the pattern name — so the error itself can't leak the secret.
30
+ */
31
+ export function assertNoSecrets(payload) {
32
+ const matches = findSecretPatterns(payload);
33
+ if (matches.length > 0) {
34
+ throw new ScanError(`Refusing to output: the bundle appears to contain a secret (${matches.join(", ")}). ` +
35
+ "This should never happen with normal usage — please report it.");
36
+ }
37
+ }