๐Ÿ“ฆ antongolub / lockfile

Read and write lockfiles with reasonable losses

โ˜… 4 stars โ‘‚ 0 forks ๐Ÿ‘ 4 watching โš–๏ธ MIT License
lockfilenpmyarn
๐Ÿ“ฅ Clone https://github.com/antongolub/lockfile.git
HTTPS git clone https://github.com/antongolub/lockfile.git
SSH git clone git@github.com:antongolub/lockfile.git
CLI gh repo clone antongolub/lockfile
Anton Golub Anton Golub fix: define `require` for vendor bundles aa3aa7d 2 years ago ๐Ÿ“ History
๐Ÿ“‚ master View all commits โ†’
๐Ÿ“ .github
๐Ÿ“ .yarn
๐Ÿ“ pics
๐Ÿ“ src
๐Ÿ“„ .gitignore
๐Ÿ“„ .yarnrc.yml
๐Ÿ“„ LICENSE
๐Ÿ“„ package.json
๐Ÿ“„ README.md
๐Ÿ“„ tsconfig.json
๐Ÿ“„ yarn.lock
๐Ÿ“„ README.md

@antongolub/lockfile

Read and write lockfiles with reasonable losses

@antongolub/lockfile Each package manager brings its own philosophy of how to describe, store and control project dependencies. It seems acceptable for developers, but literally becomes a pain in headache for isec, devops and release engineers. This lib is a naive attempt to build a pm-independent, generic, extensible and reliable deps representation.

The package.json manifest contains its own deps requirements, the lockfile holds the deps resolution snapshot*, so both of them are required to build a dependency graph. We can try to convert this data into a normalized representation for further analysis and processing (for example, to fix vulnerabilities). And then, if necessary, try convert it back to the original/another format.

Status

Proof of concept. The API may change significantly โš ๏ธ

Getting started

Install

yarn add @antongolub/lockfile@snapshot

Usage

tl;dr
import fs from 'fs/promises'
import {parse, analyze} from '@antongolub/lockfile'

const lf = await fs.readFile('yarn.lock', 'utf-8')
const pkg = await fs.readFile('package.json', 'utf-8')

const snapshot = parse(lf, pkg) // Holds JSON-friendly TEntries[]
const idx = analyze(snapshot)   // An index to represent repo dep graphs

// idx.entries
// idx.prod
// idx.edges

API

JS/TS

import { parse, format, analyze, convert } from '@antongolub/lockfile'

const lf = await fs.readFile('yarn.lock', 'utf-8')
const pkgJson = await fs.readFile('package.json', 'utf-8')
const snapshot = parse(lf, pkgJson)

const lf1 = format(snapshot)
const lf2 = format(snapshot, 'npm-1')         // Throws err: npm v1 meta does not support workspaces

const meta = await readMeta()                 // reads local package.jsons data to gather required data like `engines`, `license`, `bins`, etc
const meta2 = await fetchMeta(snapshot)       // does the same, but from the remote registry
const lf3 = format(snapshot, 'npm-3', {meta}) // format with options

const idx = analyze(snapshot)
idx.edges
// [
//  [ '', '@antongolub/npm-test@4.0.1' ],
//  [ '@antongolub/npm-test@4.0.1', '@antongolub/npm-test@3.0.1' ],
//  [ '@antongolub/npm-test@3.0.1', '@antongolub/npm-test@2.0.1' ],
//  [ '@antongolub/npm-test@2.0.1', '@antongolub/npm-test@1.0.0' ]
// ]

const lf4 = await convert(lf, pkgJson, 'yarn-berry')

CLI

npx @antongolub/lockfile@snapshot <cmd> [options]

npx @antongolub/lockfile@snapshot parse --input=yarn.lock,package.json --output=snapshot.json
npx @antongolub/lockfile@snapshot format --input=snapshot.json --output=yarn.lock

Command / OptionDescription
parseParses lockfiles and package manifests into a snapshot
formatFormats a snapshot into a lockfile
convertConverts a lockfile into another format. Shortcut for parse + format
--inputA comma-separated list of files to parse: snapshot.json or yarn.lock,package.json
--outputA file to write the result to: snapshot.json or yarn.lock
--formatA lockfile format: npm-1, npm-2, npm-3, yarn-berry, yarn-classic

Terms

