DEV Community

Cover image for 🚨 npm Vulnerabilities Are Growing β€” A Practical Defense Using OSV.dev in React Native
Amit Kumar
Amit Kumar

Posted on

🚨 npm Vulnerabilities Are Growing β€” A Practical Defense Using OSV.dev in React Native

As we know, npm packages are getting hacked day by day.

From malicious package injections to supply chain attacks, dependency management is no longer just about keeping things updated β€” it’s about security, visibility, and control.

So I built a Smart Dependency Report System that gives complete visibility into your dependencies.


🧩 The Real Problem Isn’t Dependencies β€” It’s Visibility

Most developers rely on:

  • npm outdated
  • npm audit

But let’s be honestβ€”these tools are:

  • ❌ Noisy
  • ❌ Hard to prioritize
  • ❌ Not decision-friendly

They tell you everything, but help you decide nothing.

You don’t need a list.
You need clarity.


🧠 What This System Does

  • Classifies updates (Major / Minor / Patch)
  • Detects vulnerabilities via OSV.dev
  • Fetches npm metadata (size, publish date)
  • Handles git/file/workspace dependencies
  • Generates CLI + PDF reports
  • Provides actionable recommendations

πŸ“¦ Installation & Setup

To get started with the Smart Dependency Report System, install the required dependencies:


yarn add --dev chalk cli-table3 semver pdfkit

Enter fullscreen mode Exit fullscreen mode

Note:

Please update this path based on the location where you’ve placed the dependency-report folder.

/** Repo root (directory that contains package.json). */
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');

Enter fullscreen mode Exit fullscreen mode

πŸ“ Project Structure

src/
└── config/
    └── scripts/
        └── dependency-report/
            β”œβ”€β”€ analyzeVersions.js
            β”œβ”€β”€ badges.js
            β”œβ”€β”€ constants.js
            β”œβ”€β”€ generate-report.js
            β”œβ”€β”€ npmAuditSecurity.js
            β”œβ”€β”€ npmRegistry.js
            β”œβ”€β”€ osvSecurity.js
            β”œβ”€β”€ readPackages.js
            β”œβ”€β”€ reportConsole.js
            β”œβ”€β”€ reportPdf.js
            β”œβ”€β”€ reportSummary.js
            └── runReport.js
Enter fullscreen mode Exit fullscreen mode

βš™οΈ Code Walkthrough (File by File)


πŸ“„ generate-report.js (Entry Point)

#!/usr/bin/env node
/**
 * React Native dependency report: compares installed versions to npm "latest",
 * prints a CLI table, and writes report/dependency-report.pdf under the project root.
 *
 * Usage:
 *   npm run report:deps
 *   node src/config/scripts/dependency-report/generate-report.js
 *   node src/config/scripts/dependency-report/generate-report.js --major-only
 *   node src/config/scripts/dependency-report/generate-report.js --sort=update-type
 *
 * Security: vulnerabilities come from OSV.dev (https://api.osv.dev) using package-lock.json
 * when present, otherwise installed versions from node_modules for direct dependencies.
 *
 * Optional env:
 *   DEPENDENCY_REPORT_OSV_API=https://api.osv.dev   (override OSV API base URL)
 *   DEPENDENCY_REPORT_OSV_TIMEOUT_MS=120000       (HTTP timeout for OSV calls)
 *   REPORT_SKIP_AUDIT=1                           (skip OSV; Secure column shows unknown)
 *   DEPENDENCY_REPORT_SECURE_ICON_URL=...        (PDF secure-cell image, optional)
 */

const path = require('path');
const { run } = require('./runReport');

/** Repo root (directory that contains package.json). */
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');

function printHelp() {
  console.log(`
React Native Dependency Report

Usage:
  npm run report:deps
  node src/config/scripts/dependency-report/generate-report.js [options]

Options:
  --major-only          Only include packages with a semver MAJOR update available
  --sort=update-type    Sort by update severity (Major β†’ Minor β†’ Patch β†’ …), then name
  --sort=name           Sort alphabetically by package name (default)
  --help, -h            Show this help

Outputs:
  - Table printed to stdout
  - report/dependency-report.pdf (project root)

Environment:
  DEPENDENCY_REPORT_OSV_API           OSV API base (default: https://api.osv.dev)
  DEPENDENCY_REPORT_OSV_TIMEOUT_MS    Timeout ms for OSV HTTP requests (default: 120000)
  REPORT_SKIP_AUDIT                   Set to 1 to skip OSV and mark Secure as unknown
  DEPENDENCY_REPORT_SECURE_ICON_URL   Optional HTTPS URL to PNG/JPEG for the PDF secure cell
`);
}

/**
 * @param {string[]} argv
 */
function parseArgs(argv) {
  const opts = {
    majorOnly: false,
    sort: /** @type {'name' | 'update-type'} */ ('name'),
  };

  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg === '--help' || arg === '-h') {
      return { ...opts, help: true };
    }
    if (arg === '--major-only') {
      opts.majorOnly = true;
      continue;
    }
    if (arg.startsWith('--sort=')) {
      const v = arg.slice('--sort='.length).toLowerCase();
      if (v === 'update-type' || v === 'type') {
        opts.sort = 'update-type';
      } else if (v === 'name' || v === 'package') {
        opts.sort = 'name';
      } else {
        console.warn(`Unknown --sort value "${v}", using name.`);
        opts.sort = 'name';
      }
      continue;
    }
    console.warn(`Ignoring unknown argument: ${arg}`);
  }

  return { ...opts, help: false };
}

async function main() {
  const parsed = parseArgs(process.argv);
  if (parsed.help) {
    printHelp();
    process.exit(0);
  }

  try {
    const { pdfPath } = await run(PROJECT_ROOT, {
      majorOnly: parsed.majorOnly,
      sort: parsed.sort,
    });
    console.log(`\nPDF written to: ${pdfPath}`);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    console.error('Dependency report failed:', message);
    process.exitCode = 1;
  }
}

main();


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ This is the CLI entry point that triggers everything.


πŸ“„ runReport.js (Core Orchestrator)



/**
 * Orchestrates reading package.json, querying npm, sorting/filtering, and rendering outputs.
 */

const fs = require('fs');
const path = require('path');
const {
  collectDeclaredPackages,
  readInstalledVersion,
} = require('./readPackages');
const { fetchPackageMeta } = require('./npmRegistry');
const {
  classifyUpdateType,
  describeNonRegistryRow,
} = require('./analyzeVersions');
const { UPDATE_TYPE, UPDATE_TYPE_SORT_ORDER } = require('./constants');
const { attachSecurityFromOsv } = require('./osvSecurity');
const { printConsoleTable, printConsoleSummary } = require('./reportConsole');
const { writePdfReport } = require('./reportPdf');

/**
 * @typedef {object} ReportRow
 * @property {string} name
 * @property {'dependencies'|'devDependencies'} kind
 * @property {string} wantedRange
 * @property {string | null} installedVersion
 * @property {string | null} latestVersion
 * @property {string | null} lastPublished
 * @property {number | null} unpackedSizeBytes
 * @property {string} updateType
 * @property {string | null} [registryError]
 * @property {'none'|'info'|'low'|'moderate'|'high'|'critical'|'unknown'} [securityLevel]
 * @property {number} [securityCount]
 * @property {string} [securityTooltip]
 */

/**
 * Run async work in chunks to limit concurrent registry calls.
 * @template T, R
 * @param {T[]} items
 * @param {number} limit
 * @param {(item: T, index: number) => Promise<R>} fn
 * @returns {Promise<R[]>}
 */
async function mapPool(items, limit, fn) {
  const results = new Array(items.length);
  let i = 0;
  const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
    while (i < items.length) {
      const idx = i++;
      results[idx] = await fn(items[idx], idx);
    }
  });
  await Promise.all(workers);
  return results;
}

/**
 * @param {string} projectRoot
 * @param {{ majorOnly?: boolean, sort?: 'name' | 'update-type', pdfPath?: string, concurrency?: number }} options
 * @returns {Promise<{ rows: ReportRow[], pdfPath: string }>}
 */
async function run(projectRoot, options = {}) {
  const {
    majorOnly = false,
    sort = 'name',
    pdfPath = path.join(projectRoot, 'report', 'dependency-report.pdf'),
    concurrency = 12,
  } = options;

  const declared = collectDeclaredPackages(projectRoot);

  /** @type {ReportRow[]} */
  const rows = await mapPool(declared, concurrency, async (entry) => {
    const installed = readInstalledVersion(projectRoot, entry.name);
    const { skipRegistry, note } = describeNonRegistryRow(entry.wantedRange);

    if (skipRegistry) {
      return {
        name: entry.name,
        kind: entry.kind,
        wantedRange: entry.wantedRange,
        installedVersion: installed,
        latestVersion: null,
        lastPublished: null,
        unpackedSizeBytes: null,
        updateType: UPDATE_TYPE.UNKNOWN,
        registryError: note,
      };
    }

    const meta = await fetchPackageMeta(entry.name);
    const latest = meta.latestVersion;
    let updateType = UPDATE_TYPE.UNKNOWN;

    if (latest) {
      updateType = classifyUpdateType(installed, latest);
    } else if (meta.error) {
      updateType = UPDATE_TYPE.UNKNOWN;
    }

    return {
      name: entry.name,
      kind: entry.kind,
      wantedRange: entry.wantedRange,
      installedVersion: installed,
      latestVersion: latest,
      lastPublished: meta.lastPublished,
      unpackedSizeBytes: meta.unpackedSizeBytes,
      updateType,
      registryError: meta.error,
    };
  });

  if (process.env.REPORT_SKIP_AUDIT === '1') {
    for (const row of rows) {
      row.securityLevel = 'unknown';
      row.securityCount = 0;
      row.securityTooltip = 'Skipped OSV (REPORT_SKIP_AUDIT=1)';
    }
  } else {
    await attachSecurityFromOsv(projectRoot, rows);
  }

  let filtered = rows;
  if (majorOnly) {
    filtered = rows.filter((r) => r.updateType === UPDATE_TYPE.MAJOR);
  }

  const sorted = [...filtered].sort((a, b) => {
    if (sort === 'update-type') {
      const oa =
        UPDATE_TYPE_SORT_ORDER[a.updateType] ?? UPDATE_TYPE_SORT_ORDER[UPDATE_TYPE.UNKNOWN];
      const ob =
        UPDATE_TYPE_SORT_ORDER[b.updateType] ?? UPDATE_TYPE_SORT_ORDER[UPDATE_TYPE.UNKNOWN];
      if (oa !== ob) {
        return oa - ob;
      }
    }
    return a.name.localeCompare(b.name, 'en');
  });

  printConsoleTable(sorted);
  printConsoleSummary(sorted);

  const flagged = sorted.filter((r) => r.registryError);
  if (flagged.length > 0) {
    console.warn(
      `\nNote: ${flagged.length} package(s) have registry/spec limitations (git/file/link or fetch errors).`,
    );
  }

  fs.mkdirSync(path.dirname(pdfPath), { recursive: true });
  await writePdfReport(pdfPath, sorted);

  return { rows: sorted, pdfPath };
}

