|
4 | 4 | "encoding/json" |
5 | 5 | "fmt" |
6 | 6 | "strings" |
| 7 | + |
| 8 | + "gopkg.in/yaml.v3" |
7 | 9 | ) |
8 | 10 |
|
9 | 11 | // LockfilePackage represents a single resolved package from a lockfile. |
@@ -200,3 +202,208 @@ func GlobalNodeModulesDir(prefix, goos string) string { |
200 | 202 | return prefix + "/lib/node_modules" |
201 | 203 | } |
202 | 204 | } |
| 205 | + |
| 206 | +// ---------- yarn.lock v1 parsing ---------- |
| 207 | +// |
| 208 | +// Yarn v1 lockfile uses a custom format (not JSON or YAML): |
| 209 | +// |
| 210 | +// "react@^19.0.0", "react@^19.2.5": |
| 211 | +// version "19.2.5" |
| 212 | +// resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#..." |
| 213 | +// integrity sha512-... |
| 214 | +// dependencies: |
| 215 | +// scheduler "^0.27.0" |
| 216 | +// |
| 217 | +// Each block starts with a non-indented header of name@range(s), followed by |
| 218 | +// indented key-value pairs. We extract version from each block. |
| 219 | + |
| 220 | +// ParseYarnLockV1 parses a yarn.lock v1 file and returns resolved packages. |
| 221 | +func ParseYarnLockV1(data []byte) (*LockfileResult, error) { |
| 222 | + content := string(data) |
| 223 | + lines := strings.Split(content, "\n") |
| 224 | + |
| 225 | + var pkgs []LockfilePackage |
| 226 | + var currentName string |
| 227 | + |
| 228 | + for _, line := range lines { |
| 229 | + // Skip comments and empty lines |
| 230 | + if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { |
| 231 | + continue |
| 232 | + } |
| 233 | + |
| 234 | + // Non-indented line = new block header |
| 235 | + if len(line) > 0 && line[0] != ' ' && line[0] != '\t' { |
| 236 | + currentName = extractYarnV1PackageName(line) |
| 237 | + continue |
| 238 | + } |
| 239 | + |
| 240 | + // Indented line inside a block — look for "version" |
| 241 | + trimmed := strings.TrimSpace(line) |
| 242 | + if currentName != "" && strings.HasPrefix(trimmed, "version ") { |
| 243 | + version := strings.TrimPrefix(trimmed, "version ") |
| 244 | + version = strings.Trim(version, `"`) |
| 245 | + pkgs = append(pkgs, LockfilePackage{ |
| 246 | + Name: currentName, |
| 247 | + Version: version, |
| 248 | + }) |
| 249 | + currentName = "" // only take first version per block |
| 250 | + } |
| 251 | + } |
| 252 | + |
| 253 | + return &LockfileResult{ |
| 254 | + Source: "lockfile", |
| 255 | + LockfileFormat: "yarn-v1", |
| 256 | + LockfileVersion: 1, |
| 257 | + Packages: pkgs, |
| 258 | + }, nil |
| 259 | +} |
| 260 | + |
| 261 | +// extractYarnV1PackageName extracts the package name from a yarn.lock v1 header line. |
| 262 | +// Header formats: |
| 263 | +// |
| 264 | +// "react@^19.0.0": |
| 265 | +// react@^19.0.0: |
| 266 | +// "react-dom@^19.2.5", "react-dom@^19.0.0": |
| 267 | +// "@babel/core@^7.0.0": |
| 268 | +func extractYarnV1PackageName(header string) string { |
| 269 | + // Take the first entry (before any comma) |
| 270 | + if idx := strings.Index(header, ","); idx >= 0 { |
| 271 | + header = header[:idx] |
| 272 | + } |
| 273 | + header = strings.TrimSpace(header) |
| 274 | + header = strings.Trim(header, `":`) |
| 275 | + |
| 276 | + // Find the @ that separates name from version range |
| 277 | + // For scoped packages (@scope/name@range), skip the leading @ |
| 278 | + searchFrom := 0 |
| 279 | + if strings.HasPrefix(header, "@") { |
| 280 | + searchFrom = 1 |
| 281 | + } |
| 282 | + atIdx := strings.Index(header[searchFrom:], "@") |
| 283 | + if atIdx < 0 { |
| 284 | + return "" |
| 285 | + } |
| 286 | + return header[:searchFrom+atIdx] |
| 287 | +} |
| 288 | + |
| 289 | +// ---------- pnpm-lock.yaml parsing ---------- |
| 290 | +// |
| 291 | +// pnpm-lock.yaml (v5/v6/v9) structure: |
| 292 | +// |
| 293 | +// lockfileVersion: '9.0' |
| 294 | +// importers: |
| 295 | +// .: |
| 296 | +// dependencies: |
| 297 | +// fastify: |
| 298 | +// specifier: ^5.8.4 |
| 299 | +// version: 5.8.4 |
| 300 | +// packages: |
| 301 | +// '@fastify/ajv-compiler@4.0.5': |
| 302 | +// resolution: {integrity: sha512-...} |
| 303 | +// fastify@5.8.4: |
| 304 | +// resolution: {integrity: sha512-...} |
| 305 | +// snapshots: |
| 306 | +// fastify@5.8.4: |
| 307 | +// dependencies: |
| 308 | +// ... |
| 309 | + |
| 310 | +// pnpmLockfile is the top-level structure of pnpm-lock.yaml. |
| 311 | +type pnpmLockfile struct { |
| 312 | + LockfileVersion string `yaml:"lockfileVersion"` |
| 313 | + Packages map[string]pnpmPackageEntry `yaml:"packages"` |
| 314 | + Importers map[string]pnpmImporterEntry `yaml:"importers"` |
| 315 | +} |
| 316 | + |
| 317 | +type pnpmPackageEntry struct { |
| 318 | + Resolution map[string]string `yaml:"resolution"` |
| 319 | + Dev bool `yaml:"dev"` |
| 320 | +} |
| 321 | + |
| 322 | +type pnpmImporterEntry struct { |
| 323 | + Dependencies map[string]pnpmDepRef `yaml:"dependencies"` |
| 324 | + DevDependencies map[string]pnpmDepRef `yaml:"devDependencies"` |
| 325 | +} |
| 326 | + |
| 327 | +type pnpmDepRef struct { |
| 328 | + Specifier string `yaml:"specifier"` |
| 329 | + Version string `yaml:"version"` |
| 330 | +} |
| 331 | + |
| 332 | +// ParsePnpmLock parses a pnpm-lock.yaml file and returns resolved packages. |
| 333 | +func ParsePnpmLock(data []byte) (*LockfileResult, error) { |
| 334 | + var lock pnpmLockfile |
| 335 | + if err := yaml.Unmarshal(data, &lock); err != nil { |
| 336 | + return nil, fmt.Errorf("parse pnpm-lock.yaml: %w", err) |
| 337 | + } |
| 338 | + |
| 339 | + var pkgs []LockfilePackage |
| 340 | + |
| 341 | + // Build a set of dev dependency names from importers for tagging |
| 342 | + devDeps := make(map[string]bool) |
| 343 | + for _, imp := range lock.Importers { |
| 344 | + for name := range imp.DevDependencies { |
| 345 | + devDeps[name] = true |
| 346 | + } |
| 347 | + } |
| 348 | + |
| 349 | + // Parse packages map — keys are "name@version" or "@scope/name@version" |
| 350 | + for key := range lock.Packages { |
| 351 | + name, version := parsePnpmPackageKey(key) |
| 352 | + if name == "" || version == "" { |
| 353 | + continue |
| 354 | + } |
| 355 | + pkgs = append(pkgs, LockfilePackage{ |
| 356 | + Name: name, |
| 357 | + Version: version, |
| 358 | + Dev: devDeps[name], |
| 359 | + }) |
| 360 | + } |
| 361 | + |
| 362 | + // Determine lockfile version number |
| 363 | + lockVer := 0 |
| 364 | + if strings.HasPrefix(lock.LockfileVersion, "9") { |
| 365 | + lockVer = 9 |
| 366 | + } else if strings.HasPrefix(lock.LockfileVersion, "6") { |
| 367 | + lockVer = 6 |
| 368 | + } else if strings.HasPrefix(lock.LockfileVersion, "5") { |
| 369 | + lockVer = 5 |
| 370 | + } |
| 371 | + |
| 372 | + return &LockfileResult{ |
| 373 | + Source: "lockfile", |
| 374 | + LockfileFormat: fmt.Sprintf("pnpm-v%s", lock.LockfileVersion), |
| 375 | + LockfileVersion: lockVer, |
| 376 | + Packages: pkgs, |
| 377 | + }, nil |
| 378 | +} |
| 379 | + |
| 380 | +// parsePnpmPackageKey extracts name and version from a pnpm packages key. |
| 381 | +// Keys look like "express@4.18.2" or "@fastify/ajv-compiler@4.0.5" |
| 382 | +// or "/express@4.18.2" (older lockfile versions use leading slash). |
| 383 | +func parsePnpmPackageKey(key string) (name, version string) { |
| 384 | + // Strip leading slash (pnpm v5/v6 format) |
| 385 | + key = strings.TrimPrefix(key, "/") |
| 386 | + |
| 387 | + // For scoped packages: @scope/name@version |
| 388 | + // Find the last @ that separates name from version |
| 389 | + if strings.HasPrefix(key, "@") { |
| 390 | + // Scoped package: find @ after the first / |
| 391 | + slashIdx := strings.Index(key, "/") |
| 392 | + if slashIdx < 0 { |
| 393 | + return "", "" |
| 394 | + } |
| 395 | + atIdx := strings.LastIndex(key[slashIdx:], "@") |
| 396 | + if atIdx < 0 { |
| 397 | + return "", "" |
| 398 | + } |
| 399 | + atIdx += slashIdx |
| 400 | + return key[:atIdx], key[atIdx+1:] |
| 401 | + } |
| 402 | + |
| 403 | + // Unscoped package: name@version |
| 404 | + atIdx := strings.LastIndex(key, "@") |
| 405 | + if atIdx <= 0 { |
| 406 | + return "", "" |
| 407 | + } |
| 408 | + return key[:atIdx], key[atIdx+1:] |
| 409 | +} |
0 commit comments