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
494 lines
16 KiB
Go
494 lines
16 KiB
Go
package modules
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// A package-manager workspace is a root manifest that declares a set of
|
|
// member packages — distinct from Gortex's repository-grouping
|
|
// WorkspaceID and from cross-repo edges. Three ecosystems express it:
|
|
//
|
|
// - npm / yarn: a root package.json with a "workspaces" array (or the
|
|
// `{ "workspaces": { "packages": [...] } }` object form yarn also
|
|
// accepts). Entries are glob patterns or plain directory paths.
|
|
// - pnpm: a pnpm-workspace.yaml with a top-level `packages:` list.
|
|
// - Cargo: a root Cargo.toml with `[workspace] members` (and an
|
|
// optional `exclude` list). Entries may be globs.
|
|
//
|
|
// Detection produces a WorkspaceManifest; ResolveWorkspaceMembers then
|
|
// expands the glob patterns against the filesystem to the concrete
|
|
// member manifest files. BuildWorkspaceArtifacts turns the resolved
|
|
// members into a root node plus one EdgePackageWorkspaceMember edge per
|
|
// member.
|
|
|
|
// WorkspaceEcosystem identifies which package manager owns a workspace
|
|
// root. The string value also namespaces the synthetic root node ID.
|
|
type WorkspaceEcosystem string
|
|
|
|
const (
|
|
// WorkspaceNPM covers both npm and yarn — they share the
|
|
// package.json "workspaces" field and the same glob semantics, so
|
|
// a single ecosystem tag is enough.
|
|
WorkspaceNPM WorkspaceEcosystem = "npm"
|
|
WorkspacePnpm WorkspaceEcosystem = "pnpm"
|
|
WorkspaceCargo WorkspaceEcosystem = "cargo"
|
|
)
|
|
|
|
// WorkspaceManifest is a detected package-manager workspace root: which
|
|
// ecosystem owns it, the repo-relative path of the root manifest file,
|
|
// and the verbatim member patterns it declares. Patterns are not yet
|
|
// resolved — ResolveWorkspaceMembers expands them.
|
|
type WorkspaceManifest struct {
|
|
Ecosystem WorkspaceEcosystem
|
|
// ManifestPath is the repo-relative path of the root manifest
|
|
// (e.g. "package.json", "pnpm-workspace.yaml", "Cargo.toml").
|
|
ManifestPath string
|
|
// Patterns are the declared member entries — glob patterns
|
|
// (`packages/*`) or plain directory paths (`apps/web`).
|
|
Patterns []string
|
|
// Exclude holds patterns explicitly excluded from membership.
|
|
// Only Cargo's `[workspace] exclude` populates this; npm/pnpm have
|
|
// no exclude list, so it stays nil for them.
|
|
Exclude []string
|
|
}
|
|
|
|
// WorkspaceMember is one resolved member package of a workspace: the
|
|
// repo-relative path of its own manifest file (the file node the
|
|
// membership edge targets) and the repo-relative directory it lives in.
|
|
type WorkspaceMember struct {
|
|
// ManifestPath is the repo-relative path of the member's manifest
|
|
// (package.json for npm/pnpm, Cargo.toml for cargo).
|
|
ManifestPath string
|
|
// Dir is the repo-relative directory of the member package.
|
|
Dir string
|
|
}
|
|
|
|
// memberManifestName returns the manifest filename a member package of
|
|
// the given ecosystem carries at its directory root.
|
|
func memberManifestName(eco WorkspaceEcosystem) string {
|
|
if eco == WorkspaceCargo {
|
|
return "Cargo.toml"
|
|
}
|
|
return "package.json"
|
|
}
|
|
|
|
// ParsePackageJSONWorkspaces inspects an npm/yarn root package.json and
|
|
// returns the declared workspace member patterns. Both the array form
|
|
// (`"workspaces": ["packages/*"]`) and the object form yarn also
|
|
// accepts (`"workspaces": { "packages": ["packages/*"] }`) are handled.
|
|
// Returns nil when the manifest declares no workspaces — that repo is
|
|
// simply not a workspace root.
|
|
func ParsePackageJSONWorkspaces(source []byte) []string {
|
|
if len(source) == 0 {
|
|
return nil
|
|
}
|
|
// The "workspaces" field is polymorphic: a JSON array of strings,
|
|
// or an object with a "packages" array (and an optional "nohoist"
|
|
// the graph doesn't care about). Decode into json.RawMessage first
|
|
// so we can branch on the concrete shape.
|
|
var manifest struct {
|
|
Workspaces json.RawMessage `json:"workspaces"`
|
|
}
|
|
if err := json.Unmarshal(source, &manifest); err != nil {
|
|
return nil
|
|
}
|
|
if len(manifest.Workspaces) == 0 {
|
|
return nil
|
|
}
|
|
// Array form.
|
|
var asArray []string
|
|
if err := json.Unmarshal(manifest.Workspaces, &asArray); err == nil {
|
|
return cleanPatterns(asArray)
|
|
}
|
|
// Object form.
|
|
var asObject struct {
|
|
Packages []string `json:"packages"`
|
|
}
|
|
if err := json.Unmarshal(manifest.Workspaces, &asObject); err == nil {
|
|
return cleanPatterns(asObject.Packages)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParsePnpmWorkspaceYAML inspects a pnpm-workspace.yaml file and returns
|
|
// the declared member patterns from its top-level `packages:` list. The
|
|
// file is parsed with a line-oriented scan rather than a YAML library:
|
|
// pnpm-workspace.yaml is tiny and flat, and the scan keeps the parser
|
|
// dependency-free and independent of YAML-library list-style quirks
|
|
// (block sequences vs. flow sequences). Returns nil when no `packages:`
|
|
// list is present.
|
|
func ParsePnpmWorkspaceYAML(source []byte) []string {
|
|
if len(source) == 0 {
|
|
return nil
|
|
}
|
|
var (
|
|
patterns []string
|
|
inPackages bool
|
|
)
|
|
for _, raw := range strings.Split(string(source), "\n") {
|
|
line := strings.TrimRight(raw, "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
// A non-indented line starts a new top-level key. `packages:`
|
|
// opens the section we want; any other top-level key closes it.
|
|
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
|
// The key is the text before the first ':'. Taking the
|
|
// substring (rather than trimming a trailing ':') keeps
|
|
// `packages: [...]` — an inline flow sequence on the same
|
|
// line — recognised as the `packages` key.
|
|
key := trimmed
|
|
if i := strings.IndexByte(trimmed, ':'); i >= 0 {
|
|
key = trimmed[:i]
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
// `packages:` may inline a flow sequence on the same line
|
|
// (`packages: ["a/*", "b/*"]`) — handle that before
|
|
// switching into block-sequence mode.
|
|
if key == "packages" {
|
|
if idx := strings.Index(trimmed, "["); idx >= 0 {
|
|
patterns = append(patterns, parseYAMLFlowSequence(trimmed[idx:])...)
|
|
inPackages = false
|
|
continue
|
|
}
|
|
inPackages = true
|
|
continue
|
|
}
|
|
inPackages = false
|
|
continue
|
|
}
|
|
if !inPackages {
|
|
continue
|
|
}
|
|
// Inside `packages:` — block-sequence entries look like
|
|
// ` - 'packages/*'`.
|
|
if !strings.HasPrefix(trimmed, "-") {
|
|
continue
|
|
}
|
|
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
|
item = unquoteYAMLScalar(item)
|
|
if item != "" {
|
|
patterns = append(patterns, item)
|
|
}
|
|
}
|
|
return cleanPatterns(patterns)
|
|
}
|
|
|
|
// ParseCargoWorkspace inspects a Cargo.toml and returns the member and
|
|
// exclude patterns from its `[workspace]` table. A Cargo manifest can
|
|
// be a workspace root, a regular crate, or both at once (a root crate);
|
|
// only the `[workspace]` table matters here. Returns (nil, nil) when
|
|
// the manifest has no `[workspace]` table — that crate is not a
|
|
// workspace root.
|
|
func ParseCargoWorkspace(source []byte) (members, exclude []string) {
|
|
if len(source) == 0 {
|
|
return nil, nil
|
|
}
|
|
var manifest struct {
|
|
Workspace *struct {
|
|
Members []string `toml:"members"`
|
|
Exclude []string `toml:"exclude"`
|
|
} `toml:"workspace"`
|
|
}
|
|
if err := toml.Unmarshal(source, &manifest); err != nil {
|
|
return nil, nil
|
|
}
|
|
if manifest.Workspace == nil {
|
|
return nil, nil
|
|
}
|
|
return cleanPatterns(manifest.Workspace.Members), cleanPatterns(manifest.Workspace.Exclude)
|
|
}
|
|
|
|
// DetectWorkspace reads the candidate root manifests at repoRoot and
|
|
// returns the first package-manager workspace it finds, or nil when the
|
|
// repo is not a workspace root. Detection order is npm/yarn → pnpm →
|
|
// Cargo; a repo realistically uses one package manager, so the first
|
|
// hit is the answer. repoRoot is an absolute filesystem path.
|
|
func DetectWorkspace(repoRoot string) *WorkspaceManifest {
|
|
if repoRoot == "" {
|
|
return nil
|
|
}
|
|
// npm / yarn — root package.json with a "workspaces" field.
|
|
if src, err := os.ReadFile(filepath.Join(repoRoot, "package.json")); err == nil {
|
|
if pats := ParsePackageJSONWorkspaces(src); len(pats) > 0 {
|
|
return &WorkspaceManifest{
|
|
Ecosystem: WorkspaceNPM,
|
|
ManifestPath: "package.json",
|
|
Patterns: pats,
|
|
}
|
|
}
|
|
}
|
|
// pnpm — pnpm-workspace.yaml with a `packages:` list.
|
|
if src, err := os.ReadFile(filepath.Join(repoRoot, "pnpm-workspace.yaml")); err == nil {
|
|
if pats := ParsePnpmWorkspaceYAML(src); len(pats) > 0 {
|
|
return &WorkspaceManifest{
|
|
Ecosystem: WorkspacePnpm,
|
|
ManifestPath: "pnpm-workspace.yaml",
|
|
Patterns: pats,
|
|
}
|
|
}
|
|
}
|
|
// Cargo — root Cargo.toml with a `[workspace]` table.
|
|
if src, err := os.ReadFile(filepath.Join(repoRoot, "Cargo.toml")); err == nil {
|
|
if members, exclude := ParseCargoWorkspace(src); len(members) > 0 {
|
|
return &WorkspaceManifest{
|
|
Ecosystem: WorkspaceCargo,
|
|
ManifestPath: "Cargo.toml",
|
|
Patterns: members,
|
|
Exclude: exclude,
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveWorkspaceMembers expands a WorkspaceManifest's member patterns
|
|
// against the filesystem under repoRoot (an absolute path) and returns
|
|
// one WorkspaceMember per directory that both matches a member pattern
|
|
// and actually carries the ecosystem's manifest file. Glob patterns
|
|
// (`packages/*`, `crates/*`) and plain directory paths are both
|
|
// supported; a directory matched by an exclude pattern is dropped.
|
|
//
|
|
// The workspace root's own directory is never returned as its own
|
|
// member — a root crate that also lists itself resolves to the root,
|
|
// which BuildWorkspaceArtifacts skips. Results are sorted by manifest
|
|
// path for deterministic edge emission.
|
|
func ResolveWorkspaceMembers(repoRoot string, m *WorkspaceManifest) []WorkspaceMember {
|
|
if repoRoot == "" || m == nil {
|
|
return nil
|
|
}
|
|
manifestName := memberManifestName(m.Ecosystem)
|
|
excluded := make(map[string]struct{})
|
|
for _, pat := range m.Exclude {
|
|
for _, dir := range expandMemberPattern(repoRoot, pat) {
|
|
excluded[dir] = struct{}{}
|
|
}
|
|
}
|
|
seen := make(map[string]struct{})
|
|
var members []WorkspaceMember
|
|
for _, pat := range m.Patterns {
|
|
for _, dir := range expandMemberPattern(repoRoot, pat) {
|
|
if _, skip := excluded[dir]; skip {
|
|
continue
|
|
}
|
|
// The root manifest's own directory (".") is the workspace
|
|
// root, not a member of itself.
|
|
if dir == "." {
|
|
continue
|
|
}
|
|
if _, dup := seen[dir]; dup {
|
|
continue
|
|
}
|
|
manifestRel := filepath.ToSlash(filepath.Join(dir, manifestName))
|
|
// A member directory only counts when it carries the
|
|
// ecosystem's manifest — a `packages/*` glob can match
|
|
// non-package directories (docs, fixtures) that should
|
|
// not become graph members.
|
|
if !fileExists(filepath.Join(repoRoot, filepath.FromSlash(manifestRel))) {
|
|
continue
|
|
}
|
|
seen[dir] = struct{}{}
|
|
members = append(members, WorkspaceMember{
|
|
ManifestPath: manifestRel,
|
|
Dir: dir,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(members, func(i, j int) bool {
|
|
return members[i].ManifestPath < members[j].ManifestPath
|
|
})
|
|
return members
|
|
}
|
|
|
|
// expandMemberPattern resolves one member pattern to the set of
|
|
// repo-relative directories it matches. A pattern with no glob
|
|
// metacharacter is treated as a literal directory path; a pattern with
|
|
// `*` / `?` / `[` is expanded with filepath.Glob. Only directories are
|
|
// returned. The returned paths are slash-separated and repo-relative;
|
|
// the root itself is returned as ".".
|
|
func expandMemberPattern(repoRoot, pattern string) []string {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" {
|
|
return nil
|
|
}
|
|
// Normalise to forward slashes and strip a trailing slash so
|
|
// `packages/` and `packages` behave identically.
|
|
pattern = strings.TrimSuffix(filepath.ToSlash(pattern), "/")
|
|
if pattern == "" || pattern == "." {
|
|
return []string{"."}
|
|
}
|
|
if !strings.ContainsAny(pattern, "*?[") {
|
|
// Literal directory path.
|
|
abs := filepath.Join(repoRoot, filepath.FromSlash(pattern))
|
|
if dirExists(abs) {
|
|
return []string{pattern}
|
|
}
|
|
return nil
|
|
}
|
|
// Glob pattern — expand against the filesystem.
|
|
matches, err := filepath.Glob(filepath.Join(repoRoot, filepath.FromSlash(pattern)))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var dirs []string
|
|
for _, abs := range matches {
|
|
if !dirExists(abs) {
|
|
continue
|
|
}
|
|
rel, err := filepath.Rel(repoRoot, abs)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
dirs = append(dirs, filepath.ToSlash(rel))
|
|
}
|
|
sort.Strings(dirs)
|
|
return dirs
|
|
}
|
|
|
|
// WorkspaceRootNodeID returns the canonical ID for a workspace-root
|
|
// node. The `pkgws::` prefix is a synthetic namespace (matching the
|
|
// `module::` / `external::` convention the exporter already
|
|
// recognises); ecosystem plus the repo-relative root directory keeps
|
|
// two workspace roots in the same repo — vanishingly rare, but a
|
|
// nested Cargo workspace inside an npm one is legal — distinct.
|
|
func WorkspaceRootNodeID(eco WorkspaceEcosystem, rootDir string) string {
|
|
rootDir = filepath.ToSlash(strings.TrimSpace(rootDir))
|
|
if rootDir == "" {
|
|
rootDir = "."
|
|
}
|
|
return "pkgws::" + string(eco) + ":" + rootDir
|
|
}
|
|
|
|
// BuildWorkspaceArtifacts turns a detected workspace and its resolved
|
|
// members into a (root node, edges) pair: one synthetic
|
|
// KindPackage root node and one EdgePackageWorkspaceMember edge from
|
|
// that root to each member's manifest file node. Returns (nil, nil)
|
|
// when there are no members — a workspace root with zero resolved
|
|
// members carries no graph signal.
|
|
//
|
|
// The root node's FilePath is the root manifest so navigation lands on
|
|
// the file declaring the workspace; Meta records the ecosystem and
|
|
// member count. Edge endpoints are unprefixed repo-relative paths —
|
|
// applyRepoPrefix downstream handles multi-repo namespacing.
|
|
func BuildWorkspaceArtifacts(m *WorkspaceManifest, members []WorkspaceMember) (*graph.Node, []*graph.Edge) {
|
|
if m == nil || len(members) == 0 {
|
|
return nil, nil
|
|
}
|
|
rootDir := filepath.ToSlash(filepath.Dir(m.ManifestPath))
|
|
rootID := WorkspaceRootNodeID(m.Ecosystem, rootDir)
|
|
root := &graph.Node{
|
|
ID: rootID,
|
|
Kind: graph.KindPackage,
|
|
Name: "workspace:" + string(m.Ecosystem),
|
|
FilePath: filepath.ToSlash(m.ManifestPath),
|
|
Language: manifestLanguage(m.ManifestPath),
|
|
Meta: map[string]any{
|
|
"package_workspace": true,
|
|
"ecosystem": string(m.Ecosystem),
|
|
"manifest": filepath.ToSlash(m.ManifestPath),
|
|
"member_count": len(members),
|
|
},
|
|
}
|
|
edges := make([]*graph.Edge, 0, len(members))
|
|
for _, mem := range members {
|
|
edges = append(edges, &graph.Edge{
|
|
From: rootID,
|
|
To: mem.ManifestPath,
|
|
Kind: graph.EdgePackageWorkspaceMember,
|
|
FilePath: filepath.ToSlash(m.ManifestPath),
|
|
Origin: graph.OriginASTResolved,
|
|
Meta: map[string]any{
|
|
"ecosystem": string(m.Ecosystem),
|
|
"member_dir": mem.Dir,
|
|
},
|
|
})
|
|
}
|
|
return root, edges
|
|
}
|
|
|
|
// cleanPatterns trims whitespace from each pattern and drops empties,
|
|
// preserving order.
|
|
func cleanPatterns(in []string) []string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(in))
|
|
for _, p := range in {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseYAMLFlowSequence parses a single-line YAML flow sequence
|
|
// (`["a/*", 'b/*']`) into its scalar entries. Only the flat
|
|
// string-list shape pnpm-workspace.yaml uses is supported.
|
|
func parseYAMLFlowSequence(s string) []string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(s, "[")
|
|
s = strings.TrimSuffix(s, "]")
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, part := range strings.Split(s, ",") {
|
|
item := unquoteYAMLScalar(strings.TrimSpace(part))
|
|
if item != "" {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unquoteYAMLScalar strips a single matching pair of single or double
|
|
// quotes from a YAML scalar. Unquoted scalars are returned unchanged.
|
|
func unquoteYAMLScalar(s string) string {
|
|
if len(s) >= 2 {
|
|
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// dirExists reports whether absPath exists and is a directory.
|
|
func dirExists(absPath string) bool {
|
|
info, err := os.Stat(absPath)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// fileExists reports whether absPath exists and is a regular file.
|
|
func fileExists(absPath string) bool {
|
|
info, err := os.Stat(absPath)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// manifestLanguage maps a workspace manifest path to the language tag
|
|
// stamped on its synthetic node. Kept local to the modules package so
|
|
// it does not depend on the indexer's identically-named helper.
|
|
func manifestLanguage(manifestPath string) string {
|
|
switch filepath.Base(manifestPath) {
|
|
case "package.json":
|
|
return "json"
|
|
case "pnpm-workspace.yaml":
|
|
return "yaml"
|
|
case "Cargo.toml":
|
|
return "toml"
|
|
}
|
|
return ""
|
|
}
|