module.exports = { run, mapPool };


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ This file:

  • Reads dependencies
  • Fetches registry data
  • Classifies updates
  • Attaches security
  • Generates output

πŸ“„ readPackages.js (Dependency Reader)



/**
 * Load dependencies from package.json and resolve installed versions from node_modules.
 */

const fs = require('fs');
const path = require('path');

/**
 * @param {string} projectRoot
 * @returns {{ name: string, wantedRange: string, kind: 'dependencies' | 'devDependencies' }[]}
 */
function collectDeclaredPackages(projectRoot) {
  const pkgPath = path.join(projectRoot, 'package.json');
  const raw = fs.readFileSync(pkgPath, 'utf8');
  const pkg = JSON.parse(raw);

  const out = [];

  const deps = pkg.dependencies || {};
  for (const [name, wantedRange] of Object.entries(deps)) {
    out.push({
      name,
      wantedRange: String(wantedRange),
      kind: 'dependencies',
    });
  }

  const devDeps = pkg.devDependencies || {};
  for (const [name, wantedRange] of Object.entries(devDeps)) {
    out.push({
      name,
      wantedRange: String(wantedRange),
      kind: 'devDependencies',
    });
  }

  return out;
}

/**
 * Read version from node_modules/<name>/package.json (supports scoped names).
 * @param {string} projectRoot
 * @param {string} packageName
 * @returns {string | null}
 */
function readInstalledVersion(projectRoot, packageName) {
  const parts = packageName.startsWith('@')
    ? packageName.split('/')
    : [packageName];
  const rel =
    parts.length === 2
      ? path.join('node_modules', parts[0], parts[1])
      : path.join('node_modules', parts[0]);
  const installedPkgPath = path.join(projectRoot, rel, 'package.json');

  try {
    const data = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8'));
    return typeof data.version === 'string' ? data.version : null;
  } catch {
    return null;
  }
}

/**
 * Heuristic: npm registry cannot resolve arbitrary git/file/tar URLs as "latest" the same way.
 * @param {string} range
 */
function isNonRegistrySpec(range) {
  return (
    /^(file:|git\+|git:|github:|https?:\/\/|link:|workspace:|workspace:\*)/i.test(
      range.trim(),
    ) || range.includes('/')
  );
}

module.exports = {
  collectDeclaredPackages,
  readInstalledVersion,
  isNonRegistrySpec,
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Extracts both declared and installed versions.


πŸ“„ npmRegistry.js (Registry Fetcher)



/**
 * Fetch package metadata from the public npm registry (using axios).
 */

const axios = require('axios');

const REGISTRY_BASE = 'https://registry.npmjs.org';
const REQUEST_TIMEOUT_MS = 25_000;

/**
 * @typedef {object} RegistryMeta
 * @property {string | null} latestVersion
 * @property {string | null} lastPublished
 * @property {number | null} unpackedSizeBytes
 * @property {string | null} error
 */

/**
 * @param {string} packageName
 * @returns {Promise<RegistryMeta>}
 */
async function fetchPackageMeta(packageName) {
  // Scoped names: encode @ and / per npm registry (e.g. @babel/core -> %40babel%2Fcore)
  const url = `${REGISTRY_BASE}/${encodeURIComponent(packageName)}`;

  try {
    const { data } = await axios.get(url, {
      timeout: REQUEST_TIMEOUT_MS,
      headers: { Accept: 'application/json' },
      // Full document is needed for versions[ver].dist and time
      validateStatus: (s) => s === 200 || s === 404,
    });

    if (data == null || data.error || !data['dist-tags']) {
      return {
        latestVersion: null,
        lastPublished: null,
        unpackedSizeBytes: null,
        error: data?.error || 'Not found or invalid registry response',
      };
    }

    const latest = data['dist-tags']?.latest;
    if (!latest || typeof latest !== 'string') {
      return {
        latestVersion: null,
        lastPublished: null,
        unpackedSizeBytes: null,
        error: 'No dist-tags.latest',
      };
    }

    const timeMap = data.time || {};
    const lastPublished =
      typeof timeMap[latest] === 'string' ? timeMap[latest] : null;

    const verInfo = data.versions?.[latest];
    const unpacked =
      verInfo?.dist && typeof verInfo.dist.unpackedSize === 'number'
        ? verInfo.dist.unpackedSize
        : null;

    return {
      latestVersion: latest,
      lastPublished,
      unpackedSizeBytes: unpacked,
      error: null,
    };
  } catch (err) {
    const message =
      err.response?.status === 404
        ? 'Package not found on registry'
        : err.message || String(err);
    return {
      latestVersion: null,
      lastPublished: null,
      unpackedSizeBytes: null,
      error: message,
    };
  }
}

module.exports = { fetchPackageMeta, REGISTRY_BASE };


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Fetches:

  • Latest version
  • Publish date
  • Package size

πŸ“„ analyzeVersions.js (Version Intelligence)



/**
 * Compare installed vs latest using semver and classify update type.
 */

const semver = require('semver');
const { UPDATE_TYPE } = require('./constants');
const { isNonRegistrySpec } = require('./readPackages');

/**
 * @param {string | null} installed
 * @param {string | null} latest
 * @returns {typeof UPDATE_TYPE[keyof typeof UPDATE_TYPE]}
 */
function classifyUpdateType(installed, latest) {
  if (!latest) {
    return UPDATE_TYPE.UNKNOWN;
  }
  if (!installed || !semver.valid(installed)) {
    return UPDATE_TYPE.UNKNOWN;
  }
  if (!semver.valid(latest)) {
    return UPDATE_TYPE.UNKNOWN;
  }

  if (semver.eq(installed, latest)) {
    return UPDATE_TYPE.UP_TO_DATE;
  }

  // If "latest" is not actually newer (e.g. user on newer prerelease), treat as unknown edge case
  if (!semver.gt(latest, installed)) {
    const pre = semver.prerelease(installed);
    if (pre && semver.eq(semver.coerce(installed), semver.coerce(latest))) {
      return UPDATE_TYPE.PRERELEASE;
    }
    return UPDATE_TYPE.UNKNOWN;
  }

  const diff = semver.diff(installed, latest);
  if (diff === 'major') {
    return UPDATE_TYPE.MAJOR;
  }
  if (diff === 'minor') {
    return UPDATE_TYPE.MINOR;
  }
  if (diff === 'patch') {
    return UPDATE_TYPE.PATCH;
  }
  if (diff === 'prerelease') {
    return UPDATE_TYPE.PRERELEASE;
  }
  return UPDATE_TYPE.UNKNOWN;
}

function describeNonRegistryRow(wantedRange) {
  if (isNonRegistrySpec(wantedRange)) {
    return {
      skipRegistry: true,
      note: 'Non-registry spec (git/file/link/workspace)',
    };
  }
  return { skipRegistry: false, note: null };
}

module.exports = {
  classifyUpdateType,
  describeNonRegistryRow,
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Converts raw versions into actionable insights.


πŸ“„ osvSecurity.js (Security Engine)



/**
 * Map installed npm packages to security status using the OSV.dev API (Open Source Vulnerabilities).
 * @see https://google.github.io/osv.dev/api/
 */

const fs = require('fs');
const path = require('path');

const OSV_DEFAULT_BASE = 'https://api.osv.dev';
const OSV_ECOSYSTEM = 'npm';
const BATCH_SIZE = 150;
const VULN_FETCH_CONCURRENCY = 12;
const REQUEST_TIMEOUT_MS =
  Number(process.env.DEPENDENCY_REPORT_OSV_TIMEOUT_MS) || 120000;

/** @typedef {'none' | 'info' | 'low' | 'moderate' | 'high' | 'critical' | 'unknown'} SecurityLevel */

/**
 * @param {SecurityLevel} a
 * @param {SecurityLevel} b
 * @returns {SecurityLevel}
 */
function maxSeverity(a, b) {
  const rank = { none: 0, info: 1, low: 2, moderate: 3, high: 4, critical: 5, unknown: 0 };
  const ra = rank[a] ?? 0;
  const rb = rank[b] ?? 0;
  return ra >= rb ? a : b;
}

/**
 * @param {string} v
 */
function shouldSkipVersion(v) {
  if (!v || typeof v !== 'string') {
    return true;
  }
  const t = v.trim();
  if (!t || t === 'null' || t === 'undefined') {
    return true;
  }
  return /^(file:|git\+|git:|github:|link:|workspace:|\*)/i.test(t) || /^https?:\/\//i.test(t);
}

/**
 * Derive package name from a package-lock `packages` path key (lockfile v2+).
 * @param {string} key
 */
function nameFromLockPackagesKey(key) {
  const k = key.replace(/\\/g, '/');
  const i = k.lastIndexOf('node_modules/');
  if (i === -1) {
    return null;
  }
  return k.slice(i + 'node_modules/'.length) || null;
}

/**
 * @param {string} projectRoot
 * @returns {{ name: string, version: string }[] | null}
 */
function readPackageLockPairs(projectRoot) {
  const lockPath = path.join(projectRoot, 'package-lock.json');
  if (!fs.existsSync(lockPath)) {
    return null;
  }
  let lock;
  try {
    lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
  } catch {
    return null;
  }

  /** @type {{ name: string, version: string }[]} */
  const out = [];
  const seen = new Set();

  function add(name, version) {
    if (!name || shouldSkipVersion(version)) {
      return;
    }
    const k = `${name}@${version}`;
    if (seen.has(k)) {
      return;
    }
    seen.add(k);
    out.push({ name, version: version.trim() });
  }

  if (lock.packages && typeof lock.packages === 'object') {
    for (const [pkgPath, meta] of Object.entries(lock.packages)) {
      if (!meta || typeof meta !== 'object' || !meta.version) {
        continue;
      }
      const name = typeof meta.name === 'string' ? meta.name : nameFromLockPackagesKey(pkgPath);
      add(name, meta.version);
    }
  } else if (lock.dependencies && typeof lock.dependencies === 'object') {
    const walk = (deps) => {
      if (!deps || typeof deps !== 'object') {
        return;
      }
      for (const [name, node] of Object.entries(deps)) {
        if (node && typeof node === 'object' && typeof node.version === 'string') {
          add(name, node.version);
        }
        if (node && node.dependencies) {
          walk(node.dependencies);
        }
      }
    };
    walk(lock.dependencies);
  }

  return out.length ? out : null;
}

/**
 * @param {Array<{ name: string, installedVersion?: string | null }>} rows
 */
function pairsFromRows(rows) {
  const seen = new Set();
  /** @type {{ name: string, version: string }[]} */
  const out = [];
  for (const r of rows) {
    const v = r.installedVersion;
    if (!v || shouldSkipVersion(v)) {
      continue;
    }
    const k = `${r.name}@${v}`;
    if (seen.has(k)) {
      continue;
    }
    seen.add(k);
    out.push({ name: r.name, version: v.trim() });
  }
  return out;
}

/**
 * @param {object} vuln
 * @returns {SecurityLevel}
 */
function severityFromVulnRecord(vuln) {
  if (!vuln || typeof vuln !== 'object') {
    return 'moderate';
  }

  let best = 0;
  const rank = { info: 1, low: 2, moderate: 3, high: 4, critical: 5 };

  /** @param {string} raw */
  const bumpFromLabel = (raw) => {
    if (!raw || typeof raw !== 'string') {
      return;
    }
    const s = raw.trim().toLowerCase();
    let r = 0;
    if (s === 'critical') {
      r = 5;
    } else if (s === 'high') {
      r = 4;
    } else if (s === 'moderate' || s === 'medium') {
      r = 3;
    } else if (s === 'low') {
      r = 2;
    } else if (s === 'info' || s === 'informational') {
      r = 1;
    }
    if (r > best) {
      best = r;
    }
  };

  const dsTop = vuln.database_specific;
  if (dsTop && typeof dsTop === 'object' && dsTop.severity) {
    bumpFromLabel(String(dsTop.severity));
  }

  if (Array.isArray(vuln.severity)) {
    for (const entry of vuln.severity) {
      if (!entry || typeof entry !== 'object') {
        continue;
      }
      const scoreStr = entry.score;
      if (typeof scoreStr === 'string') {
        const n = parseFloat(scoreStr);
        if (!Number.isNaN(n)) {
          if (n >= 9.0) {
            best = Math.max(best, 5);
          } else if (n >= 7.0) {
            best = Math.max(best, 4);
          } else if (n >= 4.0) {
            best = Math.max(best, 3);
          } else if (n > 0) {
            best = Math.max(best, 2);
          }
        }
      }
    }
  }

  for (const aff of vuln.affected || []) {
    if (!aff || typeof aff !== 'object') {
      continue;
    }
    const ds = aff.database_specific;
    const es = aff.ecosystem_specific;
    if (ds && typeof ds === 'object' && ds.severity) {
      bumpFromLabel(String(ds.severity));
    }
    if (es && typeof es === 'object' && es.severity) {
      bumpFromLabel(String(es.severity));
    }
  }

  if (best === 5) {
    return 'critical';
  }
  if (best === 4) {
    return 'high';
  }
  if (best === 3) {
    return 'moderate';
  }
  if (best === 2) {
    return 'low';
  }
  if (best === 1) {
    return 'info';
  }
  return 'moderate';
}

/**
 * @param {string} baseUrl
 * @param {unknown} body
 */
async function osvPostJson(baseUrl, pathname, body) {
  const url = `${baseUrl.replace(/\/$/, '')}${pathname}`;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        'User-Agent': 'dependency-report/1.0 (+https://osv.dev)',
      },
      body: JSON.stringify(body),
      signal: ac.signal,
    });
    clearTimeout(timer);
    if (!res.ok) {
      return null;
    }
    return await res.json();
  } catch {
    clearTimeout(timer);
    return null;
  }
}

