@westbayberry/dg 2.0.3 → 2.0.5

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.
@@ -6,6 +6,7 @@ import { envAuthToken } from "../auth/env-token.js";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
7
  import { sanitize, sanitizeResponse } from "../security/sanitize.js";
8
8
  import { resolveDgPaths } from "../state/index.js";
9
+ import { dgVersion } from "../commands/version.js";
9
10
  export class AnalyzeError extends Error {
10
11
  statusCode;
11
12
  body;
@@ -29,13 +30,15 @@ export async function analyzePackages(packages, options) {
29
30
  const token = resolveToken(env);
30
31
  const deviceId = getOrCreateDeviceId(env);
31
32
  const url = `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`;
32
- options.onProgress?.(0, packages.length, []);
33
+ const batchCount = Math.max(1, Math.ceil(packages.length / BATCH_SIZE));
34
+ options.onProgress?.({ done: 0, total: packages.length, batchIndex: 0, batchCount });
33
35
  const responses = [];
34
36
  for (let index = 0; index < packages.length; index += BATCH_SIZE) {
35
37
  const batch = packages.slice(index, index + BATCH_SIZE);
36
- options.onProgress?.(index, packages.length, batch.map((entry) => entry.name));
38
+ const batchIndex = Math.floor(index / BATCH_SIZE) + 1;
39
+ options.onProgress?.({ done: index, total: packages.length, batchIndex, batchCount });
37
40
  responses.push(await analyzeBatchWithRetry(url, batch, token, deviceId, fetchImpl, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
38
- options.onProgress?.(Math.min(index + batch.length, packages.length), packages.length, batch.map((entry) => entry.name));
41
+ options.onProgress?.({ done: Math.min(index + batch.length, packages.length), total: packages.length, batchIndex, batchCount });
39
42
  }
40
43
  return mergeAnalyzeResponses(responses);
41
44
  }
@@ -86,6 +89,7 @@ async function analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs) {
86
89
  headers: {
87
90
  "Content-Type": "application/json",
88
91
  "X-Device-Id": deviceId,
92
+ "X-Dg-Version": dgVersion(),
89
93
  ...(token ? { Authorization: `Bearer ${token}` } : {})
90
94
  },
91
95
  body: JSON.stringify({
@@ -128,8 +128,8 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
128
128
  case "selecting":
129
129
  return (_jsx(ProjectSelector, { projects: state.projects, onConfirm: scanSelectedProjects, onCancel: () => { process.exitCode = 0; leaveAltScreen(); exit(); }, userStatus: userStatus }));
130
130
  case "scanning":
131
- return (_jsx(ProgressBar, { value: state.done, total: state.total, label: state.currentBatch.length > 0
132
- ? state.currentBatch[state.currentBatch.length - 1]
131
+ return (_jsx(ProgressBar, { value: state.done, total: state.total, label: state.batchCount > 1 && state.batchIndex >= 1
132
+ ? `batch ${state.batchIndex}/${state.batchCount}`
133
133
  : undefined }));
134
134
  case "results":
135
135
  return (_jsx(InteractiveResultsView, { result: state.result, config: config, durationMs: state.durationMs, onExit: handleResultsExit, onBack: restartSelection ?? undefined, discoveredTotal: state.discoveredTotal, userStatus: userStatus, scanUsage: scanUsage, initialView: initialView }));
@@ -47,7 +47,7 @@ function isYankedIncomplete(pkg) {
47
47
  }
48
48
  export function packageBadge(pkg) {
49
49
  if (isYankedIncomplete(pkg))
50
- return { label: "Removed", color: chalk.yellow };
50
+ return { label: "Unverified", color: chalk.yellow };
51
51
  return actionBadge(pkg.action);
52
52
  }
53
53
  const EVIDENCE_LIMIT = 2;
@@ -21,9 +21,10 @@ export function truncate(s, max) {
21
21
  export function groupPackages(packages, keyBy = "name") {
22
22
  const map = new Map();
23
23
  for (const pkg of packages) {
24
+ const action = pkg.action ?? "pass";
24
25
  const fingerprint = pkg.findings.length === 0
25
- ? `__clean_${pkg.score}`
26
- : pkg.findings
26
+ ? `${action}|${pkg.name}@${pkg.version ?? ""}|score:${pkg.score}`
27
+ : `${action}|` + pkg.findings
27
28
  .map((f) => `${f.category ?? ""}:${f.severity}`)
28
29
  .sort()
29
30
  .join("|") + `|score:${pkg.score}`;
@@ -9,11 +9,11 @@ function reducer(_state, action) {
9
9
  case "DISCOVERY_PROGRESS":
10
10
  return { phase: "discovering", path: action.path, found: action.found };
11
11
  case "DISCOVERY_COMPLETE":
12
- return { phase: "scanning", done: 0, total: action.total, currentBatch: [] };
12
+ return { phase: "scanning", done: 0, total: action.total, batchIndex: 0, batchCount: 1 };
13
13
  case "DISCOVERY_EMPTY":
14
14
  return { phase: "empty", message: action.message };
15
15
  case "SCAN_PROGRESS":
16
- return { phase: "scanning", done: action.done, total: action.total, currentBatch: action.currentBatch };
16
+ return { phase: "scanning", done: action.done, total: action.total, batchIndex: action.batchIndex, batchCount: action.batchCount };
17
17
  case "SCAN_COMPLETE":
18
18
  return {
19
19
  phase: "results",
@@ -83,8 +83,8 @@ async function scanProjects(projects, dispatch) {
83
83
  const base = completed;
84
84
  responses.push(await analyzePackages(packages, {
85
85
  ecosystem,
86
- onProgress: (done, _ecosystemTotal, currentBatch) => {
87
- dispatch({ type: "SCAN_PROGRESS", done: base + done, total, currentBatch: [...currentBatch] });
86
+ onProgress: (progress) => {
87
+ dispatch({ type: "SCAN_PROGRESS", done: base + progress.done, total, batchIndex: progress.batchIndex, batchCount: progress.batchCount });
88
88
  }
89
89
  }));
90
90
  completed = base + packages.length;
@@ -14,6 +14,8 @@ import { OPTIONAL_SUPPORT_GATES } from "./optional-support.js";
14
14
  export const SHIM_COMMANDS = Object.freeze(["npm", "npx", "pnpm", "pnpx", "yarn", "pip", "pipx", "uv", "uvx", "cargo"]);
15
15
  export const SHIM_SENTINEL = "dg-shim-v1";
16
16
  export const RC_SENTINEL = "dg-shell-rc-v1";
17
+ export const RC_FUNCTIONS_SENTINEL = "dg-shim-functions-v1";
18
+ const RC_SHIM_HELPER = "__dg_shim";
17
19
  export const GUARD_HOOK_SENTINEL = "dg-git-hook-v1";
18
20
  export const RC_BEGIN = "# >>> dg setup >>>";
19
21
  export const RC_END = "# <<< dg setup <<<";
@@ -273,6 +275,7 @@ export function doctorReport(options = {}) {
273
275
  });
274
276
  const rcEntries = registryRead.registry.entries.filter((entry) => entry.owner === "dg" && entry.kind === "rc");
275
277
  const missingRc = rcEntries.filter((entry) => !readText(entry.path).includes(RC_SENTINEL));
278
+ const functionsPresent = rcEntries.some((entry) => readText(entry.path).includes(RC_FUNCTIONS_SENTINEL));
276
279
  checks.push({
277
280
  name: "shell-rc",
278
281
  status: rcEntries.length > 0 && missingRc.length === 0 ? "pass" : "warn",
@@ -286,7 +289,7 @@ export function doctorReport(options = {}) {
286
289
  ? "No legacy dg pip hooks in user site-packages"
287
290
  : `Legacy dg pip hooks break pip in: ${staleHookSites.join(", ")}`
288
291
  });
289
- checks.push(pathPrecedenceCheck(env, shimDir));
292
+ checks.push(pathPrecedenceCheck(env, shimDir, functionsPresent));
290
293
  checks.push({
291
294
  name: "stale-sessions",
292
295
  status: staleSessions.length === 0 ? "pass" : "warn",
@@ -510,11 +513,20 @@ function withRcBlock(existing, plan) {
510
513
  const prefix = withoutExisting.length > 0 && !withoutExisting.endsWith("\n") ? `${withoutExisting}\n` : withoutExisting;
511
514
  return `${prefix}${block}`;
512
515
  }
516
+ // The PATH export covers child processes that inherit it; the shell functions
517
+ // win even when a virtualenv prepends its own bin ahead of the shim dir, since
518
+ // a function is resolved before PATH. Each delegates to the fail-open shim and
519
+ // falls back to the real command if the shim is gone.
513
520
  function posixRcBlock(shimDir) {
514
- return `${RC_BEGIN}\n# ${RC_SENTINEL}\nexport PATH="${escapeDoubleQuotedSh(shimDir)}:$PATH"\n${RC_END}\n`;
521
+ const dir = escapeDoubleQuotedSh(shimDir);
522
+ const helper = `${RC_SHIM_HELPER}() { local __dg_c="$1"; shift; if [ -x "${dir}/$__dg_c" ]; then "${dir}/$__dg_c" "$@"; else command "$__dg_c" "$@"; fi; }`;
523
+ const fns = SHIM_COMMANDS.map((command) => `${command}() { ${RC_SHIM_HELPER} ${command} "$@"; }`).join("\n");
524
+ return `${RC_BEGIN}\n# ${RC_SENTINEL}\n# ${RC_FUNCTIONS_SENTINEL}\nexport PATH="${dir}:$PATH"\n${helper}\n${fns}\n${RC_END}\n`;
515
525
  }
516
526
  function fishRcBlock(shimDir) {
517
- return `${RC_BEGIN}\n# ${RC_SENTINEL}\nfish_add_path -p "${escapeDoubleQuotedFish(shimDir)}"\n${RC_END}\n`;
527
+ const dir = escapeDoubleQuotedFish(shimDir);
528
+ const fns = SHIM_COMMANDS.map((command) => `function ${command}; if test -x "${dir}/${command}"; "${dir}/${command}" $argv; else; command ${command} $argv; end; end`).join("\n");
529
+ return `${RC_BEGIN}\n# ${RC_SENTINEL}\n# ${RC_FUNCTIONS_SENTINEL}\nfish_add_path -p "${dir}"\n${fns}\n${RC_END}\n`;
518
530
  }
519
531
  function stripRcBlock(existing) {
520
532
  const pattern = new RegExp(`${escapeRegex(RC_BEGIN)}\\n[\\s\\S]*?${escapeRegex(RC_END)}\\n?`, "g");
@@ -778,7 +790,7 @@ function serviceCheck(env) {
778
790
  message: `Service mode is running at ${state.proxy.proxyUrl}; trust installed: ${state.trustInstalled ? "yes" : "no"}`
779
791
  };
780
792
  }
781
- function pathPrecedenceCheck(env, shimDir) {
793
+ function pathPrecedenceCheck(env, shimDir, functionsPresent) {
782
794
  const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
783
795
  const shimIndex = pathEntries.indexOf(shimDir);
784
796
  const activateFix = `activate this shell: ${currentShellActivation(env)} — or open a new terminal`;
@@ -812,11 +824,18 @@ function pathPrecedenceCheck(env, shimDir) {
812
824
  };
813
825
  }
814
826
  if (offender) {
827
+ if (functionsPresent) {
828
+ return {
829
+ name: "path",
830
+ status: "pass",
831
+ message: `${offender.dir} resolves ${offender.command} first (e.g. an active virtualenv); dg shell functions intercept bare installs regardless`
832
+ };
833
+ }
815
834
  return {
816
835
  name: "path",
817
836
  status: "warn",
818
837
  message: `${shimDir} is on PATH but ${offender.dir} resolves ${offender.command} first`,
819
- fix: activateFix
838
+ fix: `re-run dg setup to intercept inside virtualenvs — or ${activateFix}`
820
839
  };
821
840
  }
822
841
  return {
@@ -245,44 +245,73 @@ function parsePackageLock(text) {
245
245
  return [malformedLockfileObservation("package-lock.json", new Error("root must be an object"))];
246
246
  }
247
247
  const observations = [];
248
- const packages = isRecord(parsed.packages) ? parsed.packages : {};
249
- for (const [path, rawPackage] of Object.entries(packages)) {
250
- if (path.length === 0 || !isRecord(rawPackage)) {
251
- continue;
252
- }
253
- const name = typeof rawPackage.name === "string" ? rawPackage.name : packageNameFromNodeModulesPath(path);
254
- const version = typeof rawPackage.version === "string" ? rawPackage.version : null;
255
- if (!name) {
256
- continue;
248
+ if (isRecord(parsed.packages)) {
249
+ for (const [path, rawPackage] of Object.entries(parsed.packages)) {
250
+ if (path.length === 0 || !isRecord(rawPackage) || rawPackage.link === true) {
251
+ continue;
252
+ }
253
+ const declaredName = typeof rawPackage.name === "string" ? rawPackage.name : packageNameFromNodeModulesPath(path);
254
+ const alias = npmAliasVersion(stringOrNull(rawPackage.version));
255
+ const name = alias?.name ?? declaredName;
256
+ const version = alias?.version ?? (typeof rawPackage.version === "string" ? rawPackage.version : null);
257
+ if (!name) {
258
+ continue;
259
+ }
260
+ observations.push(lockfileObservation({
261
+ ecosystem: "npm",
262
+ name,
263
+ version,
264
+ requested: path,
265
+ sourceKind: "lockfile",
266
+ resolvedUrl: stringOrNull(rawPackage.resolved),
267
+ integrity: stringOrNull(rawPackage.integrity),
268
+ license: stringOrNull(rawPackage.license)
269
+ }));
257
270
  }
258
- observations.push(lockfileObservation({
259
- ecosystem: "npm",
260
- name,
261
- version,
262
- requested: path,
263
- sourceKind: "lockfile",
264
- resolvedUrl: stringOrNull(rawPackage.resolved),
265
- integrity: stringOrNull(rawPackage.integrity),
266
- license: stringOrNull(rawPackage.license)
267
- }));
271
+ return observations;
268
272
  }
269
- const dependencies = isRecord(parsed.dependencies) ? parsed.dependencies : {};
273
+ if (isRecord(parsed.dependencies)) {
274
+ walkLegacyDependencies(parsed.dependencies, observations, new Set());
275
+ }
276
+ return observations;
277
+ }
278
+ function walkLegacyDependencies(dependencies, observations, seen) {
270
279
  for (const [name, rawPackage] of Object.entries(dependencies)) {
271
- if (!isRecord(rawPackage) || observations.some((observation) => observation.identity.name === name)) {
280
+ if (!isRecord(rawPackage) || rawPackage.bundled === true) {
272
281
  continue;
273
282
  }
274
- observations.push(lockfileObservation({
275
- ecosystem: "npm",
276
- name,
277
- version: stringOrNull(rawPackage.version),
278
- requested: name,
279
- sourceKind: "lockfile",
280
- resolvedUrl: stringOrNull(rawPackage.resolved),
281
- integrity: stringOrNull(rawPackage.integrity),
282
- license: stringOrNull(rawPackage.license)
283
- }));
283
+ const alias = npmAliasVersion(stringOrNull(rawPackage.version));
284
+ const resolvedName = alias?.name ?? name;
285
+ const version = alias?.version ?? stringOrNull(rawPackage.version);
286
+ const key = `${resolvedName}@${version ?? ""}`;
287
+ if (!seen.has(key)) {
288
+ seen.add(key);
289
+ observations.push(lockfileObservation({
290
+ ecosystem: "npm",
291
+ name: resolvedName,
292
+ version,
293
+ requested: name,
294
+ sourceKind: "lockfile",
295
+ resolvedUrl: stringOrNull(rawPackage.resolved),
296
+ integrity: stringOrNull(rawPackage.integrity),
297
+ license: stringOrNull(rawPackage.license)
298
+ }));
299
+ }
300
+ if (isRecord(rawPackage.dependencies)) {
301
+ walkLegacyDependencies(rawPackage.dependencies, observations, seen);
302
+ }
284
303
  }
285
- return observations;
304
+ }
305
+ function npmAliasVersion(version) {
306
+ if (!version || !version.startsWith("npm:")) {
307
+ return null;
308
+ }
309
+ const spec = version.slice(4);
310
+ const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
311
+ if (at <= 0) {
312
+ return { name: spec, version: null };
313
+ }
314
+ return { name: spec.slice(0, at), version: spec.slice(at + 1) || null };
286
315
  }
287
316
  function parseYarnLock(text) {
288
317
  const observations = [];
@@ -293,22 +322,23 @@ function parseYarnLock(text) {
293
322
  if (!header) {
294
323
  continue;
295
324
  }
325
+ if (header.startsWith("#") || header === "__metadata") {
326
+ continue;
327
+ }
296
328
  const requested = header.split(",")[0]?.trim().replace(/^"|"$/gu, "") ?? header;
297
329
  const name = packageNameFromYarnDescriptor(requested);
298
- if (!name) {
330
+ const version = quotedValue(lines, "version");
331
+ if (!name || !version) {
299
332
  continue;
300
333
  }
301
- const version = quotedValue(lines, "version");
302
- const resolvedUrl = quotedValue(lines, "resolved");
303
- const integrity = quotedValue(lines, "integrity");
304
334
  observations.push(lockfileObservation({
305
335
  ecosystem: "npm",
306
336
  name,
307
337
  version,
308
338
  requested,
309
339
  sourceKind: "lockfile",
310
- resolvedUrl,
311
- integrity,
340
+ resolvedUrl: quotedValue(lines, "resolved"),
341
+ integrity: quotedValue(lines, "integrity") ?? quotedValue(lines, "checksum"),
312
342
  license: null
313
343
  }));
314
344
  }
@@ -317,23 +347,33 @@ function parseYarnLock(text) {
317
347
  function parsePnpmLock(text) {
318
348
  const observations = [];
319
349
  const lines = text.split(/\r?\n/u);
350
+ let inPackages = false;
320
351
  let current = null;
352
+ const flush = () => {
353
+ if (current) {
354
+ observations.push(lockfileObservation(current));
355
+ current = null;
356
+ }
357
+ };
321
358
  for (const line of lines) {
322
- const packageMatch = /^\s{2}['"]?(?:\/)?((?:@[^/\s]+\/)?[^@\s:'"]+)@([^:\s'"]+)['"]?:\s*$/u.exec(line);
323
- if (packageMatch?.[1] && packageMatch[2]) {
324
- if (current) {
325
- observations.push(lockfileObservation(current));
326
- }
327
- current = {
328
- ecosystem: "npm",
329
- name: packageMatch[1],
330
- version: packageMatch[2],
331
- requested: `${packageMatch[1]}@${packageMatch[2]}`,
332
- sourceKind: "lockfile",
333
- resolvedUrl: null,
334
- integrity: null,
335
- license: null
336
- };
359
+ const sectionMatch = /^([A-Za-z][\w-]*):\s*$/u.exec(line);
360
+ if (sectionMatch) {
361
+ flush();
362
+ // The `snapshots:` section keys the resolved peer graph, e.g.
363
+ // `eslint-utils@4.9.1(eslint@9.39.4)` — the peer suffix is not a real
364
+ // registry version, so scanning it yields a false "removed from
365
+ // registry" verdict. `packages:` is the canonical inventory (carries
366
+ // integrity); take identity only from there.
367
+ inPackages = sectionMatch[1] === "packages";
368
+ continue;
369
+ }
370
+ if (!inPackages) {
371
+ continue;
372
+ }
373
+ const keyMatch = /^\s{2}(\S.*?):\s*$/u.exec(line);
374
+ if (keyMatch?.[1]) {
375
+ flush();
376
+ current = parsePnpmPackageKey(keyMatch[1]);
337
377
  continue;
338
378
  }
339
379
  if (!current) {
@@ -354,24 +394,69 @@ function parsePnpmLock(text) {
354
394
  };
355
395
  }
356
396
  }
357
- if (current) {
358
- observations.push(lockfileObservation(current));
359
- }
397
+ flush();
360
398
  return observations;
361
399
  }
400
+ function parsePnpmPackageKey(rawKey) {
401
+ const key = stripQuotes(rawKey.trim());
402
+ if (/(?:file|link|workspace|git\+|git:|https?):/u.test(key)) {
403
+ return null;
404
+ }
405
+ const hadSlash = key.startsWith("/");
406
+ const body = hadSlash ? key.slice(1) : key;
407
+ let name = null;
408
+ 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("/");
413
+ 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]);
423
+ }
424
+ }
425
+ if (!name || !version) {
426
+ return null;
427
+ }
428
+ return {
429
+ ecosystem: "npm",
430
+ name,
431
+ version,
432
+ requested: `${name}@${version}`,
433
+ sourceKind: "lockfile",
434
+ resolvedUrl: null,
435
+ integrity: null,
436
+ license: null
437
+ };
438
+ }
439
+ function stripPnpmPeerSuffix(version) {
440
+ const peerStart = version.search(/[(_]/u);
441
+ return peerStart === -1 ? version : version.slice(0, peerStart);
442
+ }
362
443
  function parseRequirements(text) {
363
- return text.split(/\r?\n/u)
364
- .map((line) => line.trim())
365
- .filter((line) => line.length > 0 && !line.startsWith("#"))
366
- .map((line) => {
367
- if (REMOTE_SPEC_PREFIXES.some((prefix) => line.toLowerCase().startsWith(prefix))) {
368
- return packageObservation({
444
+ const observations = [];
445
+ for (const logical of joinRequirementContinuations(text.split(/\r?\n/u))) {
446
+ const line = logical.trim();
447
+ if (line.length === 0 || line.startsWith("#")) {
448
+ continue;
449
+ }
450
+ const editable = /^(?:-e|--editable)[=\s]+(.+)$/u.exec(line);
451
+ const target = (editable?.[1] ?? line).trim();
452
+ if (REMOTE_SPEC_PREFIXES.some((prefix) => target.toLowerCase().startsWith(prefix))) {
453
+ observations.push(packageObservation({
369
454
  ecosystem: "pypi",
370
- name: line,
455
+ name: target,
371
456
  version: null,
372
457
  requested: line,
373
458
  sourceKind: "lockfile-url-fallback",
374
- resolvedUrl: line,
459
+ resolvedUrl: target,
375
460
  integrity: null,
376
461
  license: null
377
462
  }, "block", {
@@ -379,15 +464,20 @@ function parseRequirements(text) {
379
464
  title: "Lockfile URL fallback identity",
380
465
  message: "lockfile entry uses a URL without package identity or hash metadata",
381
466
  location: line
382
- });
467
+ }));
468
+ continue;
383
469
  }
384
- const hash = /--hash=([A-Za-z0-9:-]+)/u.exec(line)?.[1] ?? null;
385
- const cleanLine = line.replace(/\s+--hash=[^\s]+/gu, "");
386
- const match = /^([A-Za-z0-9_.-]+)(?:==([^;\s]+))?/u.exec(cleanLine);
470
+ if (line.startsWith("-") || /^\.{0,2}\//u.test(target)) {
471
+ continue;
472
+ }
473
+ const hash = /--hash=([A-Za-z0-9:_-]+)/u.exec(line)?.[1] ?? null;
474
+ const requirement = line.replace(/\s*--hash=[^\s]+/gu, "").trim();
475
+ const match = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?(?:\s*==\s*([^;\s]+))?/u.exec(requirement);
387
476
  if (!match?.[1]) {
388
- return blockedUnknownSpec(line);
477
+ observations.push(blockedUnknownSpec(line));
478
+ continue;
389
479
  }
390
- return lockfileObservation({
480
+ observations.push(lockfileObservation({
391
481
  ecosystem: "pypi",
392
482
  name: match[1],
393
483
  version: match[2] ?? null,
@@ -396,8 +486,25 @@ function parseRequirements(text) {
396
486
  resolvedUrl: null,
397
487
  integrity: hash,
398
488
  license: null
399
- });
400
- });
489
+ }));
490
+ }
491
+ return observations;
492
+ }
493
+ function joinRequirementContinuations(lines) {
494
+ const joined = [];
495
+ let buffer = "";
496
+ for (const line of lines) {
497
+ if (line.endsWith("\\")) {
498
+ buffer += `${line.slice(0, -1)} `;
499
+ continue;
500
+ }
501
+ joined.push(buffer + line);
502
+ buffer = "";
503
+ }
504
+ if (buffer.length > 0) {
505
+ joined.push(buffer);
506
+ }
507
+ return joined;
401
508
  }
402
509
  function parseCargoLock(text) {
403
510
  return lockBlocks(text, "[[package]]").map((block) => {
@@ -426,7 +533,7 @@ function parsePoetryLock(text) {
426
533
  requested: `${name}==${version ?? "unknown"}`,
427
534
  sourceKind: "lockfile",
428
535
  resolvedUrl: null,
429
- integrity: null,
536
+ integrity: /hash\s*=\s*"([^"]+)"/u.exec(block)?.[1] ?? null,
430
537
  license: tomlString(block, "license")
431
538
  });
432
539
  }).filter((observation) => observation.identity.name !== "unknown");
@@ -588,7 +695,8 @@ function isExactVersion(version) {
588
695
  }
589
696
  function isSupportedIntegrity(value) {
590
697
  return /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/=]+$/u.test(value)
591
- || /^(?:sha256:)?[a-f0-9]{64}$/iu.test(value);
698
+ || /^(?:sha256:)?[a-f0-9]{64}$/iu.test(value)
699
+ || /^[0-9a-f]+\/[0-9a-f]{64,}$/iu.test(value);
592
700
  }
593
701
  function isUnsafeResolvedUrl(value) {
594
702
  const lower = value.toLowerCase();
@@ -657,13 +765,18 @@ function packageNameFromNodeModulesPath(path) {
657
765
  return first;
658
766
  }
659
767
  function packageNameFromYarnDescriptor(descriptor) {
660
- const value = descriptor.startsWith("@") ? descriptor.slice(1) : descriptor;
661
- const index = value.lastIndexOf("@");
662
- const name = descriptor.startsWith("@") ? `@${value.slice(0, index)}` : value.slice(0, index);
663
- return name || null;
768
+ const scoped = descriptor.startsWith("@");
769
+ const body = scoped ? descriptor.slice(1) : descriptor;
770
+ const at = body.indexOf("@");
771
+ const name = at === -1 ? descriptor : scoped ? `@${body.slice(0, at)}` : body.slice(0, at);
772
+ const spec = at === -1 ? "" : body.slice(at + 1);
773
+ // npm: alias descriptors (`alias@npm:real-pkg@range`) resolve to the alias
774
+ // target — that is the registry artifact actually fetched and scanned.
775
+ const alias = /^npm:((?:@[^/\s]+\/)?[^@\s]+)@/u.exec(spec);
776
+ return alias?.[1] ?? (name || null);
664
777
  }
665
778
  function quotedValue(lines, key) {
666
- const pattern = new RegExp(`^\\s*${key}\\s+\"?([^\"\\n]+)\"?\\s*$`, "u");
779
+ const pattern = new RegExp(`^\\s*${key}:?\\s+"?([^"\\n]+?)"?\\s*$`, "u");
667
780
  for (const line of lines) {
668
781
  const match = pattern.exec(line);
669
782
  if (match?.[1]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@westbayberry/dg",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Dependency Guardian supply-chain firewall CLI",
5
5
  "type": "module",
6
6
  "bin": {