From dd8dc7c31ec91e95e224856ae90403eaedf12ec4 Mon Sep 17 00:00:00 2001 From: indexzero Date: Sun, 15 Mar 2026 00:07:15 -0400 Subject: [PATCH 1/3] fix(detect) handle truncated pnpm lockfiles and empty yarn.lock Truncated pnpm lockfiles (incomplete YAML flow collections) caused js-yaml to throw during both detection and parsing. Detection now parses only the YAML header, and a new parsePnpmYaml helper progressively trims trailing lines until parsing succeeds. Empty yarn.lock files (header-only, no packages) were rejected by detection because it required at least one package entry. Now allows empty results when the yarn lockfile v1 header is present in the first 5 lines. Co-Authored-By: Claude Opus 4.6 --- src/detect.js | 22 ++++++++++++++++------ src/parsers/index.js | 3 ++- src/parsers/pnpm/index.js | 36 +++++++++++++++++++++++++++++++++--- src/set.js | 4 ++-- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/detect.js b/src/detect.js index f074a6a..f03b4d9 100644 --- a/src/detect.js +++ b/src/detect.js @@ -61,25 +61,35 @@ function tryParseYarnClassic(content) { const result = parseYarnClassic(content); // Must parse successfully and NOT have __metadata (that's berry) - // Must have at least one package entry (not empty object) const isValidResult = result.type === 'success' || result.type === 'merge'; - const hasEntries = result.object && Object.keys(result.object).length > 0; - const notBerry = !('__metadata' in result.object); + const hasObject = result.object && typeof result.object === 'object'; + const notBerry = hasObject && !('__metadata' in result.object); + // Must have entries OR start with the yarn lockfile header comment + const hasEntries = hasObject && Object.keys(result.object).length > 0; + const firstLines = content.split('\n', 5).join('\n'); + const hasYarnHeader = /^# yarn lockfile v1/m.test(firstLines); - return isValidResult && hasEntries && notBerry; + return isValidResult && hasObject && notBerry && (hasEntries || hasYarnHeader); } catch { return false; } } /** - * Try to parse content as pnpm lockfile + * Try to parse content as pnpm lockfile. + * + * Only parses the YAML header (first 20 lines) to check for lockfileVersion. + * This avoids failures on truncated lockfiles where the body is incomplete + * but the header is valid. + * * @param {string} content * @returns {boolean} */ function tryParsePnpm(content) { try { - const parsed = yaml.load(content); + // Parse only the header to tolerate truncated lockfiles + const header = content.split('\n', 20).join('\n'); + const parsed = yaml.load(header); // Must have lockfileVersion at root and NOT have __metadata // biome-ignore format: preserve multiline logical expression return !!(parsed diff --git a/src/parsers/index.js b/src/parsers/index.js index 9128fcd..272b25f 100644 --- a/src/parsers/index.js +++ b/src/parsers/index.js @@ -12,7 +12,8 @@ export { buildWorkspacePackages as buildPnpmWorkspacePackages, extractWorkspacePaths as extractPnpmWorkspacePaths, fromPnpmLock, - parseLockfileKey as parsePnpmKey + parseLockfileKey as parsePnpmKey, + parsePnpmYaml } from './pnpm.js'; export { buildWorkspacePackages as buildYarnBerryWorkspacePackages, diff --git a/src/parsers/pnpm/index.js b/src/parsers/pnpm/index.js index 95f76bf..85092bb 100644 --- a/src/parsers/pnpm/index.js +++ b/src/parsers/pnpm/index.js @@ -193,6 +193,36 @@ export function parseLockfileKey(key) { return parseSpec(key).name; } +/** + * Parse pnpm YAML content, tolerating truncated files. + * + * pnpm lockfiles use inline flow collections like `{integrity: sha512-...}` + * which cause js-yaml to throw if the file is truncated mid-entry. When that + * happens, we progressively trim trailing lines until parsing succeeds. + * + * @param {string} content - YAML content + * @returns {Record} Parsed lockfile object + */ +export function parsePnpmYaml(content) { + try { + return yaml.load(content); + } catch { + // Truncated file — trim lines from the end until yaml.load succeeds. + // Most truncations break an incomplete flow collection near the end, + // so we only need to trim a handful of lines. + const lines = content.split('\n'); + for (let trim = 1; trim < Math.min(20, lines.length); trim++) { + try { + return yaml.load(lines.slice(0, -trim).join('\n')); + } catch { + // keep trimming + } + } + // If trimming didn't help, re-throw the original error + throw yaml.load(content); + } +} + /** * Parse pnpm lockfile (shrinkwrap.yaml, pnpm-lock.yaml v5.x, v6, v9) * @@ -211,7 +241,7 @@ export function parseLockfileKey(key) { */ export function* fromPnpmLock(input, _options = {}) { const lockfile = /** @type {Record} */ ( - typeof input === 'string' ? yaml.load(input) : input + typeof input === 'string' ? parsePnpmYaml(input) : input ); // Detect version to determine where to look for packages @@ -250,7 +280,7 @@ export function* fromPnpmLock(input, _options = {}) { if (seen.has(key)) continue; seen.add(key); - const resolution = pkg.resolution || {}; + const resolution = (pkg && pkg.resolution) || {}; const integrity = resolution.integrity; const resolved = resolution.tarball; const link = spec.startsWith('link:') || resolution.type === 'directory'; @@ -315,7 +345,7 @@ export function* fromPnpmLock(input, _options = {}) { */ export function extractWorkspacePaths(input) { const lockfile = /** @type {Record} */ ( - typeof input === 'string' ? yaml.load(input) : input + typeof input === 'string' ? parsePnpmYaml(input) : input ); const importers = lockfile.importers || {}; diff --git a/src/set.js b/src/set.js index e6d5576..401de07 100644 --- a/src/set.js +++ b/src/set.js @@ -1,6 +1,5 @@ import { readFile } from 'node:fs/promises'; import { parseSyml } from '@yarnpkg/parsers'; -import yaml from 'js-yaml'; import { detectType, Type } from './detect.js'; import { buildNpmWorkspacePackages, @@ -11,6 +10,7 @@ import { extractYarnBerryWorkspacePaths, fromPackageLock, fromPnpmLock, + parsePnpmYaml, fromYarnBerryLock, fromYarnClassicLock, parseYarnBerryKey, @@ -195,7 +195,7 @@ export class FlatlockSet { } case Type.PNPM: { /** @type {any} */ - const lockfile = yaml.load(content); + const lockfile = parsePnpmYaml(content); packages = lockfile.packages || {}; importers = lockfile.importers || null; snapshots = lockfile.snapshots || null; From ce877e4268a0d3538c5506ddc3d1b8e4d45a53c8 Mon Sep 17 00:00:00 2001 From: indexzero Date: Sun, 15 Mar 2026 00:26:32 -0400 Subject: [PATCH 2/3] fix(detect) lint fixes and edge case tests for truncated/empty lockfiles Fix biome lint: use optional chaining for pkg?.resolution, sort imports in set.js, remove trailing commas in test arrays. Add tests for truncated pnpm v6 lockfile (synthetic, cut mid-flow- collection) and empty yarn.lock (header-only). Verify detection and parsing both succeed for each case. Co-Authored-By: Claude Opus 4.6 --- src/parsers/pnpm/index.js | 2 +- src/set.js | 2 +- test/lockfile.test.js | 97 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/parsers/pnpm/index.js b/src/parsers/pnpm/index.js index 85092bb..94437f8 100644 --- a/src/parsers/pnpm/index.js +++ b/src/parsers/pnpm/index.js @@ -280,7 +280,7 @@ export function* fromPnpmLock(input, _options = {}) { if (seen.has(key)) continue; seen.add(key); - const resolution = (pkg && pkg.resolution) || {}; + const resolution = pkg?.resolution || {}; const integrity = resolution.integrity; const resolved = resolution.tarball; const link = spec.startsWith('link:') || resolution.type === 'directory'; diff --git a/src/set.js b/src/set.js index 401de07..76dbca1 100644 --- a/src/set.js +++ b/src/set.js @@ -10,9 +10,9 @@ import { extractYarnBerryWorkspacePaths, fromPackageLock, fromPnpmLock, - parsePnpmYaml, fromYarnBerryLock, fromYarnClassicLock, + parsePnpmYaml, parseYarnBerryKey, parseYarnClassic, parseYarnClassicKey diff --git a/test/lockfile.test.js b/test/lockfile.test.js index bcf8899..92cbc10 100644 --- a/test/lockfile.test.js +++ b/test/lockfile.test.js @@ -635,4 +635,101 @@ packages: assert.ok(deps.length > 0); }); }); + + describe('Edge cases: truncated and empty lockfiles', () => { + test('detects and parses a truncated pnpm v6 lockfile', () => { + // Synthetic pnpm v6 lockfile truncated mid-flow-collection. + // The last entry's {integrity: ...} is cut off without a closing brace, + // which causes js-yaml to throw "unexpected end of the stream within + // a flow collection" on the full content. + const content = [ + "lockfileVersion: '6.0'", + '', + 'settings:', + ' autoInstallPeers: true', + ' excludeLinksFromLockfile: false', + '', + 'dependencies:', + ' express:', + ' specifier: ^4.18.0', + ' version: 4.21.2', + '', + 'packages:', + '', + ' /accepts@1.3.8:', + ' resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}', + ' dependencies:', + ' mime-types: 2.1.35', + ' negotiator: 0.6.4', + '', + ' /body-parser@1.20.3:', + ' resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}', + ' dependencies:', + ' bytes: 3.1.2', + ' depd: 2.0.0', + ' destroy: 1.2.0', + ' http-errors: 2.0.0', + ' iconv-lite: 0.4.24', + ' on-finished: 2.4.1', + ' qs: 6.13.0', + ' raw-body: 2.5.2', + ' type-is: 1.6.18', + ' unpipe: 1.0.0', + '', + ' /content-disposition@0.5.4:', + ' resolution: {integrity: sha512-FKmjBYHLd5aDqhbG', + // truncated here — no closing brace + '' + ].join('\n'); + + // Detection should succeed + const type = flatlock.detectType({ content }); + assert.equal(type, flatlock.Type.PNPM); + + // Parsing should succeed, recovering the complete entries + const deps = [...flatlock.fromString(content)]; + assert.ok(deps.length >= 2, `Expected at least 2 deps, got ${deps.length}`); + + const names = deps.map(d => d.name); + assert.ok(names.includes('accepts'), 'Should include accepts'); + assert.ok(names.includes('body-parser'), 'Should include body-parser'); + + // The truncated entry (content-disposition) may or may not appear + // depending on how many lines get trimmed — but we should not throw + }); + + test('detects and parses an empty yarn.lock (header only)', () => { + const content = [ + '# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.', + '# yarn lockfile v1', + '', + '' + ].join('\n'); + + // Detection should succeed + const type = flatlock.detectType({ content }); + assert.equal(type, flatlock.Type.YARN_CLASSIC); + + // Parsing should succeed with zero dependencies + const deps = [...flatlock.fromString(content)]; + assert.equal(deps.length, 0); + }); + + test('detects empty yarn.lock with only the version header', () => { + const content = '# yarn lockfile v1\n\n'; + + const type = flatlock.detectType({ content }); + assert.equal(type, flatlock.Type.YARN_CLASSIC); + + const deps = [...flatlock.fromString(content)]; + assert.equal(deps.length, 0); + }); + + test('rejects empty file that is not a yarn.lock', () => { + // An empty string or whitespace-only content should still throw + assert.throws(() => { + flatlock.detectType({ content: '\n\n' }); + }, /Unable to detect lockfile type/); + }); + }); }); From 3fefd444df53c0d33504f91329b438ecc09d4976 Mon Sep 17 00:00:00 2001 From: indexzero Date: Sun, 15 Mar 2026 00:39:10 -0400 Subject: [PATCH 3/3] fix(detect) add JSDoc type casts for yaml.load in parsePnpmYaml tsc requires explicit casts since yaml.load returns unknown. Co-Authored-By: Claude Opus 4.6 --- src/parsers/pnpm/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parsers/pnpm/index.js b/src/parsers/pnpm/index.js index 94437f8..63d1cdf 100644 --- a/src/parsers/pnpm/index.js +++ b/src/parsers/pnpm/index.js @@ -205,7 +205,7 @@ export function parseLockfileKey(key) { */ export function parsePnpmYaml(content) { try { - return yaml.load(content); + return /** @type {Record} */ (yaml.load(content)); } catch { // Truncated file — trim lines from the end until yaml.load succeeds. // Most truncations break an incomplete flow collection near the end, @@ -213,7 +213,7 @@ export function parsePnpmYaml(content) { const lines = content.split('\n'); for (let trim = 1; trim < Math.min(20, lines.length); trim++) { try { - return yaml.load(lines.slice(0, -trim).join('\n')); + return /** @type {Record} */ (yaml.load(lines.slice(0, -trim).join('\n'))); } catch { // keep trimming }