/**
 * @param {string} baseUrl
 * @param {string} id
 */
async function osvGetVuln(baseUrl, id) {
  const url = `${baseUrl.replace(/\/$/, '')}/v1/vulns/${encodeURIComponent(id)}`;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), Math.min(REQUEST_TIMEOUT_MS, 60000));
  try {
    const res = await fetch(url, {
      method: 'GET',
      headers: {
        Accept: 'application/json',
        'User-Agent': 'dependency-report/1.0 (+https://osv.dev)',
      },
      signal: ac.signal,
    });
    clearTimeout(timer);
    if (!res.ok) {
      return null;
    }
    return await res.json();
  } catch {
    clearTimeout(timer);
    return null;
  }
}

/**
 * @param {{ name: string, version: string }[]} queries
 * @param {string} baseUrl
 * @returns {Promise<{ ok: boolean, idLists: string[][] }>}
 */
async function runQueryBatches(queries, baseUrl) {
  /** @type {string[][]} */
  const allResults = [];

  for (let i = 0; i < queries.length; i += BATCH_SIZE) {
    const chunk = queries.slice(i, i + BATCH_SIZE);
    const payload = {
      queries: chunk.map((q) => ({
        package: { ecosystem: OSV_ECOSYSTEM, name: q.name },
        version: q.version,
      })),
    };
    const json = await osvPostJson(baseUrl, '/v1/querybatch', payload);
    if (!json || !Array.isArray(json.results) || json.results.length !== chunk.length) {
      return { ok: false, idLists: [] };
    }
    for (let j = 0; j < chunk.length; j++) {
      const r = json.results[j];
      const vulns = Array.isArray(r?.vulns) ? r.vulns : [];
      const ids = vulns.map((v) => (v && v.id ? String(v.id) : '')).filter(Boolean);
      allResults.push(ids);
    }
  }

  return { ok: true, idLists: allResults };
}

/**
 * @param {string[]} ids
 * @param {string} baseUrl
 * @returns {Promise<Map<string, object>>}
 */
async function fetchVulnDetails(ids, baseUrl) {
  const unique = [...new Set(ids)];
  const map = new Map();
  if (unique.length === 0) {
    return map;
  }

  let next = 0;
  async function worker() {
    while (true) {
      const j = next++;
      if (j >= unique.length) {
        break;
      }
      const id = unique[j];
      const detail = await osvGetVuln(baseUrl, id);
      if (detail) {
        map.set(id, detail);
      }
    }
  }

  const nWorkers = Math.min(VULN_FETCH_CONCURRENCY, unique.length);
  await Promise.all(Array.from({ length: nWorkers }, () => worker()));
  return map;
}

/**
 * @param {{ name: string, version: string }[]} queries
 * @param {string[][]} idLists
 * @param {Map<string, object>} details
 * @returns {Map<string, { level: SecurityLevel, count: number }>}
 */
function aggregateByPackageName(queries, idLists, details) {
  /** @type {Map<string, Set<string>>} */
  const idsByName = new Map();

  for (let i = 0; i < queries.length; i++) {
    const { name } = queries[i];
    const ids = idLists[i] || [];
    for (const id of ids) {
      if (!idsByName.has(name)) {
        idsByName.set(name, new Set());
      }
      idsByName.get(name).add(id);
    }
  }

  /** @type {Map<string, { level: SecurityLevel, count: number }>} */
  const out = new Map();

  for (const [name, idSet] of idsByName) {
    let level = /** @type {SecurityLevel} */ ('moderate');
    for (const id of idSet) {
      const vuln = details.get(id);
      const sev = vuln ? severityFromVulnRecord(vuln) : 'moderate';
      level = maxSeverity(level, sev);
    }
    out.set(name, { level, count: idSet.size });
  }

  return out;
}

/**
 * @param {string} projectRoot
 * @param {Array<{ name: string, installedVersion?: string | null }>} rows
 * @returns {Promise<Map<string, { level: SecurityLevel, count: number }> | null>}
 */
async function fetchOsvSeverityMap(projectRoot, rows) {
  const baseUrl = String(process.env.DEPENDENCY_REPORT_OSV_API || OSV_DEFAULT_BASE).trim() || OSV_DEFAULT_BASE;

  const fromLock = readPackageLockPairs(projectRoot);
  const queries = fromLock && fromLock.length ? fromLock : pairsFromRows(rows);

  if (!queries.length) {
    return null;
  }

  const { ok, idLists } = await runQueryBatches(queries, baseUrl);
  if (!ok || idLists.length !== queries.length) {
    return null;
  }

  const allIds = idLists.flat();
  const details = await fetchVulnDetails(allIds, baseUrl);

  return aggregateByPackageName(queries, idLists, details);
}

