penthera 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python dependency inputs for `penthera deps`, read offline.
|
|
3
|
+
*
|
|
4
|
+
* Turns requirements*.txt, poetry.lock, uv.lock, Pipfile.lock and pdm.lock into
|
|
5
|
+
* the same flat package list the npm lockfile reader produces: name (PEP 503
|
|
6
|
+
* normalised), exact version when pinned, artifact hashes (the key into pip's
|
|
7
|
+
* and uv's caches), dev flag, and a local path for path dependencies. Nothing
|
|
8
|
+
* here runs pip, resolves ranges or touches the network.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join, resolve, dirname } from "node:path";
|
|
12
|
+
import { parseToml } from "./toml.js";
|
|
13
|
+
import { assertSafeRef, readOnlyGit } from "../lockfile.js";
|
|
14
|
+
|
|
15
|
+
/** PEP 503 name normalisation: runs of -, _ and . become -, lower case. */
|
|
16
|
+
export const normalizeName = (n) => String(n || "").trim().replace(/[-_.]+/g, "-").toLowerCase();
|
|
17
|
+
|
|
18
|
+
const LOCKFILES = ["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"];
|
|
19
|
+
|
|
20
|
+
/** Requirements files in a folder, most specific lock-like name first. */
|
|
21
|
+
function requirementsFiles(dir) {
|
|
22
|
+
let names;
|
|
23
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
24
|
+
return names.filter((f) => /^requirements.*\.txt$/i.test(f)).sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the Python dependency inputs in a folder.
|
|
29
|
+
* @returns {{ lockfile: string|null, kind: string|null, requirements: string[] }}
|
|
30
|
+
*/
|
|
31
|
+
export function findPythonInputs(dir) {
|
|
32
|
+
for (const f of LOCKFILES) {
|
|
33
|
+
const p = join(dir, f);
|
|
34
|
+
if (existsSync(p)) return { lockfile: p, kind: f, requirements: [] };
|
|
35
|
+
}
|
|
36
|
+
const reqs = requirementsFiles(dir).map((f) => join(dir, f));
|
|
37
|
+
if (reqs.length) return { lockfile: reqs[0], kind: "requirements.txt", requirements: reqs };
|
|
38
|
+
return { lockfile: null, kind: null, requirements: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when a folder looks like a Python project (lockfile, requirements, pyproject or a venv). */
|
|
42
|
+
export function looksLikePythonProject(dir) {
|
|
43
|
+
if (findPythonInputs(dir).lockfile) return true;
|
|
44
|
+
return ["pyproject.toml", "setup.py", "setup.cfg", "Pipfile"].some((f) => existsSync(join(dir, f)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pkg = (o) => ({
|
|
48
|
+
name: normalizeName(o.name),
|
|
49
|
+
displayName: o.name,
|
|
50
|
+
version: o.version || null,
|
|
51
|
+
path: o.path,
|
|
52
|
+
hashes: [...new Set((o.hashes || []).filter(Boolean).map((h) => String(h).toLowerCase()))].sort(),
|
|
53
|
+
files: o.files || [],
|
|
54
|
+
dev: !!o.dev,
|
|
55
|
+
optional: false,
|
|
56
|
+
local: o.local || null,
|
|
57
|
+
link: false,
|
|
58
|
+
ecosystem: "pypi",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── requirements.txt ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** Join backslash continuations, keep the first line number of each logical line. */
|
|
64
|
+
function logicalLines(text) {
|
|
65
|
+
const out = [];
|
|
66
|
+
let buf = "";
|
|
67
|
+
let start = 0;
|
|
68
|
+
text.replace(/\r\n/g, "\n").split("\n").forEach((line, i) => {
|
|
69
|
+
if (!buf) start = i + 1;
|
|
70
|
+
if (/\\\s*$/.test(line)) { buf += line.replace(/\\\s*$/, " "); return; }
|
|
71
|
+
out.push({ text: buf + line, line: start });
|
|
72
|
+
buf = "";
|
|
73
|
+
});
|
|
74
|
+
if (buf) out.push({ text: buf, line: start });
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a requirements file. Pinned lines (`name==1.2.3`) get a version;
|
|
80
|
+
* `--hash=sha256:...` options are kept; ranges get version null (the venv, if
|
|
81
|
+
* any, supplies the installed version). `-r other.txt` is followed once.
|
|
82
|
+
*/
|
|
83
|
+
export function parseRequirements(text, { file = "requirements.txt", baseDir = ".", seen = new Set() } = {}) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const direct = new Set();
|
|
86
|
+
// pip-compile and `uv export` annotate each pin with "# via <parents>". A pin
|
|
87
|
+
// whose parents include something that is not another pin here (the
|
|
88
|
+
// project itself, or "-r requirements.in") is a direct dependency.
|
|
89
|
+
let compiled = false;
|
|
90
|
+
const via = new Map();
|
|
91
|
+
let lastPin = null;
|
|
92
|
+
const lines = logicalLines(text);
|
|
93
|
+
for (let k = 0; k < lines.length; k++) {
|
|
94
|
+
const { text: raw, line } = lines[k];
|
|
95
|
+
const v = raw.match(/^\s*#\s+via\b\s*(.*)$/) || (lastPin && via.has(lastPin) && raw.match(/^\s*#\s{2,}(\S.*)$/));
|
|
96
|
+
if (v) {
|
|
97
|
+
compiled = true;
|
|
98
|
+
if (lastPin) {
|
|
99
|
+
if (!via.has(lastPin)) via.set(lastPin, []);
|
|
100
|
+
if (v[1].trim()) via.get(lastPin).push(v[1].trim());
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const s = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
105
|
+
if (!s) continue;
|
|
106
|
+
const inc = s.match(/^(?:-r|--requirement)\s*=?\s*(\S+)/);
|
|
107
|
+
if (inc) {
|
|
108
|
+
const p = resolve(baseDir, inc[1]);
|
|
109
|
+
if (!seen.has(p) && existsSync(p)) {
|
|
110
|
+
seen.add(p);
|
|
111
|
+
const sub = parseRequirements(readFileSync(p, "utf-8"), { file: inc[1], baseDir: dirname(p), seen });
|
|
112
|
+
out.push(...sub.packages);
|
|
113
|
+
for (const n of sub.direct) direct.add(n);
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (/^-/.test(s) && !/^(?:-e|--editable)\b/.test(s)) continue; // --index-url, -c, --find-links...
|
|
118
|
+
const hashes = [...s.matchAll(/--hash[=\s]+(sha256|sha384|sha512):([0-9a-fA-F]+)/g)].map((m) => `${m[1]}:${m[2]}`);
|
|
119
|
+
const spec = s.replace(/\s--\S+(?:[=\s]+\S+)?/g, "").replace(/^(?:-e|--editable)\s+/, "").trim();
|
|
120
|
+
// Local path or file URL dependency.
|
|
121
|
+
if (/^(?:\.{1,2}[\\/]|\/|file:)/.test(spec)) {
|
|
122
|
+
const local = resolve(baseDir, spec.replace(/^file:(?:\/\/)?/, "").replace(/[#[].*$/, ""));
|
|
123
|
+
const egg = spec.match(/#egg=([\w.-]+)/);
|
|
124
|
+
out.push(pkg({ name: egg ? egg[1] : local.split(/[\\/]/).pop(), version: null, path: `${file}:${line}`, local }));
|
|
125
|
+
lastPin = out[out.length - 1].name;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const m = spec.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*(?:@\s*(\S+)|(===?)\s*([^\s;,]+)|[^;]*)?/);
|
|
129
|
+
if (!m) continue;
|
|
130
|
+
const version = m[4] && !/[*]/.test(m[5]) ? m[5] : null;
|
|
131
|
+
const p = pkg({ name: m[1], version, path: `${file}:${line}`, hashes });
|
|
132
|
+
p.line = line;
|
|
133
|
+
p.url = m[3] || null;
|
|
134
|
+
out.push(p);
|
|
135
|
+
lastPin = p.name;
|
|
136
|
+
}
|
|
137
|
+
const pinned = new Set(out.map((p) => p.name));
|
|
138
|
+
for (const p of out) {
|
|
139
|
+
const parents = via.get(p.name) || [];
|
|
140
|
+
if (!compiled || parents.length === 0 || parents.some((x) => /^-[rc]\s/.test(x) || !pinned.has(normalizeName(x.split(/\s/)[0])))) direct.add(p.name);
|
|
141
|
+
}
|
|
142
|
+
return { packages: out, direct: [...direct].sort() };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── TOML lockfiles ────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function lineIndex(text, re) {
|
|
148
|
+
const lines = text.split("\n");
|
|
149
|
+
const idx = new Map();
|
|
150
|
+
let current = null;
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
if (/^\[\[package\]\]/.test(lines[i])) current = i + 1;
|
|
153
|
+
const m = lines[i].match(re);
|
|
154
|
+
if (m && current) idx.set(normalizeName(m[1]), current);
|
|
155
|
+
}
|
|
156
|
+
return idx;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pyprojectDirect(dir) {
|
|
160
|
+
const p = join(dir, "pyproject.toml");
|
|
161
|
+
if (!existsSync(p)) return { names: [], dev: [] };
|
|
162
|
+
let t;
|
|
163
|
+
try { t = parseToml(readFileSync(p, "utf-8")); } catch { return { names: [], dev: [] }; }
|
|
164
|
+
const reqName = (s) => normalizeName(String(s).match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/)?.[1] || "");
|
|
165
|
+
const names = new Set();
|
|
166
|
+
const dev = new Set();
|
|
167
|
+
for (const s of t.project?.dependencies || []) names.add(reqName(s));
|
|
168
|
+
for (const list of Object.values(t.project?.["optional-dependencies"] || {})) for (const s of list || []) names.add(reqName(s));
|
|
169
|
+
for (const list of Object.values(t["dependency-groups"] || {})) for (const s of list || []) if (typeof s === "string") dev.add(reqName(s));
|
|
170
|
+
const poetry = t.tool?.poetry || {};
|
|
171
|
+
for (const n of Object.keys(poetry.dependencies || {})) if (n !== "python") names.add(normalizeName(n));
|
|
172
|
+
for (const n of Object.keys(poetry["dev-dependencies"] || {})) dev.add(normalizeName(n));
|
|
173
|
+
for (const g of Object.values(poetry.group || {})) for (const n of Object.keys(g?.dependencies || {})) dev.add(normalizeName(n));
|
|
174
|
+
for (const list of Object.values(t.tool?.pdm?.["dev-dependencies"] || {})) for (const s of list || []) dev.add(reqName(s));
|
|
175
|
+
names.delete("");
|
|
176
|
+
dev.delete("");
|
|
177
|
+
return { names: [...names], dev: [...dev] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hashOf = (f) => (typeof f === "string" ? f : f?.hash);
|
|
181
|
+
|
|
182
|
+
function parseUvLock(text, dir) {
|
|
183
|
+
const t = parseToml(text);
|
|
184
|
+
const lines = lineIndex(text, /^name\s*=\s*"([^"]+)"/);
|
|
185
|
+
const all = Array.isArray(t.package) ? t.package : [];
|
|
186
|
+
const roots = all.filter((p) => p.source && (p.source.virtual !== undefined || p.source.editable !== undefined) && (p.source.virtual === "." || p.source.editable === "."));
|
|
187
|
+
const direct = new Set();
|
|
188
|
+
const devNames = new Set();
|
|
189
|
+
for (const r of roots) {
|
|
190
|
+
for (const d of r.dependencies || []) direct.add(normalizeName(d.name));
|
|
191
|
+
for (const list of Object.values(r["optional-dependencies"] || {})) for (const d of list) direct.add(normalizeName(d.name));
|
|
192
|
+
for (const list of Object.values(r["dev-dependencies"] || {})) for (const d of list) { direct.add(normalizeName(d.name)); devNames.add(normalizeName(d.name)); }
|
|
193
|
+
}
|
|
194
|
+
// Dev-only: reachable from dev roots but not from the main dependencies.
|
|
195
|
+
const byName = new Map(all.map((p) => [normalizeName(p.name), p]));
|
|
196
|
+
const reach = (starts) => {
|
|
197
|
+
const seen = new Set();
|
|
198
|
+
const stack = [...starts];
|
|
199
|
+
while (stack.length) {
|
|
200
|
+
const n = stack.pop();
|
|
201
|
+
if (seen.has(n)) continue;
|
|
202
|
+
seen.add(n);
|
|
203
|
+
const p = byName.get(n);
|
|
204
|
+
for (const d of p?.dependencies || []) stack.push(normalizeName(d.name));
|
|
205
|
+
for (const list of Object.values(p?.["optional-dependencies"] || {})) for (const d of list) stack.push(normalizeName(d.name));
|
|
206
|
+
}
|
|
207
|
+
return seen;
|
|
208
|
+
};
|
|
209
|
+
const prodSet = reach([...direct].filter((n) => !devNames.has(n)));
|
|
210
|
+
const packages = [];
|
|
211
|
+
for (const p of all) {
|
|
212
|
+
if (roots.includes(p)) continue;
|
|
213
|
+
const src = p.source || {};
|
|
214
|
+
const local = src.path || src.directory || src.editable || src.virtual;
|
|
215
|
+
const files = [...(p.wheels || []), ...(p.sdist ? [p.sdist] : [])].map((f) => ({ url: f.url || null, file: f.path || f.filename || (f.url ? f.url.split("/").pop() : null), hash: f.hash || null }));
|
|
216
|
+
packages.push(pkg({
|
|
217
|
+
name: p.name, version: p.version, path: `uv.lock:${lines.get(normalizeName(p.name)) || 1}`,
|
|
218
|
+
hashes: files.map((f) => f.hash), files,
|
|
219
|
+
dev: devNames.size > 0 && !prodSet.has(normalizeName(p.name)),
|
|
220
|
+
local: local ? resolve(dir, local) : null,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
return { packages, direct: [...direct].sort(), version: t.version ?? null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parsePoetryLock(text, dir) {
|
|
227
|
+
const t = parseToml(text);
|
|
228
|
+
const lines = lineIndex(text, /^name\s*=\s*"([^"]+)"/);
|
|
229
|
+
const meta = t.metadata || {};
|
|
230
|
+
const legacyHashes = meta.files || {}; // lock-version 1.x keeps hashes here
|
|
231
|
+
const pp = pyprojectDirect(dir);
|
|
232
|
+
const devOnly = new Set(pp.dev.filter((n) => !pp.names.includes(n)));
|
|
233
|
+
const packages = (Array.isArray(t.package) ? t.package : []).map((p) => {
|
|
234
|
+
const files = (p.files || legacyHashes[p.name] || []).map((f) => ({ file: f.file || null, hash: hashOf(f), url: null }));
|
|
235
|
+
const src = p.source || {};
|
|
236
|
+
const local = (src.type === "directory" || src.type === "file") && src.url ? resolve(dir, src.url) : null;
|
|
237
|
+
const groups = p.groups || (p.category ? [p.category === "dev" ? "dev" : "main"] : ["main"]);
|
|
238
|
+
return pkg({
|
|
239
|
+
name: p.name, version: p.version, path: `poetry.lock:${lines.get(normalizeName(p.name)) || 1}`,
|
|
240
|
+
hashes: files.map((f) => f.hash), files,
|
|
241
|
+
dev: !groups.includes("main") || devOnly.has(normalizeName(p.name)),
|
|
242
|
+
local,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
return { packages, direct: [...new Set([...pp.names, ...pp.dev])].sort(), version: meta["lock-version"] ?? null };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parsePdmLock(text, dir) {
|
|
249
|
+
const t = parseToml(text);
|
|
250
|
+
const lines = lineIndex(text, /^name\s*=\s*"([^"]+)"/);
|
|
251
|
+
const pp = pyprojectDirect(dir);
|
|
252
|
+
const packages = (Array.isArray(t.package) ? t.package : []).map((p) => {
|
|
253
|
+
const files = (p.files || []).map((f) => ({ file: f.file || f.url || null, hash: hashOf(f), url: f.url || null }));
|
|
254
|
+
const groups = p.groups || ["default"];
|
|
255
|
+
return pkg({
|
|
256
|
+
name: p.name, version: p.version, path: `pdm.lock:${lines.get(normalizeName(p.name)) || 1}`,
|
|
257
|
+
hashes: files.map((f) => f.hash), files,
|
|
258
|
+
dev: !groups.includes("default"),
|
|
259
|
+
local: p.path ? resolve(dir, p.path) : null,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
return { packages, direct: [...new Set([...pp.names, ...pp.dev])].sort(), version: t.metadata?.lock_version ?? null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parsePipfileLock(text, dir) {
|
|
266
|
+
const lock = JSON.parse(text);
|
|
267
|
+
if (!lock || typeof lock !== "object" || Array.isArray(lock)) throw new Error("Pipfile.lock: not a JSON object");
|
|
268
|
+
const lines = text.split("\n");
|
|
269
|
+
const lineOf = (name) => {
|
|
270
|
+
const i = lines.findIndex((l) => l.includes(`"${name}": {`));
|
|
271
|
+
return i === -1 ? 1 : i + 1;
|
|
272
|
+
};
|
|
273
|
+
const packages = [];
|
|
274
|
+
for (const [section, dev] of [["default", false], ["develop", true]]) {
|
|
275
|
+
for (const [name, v] of Object.entries(lock[section] || {})) {
|
|
276
|
+
if (!v) continue;
|
|
277
|
+
const version = typeof v.version === "string" ? v.version.replace(/^===?/, "") : null;
|
|
278
|
+
const local = v.path ? resolve(dir, v.path) : null;
|
|
279
|
+
const existing = packages.find((p) => p.name === normalizeName(name));
|
|
280
|
+
if (existing) { if (!dev) existing.dev = false; continue; }
|
|
281
|
+
packages.push(pkg({ name, version, path: `Pipfile.lock:${lineOf(name)}`, hashes: v.hashes || [], dev, local }));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
let direct = [];
|
|
285
|
+
const pipfile = join(dir, "Pipfile");
|
|
286
|
+
if (existsSync(pipfile)) {
|
|
287
|
+
try {
|
|
288
|
+
const t = parseToml(readFileSync(pipfile, "utf-8"));
|
|
289
|
+
direct = [...Object.keys(t.packages || {}), ...Object.keys(t["dev-packages"] || {})].map(normalizeName);
|
|
290
|
+
} catch { /* unreadable Pipfile: no direct list */ }
|
|
291
|
+
}
|
|
292
|
+
return { packages, direct: [...new Set(direct)].sort(), version: lock._meta?.["pipfile-spec"] ?? null };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parse a Python dependency input by kind.
|
|
297
|
+
* @returns {{ packages, direct: string[], version, kind }}
|
|
298
|
+
*/
|
|
299
|
+
export function parsePythonInput(text, kind, dir, file = kind) {
|
|
300
|
+
let r;
|
|
301
|
+
if (kind === "uv.lock") r = parseUvLock(text, dir);
|
|
302
|
+
else if (kind === "poetry.lock") r = parsePoetryLock(text, dir);
|
|
303
|
+
else if (kind === "pdm.lock") r = parsePdmLock(text, dir);
|
|
304
|
+
else if (kind === "Pipfile.lock") r = parsePipfileLock(text, dir);
|
|
305
|
+
else r = { ...parseRequirements(text, { file, baseDir: dir }), version: null };
|
|
306
|
+
// One entry per name@version (requirements files can repeat a name across -r includes).
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
const packages = r.packages.filter((p) => {
|
|
309
|
+
const k = `${p.name}@${p.version}`;
|
|
310
|
+
if (seen.has(k)) return false;
|
|
311
|
+
seen.add(k);
|
|
312
|
+
return true;
|
|
313
|
+
}).sort((a, b) => a.name.localeCompare(b.name) || String(a.version).localeCompare(String(b.version)));
|
|
314
|
+
return { ...r, packages, kind };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Read and parse the Python inputs found in a folder; null when there are none. */
|
|
318
|
+
export function readPythonInputs(dir) {
|
|
319
|
+
const found = findPythonInputs(dir);
|
|
320
|
+
if (!found.lockfile) return null;
|
|
321
|
+
if (found.kind !== "requirements.txt") {
|
|
322
|
+
return { ...parsePythonInput(readFileSync(found.lockfile, "utf-8"), found.kind, dir), file: found.lockfile };
|
|
323
|
+
}
|
|
324
|
+
// Several requirements files (requirements.txt, requirements-dev.txt): merge, dev by file name.
|
|
325
|
+
const packages = [];
|
|
326
|
+
const direct = new Set();
|
|
327
|
+
for (const f of found.requirements) {
|
|
328
|
+
const name = f.slice(dirname(f).length + 1);
|
|
329
|
+
const r = parsePythonInput(readFileSync(f, "utf-8"), "requirements.txt", dir, name);
|
|
330
|
+
const isDev = /dev|test|lint|doc/i.test(name);
|
|
331
|
+
for (const p of r.packages) {
|
|
332
|
+
const existing = packages.find((x) => x.name === p.name && x.version === p.version);
|
|
333
|
+
if (existing) { if (!isDev) existing.dev = false; continue; }
|
|
334
|
+
packages.push({ ...p, dev: isDev });
|
|
335
|
+
}
|
|
336
|
+
for (const n of r.direct) direct.add(n);
|
|
337
|
+
}
|
|
338
|
+
return { packages, direct: [...direct].sort(), version: null, kind: "requirements.txt", file: found.lockfile, files: found.requirements };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read the Python inputs at a git ref (for `--base <ref>`). Only `git show`.
|
|
343
|
+
* @returns parsed inputs, or null when the ref has none
|
|
344
|
+
*/
|
|
345
|
+
export function readPythonInputsAtRef(dir, ref) {
|
|
346
|
+
assertSafeRef(ref);
|
|
347
|
+
const git = readOnlyGit(dir);
|
|
348
|
+
const prefix = git(["rev-parse", "--show-prefix"]).trim();
|
|
349
|
+
const show = (f) => { try { return git(["show", "--end-of-options", `${ref}:${prefix}${f}`]); } catch { return null; } };
|
|
350
|
+
for (const f of LOCKFILES) {
|
|
351
|
+
const text = show(f);
|
|
352
|
+
if (text != null) return { ...parsePythonInput(text, f, dir), file: f };
|
|
353
|
+
}
|
|
354
|
+
let names = [];
|
|
355
|
+
try { names = git(["ls-tree", "--name-only", "--end-of-options", ref, `./${prefix}`]).split("\n").map((s) => s.slice(prefix.length)); } catch { /* not a tree */ }
|
|
356
|
+
const req = names.filter((f) => /^requirements.*\.txt$/i.test(f)).sort((a, b) => a.length - b.length)[0];
|
|
357
|
+
if (req) return { ...parsePythonInput(show(req) || "", "requirements.txt", dir, req), file: req };
|
|
358
|
+
return null;
|
|
359
|
+
}
|