chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user