@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/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Domain error for scan failures (empty repo, unconfirmed authorization, a
2
+ * secret found in the payload, closed input stream, ...). Kept in its own
3
+ * module so leaf modules (secret-scan.ts, prompt.ts) can throw it without
4
+ * creating an import cycle with scan.ts. */
5
+ export class ScanError extends Error {
6
+ }
7
+ /** Session/authentication failures: device flow denied/expired, no stored
8
+ * credentials, or a stored token that belongs to a different SITE_URL. */
9
+ export class AuthError extends Error {
10
+ }
11
+ /** submit-specific failures that aren't auth or network: refused because
12
+ * the remote looks publicly reachable, malformed server response, etc. */
13
+ export class SubmitError extends Error {
14
+ }
15
+ /** A request to SITE_URL (or a remote host, for the visibility check)
16
+ * couldn't complete or came back with a non-2xx status. Message is built
17
+ * from the request's host and status only — never headers or body — so it
18
+ * can never echo a bearer token or bundle content. */
19
+ export class NetworkError extends Error {
20
+ }
package/dist/git.js ADDED
@@ -0,0 +1,141 @@
1
+ import { execFileSync } from "node:child_process";
2
+ function git(repoPath, args) {
3
+ return execFileSync("git", args, {
4
+ cwd: repoPath,
5
+ encoding: "utf8",
6
+ stdio: ["ignore", "pipe", "pipe"],
7
+ });
8
+ }
9
+ const RECORD_SEP = "\x01";
10
+ const FIELD_SEP = "\x02";
11
+ /**
12
+ * All commits reachable from HEAD, oldest first. Returns [] for a repo with
13
+ * no commits yet, rather than throwing.
14
+ */
15
+ export function getAllCommits(repoPath) {
16
+ let out;
17
+ try {
18
+ out = git(repoPath, [
19
+ "log",
20
+ "--reverse",
21
+ "--numstat",
22
+ `--format=${RECORD_SEP}%H${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%G?${FIELD_SEP}%P`,
23
+ ]);
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ return out
29
+ .split(RECORD_SEP)
30
+ .filter((chunk) => chunk.trim().length > 0)
31
+ .map((chunk) => {
32
+ const lines = chunk.split("\n");
33
+ const [sha, email, authorDateIso, signatureStatus, parents] = lines[0].split(FIELD_SEP);
34
+ const churn = [];
35
+ for (let i = 1; i < lines.length; i++) {
36
+ const line = lines[i].trim();
37
+ if (!line)
38
+ continue;
39
+ const parts = line.split("\t");
40
+ if (parts.length < 3)
41
+ continue;
42
+ const [addedRaw, deletedRaw, ...pathParts] = parts;
43
+ churn.push({
44
+ path: pathParts.join("\t"),
45
+ added: addedRaw === "-" ? 0 : parseInt(addedRaw, 10),
46
+ deleted: deletedRaw === "-" ? 0 : parseInt(deletedRaw, 10),
47
+ });
48
+ }
49
+ return {
50
+ sha,
51
+ email,
52
+ authorDate: new Date(authorDateIso),
53
+ // Only a fully verified good signature ("G") counts as signed. "U"
54
+ // (good but untrusted/unmatched key), "B" (bad), "X"/"Y"/"R"
55
+ // (expired/expired-key/revoked-key) and "E" (can't check) all mean
56
+ // the signature doesn't actually establish anything — see
57
+ // docs/schema.md's `signed` section for why.
58
+ signed: signatureStatus === "G",
59
+ churn,
60
+ isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
61
+ };
62
+ });
63
+ }
64
+ /** First line of `rev-list --max-parents=0`; multi-root histories are out of scope. */
65
+ export function getRootCommitSha(repoPath) {
66
+ return git(repoPath, ["rev-list", "--max-parents=0", "HEAD"]).trim().split("\n")[0];
67
+ }
68
+ /** Raw `origin` remote URL, read purely from local git config — null if there's none. */
69
+ export function getRemoteUrl(repoPath) {
70
+ try {
71
+ return git(repoPath, ["remote", "get-url", "origin"]).trim();
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ export function getRemoteHostType(repoPath) {
78
+ const url = getRemoteUrl(repoPath);
79
+ if (!url)
80
+ return "none";
81
+ if (/github\.com/.test(url))
82
+ return "github";
83
+ if (/gitlab\.com/.test(url))
84
+ return "gitlab";
85
+ if (/bitbucket\.org/.test(url))
86
+ return "bitbucket";
87
+ return "other";
88
+ }
89
+ /**
90
+ * Lines a single (non-merge — caller's responsibility to skip those,
91
+ * matching `getAllCommits`' own numstat behavior) commit ADDED, grouped by
92
+ * file — the input to skill-detection pattern matching (src/skill-detect.ts).
93
+ * Never removed/context lines: we care what was introduced, not what a diff
94
+ * happened to touch. `--no-color`/`--no-ext-diff`/`core.quotepath=off` keep
95
+ * the user's own git config (color.ui, an external diff tool, quoted
96
+ * non-ASCII paths) from corrupting this parser — this reads local git
97
+ * output, but the shape of that output must stay ours to depend on.
98
+ */
99
+ export function getCommitAddedLines(repoPath, sha) {
100
+ let out;
101
+ try {
102
+ out = git(repoPath, [
103
+ "-c",
104
+ "core.quotepath=off",
105
+ "show",
106
+ sha,
107
+ "--unified=0",
108
+ "--format=",
109
+ "--no-color",
110
+ "--no-ext-diff",
111
+ ]);
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ const files = [];
117
+ let currentPath = null;
118
+ let currentLines = [];
119
+ const flush = () => {
120
+ if (currentPath)
121
+ files.push({ path: currentPath, addedLines: currentLines.join("\n") });
122
+ };
123
+ for (const line of out.split("\n")) {
124
+ if (line.startsWith("+++ b/")) {
125
+ flush();
126
+ currentPath = line.slice("+++ b/".length);
127
+ currentLines = [];
128
+ }
129
+ else if (line.startsWith("+++ /dev/null")) {
130
+ // Deleted file — nothing was added to it.
131
+ flush();
132
+ currentPath = null;
133
+ currentLines = [];
134
+ }
135
+ else if (line.startsWith("+") && !line.startsWith("+++")) {
136
+ currentLines.push(line.slice(1));
137
+ }
138
+ }
139
+ flush();
140
+ return files;
141
+ }
package/dist/hash.js ADDED
@@ -0,0 +1,4 @@
1
+ import { createHash } from "node:crypto";
2
+ export function saltedHash(salt, value) {
3
+ return createHash("sha256").update(salt).update(value).digest("hex");
4
+ }
@@ -0,0 +1,129 @@
1
+ import { NetworkError } from "./errors.js";
2
+ /**
3
+ * The only module allowed to call `fetch` for JSON requests (login.ts and
4
+ * submit.ts are the other two — see test/privacy/zero-network.test.ts's
5
+ * allowlist). Error messages are built from the URL's host and the HTTP
6
+ * status only, never from response headers or body, so a failure can never
7
+ * echo a bearer token or bundle content back into a printed error.
8
+ */
9
+ export async function postJson(url, body, headers = {}) {
10
+ const host = new URL(url).host;
11
+ let res;
12
+ try {
13
+ res = await fetch(url, {
14
+ method: "POST",
15
+ headers: { "content-type": "application/json", ...headers },
16
+ body: JSON.stringify(body),
17
+ });
18
+ }
19
+ catch {
20
+ throw new NetworkError(`Could not reach ${host}.`);
21
+ }
22
+ if (!res.ok) {
23
+ throw new NetworkError(`Request to ${host} failed with status ${res.status}.`);
24
+ }
25
+ try {
26
+ return (await res.json());
27
+ }
28
+ catch {
29
+ throw new NetworkError(`Response from ${host} was not valid JSON.`);
30
+ }
31
+ }
32
+ /**
33
+ * Same as postJson, but sends `rawBody` verbatim instead of re-serializing
34
+ * an object — used by submit so the bytes on the wire are byte-identical to
35
+ * the bytes printed for user review (principle 4, "User-reviewed").
36
+ */
37
+ export async function postRawJson(url, rawBody, headers = {}) {
38
+ const host = new URL(url).host;
39
+ let res;
40
+ try {
41
+ res = await fetch(url, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json", ...headers },
44
+ body: rawBody,
45
+ });
46
+ }
47
+ catch {
48
+ throw new NetworkError(`Could not reach ${host}.`);
49
+ }
50
+ if (!res.ok) {
51
+ throw new NetworkError(`Request to ${host} failed with status ${res.status}.`);
52
+ }
53
+ try {
54
+ return (await res.json());
55
+ }
56
+ catch {
57
+ throw new NetworkError(`Response from ${host} was not valid JSON.`);
58
+ }
59
+ }
60
+ /**
61
+ * Like postJson, but for the device-token poll endpoint only: RFC 8628
62
+ * device flow returns EVERY `{error: "..."}` state — including the
63
+ * non-terminal authorization_pending/slow_down the CLI is expected to keep
64
+ * polling through — as HTTP 400, reserving 200 for `{access_token}` success
65
+ * (see docs/login-submit.md). Treats 200 and 400 alike as "parse the body";
66
+ * any other status is still a real failure. Same error-message discipline
67
+ * as postJson: built from the host and status only, never from response
68
+ * headers or body, so a failure here can never echo a bearer token.
69
+ */
70
+ export async function pollJson(url, body) {
71
+ const host = new URL(url).host;
72
+ let res;
73
+ try {
74
+ res = await fetch(url, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json" },
77
+ body: JSON.stringify(body),
78
+ });
79
+ }
80
+ catch {
81
+ throw new NetworkError(`Could not reach ${host}.`);
82
+ }
83
+ if (!res.ok && res.status !== 400) {
84
+ throw new NetworkError(`Request to ${host} failed with status ${res.status}.`);
85
+ }
86
+ try {
87
+ return (await res.json());
88
+ }
89
+ catch {
90
+ throw new NetworkError(`Response from ${host} was not valid JSON.`);
91
+ }
92
+ }
93
+ /**
94
+ * Anonymous HEAD request used only by submit's remote-visibility gate — the
95
+ * request target is the repo's own remote host, never SITE_URL. Returns
96
+ * null (not a thrown error) on any network failure/timeout: an inconclusive
97
+ * check must never be treated as a confirmed answer either way.
98
+ */
99
+ export async function headRequest(url, timeoutMs) {
100
+ try {
101
+ const res = await fetch(url, {
102
+ method: "HEAD",
103
+ redirect: "follow",
104
+ signal: AbortSignal.timeout(timeoutMs),
105
+ });
106
+ return { status: res.status };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * Anonymous GET with a timeout, used only by version-check.ts's best-effort
114
+ * npm-registry freshness check. Returns null (never throws) on any failure —
115
+ * network error, timeout, non-2xx status, or a body that isn't valid JSON —
116
+ * so a broken or offline registry can never delay or fail the login/submit
117
+ * command this is attached to.
118
+ */
119
+ export async function getJson(url, timeoutMs) {
120
+ try {
121
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
122
+ if (!res.ok)
123
+ return null;
124
+ return (await res.json());
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
@@ -0,0 +1,281 @@
1
+ // Tier 1 of skill detection (see docs/signatures.md): generic import
2
+ // parsing, language-by-language, over a commit's ADDED diff lines only.
3
+ // Returns normalized package names for src/skill-detect.ts to look up in
4
+ // signatures/package-map.json. Never sends anything anywhere — pure string
5
+ // parsing, no I/O.
6
+ //
7
+ // Deliberately regex-based, not a real parser per language (that would mean
8
+ // 5 new dependencies — CLAUDE.md's dependency policy forbids that without
9
+ // written justification). The tradeoff: perfect syntactic correctness isn't
10
+ // the goal, bounded false positives are (principle 3) — every extractor is
11
+ // anchored to reject the three near-miss classes that matter in practice:
12
+ // comments, package names embedded in string literals, and doc files.
13
+ const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".markdown"]);
14
+ function languageForPath(filePath) {
15
+ const lower = filePath.toLowerCase();
16
+ const ext = lower.slice(lower.lastIndexOf("."));
17
+ if (DOC_EXTENSIONS.has(ext))
18
+ return null; // never scan docs for imports
19
+ if ([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"].includes(ext))
20
+ return "js";
21
+ if (ext === ".py")
22
+ return "python";
23
+ if (ext === ".go")
24
+ return "go";
25
+ if (ext === ".rb" || lower.endsWith("/gemfile") || lower === "gemfile")
26
+ return "ruby";
27
+ if (ext === ".php" || lower.endsWith("/composer.json") || lower === "composer.json")
28
+ return "php";
29
+ return null;
30
+ }
31
+ // A line is "commented out" if, once trimmed, it starts with a comment
32
+ // marker — catches a same-line "// import x from 'y'" near-miss. Multi-line
33
+ // regions (block comments, template literals, triple-quoted strings) are
34
+ // NOT line-prefix detectable at all — handled separately by
35
+ // `stripNonCodeRegions`, called once before any language extractor runs.
36
+ const COMMENT_PREFIXES = ["//", "#", "/*", "*", "<!--", "--"];
37
+ function isCommentLine(line) {
38
+ const trimmed = line.trimStart();
39
+ return COMMENT_PREFIXES.some((p) => trimmed.startsWith(p));
40
+ }
41
+ // Replaces the CONTENTS of three multi-line region types with spaces
42
+ // (never removing lines — every other position in the text must keep its
43
+ // original line/column so isRealStatement's line lookups stay correct):
44
+ // block comments, JS/TS template literals, and Python triple-quoted
45
+ // strings. Without this, import-shaped text inside any of them is real
46
+ // text sitting at the start of ITS OWN line once the region spans multiple
47
+ // lines — isCommentLine/isInsideStringLiteral only ever look at a single
48
+ // line, so they can't catch a false positive that only exists because of
49
+ // what an EARLIER line opened.
50
+ function stripNonCodeRegions(text) {
51
+ const blank = (m) => m.replace(/[^\n]/g, " ");
52
+ let out = text.replace(/\/\*[\s\S]*?\*\//g, blank);
53
+ out = out.replace(/`(?:[^`\\]|\\.)*`/g, blank);
54
+ out = out.replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, blank);
55
+ return out;
56
+ }
57
+ // Rejects a match whose containing line has an odd number of unescaped
58
+ // quote characters before the match start — i.e. the match sits inside an
59
+ // outer string literal ("const s = 'import x from \"y\"';"), not as real
60
+ // syntax. A cheap, deliberately approximate stand-in for real string
61
+ // tracking: good enough to kill the near-miss this is meant to kill,
62
+ // without a full tokenizer.
63
+ function isInsideStringLiteral(line, matchStart) {
64
+ const before = line.slice(0, matchStart);
65
+ let quoteChar = null;
66
+ let count = 0;
67
+ for (let i = 0; i < before.length; i++) {
68
+ const c = before[i];
69
+ if (c === "\\") {
70
+ i++; // skip escaped char
71
+ continue;
72
+ }
73
+ if (c === "'" || c === '"' || c === "`") {
74
+ if (quoteChar === null) {
75
+ quoteChar = c;
76
+ count = 1;
77
+ }
78
+ else if (c === quoteChar) {
79
+ quoteChar = null;
80
+ count = 0;
81
+ }
82
+ }
83
+ }
84
+ return count > 0;
85
+ }
86
+ function lineAndOffsetAt(text, index) {
87
+ const lineStart = text.lastIndexOf("\n", index - 1) + 1;
88
+ let lineEnd = text.indexOf("\n", index);
89
+ if (lineEnd === -1)
90
+ lineEnd = text.length;
91
+ return { line: text.slice(lineStart, lineEnd), offsetInLine: index - lineStart };
92
+ }
93
+ // A candidate match is accepted only if: (1) the statement-starting line
94
+ // (where the keyword itself begins, even for multi-line statements) isn't
95
+ // a comment, and (2) the keyword isn't sitting inside an outer string
96
+ // literal on that line.
97
+ function isRealStatement(text, keywordIndex) {
98
+ const { line, offsetInLine } = lineAndOffsetAt(text, keywordIndex);
99
+ if (isCommentLine(line))
100
+ return false;
101
+ if (isInsideStringLiteral(line, offsetInLine))
102
+ return false;
103
+ return true;
104
+ }
105
+ function normalizeJs(raw) {
106
+ if (raw.startsWith("@"))
107
+ return raw.split("/").slice(0, 2).join("/");
108
+ return raw.split("/")[0];
109
+ }
110
+ function extractJs(text) {
111
+ const found = [];
112
+ // import ... from "pkg" / export ... from "pkg" (also covers `export * from`,
113
+ // `export { x } from`, and multi-line named-import lists via [\s\S]*?).
114
+ // `d` (hasIndices) exposes the captured package name's own start offset —
115
+ // needed because `[\s\S]*?\bfrom` is a lazy bridge that can walk INTO an
116
+ // unrelated string literal later on the same line (e.g. a SQL string
117
+ // containing the word "from") even when the line legitimately starts
118
+ // with a real `import`/`export` keyword. Checking string-nesting at the
119
+ // keyword's position alone (isRealStatement) doesn't catch that — the
120
+ // capture's OWN position must be checked too.
121
+ const fromRe = /^[ \t]*(import|export)\b[\s\S]*?\bfrom\s+["']([^"'\n]+)["']/gmd;
122
+ for (const m of text.matchAll(fromRe)) {
123
+ if (!isRealStatement(text, m.index))
124
+ continue;
125
+ // Check quote parity up to (but not including) the OPENING quote of the
126
+ // captured package string itself — not the package text's own start.
127
+ // Using the package text's start would count that opening quote as
128
+ // "already inside a string," which is trivially true for every real
129
+ // match (a quoted string always opens with a quote right before its
130
+ // content) and would reject every legitimate import.
131
+ const indices = m.indices;
132
+ const openQuotePos = indices[2][0] - 1;
133
+ const { line, offsetInLine } = lineAndOffsetAt(text, openQuotePos);
134
+ if (isInsideStringLiteral(line, offsetInLine))
135
+ continue;
136
+ found.push(normalizeJs(m[2]));
137
+ }
138
+ // import "pkg"; (side-effect import, no `from`)
139
+ const bareImportRe = /^[ \t]*import\s+["']([^"'\n]+)["']\s*;?/gm;
140
+ for (const m of text.matchAll(bareImportRe)) {
141
+ if (isRealStatement(text, m.index))
142
+ found.push(normalizeJs(m[1]));
143
+ }
144
+ // require("pkg") / import("pkg") — dynamic import, anywhere a real
145
+ // statement could reasonably put it (assignment, await, bare call).
146
+ const requireRe = /\b(?:require|import)\(\s*["']([^"'\n]+)["']\s*\)/g;
147
+ for (const m of text.matchAll(requireRe)) {
148
+ const { line, offsetInLine } = lineAndOffsetAt(text, m.index);
149
+ if (isCommentLine(line))
150
+ continue;
151
+ if (isInsideStringLiteral(line, offsetInLine))
152
+ continue;
153
+ found.push(normalizeJs(m[1]));
154
+ }
155
+ return found;
156
+ }
157
+ function extractPython(text) {
158
+ const found = [];
159
+ // import pkg[.sub][ as alias][, pkg2[ as alias2] ...] — each item can
160
+ // carry its own "as alias" before the next comma, which must be allowed
161
+ // inside the repeated group or the chain breaks and everything after the
162
+ // first alias silently falls out of the match.
163
+ const importRe = /^[ \t]*import\s+([\w.]+(?:\s+as\s+\w+)?(?:\s*,\s*[\w.]+(?:\s+as\s+\w+)?)*)/gm;
164
+ for (const m of text.matchAll(importRe)) {
165
+ if (!isRealStatement(text, m.index))
166
+ continue;
167
+ for (const part of m[1].split(",")) {
168
+ const name = part.trim().split(/\s+as\s+/)[0];
169
+ if (name)
170
+ found.push(name.split(".")[0]);
171
+ }
172
+ }
173
+ // from pkg[.sub] import x
174
+ const fromRe = /^[ \t]*from\s+([\w.]+)\s+import\b/gm;
175
+ for (const m of text.matchAll(fromRe)) {
176
+ if (isRealStatement(text, m.index))
177
+ found.push(m[1].split(".")[0]);
178
+ }
179
+ return found;
180
+ }
181
+ function extractGo(text) {
182
+ const found = [];
183
+ const normalize = (p) => p.replace(/\/v\d+$/, "");
184
+ // Single-line: import "path" or import alias "path"
185
+ const singleRe = /^[ \t]*import\s+(?:\w+\s+)?["']([^"'\n]+)["']/gm;
186
+ for (const m of text.matchAll(singleRe)) {
187
+ if (isRealStatement(text, m.index))
188
+ found.push(normalize(m[1]));
189
+ }
190
+ // Block: import (\n "path1"\n alias "path2"\n)
191
+ const blockRe = /^[ \t]*import\s*\(([\s\S]*?)\)/gm;
192
+ for (const m of text.matchAll(blockRe)) {
193
+ if (!isRealStatement(text, m.index))
194
+ continue;
195
+ const pathRe = /["']([^"'\n]+)["']/g;
196
+ for (const p of m[1].matchAll(pathRe))
197
+ found.push(normalize(p[1]));
198
+ }
199
+ return found;
200
+ }
201
+ function extractRuby(text, filePath) {
202
+ const found = [];
203
+ const isGemfile = /gemfile$/i.test(filePath);
204
+ if (isGemfile) {
205
+ // gem "name"[, "~> 1.0"] — a Gemfile dependency declaration.
206
+ const gemRe = /^[ \t]*gem\s+["']([^"'\n]+)["']/gm;
207
+ for (const m of text.matchAll(gemRe)) {
208
+ if (isRealStatement(text, m.index))
209
+ found.push(m[1].split("/")[0]);
210
+ }
211
+ return found;
212
+ }
213
+ // require "pkg" — require_relative is deliberately excluded (it loads a
214
+ // local file, not a third-party package; matching it would misattribute
215
+ // a plain relative require to some unrelated real gem sharing the name).
216
+ const requireRe = /^[ \t]*require\s+["']([^"'\n]+)["']/gm;
217
+ for (const m of text.matchAll(requireRe)) {
218
+ if (isRealStatement(text, m.index))
219
+ found.push(m[1].split("/")[0]);
220
+ }
221
+ return found;
222
+ }
223
+ function extractPhp(text, filePath) {
224
+ const found = [];
225
+ if (/composer\.json$/i.test(filePath)) {
226
+ // Structured JSON — no regex needed, and safest possible source: no
227
+ // comment/string-literal ambiguity exists in JSON added-lines at all.
228
+ try {
229
+ const parsed = JSON.parse(text);
230
+ if (parsed.require)
231
+ found.push(...Object.keys(parsed.require).filter((k) => k !== "php"));
232
+ }
233
+ catch {
234
+ // A partial diff (added lines only) is rarely valid standalone JSON —
235
+ // fall through to returning whatever we found (nothing), rather than
236
+ // guessing at a malformed fragment.
237
+ }
238
+ return found;
239
+ }
240
+ // use Vendor\Sub\Class; — namespace-to-composer-package mapping isn't
241
+ // mechanical in PHP, so this only extracts the first namespace segment
242
+ // (lowercased) as the lookup key. That's enough for framework-level
243
+ // detection (e.g. `use Illuminate\...` -> "illuminate") but deliberately
244
+ // doesn't attempt vendor/package-accurate resolution — see docs/signatures.md.
245
+ const useRe = /^[ \t]*use\s+([A-Za-z0-9_]+)\\/gm;
246
+ for (const m of text.matchAll(useRe)) {
247
+ if (isRealStatement(text, m.index))
248
+ found.push(m[1].toLowerCase());
249
+ }
250
+ return found;
251
+ }
252
+ /**
253
+ * Extracts normalized package names from one file's added diff lines.
254
+ * `filePath` selects the language (and, for Ruby/PHP, distinguishes a
255
+ * Gemfile/composer.json from ordinary source). Returns [] for files whose
256
+ * extension isn't recognized, or for excluded doc files (.md etc.) — never
257
+ * throws.
258
+ */
259
+ export function extractImportedPackages(addedLines, filePath) {
260
+ const language = languageForPath(filePath);
261
+ if (!language)
262
+ return [];
263
+ // composer.json is parsed as structured JSON — stripping would only ever
264
+ // be a harmless no-op there, but skip it anyway rather than run a regex
265
+ // pass with zero possible benefit right before a JSON.parse. Every other
266
+ // path gets the stripped text.
267
+ const isComposerJson = language === "php" && /composer\.json$/i.test(filePath);
268
+ const text = isComposerJson ? addedLines : stripNonCodeRegions(addedLines);
269
+ switch (language) {
270
+ case "js":
271
+ return extractJs(text);
272
+ case "python":
273
+ return extractPython(text);
274
+ case "go":
275
+ return extractGo(text);
276
+ case "ruby":
277
+ return extractRuby(text, filePath);
278
+ case "php":
279
+ return extractPhp(text, filePath);
280
+ }
281
+ }