@westbayberry/dg 2.0.7 → 2.0.10

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 (53) hide show
  1. package/README.md +17 -12
  2. package/dist/api/analyze.js +134 -34
  3. package/dist/audit-ui/export.js +3 -4
  4. package/dist/auth/device-login.js +13 -9
  5. package/dist/auth/store.js +43 -26
  6. package/dist/bin/dg.js +5 -0
  7. package/dist/commands/audit.js +14 -4
  8. package/dist/commands/config.js +3 -5
  9. package/dist/commands/doctor.js +3 -3
  10. package/dist/commands/explain.js +138 -6
  11. package/dist/commands/licenses.js +37 -24
  12. package/dist/commands/login.js +12 -3
  13. package/dist/commands/logout.js +15 -4
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/service.js +76 -24
  16. package/dist/commands/status.js +38 -4
  17. package/dist/commands/types.js +1 -0
  18. package/dist/config/settings.js +102 -22
  19. package/dist/install-ui/prompt.js +5 -2
  20. package/dist/launcher/install-preflight.js +158 -0
  21. package/dist/launcher/live-install.js +11 -2
  22. package/dist/launcher/output-redaction.js +5 -3
  23. package/dist/launcher/pip-report.js +18 -2
  24. package/dist/launcher/preflight-prompt.js +31 -12
  25. package/dist/launcher/run.js +87 -8
  26. package/dist/proxy/ca.js +69 -29
  27. package/dist/proxy/enforcement.js +41 -3
  28. package/dist/proxy/worker.js +21 -9
  29. package/dist/runtime/first-run.js +33 -2
  30. package/dist/runtime/nudges.js +9 -2
  31. package/dist/scan/analyze-worker.js +18 -8
  32. package/dist/scan/collect.js +35 -28
  33. package/dist/scan/command.js +80 -40
  34. package/dist/scan/discovery.js +9 -3
  35. package/dist/scan/render.js +22 -6
  36. package/dist/scan/scanner-report.js +89 -12
  37. package/dist/scan/staged.js +69 -7
  38. package/dist/scan-ui/LegacyApp.js +10 -48
  39. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  40. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  41. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  42. package/dist/scan-ui/hooks/useScan.js +74 -27
  43. package/dist/scan-ui/launch.js +18 -4
  44. package/dist/service/state.js +15 -4
  45. package/dist/service/trust-store.js +23 -2
  46. package/dist/setup/git-hook.js +28 -17
  47. package/dist/setup/plan.js +302 -18
  48. package/dist/state/cleanup-registry.js +65 -8
  49. package/dist/state/locks.js +95 -9
  50. package/dist/state/sessions.js +66 -2
  51. package/dist/verify/package-check.js +22 -3
  52. package/dist/verify/preflight.js +328 -170
  53. package/package.json +1 -1
@@ -1,14 +1,16 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { basename, relative, resolve, sep } from "node:path";
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { loadUserConfig } from "../config/settings.js";
4
4
  import { evaluatePackagePolicy, resolveEffectivePolicy } from "../policy/evaluate.js";
5
5
  const LOCKFILE_NAMES = new Set([
6
6
  "Cargo.lock",
7
7
  "Pipfile.lock",
8
+ "npm-shrinkwrap.json",
8
9
  "package-lock.json",
9
10
  "pnpm-lock.yaml",
10
11
  "poetry.lock",
11
12
  "requirements.txt",
13
+ "uv.lock",
12
14
  "yarn.lock"
13
15
  ]);
