Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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 ""
}