a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
609 lines
19 KiB
Go
609 lines
19 KiB
Go
package indexer
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/zzet/gortex/internal/modules"
|
|
)
|
|
|
|
// npmAliasIndex implements resolver.NpmAliasResolver: it rewrites a
|
|
// JS/TS import specifier that resolves through an npm alias declared
|
|
// in the importing file's nearest-ancestor package.json.
|
|
//
|
|
// An npm alias declares a dependency under one name while pointing it
|
|
// at a different real package:
|
|
//
|
|
// "dependencies": { "shared": "npm:@acme/shared-lib@1.4.0" }
|
|
//
|
|
// `import x from 'shared'` then refers to `@acme/shared-lib`, and
|
|
// `import x from 'shared/util'` to `@acme/shared-lib/util`. The
|
|
// resolver only knows the bare specifier, so without this rewrite a
|
|
// locally-vendored `@acme/shared-lib` is missed and the import falls
|
|
// through to an external stub.
|
|
//
|
|
// The index is read-only after construction apart from its parsed-
|
|
// manifest cache, which is guarded by mu because the resolver's
|
|
// resolveEdge workers run in parallel.
|
|
type npmAliasIndex struct {
|
|
// roots maps a repo prefix to its on-disk root. Entries with an
|
|
// empty prefix model single-repo mode (no prefix on graph paths).
|
|
roots map[string]string
|
|
|
|
mu sync.Mutex
|
|
// aliasCache memoises one parsed package.json: disk path → the
|
|
// alias map (dependency key → real package name) for that file.
|
|
// A nil value records "read, but no npm-alias entries / missing
|
|
// file" so a miss is not re-read on every import edge.
|
|
aliasCache map[string]map[string]string
|
|
// exportsCache memoises the parsed `exports` subpath map of one
|
|
// package.json: disk path → packageExports. A nil value records
|
|
// "read, but no usable `exports` field / missing file" so a miss
|
|
// is not re-parsed on every import edge.
|
|
exportsCache map[string]*packageExports
|
|
// wsNames maps a monorepo workspace package's `name` (from its
|
|
// package.json) to its graph-path directory, built lazily once from the
|
|
// root manifests' `workspaces` globs. A non-nil empty map records "built,
|
|
// no workspaces" so it is not re-scanned.
|
|
wsNames map[string]string
|
|
}
|
|
|
|
// packageExports is one package.json's parsed `exports` field: the
|
|
// declaring package's own `"name"` plus its subpath map flattened to
|
|
// subpath-key → target file path. The name lets the resolver confirm
|
|
// an import addresses this very package before consulting the map.
|
|
type packageExports struct {
|
|
name string
|
|
// subpaths maps an `exports` key (`"."`, `"./feature"`, `"./*"`)
|
|
// to its resolved target file (`"./dist/feature.js"`), with the
|
|
// leading `./` kept so the matcher can splice wildcard tails.
|
|
subpaths map[string]string
|
|
}
|
|
|
|
// newNpmAliasIndex builds an index over the given repo roots. Returns
|
|
// nil when no usable root is supplied — callers treat a nil resolver
|
|
// as "no alias rewriting", which is the pre-feature behaviour.
|
|
func newNpmAliasIndex(roots map[string]string) *npmAliasIndex {
|
|
usable := make(map[string]string, len(roots))
|
|
for prefix, root := range roots {
|
|
if root != "" {
|
|
usable[prefix] = root
|
|
}
|
|
}
|
|
if len(usable) == 0 {
|
|
return nil
|
|
}
|
|
return &npmAliasIndex{
|
|
roots: usable,
|
|
aliasCache: map[string]map[string]string{},
|
|
exportsCache: map[string]*packageExports{},
|
|
}
|
|
}
|
|
|
|
// Resolve is the resolver.NpmAliasResolver entry point. callerFile is
|
|
// the importing file's graph path (repo-prefixed in multi-repo mode);
|
|
// specifier is the verbatim import specifier. It returns the specifier
|
|
// with its package portion swapped for the npm-alias real name, or ""
|
|
// when the specifier is not an npm alias for this importer.
|
|
func (x *npmAliasIndex) Resolve(callerFile, specifier string) string {
|
|
if x == nil || callerFile == "" || specifier == "" {
|
|
return ""
|
|
}
|
|
// Only JS/TS imports go through npm aliases. A relative or
|
|
// absolute specifier is a path import, never a package name.
|
|
if !isJSTSFile(callerFile) {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(specifier, ".") || strings.HasPrefix(specifier, "/") {
|
|
return ""
|
|
}
|
|
root, relDir, ok := x.locate(callerFile)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
// Workspace-package rewrite (longest-name-wins): a bare specifier whose
|
|
// leading segments name a monorepo workspace package rewrites to that
|
|
// package's directory + remaining sub-path. Returned as a relative
|
|
// specifier (resolved against the importing file) so it lands on the
|
|
// in-repo file regardless of tsconfig `paths`, and only when the file
|
|
// actually exists on disk — otherwise it falls through to the npm-alias /
|
|
// tsconfig resolution below.
|
|
if rel := x.workspaceRewrite(root, relDir, specifier); rel != "" {
|
|
return rel
|
|
}
|
|
pkgName, subPath := splitPackageSpecifier(specifier)
|
|
if pkgName == "" {
|
|
return ""
|
|
}
|
|
// Walk from the importing file's directory up to the repo root,
|
|
// stopping at the first package.json that declares the specifier
|
|
// — npm resolution honours the nearest manifest.
|
|
for dir := relDir; ; dir = path.Dir(dir) {
|
|
manifest := joinPath(root, joinRel(dir, "package.json"))
|
|
if real, found := x.aliasesFor(manifest)[pkgName]; found {
|
|
if subPath == "" {
|
|
return real
|
|
}
|
|
return real + "/" + subPath
|
|
}
|
|
// `exports` subpath map: when this manifest IS the imported
|
|
// package (its `"name"` matches the specifier's package
|
|
// portion), resolve the sub-path through the package's declared
|
|
// `exports` entry points rather than treating it as a bare
|
|
// directory import. `pkg/feature` → `pkg/dist/feature.js`.
|
|
if mapped := x.exportTargetFor(manifest, pkgName, subPath); mapped != "" {
|
|
return pkgName + "/" + mapped
|
|
}
|
|
if dir == "." || dir == "" || dir == "/" {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// exportTargetFor resolves an import of `pkgName` (sub-path `subPath`,
|
|
// "" for the package root) through the `exports` field of the
|
|
// package.json at absPath, but only when that manifest declares
|
|
// `pkgName` as its own `"name"`. It returns the mapped target file
|
|
// relative to the package root with the leading `./` stripped (so the
|
|
// caller can splice it after `pkgName`), or "" when the manifest is a
|
|
// different package, declares no `exports`, or maps no matching
|
|
// sub-path.
|
|
func (x *npmAliasIndex) exportTargetFor(absPath, pkgName, subPath string) string {
|
|
exp := x.exportsFor(absPath)
|
|
if exp == nil || exp.name != pkgName {
|
|
return ""
|
|
}
|
|
target := resolveExportsSubpath(exp.subpaths, subPath)
|
|
return strings.TrimPrefix(target, "./")
|
|
}
|
|
|
|
// locate resolves callerFile to (repoRoot, repoRelativeDir). The
|
|
// longest matching prefix wins so nested repo roots resolve to the
|
|
// most specific one.
|
|
func (x *npmAliasIndex) locate(callerFile string) (root, relDir string, ok bool) {
|
|
bestPrefix := ""
|
|
bestRoot := ""
|
|
for prefix, r := range x.roots {
|
|
switch {
|
|
case prefix == "":
|
|
// Single-repo mode: graph paths carry no prefix.
|
|
if bestRoot == "" {
|
|
bestRoot = r
|
|
}
|
|
case callerFile == prefix || strings.HasPrefix(callerFile, prefix+"/"):
|
|
if len(prefix) > len(bestPrefix) {
|
|
bestPrefix = prefix
|
|
bestRoot = r
|
|
}
|
|
}
|
|
}
|
|
if bestRoot == "" {
|
|
return "", "", false
|
|
}
|
|
rel := callerFile
|
|
if bestPrefix != "" {
|
|
rel = strings.TrimPrefix(callerFile, bestPrefix+"/")
|
|
}
|
|
return bestRoot, path.Dir(rel), true
|
|
}
|
|
|
|
// aliasesFor returns the npm-alias map (dependency key → real package
|
|
// name) parsed from the package.json at absPath, reading and caching
|
|
// it on first request. The result is never nil-returned to callers as
|
|
// a map — a missing or alias-free manifest yields an empty map so the
|
|
// caller's lookup is a clean miss.
|
|
func (x *npmAliasIndex) aliasesFor(absPath string) map[string]string {
|
|
x.mu.Lock()
|
|
defer x.mu.Unlock()
|
|
if cached, seen := x.aliasCache[absPath]; seen {
|
|
return cached
|
|
}
|
|
var aliases map[string]string
|
|
if src, ok := readDiskFile(absPath); ok {
|
|
for _, spec := range modules.ParsePackageJSON(src) {
|
|
if spec.Ecosystem != "npm" || spec.Alias == "" {
|
|
continue
|
|
}
|
|
if aliases == nil {
|
|
aliases = map[string]string{}
|
|
}
|
|
aliases[spec.Path] = spec.Alias
|
|
}
|
|
}
|
|
x.aliasCache[absPath] = aliases
|
|
return aliases
|
|
}
|
|
|
|
// exportsFor returns the parsed `exports` subpath map of the
|
|
// package.json at absPath, reading and caching it on first request. A
|
|
// nil result records "read, but no usable `exports` field / missing
|
|
// file" so a miss is not re-parsed on every import edge.
|
|
func (x *npmAliasIndex) exportsFor(absPath string) *packageExports {
|
|
x.mu.Lock()
|
|
defer x.mu.Unlock()
|
|
if cached, seen := x.exportsCache[absPath]; seen {
|
|
return cached
|
|
}
|
|
var exp *packageExports
|
|
if src, ok := readDiskFile(absPath); ok {
|
|
exp = parsePackageExports(src)
|
|
}
|
|
x.exportsCache[absPath] = exp
|
|
return exp
|
|
}
|
|
|
|
// parsePackageExports parses the `name` and `exports` fields of a
|
|
// package.json. The `exports` field is the modern package entry-point
|
|
// map; this flattens it to subpath-key → target file, handling the two
|
|
// target shapes npm supports:
|
|
//
|
|
// "./feature": "./dist/feature.js" (string)
|
|
// "./feature": { "import": "...", "default": "..." } (conditional)
|
|
//
|
|
// For a conditional object the `import` condition is preferred, then
|
|
// `default` — the order an ES-module consumer would resolve. A bare
|
|
// string `exports` (`"exports": "./index.js"`) is treated as the `"."`
|
|
// root entry. Returns nil when the manifest is unparseable or declares
|
|
// no usable `exports` map.
|
|
func parsePackageExports(source []byte) *packageExports {
|
|
if len(source) == 0 {
|
|
return nil
|
|
}
|
|
var manifest struct {
|
|
Name string `json:"name"`
|
|
Exports json.RawMessage `json:"exports"`
|
|
}
|
|
if err := json.Unmarshal(source, &manifest); err != nil {
|
|
return nil
|
|
}
|
|
if len(manifest.Exports) == 0 {
|
|
return nil
|
|
}
|
|
subpaths := map[string]string{}
|
|
// A string `exports` is the package root entry point; an object is
|
|
// the subpath map. Conditional objects keyed by condition (`import`
|
|
// / `default`) at the top level also collapse to the `"."` root.
|
|
var asString string
|
|
if err := json.Unmarshal(manifest.Exports, &asString); err == nil {
|
|
if t := strings.TrimSpace(asString); t != "" {
|
|
subpaths["."] = t
|
|
}
|
|
} else {
|
|
var asMap map[string]json.RawMessage
|
|
if err := json.Unmarshal(manifest.Exports, &asMap); err != nil {
|
|
return nil
|
|
}
|
|
// A top-level conditional object (no `"."` / `"./..."` keys, just
|
|
// `import` / `default`) is the root entry point in disguise.
|
|
if !hasSubpathKeys(asMap) {
|
|
if t := pickConditionalTarget(manifest.Exports); t != "" {
|
|
subpaths["."] = t
|
|
}
|
|
} else {
|
|
for key, raw := range asMap {
|
|
if t := pickConditionalTarget(raw); t != "" {
|
|
subpaths[key] = t
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(subpaths) == 0 {
|
|
return nil
|
|
}
|
|
return &packageExports{name: manifest.Name, subpaths: subpaths}
|
|
}
|
|
|
|
// hasSubpathKeys reports whether an `exports` object is a subpath map
|
|
// (keys begin with `.`) rather than a bare top-level conditional object
|
|
// (keys like `import` / `default`). An empty map is treated as a
|
|
// subpath map — there is nothing to collapse to the root.
|
|
func hasSubpathKeys(m map[string]json.RawMessage) bool {
|
|
for key := range m {
|
|
if strings.HasPrefix(key, ".") {
|
|
return true
|
|
}
|
|
}
|
|
return len(m) == 0
|
|
}
|
|
|
|
// pickConditionalTarget resolves one `exports` value to a target file
|
|
// string. A JSON string is returned verbatim; a conditional object
|
|
// (`{ "import": "...", "require": "...", "default": "..." }`) is
|
|
// resolved by preferring the `import` condition, then `default` — the
|
|
// resolution an ES-module consumer performs. Returns "" for any other
|
|
// shape (nested arrays, the `null` block-out sentinel, unknown
|
|
// conditions only).
|
|
func pickConditionalTarget(raw json.RawMessage) string {
|
|
var asString string
|
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
|
return strings.TrimSpace(asString)
|
|
}
|
|
var conditions map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &conditions); err != nil {
|
|
return ""
|
|
}
|
|
for _, cond := range []string{"import", "default"} {
|
|
if sub, ok := conditions[cond]; ok {
|
|
if t := pickConditionalTarget(sub); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveExportsSubpath matches an imported sub-path against a parsed
|
|
// `exports` subpath map and returns the mapped target file (leading
|
|
// `./` preserved), or "" for no match. The import key is the package
|
|
// root (`"."`) when subPath is empty, else `"./" + subPath`. Matching
|
|
// order mirrors Node: an exact key wins; otherwise the longest `./*`
|
|
// wildcard whose static prefix matches splices the captured tail into
|
|
// the target's own `*`. A sub-path containing a `..` segment escapes
|
|
// the package and is rejected outright, as Node's resolver does.
|
|
func resolveExportsSubpath(subpaths map[string]string, subPath string) string {
|
|
if len(subpaths) == 0 || hasDotDotSegment(subPath) {
|
|
return ""
|
|
}
|
|
key := "."
|
|
if subPath != "" {
|
|
key = "./" + subPath
|
|
}
|
|
if target, ok := subpaths[key]; ok {
|
|
return target
|
|
}
|
|
bestPrefixLen := -1
|
|
best := ""
|
|
for pat, target := range subpaths {
|
|
star := strings.IndexByte(pat, '*')
|
|
if star < 0 {
|
|
continue
|
|
}
|
|
prefix := pat[:star]
|
|
suffix := pat[star+1:]
|
|
if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, suffix) {
|
|
continue
|
|
}
|
|
if len(key) < len(prefix)+len(suffix) {
|
|
continue
|
|
}
|
|
if len(prefix) <= bestPrefixLen {
|
|
continue
|
|
}
|
|
captured := key[len(prefix) : len(key)-len(suffix)]
|
|
best = strings.Replace(target, "*", captured, 1)
|
|
bestPrefixLen = len(prefix)
|
|
}
|
|
return best
|
|
}
|
|
|
|
// hasDotDotSegment reports whether a slash-separated sub-path contains
|
|
// a `..` segment — a parent-directory escape an `exports` lookup must
|
|
// reject so a wildcard like `./*` can never resolve outside the
|
|
// package.
|
|
func hasDotDotSegment(subPath string) bool {
|
|
for _, seg := range strings.Split(subPath, "/") {
|
|
if seg == ".." {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// splitPackageSpecifier splits an import specifier into its package
|
|
// name and the sub-path within that package. A scoped package keeps
|
|
// its `@scope/name` as the package portion:
|
|
//
|
|
// "shared" → ("shared", "")
|
|
// "shared/util" → ("shared", "util")
|
|
// "@acme/lib" → ("@acme/lib", "")
|
|
// "@acme/lib/util/x" → ("@acme/lib", "util/x")
|
|
func splitPackageSpecifier(specifier string) (pkgName, subPath string) {
|
|
parts := strings.SplitN(specifier, "/", 4)
|
|
if strings.HasPrefix(specifier, "@") {
|
|
// Scoped: the first two segments form the package name.
|
|
if len(parts) < 2 || parts[0] == "@" || parts[1] == "" {
|
|
return "", ""
|
|
}
|
|
pkgName = parts[0] + "/" + parts[1]
|
|
subPath = strings.TrimPrefix(specifier, pkgName)
|
|
return pkgName, strings.TrimPrefix(subPath, "/")
|
|
}
|
|
pkgName = parts[0]
|
|
subPath = strings.TrimPrefix(specifier, pkgName)
|
|
return pkgName, strings.TrimPrefix(subPath, "/")
|
|
}
|
|
|
|
// joinRel joins a repo-relative directory with a file name, treating
|
|
// the repo root (".") as no directory prefix.
|
|
func joinRel(dir, name string) string {
|
|
if dir == "." || dir == "" {
|
|
return name
|
|
}
|
|
return dir + "/" + name
|
|
}
|
|
|
|
// isJSTSFile reports whether filePath has a JavaScript/TypeScript
|
|
// extension — the only files whose imports resolve through npm.
|
|
func isJSTSFile(filePath string) bool {
|
|
switch path.Ext(filePath) {
|
|
case ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// workspaceRewrite resolves a specifier whose leading segments name a monorepo
|
|
// workspace package to a relative import of the matching in-repo file. The
|
|
// longest workspace-package name that is a segment-prefix of the specifier
|
|
// wins (so `@acme/ui-core/button` binds `@acme/ui-core`, never `@acme/ui`).
|
|
// Returns a `../`-relative specifier from relDir to the resolved file stem, or
|
|
// "" when no workspace package matches or no file exists on disk.
|
|
func (x *npmAliasIndex) workspaceRewrite(root, relDir, specifier string) string {
|
|
names := x.workspaceNames()
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
segs := strings.Split(specifier, "/")
|
|
minSegs := 1
|
|
if strings.HasPrefix(specifier, "@") {
|
|
minSegs = 2
|
|
}
|
|
for n := len(segs); n >= minSegs; n-- {
|
|
dir, ok := names[strings.Join(segs[:n], "/")]
|
|
if !ok {
|
|
continue
|
|
}
|
|
stem := dir
|
|
if sub := strings.Join(segs[n:], "/"); sub != "" {
|
|
stem = dir + "/" + sub
|
|
}
|
|
if !workspaceFileExists(root, stem) {
|
|
return ""
|
|
}
|
|
return relativeImportSpecifier(relDir, stem)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// workspaceFileExists reports whether the repo-relative JS/TS module stem
|
|
// resolves to a file on disk under root — either `stem.<ext>` or
|
|
// `stem/index.<ext>`.
|
|
func workspaceFileExists(root, stem string) bool {
|
|
abs := filepath.Join(root, filepath.FromSlash(stem))
|
|
for _, ext := range []string{".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"} {
|
|
if fi, err := os.Stat(abs + ext); err == nil && !fi.IsDir() {
|
|
return true
|
|
}
|
|
}
|
|
for _, ext := range []string{".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"} {
|
|
if fi, err := os.Stat(filepath.Join(abs, "index"+ext)); err == nil && !fi.IsDir() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// relativeImportSpecifier returns a `./`/`../`-prefixed specifier that resolves
|
|
// from the importing file's directory fromDir to the repo-relative target stem.
|
|
func relativeImportSpecifier(fromDir, toStem string) string {
|
|
fromSegs := splitNonEmptyPath(fromDir)
|
|
toSegs := splitNonEmptyPath(toStem)
|
|
i := 0
|
|
for i < len(fromSegs) && i < len(toSegs) && fromSegs[i] == toSegs[i] {
|
|
i++
|
|
}
|
|
var rel []string
|
|
for j := i; j < len(fromSegs); j++ {
|
|
rel = append(rel, "..")
|
|
}
|
|
rel = append(rel, toSegs[i:]...)
|
|
if len(rel) == 0 {
|
|
return "."
|
|
}
|
|
p := strings.Join(rel, "/")
|
|
if !strings.HasPrefix(p, ".") {
|
|
p = "./" + p
|
|
}
|
|
return p
|
|
}
|
|
|
|
func splitNonEmptyPath(p string) []string {
|
|
var out []string
|
|
for _, seg := range strings.Split(p, "/") {
|
|
if seg != "" && seg != "." {
|
|
out = append(out, seg)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// workspaceNames builds (once) the workspace-package name → graph-path-dir map
|
|
// from each root manifest's `workspaces` globs.
|
|
func (x *npmAliasIndex) workspaceNames() map[string]string {
|
|
x.mu.Lock()
|
|
defer x.mu.Unlock()
|
|
if x.wsNames != nil {
|
|
return x.wsNames
|
|
}
|
|
names := map[string]string{}
|
|
for prefix, root := range x.roots {
|
|
for _, pkgDir := range workspacePackageDirs(root) {
|
|
src, ok := readDiskFile(joinPath(root, joinRel(pkgDir, "package.json")))
|
|
if !ok {
|
|
continue
|
|
}
|
|
var m struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if json.Unmarshal(src, &m) != nil || m.Name == "" {
|
|
continue
|
|
}
|
|
dir := pkgDir
|
|
if prefix != "" {
|
|
dir = prefix + "/" + pkgDir
|
|
}
|
|
names[m.Name] = dir
|
|
}
|
|
}
|
|
x.wsNames = names
|
|
return names
|
|
}
|
|
|
|
// workspacePackageDirs returns the repo-relative directories of every workspace
|
|
// package declared by the root package.json's `workspaces` field (npm/yarn
|
|
// array form and the yarn `{ "packages": [...] }` object form), expanding each
|
|
// glob against disk.
|
|
func workspacePackageDirs(root string) []string {
|
|
src, ok := readDiskFile(joinPath(root, "package.json"))
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var m struct {
|
|
Workspaces json.RawMessage `json:"workspaces"`
|
|
}
|
|
if json.Unmarshal(src, &m) != nil {
|
|
return nil
|
|
}
|
|
var dirs []string
|
|
for _, glob := range parseWorkspacesField(m.Workspaces) {
|
|
matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(glob)))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, abs := range matches {
|
|
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
|
|
continue
|
|
}
|
|
rel, err := filepath.Rel(root, abs)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
dirs = append(dirs, filepath.ToSlash(rel))
|
|
}
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// parseWorkspacesField normalises the two `workspaces` shapes (a bare array of
|
|
// globs, or a `{ "packages": [...] }` object) to a glob list.
|
|
func parseWorkspacesField(raw json.RawMessage) []string {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var asArray []string
|
|
if json.Unmarshal(raw, &asArray) == nil {
|
|
return asArray
|
|
}
|
|
var asObj struct {
|
|
Packages []string `json:"packages"`
|
|
}
|
|
if json.Unmarshal(raw, &asObj) == nil {
|
|
return asObj.Packages
|
|
}
|
|
return nil
|
|
}
|