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
351 lines
9.8 KiB
Go
351 lines
9.8 KiB
Go
// Package artifacts materialises the `.gortex.yaml::artifacts`
|
|
// manifest — non-code knowledge files such as DB schemas, API specs,
|
|
// infra configs, and architecture docs — into first-class
|
|
// KindArtifact graph nodes linked by EdgeReferences to the symbols
|
|
// they mention.
|
|
//
|
|
// Artifacts are the structured slice of "context" the import graph
|
|
// never sees: the OpenAPI spec a handler implements, the SQL schema a
|
|
// model mirrors, the ADR that explains why a package exists. Tracking
|
|
// them as graph nodes lets an agent pull the right spec alongside the
|
|
// code via search_artifacts / get_artifact.
|
|
package artifacts
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// minRefTokenLen is the shortest symbol name considered for reference
|
|
// matching. Shorter identifiers ("ID", "Get", "New") collide with
|
|
// ordinary prose and would link an artifact to half the graph.
|
|
const minRefTokenLen = 4
|
|
|
|
// maxScanBytes caps how much of a file is scanned for symbol
|
|
// references. The content hash still covers the whole file.
|
|
const maxScanBytes = 1 << 20 // 1 MiB
|
|
|
|
// maxRefsPerArtifact bounds the EdgeReferences fan-out of one
|
|
// artifact so a schema naming hundreds of types stays navigable.
|
|
const maxRefsPerArtifact = 200
|
|
|
|
// Artifact is one materialised knowledge file.
|
|
type Artifact struct {
|
|
ID string `json:"id"` // graph node ID — artifact::<path>
|
|
Path string `json:"path"` // repo-relative file path
|
|
Name string `json:"name"` // display name
|
|
Kind string `json:"kind"` // schema | api | infra | doc
|
|
ContentHash string `json:"content_hash"` // sha256 hex of the file
|
|
Size int `json:"size"` // byte length
|
|
References []string `json:"references"` // symbol node IDs the artifact mentions
|
|
}
|
|
|
|
// Materialize reads the configured artifact files under root, builds
|
|
// KindArtifact nodes, and links each to the symbols it mentions via
|
|
// EdgeReferences. Returns the artifacts materialised, sorted by path.
|
|
//
|
|
// repoPrefix scopes node IDs / paths in a multi-repo graph; pass ""
|
|
// for a single-repo graph. Best-effort — missing or unreadable files
|
|
// are skipped rather than failing the whole pass.
|
|
func Materialize(g graph.Store, root string, entries []config.ArtifactEntry, repoPrefix string) []Artifact {
|
|
if g == nil || root == "" || len(entries) == 0 {
|
|
return nil
|
|
}
|
|
nameIndex := buildSymbolIndex(g, repoPrefix)
|
|
|
|
seen := make(map[string]bool)
|
|
var out []Artifact
|
|
for _, entry := range entries {
|
|
for _, rel := range expandGlob(root, entry.Path) {
|
|
if seen[rel] {
|
|
continue
|
|
}
|
|
seen[rel] = true
|
|
art, ok := materializeOne(g, root, rel, entry, repoPrefix, nameIndex)
|
|
if ok {
|
|
out = append(out, art)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out
|
|
}
|
|
|
|
// materializeOne reads one artifact file and projects it onto the graph.
|
|
func materializeOne(g graph.Store, root, rel string, entry config.ArtifactEntry, repoPrefix string, nameIndex map[string][]string) (Artifact, bool) {
|
|
data, err := os.ReadFile(filepath.Join(root, rel))
|
|
if err != nil {
|
|
return Artifact{}, false
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
hash := hex.EncodeToString(sum[:])
|
|
|
|
kind := strings.TrimSpace(entry.Kind)
|
|
if kind == "" {
|
|
kind = detectKind(rel)
|
|
}
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name == "" {
|
|
name = path.Base(rel)
|
|
}
|
|
|
|
graphPath := rel
|
|
if repoPrefix != "" {
|
|
graphPath = repoPrefix + "/" + rel
|
|
}
|
|
nodeID := "artifact::" + graphPath
|
|
|
|
refs := scanReferences(data, nameIndex)
|
|
|
|
node := &graph.Node{
|
|
ID: nodeID,
|
|
Kind: graph.KindArtifact,
|
|
Name: name,
|
|
FilePath: graphPath,
|
|
RepoPrefix: repoPrefix,
|
|
Meta: map[string]any{
|
|
"artifact_kind": kind,
|
|
"content_hash": hash,
|
|
"size": len(data),
|
|
"title": name,
|
|
},
|
|
}
|
|
if repoPrefix != "" {
|
|
node.WorkspaceID = repoPrefix
|
|
}
|
|
g.AddNode(node)
|
|
|
|
for _, symID := range refs {
|
|
g.AddEdge(&graph.Edge{
|
|
From: nodeID,
|
|
To: symID,
|
|
Kind: graph.EdgeReferences,
|
|
FilePath: graphPath,
|
|
Origin: graph.OriginTextMatched,
|
|
})
|
|
}
|
|
|
|
return Artifact{
|
|
ID: nodeID,
|
|
Path: graphPath,
|
|
Name: name,
|
|
Kind: kind,
|
|
ContentHash: hash,
|
|
Size: len(data),
|
|
References: refs,
|
|
}, true
|
|
}
|
|
|
|
// SymbolNameIndex builds a name -> []symbolID index over g for callers that
|
|
// scan many texts against one index (e.g. the content -> code linker). An empty
|
|
// repoPrefix unions every repo. Exposes buildSymbolIndex's bounded, code-only
|
|
// (function / method / type / interface) mapping.
|
|
func SymbolNameIndex(g graph.Store, repoPrefix string) map[string][]string {
|
|
return buildSymbolIndex(g, repoPrefix)
|
|
}
|
|
|
|
// ScanSymbolRefs returns the IDs of every symbol whose name appears as a whole
|
|
// token in data, capped and bounded exactly like the artifact reference
|
|
// scanner. Pass an index from SymbolNameIndex.
|
|
func ScanSymbolRefs(data []byte, nameIndex map[string][]string) []string {
|
|
return scanReferences(data, nameIndex)
|
|
}
|
|
|
|
// buildSymbolIndex maps every sufficiently-long symbol name to the
|
|
// node IDs that declare it, scoped to repoPrefix.
|
|
func buildSymbolIndex(g graph.Store, repoPrefix string) map[string][]string {
|
|
index := make(map[string][]string)
|
|
for _, n := range g.AllNodes() {
|
|
switch n.Kind {
|
|
case graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface:
|
|
default:
|
|
continue
|
|
}
|
|
if repoPrefix != "" && n.RepoPrefix != repoPrefix {
|
|
continue
|
|
}
|
|
if len(n.Name) < minRefTokenLen {
|
|
continue
|
|
}
|
|
index[n.Name] = append(index[n.Name], n.ID)
|
|
}
|
|
return index
|
|
}
|
|
|
|
// scanReferences tokenises artifact content and returns the IDs of
|
|
// every symbol whose name appears as a whole token, capped and sorted.
|
|
func scanReferences(data []byte, nameIndex map[string][]string) []string {
|
|
if len(nameIndex) == 0 {
|
|
return nil
|
|
}
|
|
scan := data
|
|
if len(scan) > maxScanBytes {
|
|
scan = scan[:maxScanBytes]
|
|
}
|
|
hits := make(map[string]bool)
|
|
for _, tok := range identifierTokens(string(scan)) {
|
|
for _, id := range nameIndex[tok] {
|
|
hits[id] = true
|
|
}
|
|
}
|
|
if len(hits) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(hits))
|
|
for id := range hits {
|
|
out = append(out, id)
|
|
}
|
|
sort.Strings(out)
|
|
if len(out) > maxRefsPerArtifact {
|
|
out = out[:maxRefsPerArtifact]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// identifierTokens splits text into the set of identifier-shaped
|
|
// tokens ([A-Za-z_][A-Za-z0-9_]*).
|
|
func identifierTokens(text string) []string {
|
|
seen := make(map[string]bool)
|
|
var out []string
|
|
var b strings.Builder
|
|
flush := func() {
|
|
if b.Len() == 0 {
|
|
return
|
|
}
|
|
tok := b.String()
|
|
b.Reset()
|
|
if len(tok) >= minRefTokenLen && !seen[tok] {
|
|
seen[tok] = true
|
|
out = append(out, tok)
|
|
}
|
|
}
|
|
for _, r := range text {
|
|
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
} else {
|
|
flush()
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
// detectKind classifies an artifact file from its path when the
|
|
// manifest entry leaves Kind unset.
|
|
func detectKind(rel string) string {
|
|
lower := strings.ToLower(rel)
|
|
base := strings.ToLower(path.Base(rel))
|
|
ext := path.Ext(base)
|
|
switch ext {
|
|
case ".sql", ".prisma":
|
|
return "schema"
|
|
case ".graphql", ".gql", ".proto":
|
|
return "api"
|
|
case ".tf", ".hcl", ".tfvars":
|
|
return "infra"
|
|
case ".md", ".markdown", ".rst", ".txt":
|
|
return "doc"
|
|
}
|
|
if base == "kustomization.yaml" || base == "kustomization.yml" || base == "chart.yaml" {
|
|
return "infra"
|
|
}
|
|
if strings.Contains(lower, "openapi") || strings.Contains(lower, "swagger") {
|
|
return "api"
|
|
}
|
|
if strings.Contains(lower, "/adr") || strings.Contains(lower, "adr/") || strings.Contains(lower, "decision") {
|
|
return "doc"
|
|
}
|
|
if ext == ".yaml" || ext == ".yml" || ext == ".json" {
|
|
return "infra"
|
|
}
|
|
return "doc"
|
|
}
|
|
|
|
// expandGlob resolves a manifest path entry against root and returns
|
|
// the matching repo-relative file paths. A literal path, a single-
|
|
// segment glob (filepath.Glob), and a ** recursive glob are all
|
|
// supported.
|
|
func expandGlob(root, pattern string) []string {
|
|
pattern = strings.TrimPrefix(filepath.ToSlash(pattern), "./")
|
|
if pattern == "" {
|
|
return nil
|
|
}
|
|
if !strings.ContainsAny(pattern, "*?[") {
|
|
if info, err := os.Stat(filepath.Join(root, pattern)); err == nil && !info.IsDir() {
|
|
return []string{pattern}
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.Contains(pattern, "**") {
|
|
matches, err := filepath.Glob(filepath.Join(root, filepath.FromSlash(pattern)))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, m := range matches {
|
|
if info, err := os.Stat(m); err == nil && !info.IsDir() {
|
|
if rel, err := filepath.Rel(root, m); err == nil {
|
|
out = append(out, filepath.ToSlash(rel))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
// Recursive ** — walk the tree and segment-match each file.
|
|
var out []string
|
|
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if matchGlob(pattern, rel) {
|
|
out = append(out, rel)
|
|
}
|
|
return nil
|
|
})
|
|
return out
|
|
}
|
|
|
|
// matchGlob reports whether path matches a glob pattern; "**" matches
|
|
// any number of path segments, "*"/"?" match within one segment.
|
|
func matchGlob(pattern, p string) bool {
|
|
return matchSegments(strings.Split(pattern, "/"), strings.Split(p, "/"))
|
|
}
|
|
|
|
func matchSegments(pat, seg []string) bool {
|
|
for len(pat) > 0 {
|
|
if pat[0] == "**" {
|
|
if len(pat) == 1 {
|
|
return true
|
|
}
|
|
for i := 0; i <= len(seg); i++ {
|
|
if matchSegments(pat[1:], seg[i:]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if len(seg) == 0 {
|
|
return false
|
|
}
|
|
if ok, err := path.Match(pat[0], seg[0]); err != nil || !ok {
|
|
return false
|
|
}
|
|
pat, seg = pat[1:], seg[1:]
|
|
}
|
|
return len(seg) == 0
|
|
}
|