nmtree โ€” fs projection of deps, directories structure deptree โ€” bounds full dep paths with their resolved packages depgraph โ€” describes how resolved pkgs are related with each other

Lockfiles types

Package managerMeta formatReadWrite
npm <71โœ“โœ“
npm >=72โœ“
npm >=93โœ“
| yarn 1 (classic) | 1 | โœ“ | โœ“ | | yarn 2, 3, 4 (berry) | 5, 6, 7 | โœ“ | โœ“ |

Dependency protocols

TypeSupportedExampleDescription
semverโœ“^1.2.3Resolves from the default registry
taglatestResolves from the default registry
npmโœ“npm:name@...Resolves from the npm registry
gitgit@github.com:foo/bar.gitDownloads a public package from a Git repository
githubgithub:foo/barDownloads a public package from GitHub
githubโœ“foo/barAlias for the github: protocol
filefile:./my-packageCopies the target location into the cache
linklink:./my-folderCreates a link to the ./my-folder folder (ignore dependencies)
patchlimitedpatch:left-pad@1.0.0#./my-patch.patchCreates a patched copy of the original package
portalportal:./my-folderCreates a link to the ./my-folder folder (follow dependencies)
workspacelimitedworkspace:*Creates a link to a package in another workspace
https://v3.yarnpkg.com/features/protocols https://yarnpkg.com/protocols https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies

TSnapshot

export type TSnapshot = Record<string, TEntry>

export type TEntry = {
  name:       string
  version:    string
  ranges:     string[]
  hashes:     {
    sha512?:  string
    sha256?:  string
    sha1?:    string
    checksum?: string
    md5?:     string
  }
  source:     {
    type:     TSourceType // npm, workspace, gh, patch, etc
    id:       string
    registry?: string
  }
  // optional pm-specific lockfile meta
  manifest?:              TManifest
  conditions?:            string
  dependencies?:          TDependencies
  dependenciesMeta?:      TDependenciesMeta
  devDependencies?:       TDependencies
  optionalDependencies?:  TDependencies
  peerDependencies?:      TDependencies
  peerDependenciesMeta?:  TDependenciesMeta
  bin?:                   Record<string, string>
  engines?:               Record<string, string>
  funding?:               Record<string, string>
}

TSnapshotIndex

export interface TSnapshotIndex {
  snapshot: TSnapshot
  entries:  TEntry[]
  roots:    TEntry[]
  edges:    [string, string][]
  tree:       Record<string, {
    key:      string
    chunks:   string[]
    parents:  TEntry[]
    id:       string
    name:     string
    version:  string
    entry:    TEntry
    depth:    number // the lowest level where the dep@ver first time occurs
  }>
  prod: Set<TEntry>
  getEntryId ({name, version}: TEntry): string
  getEntry (name: string, version?: string): TEntry | undefined,
  getEntryByRange (name: string, range: string): TEntry | undefined
  getEntryDeps(entry: TEntry): TEntry[]
}

Caveats

  • There is an infinite number of nmtrees that corresponds to the specified deptree, but among them there is a finite set of effective (sufficient) for the target criterion โ€” for example, nesting, size, homogeneity of versions
  • npm1: optional: true label is not supported yet
  • yarn berry: no idea how to resolve and inject PnP patches https://github.com/yarnpkg/berry/tree/master/packages/plugin-compat
npm2 and npm3 requires engines and funding data, while yarn or npm1 does not contain it
  • many nmtree projections may correspond to the specified depgraph
  • pkg.json resolutions and overrides directives are completely ignored for now
  • pkg aliases are not fully supported yet #2

Snippets

Extracts all deps by depth:
const getDepsByDepth = (idx: TSnapshotIndex, depth = 0) => Object.values(idx.tree)
  .filter(({depth: d}) => d === depth)
  .map(({entry}) => entry)

Get the longest dep chain:

const getLongestChain = (): TEntry[] => {
  let max = 0
  let chain: TEntry[] = []

  for (const e of Object.values(idx.tree)) {
    if (e.depth > max) {
      max = e.depth
      chain = [...e.parents, e.entry]
    }
  }
  return chain
}

constole.log(
  getLongestChain()
    .map((e) => idx.getEntryId(e))
    .join(' -> ')
)

Inspired by

Refs

more

License

MIT