/**
 * @param {SecurityLevel} level
 * @param {number} count
 */
function securityTooltip(level, count) {
  if (level === 'unknown') {
    return 'Security check unavailable (OSV request failed or invalid response)';
  }
  if (level === 'none') {
    return 'No known issues in OSV for queried versions';
  }
  const sev =
    level === 'critical'
      ? 'Critical'
      : level === 'high'
        ? 'High'
        : level === 'moderate'
          ? 'Moderate'
          : level === 'low'
            ? 'Low'
            : 'Info';
  return `${count} OSV record(s) β€” highest severity: ${sev}`;
}

/**
 * @param {string} projectRoot
 * @param {Array<{ name: string, installedVersion?: string | null }>} rows
 */
async function attachSecurityFromOsv(projectRoot, rows) {
  const fromLock = readPackageLockPairs(projectRoot);
  const queries = fromLock && fromLock.length ? fromLock : pairsFromRows(rows);

  if (!queries.length) {
    const msg =
      'Security check skipped: add package-lock.json or install deps so versions are known for OSV';
    for (const row of rows) {
      row.securityLevel = 'unknown';
      row.securityCount = 0;
      row.securityTooltip = msg;
    }
    return;
  }

  let byPkg;
  try {
    byPkg = await fetchOsvSeverityMap(projectRoot, rows);
  } catch {
    byPkg = null;
  }

  if (byPkg == null) {
    for (const row of rows) {
      row.securityLevel = 'unknown';
      row.securityCount = 0;
      row.securityTooltip = securityTooltip('unknown', 0);
    }
    return;
  }

  for (const row of rows) {
    const hit = byPkg.get(row.name);
    if (hit) {
      row.securityLevel = hit.level;
      row.securityCount = hit.count;
    } else {
      row.securityLevel = 'none';
      row.securityCount = 0;
    }
    row.securityTooltip = securityTooltip(row.securityLevel, row.securityCount);
  }
}

