diff --git a/README.md b/README.md index 3d03acf..ca6a154 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ Each yielded package has: name: string; // Package name (e.g., "@babel/core") version: string; // Resolved version (e.g., "7.23.0") integrity?: string; // Integrity hash (sha512, sha384, sha256, sha1) - resolved?: string; // Download URL + resolved?: string; // Download URL (registry or private) + link?: boolean; // True if this is a workspace symlink } ``` diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index d41e3c3..09bf685 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 🆕 Added +- **npm lockfileVersion 1 support**: `fromPackageLock` now parses v1 lockfiles by falling back to a new `fromDependenciesTree` generator when the `packages` map is absent. v1 lockfiles use a nested `dependencies` tree instead of the flat `packages` map — `fromDependenciesTree` walks the tree iteratively and yields the same `Dependency` shape. The README already claimed v1 support; the parser now delivers on it. + ## [1.5.1] - 2026-03-16 ### 🐛 Fixed diff --git a/src/index.js b/src/index.js index 06d3a65..42d49a0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { detectType, Type } from './detect.js'; import { + fromDependenciesTree, fromPackageLock, fromPnpmLock, fromYarnBerryLock, @@ -25,7 +26,13 @@ export { Type, detectType }; export { Ok, Err }; // Re-export individual parsers -export { fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock }; +export { + fromDependenciesTree, + fromPackageLock, + fromPnpmLock, + fromYarnClassicLock, + fromYarnBerryLock +}; // Re-export FlatlockSet class export { FlatlockSet }; diff --git a/src/parsers/index.js b/src/parsers/index.js index 272b25f..36fe731 100644 --- a/src/parsers/index.js +++ b/src/parsers/index.js @@ -5,6 +5,7 @@ export { buildWorkspacePackages as buildNpmWorkspacePackages, extractWorkspacePaths as extractNpmWorkspacePaths, + fromDependenciesTree, fromPackageLock, parseLockfileKey as parseNpmKey } from './npm.js'; diff --git a/src/parsers/npm.js b/src/parsers/npm.js index 19359b5..d12f267 100644 --- a/src/parsers/npm.js +++ b/src/parsers/npm.js @@ -122,6 +122,12 @@ export function parseLockfileKey(path) { /** * Parse npm package-lock.json (v1, v2, v3) + * + * v2/v3 lockfiles use the `packages` map (flat, path-keyed). + * v1 lockfiles use the `dependencies` tree (nested, name-keyed). + * When `packages` is empty or absent and `dependencies` exists, + * falls back to `fromDependenciesTree`. + * * @param {string | object} input - Lockfile content string or pre-parsed object * @param {Object} [_options] - Parser options (unused, reserved for future use) * @returns {Generator} @@ -130,6 +136,21 @@ export function* fromPackageLock(input, _options = {}) { const lockfile = typeof input === 'string' ? JSON.parse(input) : input; const packages = lockfile.packages || {}; + // Check if the packages map has any non-root entries + let hasPackageEntries = false; + for (const path of Object.keys(packages)) { + if (path !== '') { + hasPackageEntries = true; + break; + } + } + + // v1 fallback: no packages entries but has dependencies tree + if (!hasPackageEntries && lockfile.dependencies) { + yield* fromDependenciesTree(lockfile); + return; + } + for (const [path, pkg] of Object.entries(packages)) { // Skip root package if (path === '') continue; @@ -155,6 +176,56 @@ export function* fromPackageLock(input, _options = {}) { } } +/** + * Parse npm lockfileVersion 1 dependencies tree. + * + * v1 lockfiles store dependencies as a nested object tree where each key + * is a package name and each value contains { version, resolved, integrity, + * requires, dependencies }. Nested `dependencies` represent version conflicts + * that couldn't be hoisted. + * + * @param {string | object} input - Lockfile content string or pre-parsed object + * @param {Object} [_options] - Parser options (unused, reserved for future use) + * @returns {Generator} + */ +export function* fromDependenciesTree(input, _options = {}) { + const lockfile = typeof input === 'string' ? JSON.parse(input) : input; + const dependencies = lockfile.dependencies; + if (!dependencies) return; + + // Iterative depth-first walk to avoid stack overflow on deep trees + /** @type {Array<[string, object]>} */ + const stack = []; + + // Push in reverse order so first entries are yielded first + const topLevel = Object.entries(dependencies); + for (let i = topLevel.length - 1; i >= 0; i--) { + stack.push(topLevel[i]); + } + + while (stack.length > 0) { + const [name, info] = /** @type {[string, any]} */ (stack.pop()); + const { version, integrity, resolved } = info; + + if (name && version) { + /** @type {Dependency} */ + const dep = { name, version }; + if (integrity) dep.integrity = integrity; + if (resolved) dep.resolved = resolved; + yield dep; + } + + // Push nested dependencies (conflict resolution overrides) + const nested = info.dependencies; + if (nested) { + const entries = Object.entries(nested); + for (let i = entries.length - 1; i >= 0; i--) { + stack.push(entries[i]); + } + } + } +} + /** * Extract workspace paths from npm lockfile. * diff --git a/test/parsers/npm.test.js b/test/parsers/npm.test.js index 61a4954..2e40da8 100644 --- a/test/parsers/npm.test.js +++ b/test/parsers/npm.test.js @@ -2,12 +2,9 @@ * @fileoverview Comprehensive tests for npm lockfile parsers * * Tests cover npm package-lock.json formats: - * - v1 (legacy dependencies format - NOT supported, returns empty) + * - v1 (legacy dependencies tree format, parsed via fromDependenciesTree) * - v2 (current format with packages field) * - v3 (same as v2, optimized for npm 7+) - * - * Note: This parser only supports v2/v3 format (packages field). - * v1 format uses dependencies field and is not supported. */ import assert from 'node:assert/strict'; @@ -17,7 +14,7 @@ import { describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; // Public API -import { fromPackageLock, parseLockfileKey } from '../../src/parsers/npm.js'; +import { fromDependenciesTree, fromPackageLock, parseLockfileKey } from '../../src/parsers/npm.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const decodedDir = join(__dirname, '..', 'decoded', 'npm'); @@ -125,13 +122,22 @@ describe('npm parsers', () => { // ============================================================================ describe('fromPackageLock', () => { describe('[npm-01] version detection', () => { - test('returns empty for v1 format (uses dependencies, not packages)', () => { + test('parses v1 format via dependencies tree fallback', () => { const content = loadFixture('package-lock.json.v1'); const deps = [...fromPackageLock(content)]; - // v1 format uses dependencies field, not packages - // Our parser only supports v2/v3 (packages field) - assert.equal(deps.length, 0, 'v1 format should return empty (not supported)'); + // v1 format falls back to fromDependenciesTree + assert.ok(deps.length > 0, `v1 should yield deps, got ${deps.length}`); + + // Every dep should have name and version + for (const dep of deps) { + assert.ok(dep.name, 'Every dep should have name'); + assert.ok(dep.version, 'Every dep should have version'); + } + + // Most deps should have integrity (the v1 fixture has them) + const withIntegrity = deps.filter(d => d.integrity); + assert.ok(withIntegrity.length > deps.length * 0.9, 'Most deps should have integrity'); }); test('parses v2 format', () => { @@ -390,4 +396,211 @@ describe('npm parsers', () => { }); }); }); + + // ============================================================================ + // fromDependenciesTree tests (v1 lockfile support) + // ============================================================================ + describe('fromDependenciesTree', () => { + test('parses flat dependencies', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + integrity: 'sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ==' + }, + express: { + version: '4.18.0', + resolved: 'https://registry.npmjs.org/express/-/express-4.18.0.tgz' + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 2); + assert.equal(deps[0].name, 'lodash'); + assert.equal(deps[0].version, '4.17.21'); + assert.equal(deps[0].resolved, 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz'); + assert.ok(deps[0].integrity); + assert.equal(deps[1].name, 'express'); + assert.equal(deps[1].version, '4.18.0'); + }); + + test('walks nested dependencies recursively', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + base: { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/base/-/base-1.0.0.tgz', + dependencies: { + 'define-property': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz', + dependencies: { + 'is-descriptor': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.0.tgz' + } + } + } + } + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 3); + + const names = deps.map(d => d.name); + assert.ok(names.includes('base')); + assert.ok(names.includes('define-property')); + assert.ok(names.includes('is-descriptor')); + }); + + test('skips entries without version', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { version: '4.17.21' }, + broken: {} + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 1); + assert.equal(deps[0].name, 'lodash'); + }); + + test('handles empty dependencies object', () => { + const lockfile = { lockfileVersion: 1, dependencies: {} }; + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 0); + }); + + test('handles missing dependencies field', () => { + const lockfile = { lockfileVersion: 1 }; + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 0); + }); + + test('accepts JSON string input', () => { + const content = JSON.stringify({ + lockfileVersion: 1, + dependencies: { + lodash: { version: '4.17.21' } + } + }); + + const deps = [...fromDependenciesTree(content)]; + assert.equal(deps.length, 1); + }); + + test('yields resolved when present', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { + version: '4.17.21', + resolved: 'https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz' + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps[0].resolved, 'https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz'); + }); + + test('omits resolved when absent', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { version: '4.17.21' } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps[0].resolved, undefined); + }); + + test('parses v1 fixture with correct count', () => { + const content = loadFixture('package-lock.json.v1'); + const deps = [...fromDependenciesTree(content)]; + + // The v1 fixture (meteor-guide) has 345 top-level + 273 nested = 618 total + assert.ok(deps.length > 300, `Expected >300 deps, got ${deps.length}`); + + // Verify structure + for (const dep of deps) { + assert.ok(dep.name, 'Every dep should have name'); + assert.ok(dep.version, 'Every dep should have version'); + } + + // Most should have resolved + const withResolved = deps.filter(d => d.resolved); + assert.ok(withResolved.length > deps.length * 0.9, 'Most deps should have resolved'); + }); + + test('fromPackageLock delegates to fromDependenciesTree for v1', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + lodash: { + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz' + } + } + }; + + // fromPackageLock should fall back to fromDependenciesTree + const viaPL = [...fromPackageLock(lockfile)]; + const viaDT = [...fromDependenciesTree(lockfile)]; + + assert.equal(viaPL.length, viaDT.length); + assert.deepEqual( + viaPL.map(d => `${d.name}@${d.version}`), + viaDT.map(d => `${d.name}@${d.version}`) + ); + }); + + test('fromPackageLock prefers packages over dependencies for v2', () => { + // v2 lockfiles have both packages and dependencies + // fromPackageLock should use packages, not dependencies + const lockfile = { + lockfileVersion: 2, + packages: { + '': { name: 'root', version: '1.0.0' }, + 'node_modules/lodash': { version: '4.17.21' } + }, + dependencies: { + lodash: { version: '4.17.20' } // different version to prove packages wins + } + }; + + const deps = [...fromPackageLock(lockfile)]; + assert.equal(deps.length, 1); + assert.equal(deps[0].version, '4.17.21', 'Should use packages version, not dependencies'); + }); + + test('handles scoped packages in v1 format', () => { + const lockfile = { + lockfileVersion: 1, + dependencies: { + '@babel/core': { + version: '7.23.0', + resolved: 'https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz' + }, + '@types/node': { + version: '20.0.0' + } + } + }; + + const deps = [...fromDependenciesTree(lockfile)]; + assert.equal(deps.length, 2); + assert.ok(deps.some(d => d.name === '@babel/core')); + assert.ok(deps.some(d => d.name === '@types/node')); + }); + }); });