@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.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/dist/build-bundle.js +37 -0
- package/dist/categorize.js +25 -0
- package/dist/churn-exclusions.js +51 -0
- package/dist/cli.js +87 -0
- package/dist/config.js +13 -0
- package/dist/credentials.js +26 -0
- package/dist/errors.js +20 -0
- package/dist/git.js +141 -0
- package/dist/hash.js +4 -0
- package/dist/http-client.js +129 -0
- package/dist/import-detect.js +281 -0
- package/dist/login.js +133 -0
- package/dist/logout.js +6 -0
- package/dist/merkle.js +20 -0
- package/dist/prompt.js +70 -0
- package/dist/public-remote.js +43 -0
- package/dist/salt.js +20 -0
- package/dist/scan-command.js +24 -0
- package/dist/scan.js +145 -0
- package/dist/secret-scan.js +37 -0
- package/dist/skill-detect.js +234 -0
- package/dist/submit-command.js +49 -0
- package/dist/submit.js +71 -0
- package/dist/summary.js +148 -0
- package/dist/types.js +12 -0
- package/dist/version-check.js +62 -0
- package/package.json +48 -0
- package/signatures/ai/vector-search.json +39 -0
- package/signatures/ai/whisper.json +36 -0
- package/signatures/auth/firebase-auth.json +43 -0
- package/signatures/auth/oauth-oidc.json +41 -0
- package/signatures/auth/supabase-auth.json +29 -0
- package/signatures/backend/axum.json +24 -0
- package/signatures/db/activerecord.json +21 -0
- package/signatures/db/eloquent.json +22 -0
- package/signatures/db/supabase.json +31 -0
- package/signatures/frontend/tailwind.json +28 -0
- package/signatures/infra/docker.json +20 -0
- package/signatures/infra/github-actions.json +19 -0
- package/signatures/infra/kubernetes.json +21 -0
- package/signatures/infra/terraform.json +22 -0
- package/signatures/package-map.json +483 -0
- package/signatures/testing/jest.json +25 -0
- 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,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
|
+
}
|