module.exports = {
  attachSecurityFromOsv,
  fetchOsvSeverityMap,
  securityTooltip,
  readPackageLockPairs,
  pairsFromRows,
  severityFromVulnRecord,
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Uses OSV.dev to:

  • Detect vulnerabilities
  • Assign severity levels

πŸ“„ reportConsole.js (CLI Output)



/**
 * CLI table: dark terminal + white text; Installed / Latest / Update use centered
 * calm pill tags (light fills + darker text; ANSI bg only on the label).
 */

// Enable ANSI colors when supported (some IDEs need this before chalk loads).
if (!process.env.NO_COLOR) {
  process.env.FORCE_COLOR = process.env.FORCE_COLOR || '3';
}

const chalk = require('chalk');
const Table = require('cli-table3');
const {
  UPDATE_TYPE,
  formatUpdateColumnLabel,
  PDF_TABLE,
  hexToRgb,
} = require('./constants');
const {
  summarizeDependencyRows,
  buildStructuredSummary,
  capitalizeSeverity,
} = require('./reportSummary');

/** Default body cells: bright white on terminal background. */
function cellPlain(text) {
  return chalk.white(String(text));
}

/** Table header: bold white (borders stay visible on dark terminals). */
function cellHeader(text) {
  return chalk.white.bold(String(text));
}

/**
 * @param {{ bg: string, fg: string }} style
 * @param {string} text
 */
function cellPill(style, text) {
  const [r, g, b] = hexToRgb(style.bg);
  const pad = ' ';
  return chalk.bgRgb(r, g, b).hex(style.fg).bold(`${pad}${text}${pad}`);
}

/** @param {string | null | undefined} version */
function cellInstalledVersion(version) {
  const t = version ?? 'β€”';
  if (t === 'β€”') {
    return chalk.gray('β€”');
  }
  return cellPill(PDF_TABLE.installedPill, t);
}

/** @param {string | null | undefined} version */
function cellLatestVersion(version) {
  const t = version ?? 'β€”';
  if (t === 'β€”') {
    return chalk.gray('β€”');
  }
  return cellPill(PDF_TABLE.latestPill, t);
}

/**
 * @param {string} updateType
 * @param {boolean} registryNote
 */
function cellUpdateType(updateType, registryNote) {
  const label = formatUpdateColumnLabel(updateType, registryNote);
  const style =
    PDF_TABLE.updateBadge[updateType] ?? PDF_TABLE.updateBadge[UPDATE_TYPE.UNKNOWN];
  return cellPill(style, label);
}

/**
 * Terminal Secure column: compact β€œicon” tile (saturated bg + light glyph) so it reads
 * clearly on dark backgrounds β€” same idea as a green check badge in GUI tools.
 * @param {string} bgHex
 * @param {string} fgHex
 * @param {string} symbol single character or short mark
 */
function cellSecureIcon(bgHex, fgHex, symbol) {
  const [r, g, b] = hexToRgb(bgHex);
  const pad = '  ';
  return chalk.bgRgb(r, g, b).hex(fgHex).bold(`${pad}${symbol}${pad}`);
}

/** bg / fg / symbol for each OSV outcome (βœ“ = no known vulns, ⚠ = vulnerability, β€” = check unavailable). */
const GLYPH_SECURE = '\u2713';
const GLYPH_UNSECURE = '\u26A0';

const SECURE_ICON = {
  none: { bg: '#2E7D50', fg: '#FFFFFF', symbol: GLYPH_SECURE },
  unknown: { bg: '#546E7A', fg: '#ECEFF1', symbol: '\u2014' },
  info: { bg: '#0277BD', fg: '#FFFFFF', symbol: 'i' },
  low: { bg: '#B45309', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
  moderate: { bg: '#EA580C', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
  high: { bg: '#DC2626', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
  critical: { bg: '#991B1B', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
};

/**
 * @param {{
 *   securityLevel?: string,
 *   securityCount?: number,
 *   securityTooltip?: string
 * }} r
 */
const SECURE_ICON_BY_LEVEL = {
  none: SECURE_ICON.none,
  unknown: SECURE_ICON.unknown,
  info: SECURE_ICON.info,
  low: SECURE_ICON.low,
  moderate: SECURE_ICON.moderate,
  high: SECURE_ICON.high,
  critical: SECURE_ICON.critical,
};

function cellSecure(r) {
  const level = r.securityLevel ?? 'unknown';
  const pick = SECURE_ICON_BY_LEVEL[level] ?? { bg: '#546E7A', fg: '#FFFFFF', symbol: '?' };
  return cellSecureIcon(pick.bg, pick.fg, pick.symbol);
}

/**
 * @param {number | null | undefined} n
 */
function formatBytes(n) {
  if (n == null || Number.isNaN(n)) {
    return 'β€”';
  }
  if (n < 1024) {
    return `${n} B`;
  }
  if (n < 1024 * 1024) {
    return `${(n / 1024).toFixed(1)} KB`;
  }
  return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}

/**
 * Registry publish time β†’ relative phrasing (e.g. "18 days ago", "1 month ago").
 * @param {string | null | undefined} isoOrDateString
 */
function formatLastUpdate(isoOrDateString) {
  if (isoOrDateString == null || isoOrDateString === '') {
    return 'β€”';
  }
  const then = new Date(isoOrDateString).getTime();
  if (Number.isNaN(then)) {
    return 'β€”';
  }
  const now = Date.now();
  const diffSec = Math.floor((now - then) / 1000);
  if (diffSec < 0) {
    return new Date(isoOrDateString).toISOString().slice(0, 10);
  }
  if (diffSec < 60) {
    return 'just now';
  }
  const mins = Math.floor(diffSec / 60);
  if (mins < 60) {
    return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
  }
  const hours = Math.floor(mins / 60);
  if (hours < 24) {
    return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
  }
  const days = Math.floor(hours / 24);
  if (days < 30) {
    if (days === 0) {
      return 'today';
    }
    if (days === 1) {
      return '1 day ago';
    }
    return `${days} days ago`;
  }
  const months = Math.floor(days / 30);
  if (months < 12) {
    if (months === 1) {
      return '1 month ago';
    }
    return `${months} months ago`;
  }
  const years = Math.floor(days / 365);
  if (years === 1) {
    return '1 year ago';
  }
  return `${years} years ago`;
}

/**
 * PDF β€œGenerated:” line β€” local time, 12-hour clock.
 * @param {Date} [date]
 */
function formatGeneratedAt(date = new Date()) {
  return date.toLocaleString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    second: '2-digit',
    hour12: true,
    timeZoneName: 'short',
  });
}

/**
 * @param {Array<{
 *   name: string,
 *   kind: string,
 *   installedVersion: string | null,
 *   latestVersion: string | null,
 *   updateType: string,
 *   lastPublished: string | null,
 *   unpackedSizeBytes: number | null,
 *   registryError?: string | null,
 *   securityLevel?: string,
 *   securityCount?: number,
 *   securityTooltip?: string
 * }>} rows
 */
function printConsoleTable(rows) {
  const table = new Table({
    head: [
      cellHeader('Package'),
      cellHeader('Secure'),
      cellHeader('Installed'),
      cellHeader('Latest'),
      cellHeader('Update'),
      cellHeader('Size'),
      cellHeader('Last Update'),
    ],
    colAligns: ['left', 'center', 'center', 'center', 'center', 'right', 'left'],
    style: {
      head: [],
      border: ['gray'],
    },
    wordWrap: true,
  });

  for (const r of rows) {
    const lastUpdate = formatLastUpdate(r.lastPublished);
    const registryNote = Boolean(r.registryError);

    table.push([
      cellPlain(r.name),
      cellSecure(r),
      cellInstalledVersion(r.installedVersion),
      cellLatestVersion(r.latestVersion),
      cellUpdateType(r.updateType, registryNote),
      cellPlain(formatBytes(r.unpackedSizeBytes)),
      cellPlain(lastUpdate),
    ]);
  }

  console.log(`${table.toString()}\u001b[0m`);
  console.log(
    chalk.gray('Secure (OSV): ') +
      cellSecureIcon(SECURE_ICON.none.bg, SECURE_ICON.none.fg, SECURE_ICON.none.symbol) +
      chalk.gray(' secure Β· ') +
      cellSecureIcon(SECURE_ICON.moderate.bg, SECURE_ICON.moderate.fg, SECURE_ICON.moderate.symbol) +
      chalk.gray('/') +
      cellSecureIcon(SECURE_ICON.high.bg, SECURE_ICON.high.fg, SECURE_ICON.high.symbol) +
      chalk.gray(' vulnerabilities (by severity) Β· ') +
      cellSecureIcon(SECURE_ICON.unknown.bg, SECURE_ICON.unknown.fg, SECURE_ICON.unknown.symbol) +
      chalk.gray(' check unavailable'),
  );
}

const MAX_ADVISORY_CONSOLE = 15;

/**
 * Same structured summary as the PDF (dependency health, security, recommendations, overall).
 * @param {Array<{
 *   name: string,
 *   updateType: string,
 *   securityLevel?: string,
 * }>} rows
 */
function printConsoleSummary(rows) {
  const stats = summarizeDependencyRows(rows);
  const narrative = buildStructuredSummary(stats);

  console.log('');
  console.log(chalk.white.bold('Summary'));
  console.log('');
  console.log(chalk.cyan.bold('Dependency Health Overview'));
  for (const line of narrative.healthLines) {
    console.log(`${chalk.gray('  β€’')} ${chalk.white(line)}`);
  }

  console.log('');
  console.log(chalk.cyan.bold('Security Status'));
  for (const line of narrative.securityLines) {
    console.log(`${chalk.gray('  β€’')} ${chalk.white(line)}`);
  }

  if (stats.withAdvisory.length > 0) {
    console.log('');
    console.log(chalk.red.bold('Packages with vulnerabilities'));
    for (const { name, level } of stats.withAdvisory.slice(0, MAX_ADVISORY_CONSOLE)) {
      console.log(
        `${chalk.gray('  β€’')} ${chalk.white(`${name} (highest: ${capitalizeSeverity(level)})`)}`,
      );
    }
    if (stats.withAdvisory.length > MAX_ADVISORY_CONSOLE) {
      console.log(
        chalk.gray(
          `  β€’ …and ${stats.withAdvisory.length - MAX_ADVISORY_CONSOLE} more (see OSV.dev)`,
        ),
      );
    }
  }

  console.log('');
  console.log(chalk.cyan.bold('Recommendation'));
  for (const line of narrative.recommendationLines) {
    console.log(`${chalk.gray('  β€’')} ${chalk.white(line)}`);
  }

  console.log('');
  const sym = narrative.overallIsPositive ? '\u2705 ' : '\u26a0 ';
  const detailStyle = narrative.overallIsPositive ? chalk.green.bold : chalk.hex('#F59E0B').bold;
  console.log(
    chalk.white.bold(`${narrative.overallLabel}: `) +
      detailStyle(`${sym}${narrative.overallDetail}`),
  );
  console.log('');
}

module.exports = {
  printConsoleTable,
  printConsoleSummary,
  formatBytes,
  formatLastUpdate,
  formatGeneratedAt,
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Produces:

  • Clean terminal table
  • Colored badges
  • Security indicators

πŸ“„ reportPdf.js (PDF Generator)



/**
 * PDF report (PDFKit): white rows; Installed / Latest / Update use centered pill tags.
 */

const fs = require('fs');
const PDFDocument = require('pdfkit');
const {
  PDF_TABLE,
  formatUpdateColumnLabel,
  UPDATE_TYPE,
  BADGE_LABEL_FG,
} = require('./constants');
const { formatBytes, formatLastUpdate, formatGeneratedAt } = require('./reportConsole');
const {
  summarizeDependencyRows,
  buildStructuredSummary,
  capitalizeSeverity,
} = require('./reportSummary');

/**
 * Load a PNG/JPEG (or GIF) from an http(s) URL for the β€œsecure” PDF icon.
 * Returns null on failure (caller falls back to the vector check).
 * @param {string} url
 * @param {{ timeoutMs?: number, maxBytes?: number }} [limits]
 * @returns {Promise<Buffer | null>}
 */
async function fetchSecureIconBuffer(url, limits = {}) {
  const timeoutMs =
    limits.timeoutMs ?? (Number(process.env.DEPENDENCY_REPORT_ICON_TIMEOUT_MS) || 8000);
  const maxBytes =
    limits.maxBytes ?? (Number(process.env.DEPENDENCY_REPORT_ICON_MAX_BYTES) || 524288);
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
    clearTimeout(timer);
    if (!res.ok) {
      return null;
    }
    const buf = Buffer.from(await res.arrayBuffer());
    if (!buf.length || buf.length > maxBytes) {
      return null;
    }
    return buf;
  } catch {
    clearTimeout(timer);
    return null;
  }
}

/**
 * Secure β€œclean” state: avoid Unicode βœ“ in Helvetica (thin / missing). Use a vector check
 * in the PDF pill, or an embedded PNG when DEPENDENCY_REPORT_SECURE_ICON_URL loads.
 */
const GLYPH_INFO = 'i';
/** ASCII β€” always renders in Helvetica (avoid ⚠ for the same encoding issue). */
const GLYPH_ADVISORY = '!';

/**
 * @param {*} doc
 * @param {number} cx
 * @param {number} cy
 * @param {number} arm
 * @param {string} strokeColor
 */
function drawPdfCheckStroke(doc, cx, cy, arm, strokeColor) {
  doc.save();
  doc.strokeColor(strokeColor).lineWidth(1.05).lineCap('round').lineJoin('round');
  doc.moveTo(cx - arm * 0.78, cy + arm * 0.02);
  doc.lineTo(cx - arm * 0.1, cy + arm * 0.72);
  doc.lineTo(cx + arm * 0.98, cy - arm * 0.62);
  doc.stroke();
  doc.restore();
}

/**
 * Secure column pills reuse the same pastel + label colors as the Update column
 * (no custom shapes β€” matches Installed / Latest / Update visually).
 */
const SECURE_ISSUE_PILL = {
  info: PDF_TABLE.updateBadge[UPDATE_TYPE.PATCH],
  low: PDF_TABLE.updateBadge[UPDATE_TYPE.MINOR],
  moderate: { bg: '#FED7AA', fg: BADGE_LABEL_FG },
  high: PDF_TABLE.updateBadge[UPDATE_TYPE.MAJOR],
  critical: { bg: '#FECACA', fg: BADGE_LABEL_FG },
};

/**
 * @typedef {object} ReportRow
 * @property {string} name
 * @property {'dependencies'|'devDependencies'} kind
 * @property {string | null} installedVersion
 * @property {string | null} latestVersion
 * @property {string} updateType
 * @property {string | null} lastPublished
 * @property {number | null} unpackedSizeBytes
 * @property {string | null} [registryError]
 * @property {string} [securityLevel]
 * @property {number} [securityCount]
 * @property {string} [securityTooltip]
 */

/**
 * @param {string} updateType
 * @returns {{ bg: string, fg: string } | null}
 */
function updateBadgeColors(updateType) {
  return (
    PDF_TABLE.updateBadge[updateType] ??
    PDF_TABLE.updateBadge[UPDATE_TYPE.UNKNOWN]
  );
}

const TAG_FONT = 6.5;
const PILL_PAD_X = 7;
const PILL_PAD_Y = 3;
const PILL_RADIUS = 5;

/** Dark green for β€œall secure” summary line (matches Secure column tone). */
const SUMMARY_ALL_SECURE_FG = '#2D5A45';

/**
 * @param {string} outPath
 * @param {ReportRow[]} rows
 * @param {{ title?: string, secureIconUrl?: string }} [opts]
 */
async function writePdfReport(outPath, rows, opts = {}) {
  const iconUrl = String(opts.secureIconUrl || process.env.DEPENDENCY_REPORT_SECURE_ICON_URL || '').trim();
  let secureIconBuffer = null;
  if (iconUrl && /^https?:\/\//i.test(iconUrl)) {
    secureIconBuffer = await fetchSecureIconBuffer(iconUrl);
    if (!secureIconBuffer) {
      console.warn(
        'dependency-report: DEPENDENCY_REPORT_SECURE_ICON_URL could not be loaded; using built-in check icon in PDF.',
      );
    }
  }

  const title = opts.title || 'React Native Dependency Report';
  const doc = new PDFDocument({
    size: 'A4',
    margin: 40,
    info: {
      Title: title,
      Author: 'Dependency Report Script',
    },
  });

  const stream = fs.createWriteStream(outPath);
  doc.pipe(stream);

  doc.fontSize(20).font('Helvetica-Bold').fillColor('#1a1a1a').text(title, { align: 'center' });
  doc.moveDown(0.3);
  doc
    .fontSize(10)
    .font('Helvetica')
    .fillColor('#333333')
    .text(`Generated: ${formatGeneratedAt()}`, { align: 'center' });
  doc.moveDown(1.2);

  const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
  const x0 = doc.page.margins.left;
  /* Secure needs enough width so β€œSecure” stays on one line at 8pt (was wrapping as Secu/re). */
  /* Secure column: clean rows use a vector check (or custom PNG); vulnerable rows uses ASCII β€œ!”. */
  const weights = [1.88, 0.55, 0.92, 0.92, 0.88, 0.72, 1.13];
  const sum = weights.reduce((a, b) => a + b, 0);
  const cols = weights.map((w) => (w / sum) * pageWidth);
  const SECURE_COL = 1;
  const INSTALLED_COL = 2;
  const LATEST_COL = 3;
  const UPDATE_COL = 4;
  const SIZE_COL = 5;
  const LAST_UPDATE_COL = 6;

  const rowH = 22;
  const headerH = 26;
  let y = doc.y;

  function ensureSpace(heightNeeded) {
    if (y + heightNeeded > doc.page.height - doc.page.margins.bottom) {
      doc.addPage();
      y = doc.page.margins.top;
    }
  }

  function columnLeft(index) {
    let left = x0;
    for (let j = 0; j < index; j++) {
      left += cols[j];
    }
    return left;
  }

  /**
   * @param {number} x
   * @param {number} width
   * @param {string} text
   * @param {{ size?: number, bold?: boolean, color?: string, align?: 'left'|'center'|'right' }} [options]
   */
  function drawCellText(x, width, text, options = {}) {
    const t = String(text ?? 'β€”');
    const align = options.align || 'left';
    doc.save();
    doc
      .font(options.bold ? 'Helvetica-Bold' : 'Helvetica')
      .fontSize(options.size || 7)
      .fillColor(options.color || PDF_TABLE.bodyText);
    doc.text(t, x + 4, y + 5, {
      width: width - 8,
      ellipsis: true,
      lineBreak: false,
      align,
    });
    doc.restore();
  }

  /**
   * Centered rounded pill (Installed, Latest, Update columns).
   * @param {number} ux
   * @param {number} colW
   * @param {string} label
   * @param {{ bg: string, fg: string }} style
   * @param {{ bold?: boolean, fontSize?: number }} [fontOpts]
   */
  function drawPill(ux, colW, label, style, fontOpts = {}) {
    const bold = fontOpts.bold !== false;
    const fs = typeof fontOpts.fontSize === 'number' ? fontOpts.fontSize : TAG_FONT;
    doc.save();
    const innerPad = 4;
    const maxTagW = Math.max(0, colW - innerPad * 2);

    doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(fs);
    const textW = doc.widthOfString(label);
    let tagW = Math.min(textW + PILL_PAD_X * 2, maxTagW);
    if (tagW < fs + PILL_PAD_X) {
      tagW = Math.min(maxTagW, fs + PILL_PAD_X * 2);
    }
    const tagH = fs + PILL_PAD_Y * 2;
    const tagX = ux + (colW - tagW) / 2;
    const tagY = y + (rowH - tagH) / 2;

    doc.roundedRect(tagX, tagY, tagW, tagH, PILL_RADIUS).fill(style.bg);
    doc.fillColor(style.fg);
    doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(fs);
    const lineH = doc.currentLineHeight(true);
    const textY = tagY + (tagH - lineH) / 2 + lineH * 0.08;
    doc.text(label, tagX, textY, {
      width: tagW,
      align: 'center',
      lineBreak: false,
      ellipsis: true,
    });
    doc.restore();
  }

  /**
   * Mint β€œsecure” pill: optional remote PNG/JPEG in `iconBuffer`, else vector checkmark.
   * @param {number} ux
   * @param {number} colW
   * @param {Buffer | null} [iconBuffer]
   */
  function drawSecureCleanPill(ux, colW, iconBuffer) {
    const style = PDF_TABLE.latestPill;
    const fs = 7.2;
    const innerPad = 4;
    const maxTagW = Math.max(0, colW - innerPad * 2);
    let tagW = Math.min(fs + PILL_PAD_X * 2, maxTagW);
    const tagH = fs + PILL_PAD_Y * 2;
    const tagX = ux + (colW - tagW) / 2;
    const tagY = y + (rowH - tagH) / 2;
    doc.save();
    doc.roundedRect(tagX, tagY, tagW, tagH, PILL_RADIUS).fill(style.bg);
    const cx = tagX + tagW / 2;
    const cy = tagY + tagH / 2 + 0.35;
    const arm = Math.min(tagW, tagH) * 0.3;
    if (iconBuffer && Buffer.isBuffer(iconBuffer) && iconBuffer.length > 0) {
      try {
        const maxSide = Math.min(tagW - 2, tagH - 2, 11);
        const ix = tagX + (tagW - maxSide) / 2;
        const iy = tagY + (tagH - maxSide) / 2;
        doc.image(iconBuffer, ix, iy, { width: maxSide, height: maxSide, fit: [maxSide, maxSide] });
      } catch {
        drawPdfCheckStroke(doc, cx, cy, arm, style.fg);
      }
    } else {
      drawPdfCheckStroke(doc, cx, cy, arm, style.fg);
    }
    doc.restore();
  }

  // Header row
  ensureSpace(headerH);
  doc.save();
  doc.rect(x0, y, pageWidth, headerH).fill(PDF_TABLE.headerBg);
  doc.restore();

  let cx = x0;
  const headers = [
    'Package',
    'Secure',
    'Installed',
    'Latest',
    'Update',
    'Size',
    'Last Update',
  ];
  headers.forEach((h, i) => {
    doc.save();
    doc.font('Helvetica-Bold').fontSize(8).fillColor(PDF_TABLE.headerText);
    let hAlign = 'left';
    if (i === SECURE_COL || i === INSTALLED_COL || i === LATEST_COL || i === UPDATE_COL) {
      hAlign = 'center';
    }
    if (i === SIZE_COL) {
      hAlign = 'right';
    }
    doc.text(h, cx + 4, y + 8, {
      width: cols[i] - 8,
      align: hAlign,
      lineBreak: false,
      ellipsis: true,
    });
    doc.restore();
    cx += cols[i];
  });
  y += headerH;

  for (const r of rows) {
    ensureSpace(rowH);

    doc.save();
    doc.rect(x0, y, pageWidth, rowH).fill(PDF_TABLE.rowBg);
    doc.strokeColor(PDF_TABLE.grid).lineWidth(0.25);
    doc.rect(x0, y, pageWidth, rowH).stroke();
    doc.restore();

    const lastUpdate = formatLastUpdate(r.lastPublished);
    const registryNote = Boolean(r.registryError);
    const updateLabel = formatUpdateColumnLabel(r.updateType, registryNote);
    const badge = updateBadgeColors(r.updateType);

    drawCellText(columnLeft(0), cols[0], r.name, { align: 'left' });

    const secUx = columnLeft(SECURE_COL);
    const secW = cols[SECURE_COL];
    const secLevel = r.securityLevel ?? 'unknown';
    if (secLevel === 'none') {
      drawSecureCleanPill(secUx, secW, secureIconBuffer);
    } else if (secLevel === 'unknown') {
      drawCellText(secUx, secW, '\u2014', { align: 'center', size: 9, color: '#94A3B8' });
    } else if (secLevel === 'info') {
      drawPill(secUx, secW, GLYPH_INFO, SECURE_ISSUE_PILL.info, { fontSize: 7.2 });
    } else {
      const issueStyle = SECURE_ISSUE_PILL[secLevel] ?? SECURE_ISSUE_PILL.high;
      drawPill(secUx, secW, GLYPH_ADVISORY, issueStyle, { fontSize: 7.5 });
    }

    const installedText = r.installedVersion ?? 'β€”';
    const latestText = r.latestVersion ?? 'β€”';
    if (installedText === 'β€”') {
      drawCellText(columnLeft(INSTALLED_COL), cols[INSTALLED_COL], 'β€”', {
        align: 'center',
      });
    } else {
      drawPill(columnLeft(INSTALLED_COL), cols[INSTALLED_COL], installedText, PDF_TABLE.installedPill);
    }
    if (latestText === 'β€”') {
      drawCellText(columnLeft(LATEST_COL), cols[LATEST_COL], 'β€”', { align: 'center' });
    } else {
      drawPill(columnLeft(LATEST_COL), cols[LATEST_COL], latestText, PDF_TABLE.latestPill);
    }

    drawCellText(columnLeft(SIZE_COL), cols[SIZE_COL], formatBytes(r.unpackedSizeBytes), {
      align: 'right',
    });
    drawCellText(columnLeft(LAST_UPDATE_COL), cols[LAST_UPDATE_COL], lastUpdate, { align: 'left' });

    drawPill(columnLeft(UPDATE_COL), cols[UPDATE_COL], updateLabel, badge);

    y += rowH;
  }

  const stats = summarizeDependencyRows(rows);
  const narrative = buildStructuredSummary(stats);
  const summaryReserve =
    300 +
    Math.min(160, stats.withAdvisory.length * 12) +
    narrative.recommendationLines.length * 10;

  const needsRegistryFootnote = rows.some((row) => row.registryError);
  doc.moveDown(0.75);
  y = doc.y;
  /* Room for registry note + Summary (counts + secure list). */
  ensureSpace((needsRegistryFootnote ? 72 : 0) + summaryReserve);
  if (needsRegistryFootnote) {
    const footnoteRest =
      'If an update tag shows a trailing asterisk (*), the latest version could not be read from the npm registry. ' +
      'Typical reasons are git, file, link, or workspace dependencies, or a failed registry request.';
    doc
      .font('Helvetica-Bold')
      .fontSize(8)
      .fillColor('#555555')
      .text('Note: ', x0, y, { continued: true, lineBreak: false });
    doc
      .font('Helvetica')
      .fillColor('#666666')
      .text(footnoteRest, {
        width: pageWidth,
        lineGap: 2,
        align: 'left',
      });
    doc.moveDown(0.55);
    y = doc.y;
  }
  doc.moveDown(0.5);
  ensureSpace(Math.min(380, 260 + stats.withAdvisory.length * 11));

  const bulletX = x0 + 6;
  const bulletW = pageWidth - 12;

  doc.font('Helvetica-Bold').fontSize(10).fillColor('#1a1a1a').text('Summary', x0, doc.y, {
    width: pageWidth,
    lineGap: 2,
  });
  doc.moveDown(0.35);

  /**
   * @param {string} sectionTitle
   * @param {string[]} lines
   */
  function writeSummarySection(sectionTitle, lines) {
    doc.font('Helvetica-Bold').fontSize(8.5).fillColor('#1a1a1a').text(sectionTitle, x0, doc.y, {
      width: pageWidth,
      lineGap: 2,
    });
    doc.moveDown(0.14);
    doc.font('Helvetica').fontSize(8).fillColor('#374151');
    for (const line of lines) {
      doc.text(`β€’ ${line}`, bulletX, doc.y, {
        width: bulletW,
        lineGap: 2.5,
        align: 'left',
      });
    }
    doc.moveDown(0.32);
  }

  writeSummarySection('Dependency Health Overview', narrative.healthLines);
  writeSummarySection('Security Status', narrative.securityLines);

  if (stats.withAdvisory.length > 0) {
    doc.font('Helvetica-Bold').fontSize(8).fillColor('#991B1B').text('Packages with vulnerabilities', x0, doc.y, {
      width: pageWidth,
      lineGap: 2,
    });
    doc.moveDown(0.1);
    doc.font('Helvetica').fontSize(8).fillColor('#444444');
    const maxList = 15;
    const list = stats.withAdvisory.slice(0, maxList);
    for (const { name, level } of list) {
      doc.text(`β€’ ${name} (highest: ${capitalizeSeverity(level)})`, bulletX + 2, doc.y, {
        width: bulletW - 2,
        lineGap: 1.5,
        align: 'left',
      });
    }
    if (stats.withAdvisory.length > maxList) {
      doc.text(
        `β€’ ... and ${stats.withAdvisory.length - maxList} more (see OSV.dev)`,
        bulletX + 2,
        doc.y,
        { width: bulletW - 2, lineGap: 2 },
      );
    }
    doc.moveDown(0.28);
  }

  writeSummarySection('Recommendation', narrative.recommendationLines);

  const overallColor = narrative.overallIsPositive ? SUMMARY_ALL_SECURE_FG : '#B45309';
  const yOverall = doc.y;
  const overallFs = 9;
  const overallGap = 4;
  doc.font('Helvetica-Bold').fontSize(overallFs);
  const overallLabelStr = `${narrative.overallLabel}: `;
  const labelW = doc.widthOfString(overallLabelStr);
  doc.fillColor('#1a1a1a').text(overallLabelStr, x0, yOverall, { lineBreak: false });

  const detailX = x0 + labelW + overallGap;
  doc.font('Helvetica-Bold').fontSize(overallFs).fillColor(overallColor);
  doc.text(narrative.overallDetail, detailX, yOverall, { lineBreak: false });

  doc.end();

  return new Promise((resolve, reject) => {
    stream.on('finish', () => resolve(outPath));
    stream.on('error', reject);
  });
}

module.exports = { writePdfReport };


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Generates:

  • Styled report
  • Visual badges
  • Summary insights

πŸ“„ reportSummary.js (Insight Engine)



/**
 * Shared dependency report summary (PDF + terminal): counts, narrative, recommendations.
 */

const { UPDATE_TYPE } = require('./constants');

/**
 * @param {Array<{
 *   name: string,
 *   updateType: string,
 *   securityLevel?: string,
 * }>} rows
 */
function summarizeDependencyRows(rows) {
  const byUpdate = {
    minor: 0,
    major: 0,
    patch: 0,
    latest: 0,
    unknown: 0,
    prerelease: 0,
  };
  for (const r of rows) {
    switch (r.updateType) {
      case UPDATE_TYPE.MINOR:
        byUpdate.minor++;
        break;
      case UPDATE_TYPE.MAJOR:
        byUpdate.major++;
        break;
      case UPDATE_TYPE.PATCH:
        byUpdate.patch++;
        break;
      case UPDATE_TYPE.UP_TO_DATE:
        byUpdate.latest++;
        break;
      case UPDATE_TYPE.UNKNOWN:
        byUpdate.unknown++;
        break;
      case UPDATE_TYPE.PRERELEASE:
        byUpdate.prerelease++;
        break;
      default:
        byUpdate.unknown++;
    }
  }

  /** @type {{ name: string, level: string }[]} */
  const withAdvisory = [];
  let nClean = 0;
  let nAuditUnknown = 0;
  for (const r of rows) {
    const s = r.securityLevel ?? 'unknown';
    if (s === 'none') {
      nClean++;
    } else if (s === 'unknown') {
      nAuditUnknown++;
    } else if (s === 'info' || s === 'low' || s === 'moderate' || s === 'high' || s === 'critical') {
      withAdvisory.push({ name: r.name, level: s });
    }
  }

  const allAuditClean = rows.length > 0 && nClean === rows.length && nAuditUnknown === 0;
  const allAuditUnknown = rows.length > 0 && nAuditUnknown === rows.length;

  return {
    total: rows.length,
    byUpdate,
    withAdvisory,
    nClean,
    nAuditUnknown,
    allAuditClean,
    allAuditUnknown,
  };
}

function capitalizeSeverity(level) {
  return level.charAt(0).toUpperCase() + level.slice(1);
}

/** @param {number} n @param {string} singular @param {string} plural */
function nounCount(n, singular, plural) {
  return `${n} ${n === 1 ? singular : plural}`;
}

/**
 * @param {{
 *   total: number,
 *   byUpdate: object,
 *   withAdvisory: { name: string, level: string }[],
 *   nClean: number,
 *   nAuditUnknown: number,
 *   allAuditClean: boolean,
 *   allAuditUnknown: boolean,
 * }} stats
 */
function buildStructuredSummary(stats) {
  const { total, byUpdate, withAdvisory, nClean, nAuditUnknown, allAuditClean, allAuditUnknown } = stats;
  const { minor, major, patch, latest, unknown, prerelease } = byUpdate;

  const healthLines = [
    `${total} package${total === 1 ? '' : 's'} analyzed`,
    `${minor} minor update${minor === 1 ? '' : 's'} available (safe to upgrade)`,
    `${major} major update${major === 1 ? '' : 's'} available (requires review)`,
    `${patch} patch update${patch === 1 ? '' : 's'} available (low-risk improvements)`,
    `${latest} package${latest === 1 ? '' : 's'} ${latest === 1 ? 'is' : 'are'} up to date`,
    `${unknown} package${unknown === 1 ? '' : 's'} ${unknown === 1 ? 'has' : 'have'} unknown status`,
  ];
  if (prerelease > 0) {
    healthLines.push(
      `${prerelease} package${prerelease === 1 ? '' : 's'} ${prerelease === 1 ? 'has' : 'have'} prerelease-related semver`,
    );
  }

  /** @type {string[]} */
  const securityLines = [];
  if (total === 0) {
    securityLines.push('No packages were included in this report.');
  } else if (withAdvisory.length > 0) {
    securityLines.push(
      `${nClean} package${nClean === 1 ? '' : 's'} ${nClean === 1 ? 'has' : 'have'} no known issues in OSV for queried versions`,
    );
    securityLines.push(
      `${withAdvisory.length} package${withAdvisory.length === 1 ? '' : 's'} ${withAdvisory.length === 1 ? 'has' : 'have'} known vulnerabilities (OSV.dev)`,
    );
    securityLines.push('Review the table and OSV records (https://osv.dev) for remediation steps.');
  } else if (allAuditClean) {
    securityLines.push(`All ${total} package${total === 1 ? '' : 's'} are secure`);
    securityLines.push('No vulnerabilities detected via OSV for queried versions');
    securityLines.push('No packages with known vulnerabilities');
  } else if (allAuditUnknown) {
    securityLines.push('OSV did not return usable results for this run');
    securityLines.push('Secure column shows - until OSV succeeds (network, lockfile, or API errors)');
    securityLines.push('Ensure package-lock.json exists or deps are installed, then re-run the report');
  } else {
    securityLines.push(
      `No known OSV vulnerabilities for ${nClean} package${nClean === 1 ? '' : 's'} where the check applied`,
    );
    securityLines.push(
      `${nAuditUnknown} package${nAuditUnknown === 1 ? '' : 's'} could not be assessed (OSV unavailable)`,
    );
  }

  /** @type {string[]} */
  const recommendationLines = [];
  if (total === 0) {
    recommendationLines.push('Add dependencies to package.json to populate this report.');
  } else {
    if (major > 0) {
      recommendationLines.push(
        `Prioritize upgrading ${nounCount(major, 'major dependency', 'major dependencies')} with caution`,
      );
    }
    if (minor > 0 || patch > 0) {
      const bits = [];
      if (minor > 0) {
        bits.push(`${minor} minor`);
      }
      if (patch > 0) {
        bits.push(`${patch} patch`);
      }
      recommendationLines.push(
        `Apply ${bits.join(' and ')} update${minor + patch === 1 ? '' : 's'} to keep the project current`,
      );
    }
    if (unknown > 0) {
      recommendationLines.push(`Investigate ${nounCount(unknown, 'package', 'packages')} with unknown status`);
    }
    if (withAdvisory.length > 0) {
      recommendationLines.push(
        `Remediate ${nounCount(withAdvisory.length, 'package', 'packages')} with known security vulnerabilities`,
      );
    }
    if (recommendationLines.length === 0) {
      recommendationLines.push('No semver upgrades flagged; continue monitoring for new releases.');
    }
  }

  let overallLabel = 'Overall Status';
  let overallDetail = '';
  let overallIsPositive = true;

  if (total === 0) {
    overallDetail = 'No data';
    overallIsPositive = false;
  } else if (withAdvisory.length > 0) {
    overallDetail = 'Needs attention - security vulnerabilities present';
    overallIsPositive = false;
  } else if (allAuditUnknown) {
    overallDetail = 'Security check unavailable - confirm OSV / lockfile';
    overallIsPositive = false;
  } else if (major > 0 || minor > 0 || patch > 0 || unknown > 0) {
    overallDetail = 'Healthy with pending upgrades';
    overallIsPositive = true;
  } else {
    overallDetail = 'Up to date';
    overallIsPositive = true;
  }

  return {
    healthLines,
    securityLines,
    recommendationLines,
    overallLabel,
    overallDetail,
    overallIsPositive,
  };
}

module.exports = {
  summarizeDependencyRows,
  buildStructuredSummary,
  capitalizeSeverity,
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Converts data β†’ decisions.


πŸ“„ constants.js (Shared Config)



/**
 * Shared constants for dependency reporting (colors, sort order, labels).
 */

/** @enum {string} */
exports.UPDATE_TYPE = {
  MAJOR: 'Major',
  MINOR: 'Minor',
  PATCH: 'Patch',
  UP_TO_DATE: 'Up to date',
  PRERELEASE: 'Prerelease',
  UNKNOWN: 'Unknown',
};

/** Sort priority: lower = earlier in list when sorting by update type. */
exports.UPDATE_TYPE_SORT_ORDER = {
  [exports.UPDATE_TYPE.MAJOR]: 0,
  [exports.UPDATE_TYPE.MINOR]: 1,
  [exports.UPDATE_TYPE.PATCH]: 2,
  [exports.UPDATE_TYPE.PRERELEASE]: 3,
  [exports.UPDATE_TYPE.UP_TO_DATE]: 4,
  [exports.UPDATE_TYPE.UNKNOWN]: 5,
};

/** Soft β€œlight black” for badge text (calmer than #000, still readable on pastels). */
exports.BADGE_LABEL_FG = '#4B5563';

/**
 * PDF / shared pill colors (hex). Light, calm fills with darker text for contrast.
 */
exports.PDF_TABLE = {
  rowBg: '#FFFFFF',
  grid: '#DDE1E6',
  headerBg: '#2c3e50',
  headerText: '#FFFFFF',
  bodyText: '#1a1a1a',
  /** Version columns β€” dark text on light fills */
  installedPill: { bg: '#F3F4F6', fg: exports.BADGE_LABEL_FG },
  latestPill: { bg: '#D1FAE5', fg: exports.BADGE_LABEL_FG },
  /** Update status β€” soft pastels */
  updateBadge: {
    [exports.UPDATE_TYPE.MAJOR]: { bg: '#FEE2E2', fg: exports.BADGE_LABEL_FG },
    [exports.UPDATE_TYPE.MINOR]: { bg: '#FEF3C7', fg: exports.BADGE_LABEL_FG },
    [exports.UPDATE_TYPE.PATCH]: { bg: '#DBEAFE', fg: exports.BADGE_LABEL_FG },
    [exports.UPDATE_TYPE.UP_TO_DATE]: { bg: '#D1FAE5', fg: exports.BADGE_LABEL_FG },
    [exports.UPDATE_TYPE.UNKNOWN]: { bg: '#F3F4F6', fg: exports.BADGE_LABEL_FG },
    [exports.UPDATE_TYPE.PRERELEASE]: { bg: '#EDE9FE', fg: exports.BADGE_LABEL_FG },
  },
};

/** @param {string} hex #RRGGBB */
exports.hexToRgb = function hexToRgb(hex) {
  const h = hex.replace('#', '');
  return [
    parseInt(h.slice(0, 2), 16),
    parseInt(h.slice(2, 4), 16),
    parseInt(h.slice(4, 6), 16),
  ];
};

/**
 * @param {string} updateType
 * @param {boolean} registryNote
 */
exports.formatUpdateColumnLabel = function formatUpdateColumnLabel(updateType, registryNote) {
  const star = registryNote ? '*' : '';
  let label;
  if (updateType === exports.UPDATE_TYPE.UP_TO_DATE) {
    label = 'LATEST';
  } else if (updateType === exports.UPDATE_TYPE.UNKNOWN) {
    label = 'Unknown';
  } else {
    label = String(updateType).toUpperCase();
  }
  return `${label}${star}`;
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Central source of truth.


πŸ“„ badges.js (UI Styling Engine)



/**
 * npm / GitHub Primer–style badge system: flat chips, 1px border, tight padding,
 * monospace for semver (like registry UI), sans-semibold for status labels.
 */

const { UPDATE_TYPE, BADGE_LABEL_FG } = require('./constants');

/**
 * Layout tuned for PDF points (~11–12px equivalent at table scale).
 */
exports.NPM_BADGE_LAYOUT = {
  /** ~4px radius β€” flat chip, not a pill */
  radius: 3,
  borderWidth: 0.55,
  padX: 6.75,
  padY: 3.75,
  /** Courier = registry-style version strings */
  fontVersion: 7,
  fontStatus: 7.35,
};

/**
 * @typedef {{ bg: string, fg: string, border: string }} NpmBadgeStyle
 */

/** Installed / Latest β€” version column chips (Primer default + npm blue β€œlatest”) */
exports.versionBadges = {
  installed: {
    bg: '#FFFFFF',
    fg: BADGE_LABEL_FG,
    border: '#D0D7DE',
  },
  latest: {
    bg: '#DDF4FF',
    fg: BADGE_LABEL_FG,
    border: '#79C0FF',
  },
};

/** Update column β€” Primer Label–style danger / attention / accent / success / neutral */
exports.statusBadges = {
  [UPDATE_TYPE.MAJOR]: {
    bg: '#FFEBE9',
    fg: BADGE_LABEL_FG,
    border: '#FF8182',
  },
  [UPDATE_TYPE.MINOR]: {
    bg: '#FFF8C5',
    fg: BADGE_LABEL_FG,
    border: '#EAC54F',
  },
  [UPDATE_TYPE.PATCH]: {
    bg: '#DDF4FF',
    fg: BADGE_LABEL_FG,
    border: '#79C0FF',
  },
  [UPDATE_TYPE.UP_TO_DATE]: {
    bg: '#DAFBE1',
    fg: BADGE_LABEL_FG,
    border: '#4AC26B',
  },
  [UPDATE_TYPE.UNKNOWN]: {
    bg: '#F6F8FA',
    fg: BADGE_LABEL_FG,
    border: '#D0D7DE',
  },
  [UPDATE_TYPE.PRERELEASE]: {
    bg: '#FBEFFF',
    fg: BADGE_LABEL_FG,
    border: '#D8B9FF',
  },
};

/**
 * @param {string} updateType
 * @returns {NpmBadgeStyle}
 */
exports.getStatusBadgeStyle = function getStatusBadgeStyle(updateType) {
  return exports.statusBadges[updateType] ?? exports.statusBadges[UPDATE_TYPE.UNKNOWN];
};

/**
 * @param {'version' | 'status'} kind
 */
function badgeFonts(kind) {
  if (kind === 'version') {
    return { family: 'Courier-Bold', size: exports.NPM_BADGE_LAYOUT.fontVersion };
  }
  return { family: 'Helvetica-Bold', size: exports.NPM_BADGE_LAYOUT.fontStatus };
}

/**
 * Draw a bordered, filled rounded rect + centered label (npm chip).
 * @param {object} doc PDFKit document
 * @param {number} ux column left
 * @param {number} colW column width
 * @param {number} rowTop
 * @param {number} rowH
 * @param {string} label
 * @param {NpmBadgeStyle} style
 * @param {'version' | 'status'} kind
 */
exports.drawNpmBadge = function drawNpmBadge(
  doc,
  ux,
  colW,
  rowTop,
  rowH,
  label,
  style,
  kind,
) {
  const L = exports.NPM_BADGE_LAYOUT;
  const innerPad = 2.5;
  const maxTagW = Math.max(0, colW - innerPad * 2);
  const { family, size } = badgeFonts(kind);

  doc.font(family).fontSize(size);
  const textW = doc.widthOfString(label);
  let tagW = Math.min(textW + L.padX * 2, maxTagW);
  const minW = size + L.padX * 1.25;
  if (tagW < minW) {
    tagW = Math.min(maxTagW, minW);
  }
  const tagH = size + L.padY * 2;
  const tagX = ux + (colW - tagW) / 2;
  const tagY = rowTop + (rowH - tagH) / 2;

  doc.save();
  doc.roundedRect(tagX, tagY, tagW, tagH, L.radius).fill(style.bg);
  doc
    .roundedRect(tagX, tagY, tagW, tagH, L.radius)
    .lineWidth(L.borderWidth)
    .strokeColor(style.border)
    .stroke();
  doc.fillColor(style.fg).font(family).fontSize(size);
  const lineH = doc.currentLineHeight(true);
  const textY = tagY + (tagH - lineH) / 2 + lineH * 0.065;
  doc.text(label, tagX, textY, {
    width: tagW,
    align: 'center',
    lineBreak: false,
    ellipsis: true,
  });
  doc.restore();
};


Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Controls visual consistency across CLI & PDF.


πŸ“„ package.json (Script Entry)



"scripts": {
  "report:deps": "node src/config/scripts/dependency-report/generate-report.js"
}

Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ Run everything with:

yarn report:deps

Enter fullscreen mode Exit fullscreen mode

πŸ“Š Output


Terminal View


PDF View



⚠️ Honest Reality Check

  • npm registry calls can be slow
  • OSV dependency introduces risk
  • No CI/CD integration yet
  • Transitive deps not fully visible

πŸ”— References


πŸ”₯ Final Thought

Dependency management is no longer optional.

It’s a security layer.

If you're not tracking it properly β€” you're exposed.

Top comments (0)