14
16
  const REMOTE_SPEC_PREFIXES = [
@@ -22,6 +24,10 @@ const REMOTE_SPEC_PREFIXES = [
22
24
  export function isSupportedLockfilePath(target) {
23
25
  return LOCKFILE_NAMES.has(basename(target));
24
26
  }
27
+ export function isRemotePackageSpec(spec) {
28
+ const lowered = spec.trim().toLowerCase();
29
+ return REMOTE_SPEC_PREFIXES.some((prefix) => lowered.startsWith(prefix)) || lowered.startsWith("file:");
30
+ }
25
31
  export function verifyPackageSpec(spec, options = {}) {
26
32
  const parsed = parsePackageSpec(spec);
27
33
  const observations = parsed
@@ -68,7 +74,7 @@ export function verifyLockfile(targetPath, options = {}) {
68
74
  errors: [`could not read lockfile: ${error instanceof Error ? error.message : "unknown read error"}`]
69
75
  });
70
76
  }
71
- const observations = parseLockfile(basename(absoluteTarget), text);
77
+ const observations = parseLockfile(text, createParseContext(basename(absoluteTarget), absoluteTarget));
72
78
  return preflightReport({
73
79
  target: displayTarget,
74
80
  inputKind: "lockfile",
@@ -77,6 +83,50 @@ export function verifyLockfile(targetPath, options = {}) {
77
83
  options
78
84
  });
79
85
  }
86
+ export function parseLockfilePackages(targetPath) {
87
+ const absoluteTarget = resolve(targetPath);
88
+ const fileName = basename(absoluteTarget);
89
+ let text;
90
+ try {
91
+ text = readFileSync(absoluteTarget, "utf8");
92
+ }
93
+ catch (error) {
94
+ return {
95
+ packages: [],
96
+ skipped: [],
97
+ parseError: {
98
+ file: fileName,
99
+ reason: error instanceof Error ? error.message : "could not read lockfile"
100
+ }
101
+ };
102
+ }
103
+ const context = createParseContext(fileName, absoluteTarget);
104
+ const observations = parseLockfile(text, context);
105
+ return {
106
+ packages: observations
107
+ .filter((observation) => observation.verdict !== "block" && observation.identity.sourceKind === "lockfile")
108
+ .map((observation) => observation.identity),
109
+ skipped: context.skipped,
110
+ parseError: context.errors[0] ?? null
111
+ };
112
+ }
113
+ function createParseContext(fileName, filePath) {
114
+ return {
115
+ fileName,
116
+ filePath,
117
+ skipped: [],
118
+ errors: []
119
+ };
120
+ }
121
+ function recordSkip(context, name, reason, location) {
122
+ context.skipped.push({ name, reason, location });
123
+ }
124
+ function recordParseError(context, file, error) {
125
+ context.errors.push({
126
+ file,
127
+ reason: error instanceof Error ? error.message : String(error)
128
+ });
129
+ }
80
130
  function preflightReport(input) {
81
131
  const policy = resolveEffectivePolicy({
82
132
  userConfig: loadUserConfig()
@@ -128,99 +178,38 @@ function parsePackageSpec(spec) {
128
178
  if (trimmed.length === 0 || /[\u0000-\u001f\u007f]/u.test(trimmed)) {
129
179
  return null;
130
180
  }
131
- const lowered = trimmed.toLowerCase();
132
- if (REMOTE_SPEC_PREFIXES.some((prefix) => lowered.startsWith(prefix)) || lowered.startsWith("file:")) {
133
- return packageObservation({
134
- ecosystem: "unknown",
135
- name: trimmed,
136
- version: null,
137
- requested: spec,
138
- sourceKind: "package-spec",
139
- resolvedUrl: trimmed,
140
- integrity: null,
141
- license: null
142
- }, "block", {
143
- id: "unverified-network-spec",
144
- title: "Unverified network package spec",
145
- message: "direct URL, git, and file package specs require artifact verification before install",
146
- location: spec
147
- });
148
- }
149
- if (lowered.startsWith("npm:")) {
150
- return parseNpmSpec(trimmed.slice(4), spec);
151
- }
152
- if (lowered.startsWith("pypi:")) {
153
- return parsePypiSpec(trimmed.slice(5), spec);
154
- }
155
- if (lowered.startsWith("cargo:")) {
156
- return parseCargoSpec(trimmed.slice(6), spec);
157
- }
158
- if (trimmed.includes("==")) {
159
- return parsePypiSpec(trimmed, spec);
160
- }
161
- return parseNpmSpec(trimmed, spec);
162
- }
163
- function parseNpmSpec(value, requested) {
164
- const parsed = splitNameVersion(value);
165
- if (!parsed || !isValidPackageName(parsed.name)) {
166
- return null;
167
- }
168
- return packageObservation({
169
- ecosystem: "npm",
170
- name: parsed.name,
171
- version: parsed.version,
172
- requested,
173
- sourceKind: "package-spec",
174
- resolvedUrl: null,
175
- integrity: null,
176
- license: null
177
- }, exactVersionVerdict(parsed.version), exactVersionFinding(parsed.name, parsed.version, requested));
178
- }
179
- function parsePypiSpec(value, requested) {
180
- const match = /^([A-Za-z0-9_.-]+)(?:==([^<>=!~\s]+))?$/.exec(value.trim());
181
- if (!match?.[1]) {
182
- return null;
183
- }
184
- const version = match[2] ?? null;
185
- return packageObservation({
186
- ecosystem: "pypi",
187
- name: match[1],
188
- version,
189
- requested,
190
- sourceKind: "package-spec",
191
- resolvedUrl: null,
192
- integrity: null,
193
- license: null
194
- }, exactVersionVerdict(version), exactVersionFinding(match[1], version, requested));
195
- }
196
- function parseCargoSpec(value, requested) {
197
- const parsed = splitNameVersion(value);
198
- if (!parsed || !/^[A-Za-z0-9_-]+$/.test(parsed.name)) {
181
+ if (!isRemotePackageSpec(trimmed)) {
199
182
  return null;
200
183
  }
201
184
  return packageObservation({
202
- ecosystem: "cargo",
203
- name: parsed.name,
204
- version: parsed.version,
205
- requested,
185
+ ecosystem: "unknown",
186
+ name: trimmed,
187
+ version: null,
188
+ requested: spec,
206
189
  sourceKind: "package-spec",
207
- resolvedUrl: null,
190
+ resolvedUrl: trimmed,
208
191
  integrity: null,
209
192
  license: null
210
- }, exactVersionVerdict(parsed.version), exactVersionFinding(parsed.name, parsed.version, requested));
193
+ }, "block", {
194
+ id: "unverified-network-spec",
195
+ title: "Unverified network package spec",
196
+ message: "direct URL, git, and file package specs require artifact verification before install",
197
+ location: spec
198
+ });
211
199
  }
212
- function parseLockfile(name, text) {
213
- if (name === "package-lock.json") {
214
- return parsePackageLock(text);
200
+ function parseLockfile(text, context) {
201
+ const name = context.fileName;
202
+ if (name === "package-lock.json" || name === "npm-shrinkwrap.json") {
203
+ return parsePackageLock(text, context);
215
204
  }
216
205
  if (name === "yarn.lock") {
217
- return parseYarnLock(text);
206
+ return parseYarnLock(text, context);
218
207
  }
219
208
  if (name === "pnpm-lock.yaml") {
220
- return parsePnpmLock(text);
209
+ return parsePnpmLock(text, context);
221
210
  }
222
211
  if (name === "requirements.txt") {
223
- return parseRequirements(text);
212
+ return parseRequirements(text, context);
224
213
  }
225
214
  if (name === "Cargo.lock") {
226
215
  return parseCargoLock(text);
@@ -228,42 +217,111 @@ function parseLockfile(name, text) {
228
217
  if (name === "poetry.lock") {
229
218
  return parsePoetryLock(text);
230
219
  }
220
+ if (name === "uv.lock") {
221
+ return parseUvLock(text, context);
222
+ }
231
223
  if (name === "Pipfile.lock") {
232
- return parsePipfileLock(text);
224
+ return parsePipfileLock(text, context);
233
225
  }
234
226
  return [];
235
227
  }
236
- function parsePackageLock(text) {
228
+ function specSourceKind(spec) {
229
+ const lower = spec.trim().toLowerCase();
230
+ if (lower.startsWith("workspace:")) {
231
+ return "workspace";
232
+ }
233
+ if (lower.startsWith("portal:") || lower.startsWith("link:") || lower.startsWith("file:")) {
234
+ return "local";
235
+ }
236
+ if (lower.startsWith("git+") || lower.startsWith("git://") || lower.startsWith("github:") || lower.startsWith("ssh://")) {
237
+ return "git";
238
+ }
239
+ if (lower.startsWith("http://") || lower.startsWith("https://")) {
240
+ return lower.includes(".git") ? "git" : "direct-url";
241
+ }
242
+ return null;
243
+ }
244
+ const NPM_LOCK_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
245
+ function npmLockSpecKinds(packages) {
246
+ const kinds = new Map();
247
+ for (const rawPackage of Object.values(packages)) {
248
+ if (!isRecord(rawPackage)) {
249
+ continue;
250
+ }
251
+ for (const section of NPM_LOCK_DEPENDENCY_SECTIONS) {
252
+ const dependencies = isRecord(rawPackage[section]) ? rawPackage[section] : {};
253
+ for (const [name, spec] of Object.entries(dependencies)) {
254
+ if (typeof spec !== "string") {
255
+ continue;
256
+ }
257
+ const kind = specSourceKind(spec);
258
+ if (kind) {
259
+ kinds.set(name, kind);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ return kinds;
265
+ }
266
+ function parsePackageLock(text, context) {
237
267
  let parsed;
238
268
  try {
239
269
  parsed = JSON.parse(text);
240
270
  }
241
271
  catch (error) {
242
- return [malformedLockfileObservation("package-lock.json", error)];
272
+ recordParseError(context, context.fileName, error);
273
+ return [malformedLockfileObservation(context.fileName, error)];
243
274
  }
244
275
  if (!isRecord(parsed)) {
245
- return [malformedLockfileObservation("package-lock.json", new Error("root must be an object"))];
276
+ const error = new Error("root must be an object");
277
+ recordParseError(context, context.fileName, error);
278
+ return [malformedLockfileObservation(context.fileName, error)];
246
279
  }
247
280
  const observations = [];
248
281
  if (isRecord(parsed.packages)) {
249
- for (const [path, rawPackage] of Object.entries(parsed.packages)) {
250
- if (path.length === 0 || !isRecord(rawPackage) || rawPackage.link === true) {
282
+ const packagesMap = parsed.packages;
283
+ const specKinds = npmLockSpecKinds(packagesMap);
284
+ for (const [path, rawPackage] of Object.entries(packagesMap)) {
285
+ if (path.length === 0 || !isRecord(rawPackage)) {
251
286
  continue;
252
287
  }
253
288
  const declaredName = typeof rawPackage.name === "string" ? rawPackage.name : packageNameFromNodeModulesPath(path);
289
+ const resolved = stringOrNull(rawPackage.resolved);
290
+ if (rawPackage.link === true) {
291
+ if (!(resolved && isRecord(packagesMap[resolved]))) {
292
+ recordSkip(context, declaredName ?? packageNameFromWorkspacePath(path), "local", path);
293
+ }
294
+ continue;
295
+ }
296
+ if (!path.includes("node_modules")) {
297
+ recordSkip(context, declaredName ?? packageNameFromWorkspacePath(path), "workspace", path);
298
+ continue;
299
+ }
254
300
  const alias = npmAliasVersion(stringOrNull(rawPackage.version));
255
301
  const name = alias?.name ?? declaredName;
256
302
  const version = alias?.version ?? (typeof rawPackage.version === "string" ? rawPackage.version : null);
257
303
  if (!name) {
258
304
  continue;
259
305
  }
306
+ const resolvedIsGit = resolved !== null && isUnsafeResolvedUrl(resolved);
307
+ const skipReason = resolvedIsGit
308
+ ? "git"
309
+ : resolved?.toLowerCase().startsWith("file:")
310
+ ? "local"
311
+ : specKinds.get(name) ?? null;
312
+ if (skipReason) {
313
+ recordSkip(context, name, skipReason, path);
314
+ if (!resolvedIsGit) {
315
+ continue;
316
+ }
317
+ }
260
318
  observations.push(lockfileObservation({
261
319
  ecosystem: "npm",
262
320
  name,
263
321
  version,
264
322
  requested: path,
265
323
  sourceKind: "lockfile",
266
- resolvedUrl: stringOrNull(rawPackage.resolved),
324
+ resolvedUrl: resolved,
267
325
  integrity: stringOrNull(rawPackage.integrity),
268
326
  license: stringOrNull(rawPackage.license)
269
327
  }));
@@ -271,20 +329,28 @@ function parsePackageLock(text) {
271
329
  return observations;
272
330
  }
273
331
  if (isRecord(parsed.dependencies)) {
274
- walkLegacyDependencies(parsed.dependencies, observations, new Set());
332
+ walkLegacyDependencies(parsed.dependencies, observations, new Set(), context);
275
333
  }
276
334
  return observations;
277
335
  }
278
- function walkLegacyDependencies(dependencies, observations, seen) {
336
+ function walkLegacyDependencies(dependencies, observations, seen, context) {
279
337
  for (const [name, rawPackage] of Object.entries(dependencies)) {
280
338
  if (!isRecord(rawPackage) || rawPackage.bundled === true) {
281
339
  continue;
282
340
  }
283
- const alias = npmAliasVersion(stringOrNull(rawPackage.version));
341
+ const rawVersion = stringOrNull(rawPackage.version);
342
+ const resolved = stringOrNull(rawPackage.resolved);
343
+ const resolvedIsGit = resolved !== null && isUnsafeResolvedUrl(resolved);
344
+ const versionKind = rawVersion && !rawVersion.startsWith("npm:") ? specSourceKind(rawVersion) : null;
345
+ const skipReason = resolvedIsGit ? "git" : versionKind;
346
+ const alias = npmAliasVersion(rawVersion);
284
347
  const resolvedName = alias?.name ?? name;
285
- const version = alias?.version ?? stringOrNull(rawPackage.version);
348
+ const version = alias?.version ?? rawVersion;
286
349
  const key = `${resolvedName}@${version ?? ""}`;
287
- if (!seen.has(key)) {
350
+ if (skipReason) {
351
+ recordSkip(context, resolvedName, skipReason, name);
352
+ }
353
+ if (!seen.has(key) && (!skipReason || resolvedIsGit)) {
288
354
  seen.add(key);
289
355
  observations.push(lockfileObservation({
290
356
  ecosystem: "npm",
@@ -292,13 +358,13 @@ function walkLegacyDependencies(dependencies, observations, seen) {
292
358
  version,
293
359
  requested: name,
294
360
  sourceKind: "lockfile",
295
- resolvedUrl: stringOrNull(rawPackage.resolved),
361
+ resolvedUrl: resolved,
296
362
  integrity: stringOrNull(rawPackage.integrity),
297
363
  license: stringOrNull(rawPackage.license)
298
364
  }));
299
365
  }
300
366
  if (isRecord(rawPackage.dependencies)) {
301
- walkLegacyDependencies(rawPackage.dependencies, observations, seen);
367
+ walkLegacyDependencies(rawPackage.dependencies, observations, seen, context);
302
368
  }
303
369
  }
304
370
  }
@@ -313,7 +379,7 @@ function npmAliasVersion(version) {
313
379
  }
314
380
  return { name: spec.slice(0, at), version: spec.slice(at + 1) || null };
315
381
  }
316
- function parseYarnLock(text) {
382
+ function parseYarnLock(text, context) {
317
383
  const observations = [];
318
384
  const blocks = text.split(/\n(?=(?:"?@?[^"\s].*"?):\n)/u);
319
385
  for (const block of blocks) {
@@ -327,6 +393,14 @@ function parseYarnLock(text) {
327
393
  }
328
394
  const requested = header.split(",")[0]?.trim().replace(/^"|"$/gu, "") ?? header;
329
395
  const name = packageNameFromYarnDescriptor(requested);
396
+ const resolved = quotedValue(lines, "resolved");
397
+ const skipReason = specSourceKind(yarnDescriptorSpec(requested));
398
+ if (skipReason) {
399
+ recordSkip(context, name ?? requested, skipReason, requested);
400
+ if (!(resolved && isUnsafeResolvedUrl(resolved))) {
401
+ continue;
402
+ }
403
+ }
330
404
  const version = quotedValue(lines, "version");
331
405
  if (!name || !version) {
332
406
  continue;
@@ -337,14 +411,14 @@ function parseYarnLock(text) {
337
411
  version,
338
412
  requested,
339
413
  sourceKind: "lockfile",
340
- resolvedUrl: quotedValue(lines, "resolved"),
414
+ resolvedUrl: resolved,
341
415
  integrity: quotedValue(lines, "integrity") ?? quotedValue(lines, "checksum"),
342
416
  license: null
343
417
  }));
344
418
  }
345
419
  return observations;
346
420
  }
347
- function parsePnpmLock(text) {
421
+ function parsePnpmLock(text, context) {
348
422
  const observations = [];
349
423
  const lines = text.split(/\r?\n/u);
350
424
  let inPackages = false;
@@ -373,7 +447,15 @@ function parsePnpmLock(text) {
373
447
  const keyMatch = /^\s{2}(\S.*?):\s*$/u.exec(line);
374
448
  if (keyMatch?.[1]) {
375
449
  flush();
376
- current = parsePnpmPackageKey(keyMatch[1]);
450
+ const key = stripQuotes(keyMatch[1].trim());
451
+ const skipReason = pnpmKeySkipReason(key);
452
+ if (skipReason) {
453
+ recordSkip(context, pnpmKeyName(key), skipReason, key);
454
+ current = null;
455
+ }
456
+ else {
457
+ current = parsePnpmPackageKey(key);
458
+ }
377
459
  continue;
378
460
  }
379
461
  if (!current) {
@@ -397,29 +479,45 @@ function parsePnpmLock(text) {
397
479
  flush();
398
480
  return observations;
399
481
  }
400
- function parsePnpmPackageKey(rawKey) {
401
- const key = stripQuotes(rawKey.trim());
402
- if (/(?:file|link|workspace|git\+|git:|https?):/u.test(key)) {
482
+ const PNPM_NON_REGISTRY_MARKER = /(?:file|link|workspace|git\+|git:|ssh|https?):/u;
483
+ function pnpmKeySkipReason(key) {
484
+ if (!PNPM_NON_REGISTRY_MARKER.test(key)) {
403
485
  return null;
404
486
  }
487
+ if (key.includes("workspace:")) {
488
+ return "workspace";
489
+ }
490
+ if (/git\+|git:/u.test(key)) {
491
+ return "git";
492
+ }
493
+ if (/file:|link:/u.test(key)) {
494
+ return "local";
495
+ }
496
+ return "direct-url";
497
+ }
498
+ function pnpmKeyName(key) {
499
+ const named = /^\/?((?:@[^/\s]+\/)?[^@\s/]+)@/u.exec(key);
500
+ return named?.[1] ?? key;
501
+ }
502
+ function parsePnpmPackageKey(key) {
405
503
  const hadSlash = key.startsWith("/");
406
504
  const body = hadSlash ? key.slice(1) : key;
505
+ const parenStart = body.indexOf("(");
506
+ const core = parenStart === -1 ? body : body.slice(0, parenStart);
407
507
  let name = null;
408
508
  let version = null;
409
- if (hadSlash) {
410
- // lockfileVersion 5.x keys are /<name>/<version>[_peer]; the version
411
- // follows the final slash and the name may itself be scoped (two slashes).
412
- const lastSlash = body.lastIndexOf("/");
509
+ // lockfileVersion 5.x keys are /<name>/<version>[_peer]; 6.0 keys are
510
+ // /<name>@<version>[(peer)]; 9.0 keys drop the leading slash.
511
+ const atForm = /^((?:@[^/\s]+\/)?[^/@\s]+)@([^/\s]+)$/u.exec(core);
512
+ if (atForm?.[1] && atForm[2]) {
513
+ name = atForm[1];
514
+ version = stripPnpmPeerSuffix(atForm[2]);
515
+ }
516
+ else if (hadSlash) {
517
+ const lastSlash = core.lastIndexOf("/");
413
518
  if (lastSlash > 0) {
414
- name = body.slice(0, lastSlash);
415
- version = stripPnpmPeerSuffix(body.slice(lastSlash + 1));
416
- }
417
- }
418
- else {
419
- const atForm = /^((?:@[^/\s]+\/)?[^@\s]+)@(.+)$/u.exec(body);
420
- if (atForm?.[1] && atForm[2]) {
421
- name = atForm[1];
422
- version = stripPnpmPeerSuffix(atForm[2]);
519
+ name = core.slice(0, lastSlash);
520
+ version = stripPnpmPeerSuffix(core.slice(lastSlash + 1));
423
521
  }
424
522
  }
425
523
  if (!name || !version) {
@@ -440,16 +538,32 @@ function stripPnpmPeerSuffix(version) {
440
538
  const peerStart = version.search(/[(_]/u);
441
539
  return peerStart === -1 ? version : version.slice(0, peerStart);
442
540
  }
443
- function parseRequirements(text) {
541
+ function parseRequirements(text, context) {
444
542
  const observations = [];
543
+ const baseDir = context.filePath ? dirname(context.filePath) : null;
544
+ const state = {
545
+ rootDir: baseDir,
546
+ visited: new Set(context.filePath ? [resolve(context.filePath)] : []),
547
+ seen: new Set()
548
+ };
549
+ collectRequirements(text, baseDir, state, context, observations);
550
+ return observations;
551
+ }
552
+ function collectRequirements(text, baseDir, state, context, observations) {
445
553
  for (const logical of joinRequirementContinuations(text.split(/\r?\n/u))) {
446
554
  const line = logical.trim();
447
555
  if (line.length === 0 || line.startsWith("#")) {
448
556
  continue;
449
557
  }
558
+ const include = /^(?:-r|--requirement|-c|--constraint)[=\s]+(\S+)/u.exec(line);
559
+ if (include?.[1]) {
560
+ followRequirementInclude(include[1], baseDir, state, context, observations);
561
+ continue;
562
+ }
450
563
  const editable = /^(?:-e|--editable)[=\s]+(.+)$/u.exec(line);
451
564
  const target = (editable?.[1] ?? line).trim();
452
565
  if (REMOTE_SPEC_PREFIXES.some((prefix) => target.toLowerCase().startsWith(prefix))) {
566
+ recordSkip(context, target, specSourceKind(target) ?? "direct-url", line);
453
567
  observations.push(packageObservation({
454
568
  ecosystem: "pypi",
455
569
  name: target,
@@ -467,16 +581,25 @@ function parseRequirements(text) {
467
581
  }));
468
582
  continue;
469
583
  }
470
- if (line.startsWith("-") || /^\.{0,2}\//u.test(target)) {
584
+ if (editable !== null || /^\.{0,2}\//u.test(target)) {
585
+ recordSkip(context, target, "local", line);
586
+ continue;
587
+ }
588
+ if (line.startsWith("-")) {
471
589
  continue;
472
590
  }
473
591
  const hash = /--hash=([A-Za-z0-9:_-]+)/u.exec(line)?.[1] ?? null;
474
592
  const requirement = line.replace(/\s*--hash=[^\s]+/gu, "").trim();
475
- const match = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?(?:\s*==\s*([^;\s]+))?/u.exec(requirement);
593
+ const match = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?(?:\s*={2,3}\s*([^;\s]+))?/u.exec(requirement);
476
594
  if (!match?.[1]) {
477
595
  observations.push(blockedUnknownSpec(line));
478
596
  continue;
479
597
  }
598
+ const pinKey = `${match[1].toLowerCase()}@${match[2] ?? ""}`;
599
+ if (state.seen.has(pinKey)) {
600
+ continue;
601
+ }
602
+ state.seen.add(pinKey);
480
603
  observations.push(lockfileObservation({
481
604
  ecosystem: "pypi",
482
605
  name: match[1],
@@ -488,7 +611,30 @@ function parseRequirements(text) {
488
611
  license: null
489
612
  }));
490
613
  }
491
- return observations;
614
+ }
615
+ function followRequirementInclude(target, baseDir, state, context, observations) {
616
+ if (!baseDir || !state.rootDir) {
617
+ return;
618
+ }
619
+ const includePath = resolve(baseDir, target);
620
+ const containment = relative(state.rootDir, includePath);
621
+ if (containment.startsWith("..") || isAbsolute(containment)) {
622
+ recordParseError(context, target, new Error("requirements include escapes the project directory"));
623
+ return;
624
+ }
625
+ if (state.visited.has(includePath)) {
626
+ return;
627
+ }
628
+ state.visited.add(includePath);
629
+ let text;
630
+ try {
631
+ text = readFileSync(includePath, "utf8");
632
+ }
633
+ catch (error) {
634
+ recordParseError(context, target, error);
635
+ return;
636
+ }
637
+ collectRequirements(text, dirname(includePath), state, context, observations);
492
638
  }
493
639
  function joinRequirementContinuations(lines) {
494
640
  const joined = [];
@@ -538,15 +684,62 @@ function parsePoetryLock(text) {
538
684
  });
539
685
  }).filter((observation) => observation.identity.name !== "unknown");
540
686
  }
541
- function parsePipfileLock(text) {
687
+ function parseUvLock(text, context) {
688
+ const observations = [];
689
+ for (const block of lockBlocks(text, "[[package]]")) {
690
+ const name = tomlString(block, "name");
691
+ if (!name) {
692
+ continue;
693
+ }
694
+ const version = tomlString(block, "version");
695
+ const source = /^source\s*=\s*\{([^}]*)\}/mu.exec(block)?.[1] ?? "";
696
+ const skipReason = uvSourceSkipReason(source);
697
+ if (skipReason) {
698
+ recordSkip(context, name, skipReason, `${name}${version ? `==${version}` : ""}`);
699
+ continue;
700
+ }
701
+ observations.push(lockfileObservation({
702
+ ecosystem: "pypi",
703
+ name,
704
+ version,
705
+ requested: `${name}==${version ?? "unknown"}`,
706
+ sourceKind: "lockfile",
707
+ resolvedUrl: null,
708
+ integrity: /hash\s*=\s*"([^"]+)"/u.exec(block)?.[1] ?? null,
709
+ license: null
710
+ }));
711
+ }
712
+ return observations;
713
+ }
714
+ function uvSourceSkipReason(source) {
715
+ if (source.length === 0 || /\bregistry\s*=/u.test(source)) {
716
+ return null;
717
+ }
718
+ if (/\bgit\s*=/u.test(source)) {
719
+ return "git";
720
+ }
721
+ if (/\burl\s*=/u.test(source)) {
722
+ return "direct-url";
723
+ }
724
+ if (/\b(?:editable|virtual)\s*=/u.test(source)) {
725
+ return "workspace";
726
+ }
727
+ if (/\b(?:path|directory)\s*=/u.test(source)) {
728
+ return "local";
729
+ }
730
+ return null;
731
+ }
732
+ function parsePipfileLock(text, context) {
542
733
  let parsed;
543
734
  try {
544
735
  parsed = JSON.parse(text);
545
736
  }
546
737
  catch (error) {
547
- return [malformedLockfileObservation("Pipfile.lock", error)];
738
+ recordParseError(context, context.fileName, error);
739
+ return [malformedLockfileObservation(context.fileName, error)];
548
740
  }
549
741
  if (!isRecord(parsed)) {
742
+ recordParseError(context, context.fileName, new Error("root must be an object"));
550
743
  return [];
551
744
  }
552
745
  return ["default", "develop"].flatMap((section) => {
@@ -594,20 +787,6 @@ function integrityPolicyFinding(identity) {
594
787
  location: identity.requested
595
788
  };
596
789
  }
597
- function exactVersionFinding(name, version, location) {
598
- if (version && isExactVersion(version)) {
599
- return null;
600
- }
601
- return {
602
- id: "unpinned-package-spec",
603
- title: "Unpinned package spec",
604
- message: `${name} is not pinned to an exact package version`,
605
- location
606
- };
607
- }
608
- function exactVersionVerdict(version) {
609
- return version && isExactVersion(version) ? "pass" : "warn";
610
- }
611
790
  function deniedLicenseFinding(identity, deniedLicenses) {
612
791
  if (!identity.license || !deniedLicenses.has(normalizeLicense(identity.license))) {
613
792
  return null;
@@ -660,41 +839,8 @@ function packageObservation(identity, verdict, finding) {
660
839
  finding
661
840
  };
662
841
  }
663
- function splitNameVersion(value) {
664
- const trimmed = value.trim();
665
- if (trimmed.startsWith("@")) {
666
- const index = trimmed.lastIndexOf("@");
667
- if (index <= 0) {
668
- return {
669
- name: trimmed,
670
- version: null
671
- };
672
- }
673
- return {
674
- name: trimmed.slice(0, index),
675
- version: trimmed.slice(index + 1) || null
676
- };
677
- }
678
- const index = trimmed.lastIndexOf("@");
679
- if (index > 0) {
680
- return {
681
- name: trimmed.slice(0, index),
682
- version: trimmed.slice(index + 1) || null
683
- };
684
- }
685
- return {
686
- name: trimmed,
687
- version: null
688
- };
689
- }
690
- function isValidPackageName(name) {
691
- return /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/iu.test(name) && !/[\\\s]/u.test(name);
692
- }
693
- function isExactVersion(version) {
694
- return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(version);
695
- }
696
842
  function isSupportedIntegrity(value) {
697
- return /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/=]+$/u.test(value)
843
+ return /^(?:sha1|sha256|sha384|sha512)-[A-Za-z0-9+/=]+$/u.test(value)
698
844
  || /^(?:sha256:)?[a-f0-9]{64}$/iu.test(value)
699
845
  || /^[0-9a-f]+\/[0-9a-f]{64,}$/iu.test(value);
700
846
  }
@@ -749,6 +895,12 @@ function packageDisplayName(identity) {
749
895
  const version = identity.version ? `@${identity.version}` : "";
750
896
  return `${identity.ecosystem}:${identity.name}${version}`;
751
897
  }
898
+ function packageNameFromWorkspacePath(path) {
899
+ const parts = path.split("/").filter((part) => part.length > 0);
900
+ const last = parts[parts.length - 1] ?? path;
901
+ const prior = parts[parts.length - 2];
902
+ return prior?.startsWith("@") ? `${prior}/${last}` : last;
903
+ }
752
904
  function packageNameFromNodeModulesPath(path) {
753
905
  const parts = path.split("/");
754
906
  const nodeModulesIndex = parts.lastIndexOf("node_modules");
@@ -764,6 +916,12 @@ function packageNameFromNodeModulesPath(path) {
764
916
  }
765
917
  return first;
766
918
  }
919
+ function yarnDescriptorSpec(descriptor) {
920
+ const scoped = descriptor.startsWith("@");
921
+ const body = scoped ? descriptor.slice(1) : descriptor;
922
+ const at = body.indexOf("@");
923
+ return at === -1 ? "" : body.slice(at + 1);
924
+ }
767
925
  function packageNameFromYarnDescriptor(descriptor) {
768
926
  const scoped = descriptor.startsWith("@");
769
927
  const body = scoped ? descriptor.slice(1) : descriptor;