diff options
Diffstat (limited to 'internal/licensegen/scan_npm.go')
| -rw-r--r-- | internal/licensegen/scan_npm.go | 238 | +238 −0 |
1 files changed, 238 insertions, 0 deletions
diff --git a/internal/licensegen/scan_npm.go b/internal/licensegen/scan_npm.go new file mode 100644 --- /dev/null +++ b/internal/licensegen/scan_npm.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +const webDir = "cmd/mirum-server/web" + +// scanNPM reads package-lock.json (v3+), filters to runtime packages, reads +// each package's LICENSE verbatim (or synthesizes a copyright notice when +// none ships — canonical SPDX text is never substituted), and collapses the +// scopes listed in collapsedScopes into single "@scope" rows. +func scanNPM(root string) ([]Dep, error) { + var lock struct { + LockfileVersion int `json:"lockfileVersion"` + Packages map[string]struct { + Version string `json:"version"` + License any `json:"license"` + Dev, DevOptional, Link, Peer bool + } `json:"packages"` + } + raw, err := os.ReadFile(filepath.Join(root, webDir, "package-lock.json")) + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &lock); err != nil { + return nil, err + } + if lock.LockfileVersion < 3 { + return nil, fmt.Errorf("lockfileVersion %d unsupported, regenerate with npm v7+", lock.LockfileVersion) + } + + // npm hoists identical name@version under multiple paths; skip duplicates + // before the expensive LICENSE read. + seen := map[string]bool{} + var deps []Dep + for path, pkg := range lock.Packages { + if path == "" || pkg.Link || pkg.Dev || pkg.DevOptional { + continue + } + name := npmName(path) + key := name + "@" + pkg.Version + if seen[key] { + continue + } + seen[key] = true + + expr, err := npmSPDX(pkg.License) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + + atoms, err := validateSPDX(expr) + if err != nil { + return nil, fmt.Errorf("%s declares %q: %w", key, expr, err) + } + + pkgDir := filepath.Join(root, webDir, path) + deps = append(deps, Dep{ + Name: name, + Version: pkg.Version, + SPDX: expr, + URL: "https://www.npmjs.com/package/" + name + "/v/" + pkg.Version, + atoms: atoms, + text: npmText(pkgDir, name), + }) + } + + scopeOf := func(name string) string { + if !strings.HasPrefix(name, "@") { + return "" + } + scope, _, ok := strings.Cut(name, "/") + if !ok { + return "" + } + return scope + } + + buckets := map[string][]Dep{} + var out []Dep + for _, d := range deps { + if s := scopeOf(d.Name); slices.Contains(collapsedScopes, s) { + buckets[s] = append(buckets[s], d) + continue + } + out = append(out, d) + } + + for scope, items := range buckets { + spdx := items[0].SPDX + version := items[0].Version + for _, d := range items[1:] { + if d.SPDX != spdx { + return nil, fmt.Errorf("scope %s: mixed SPDX %q vs %q (%s)", scope, spdx, d.SPDX, d.Name) + } + if d.Version != version { + version = "" + } + } + + // Prefer umbrella LICENSE, then any sub-package's own. + text, _, err := readLicenseFile(filepath.Join(root, webDir, "node_modules", strings.TrimPrefix(scope, "@"))) + if err != nil { + text = items[0].text + for _, d := range items { + if !strings.HasPrefix(d.text, "Copyright (c) contributors to ") { + text = d.text + break + } + } + } + + out = append(out, Dep{ + Name: strings.TrimPrefix(scope, "@"), + Version: version, + SPDX: spdx, + URL: "https://www.npmjs.com/~" + strings.TrimPrefix(scope, "@"), + Count: len(items), + atoms: items[0].atoms, + text: text, + }) + } + return out, nil +} + +// npmName extracts the package name from an npm lockfile key like +// "node_modules/foo" or "node_modules/foo/node_modules/@scope/bar". +func npmName(path string) string { + i := strings.LastIndex(path, "node_modules/") + if i < 0 { + return "" + } + n := path[i+len("node_modules/"):] + if strings.HasPrefix(n, "@") { + return n // scoped "@scope/name" is one name + } + head, _, _ := strings.Cut(n, "/") + return head +} + +// npmSPDX normalises package.json's `license` field. Modern packages use a +// string; we accept legacy array-of-objects too. Outer parens are stripped +// so "(MIT OR Apache-2.0)" displays as "MIT OR Apache-2.0". +func npmSPDX(v any) (string, error) { + var s string + switch x := v.(type) { + case string: + s = strings.TrimSpace(x) + case []any: + var ids []string + for _, it := range x { + if m, ok := it.(map[string]any); ok { + if t, ok := m["type"].(string); ok && t != "" { + ids = append(ids, t) + } + } + } + s = strings.Join(ids, " OR ") + } + if s == "" { + return "", fmt.Errorf("no license field") + } + for strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { + s = strings.TrimSpace(s[1 : len(s)-1]) + } + return s, nil +} + +// npmText returns the LICENSE text shipped with a package, or a copyright +// notice derived from package.json when no LICENSE file exists. Canonical +// SPDX text is never substituted — doing so would claim the author wrote +// something they didn't ship. +func npmText(pkgDir, name string) string { + if text, _, err := readLicenseFile(pkgDir); err == nil { + return text + } + if c := copyrightFromPackageJSON(pkgDir); c != "" { + return c + "\n" + } + return "Copyright (c) contributors to " + name + "\n" +} + +// copyrightFromPackageJSON builds a "Copyright (c) ..." line from the +// package's author/contributors fields. Returns empty if neither is present. +func copyrightFromPackageJSON(pkgDir string) string { + data, err := os.ReadFile(filepath.Join(pkgDir, "package.json")) + if err != nil { + return "" + } + var pj struct { + Author any `json:"author"` + Contributors []any `json:"contributors"` + } + if err := json.Unmarshal(data, &pj); err != nil { + return "" + } + var names []string + for _, v := range append([]any{pj.Author}, pj.Contributors...) { + if s := personName(v); s != "" { + names = append(names, s) + } + } + if len(names) == 0 { + return "" + } + return "Copyright (c) " + strings.Join(names, ", ") +} + +// personName renders an npm author/contributor entry (string or {name,email}) +// as "Name <email>" or just "Name". +func personName(v any) string { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + case map[string]any: + name, _ := x["name"].(string) + email, _ := x["email"].(string) + name = strings.TrimSpace(name) + email = strings.TrimSpace(email) + if name == "" { + return "" + } + if email == "" { + return name + } + return name + " <" + email + ">" + } + return "" +} |
