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,350 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/config"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, root, rel, content string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func symbolGraph() *graph.Graph {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "models.go::User", Kind: graph.KindType, Name: "User", FilePath: "models.go"})
|
||||
g.AddNode(&graph.Node{ID: "orders.go::CreateOrder", Kind: graph.KindFunction, Name: "CreateOrder", FilePath: "orders.go"})
|
||||
g.AddNode(&graph.Node{ID: "x.go::Go", Kind: graph.KindFunction, Name: "Go", FilePath: "x.go"}) // too short to index
|
||||
return g
|
||||
}
|
||||
|
||||
func TestMaterialize_CreatesNodesAndReferences(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "db/schema.sql", "CREATE TABLE User (id INT);\n-- linked to CreateOrder handler\n")
|
||||
writeFile(t, root, "docs/adr/0001-choice.md", "# ADR 0001\nSome decision text.\n")
|
||||
|
||||
g := symbolGraph()
|
||||
entries := []config.ArtifactEntry{
|
||||
{Path: "db/schema.sql", Kind: "schema"},
|
||||
{Path: "docs/adr/*.md"},
|
||||
}
|
||||
arts := Materialize(g, root, entries, "")
|
||||
if len(arts) != 2 {
|
||||
t.Fatalf("expected 2 artifacts, got %d: %+v", len(arts), arts)
|
||||
}
|
||||
|
||||
var schema *Artifact
|
||||
for i := range arts {
|
||||
if arts[i].Path == "db/schema.sql" {
|
||||
schema = &arts[i]
|
||||
}
|
||||
}
|
||||
if schema == nil {
|
||||
t.Fatal("db/schema.sql artifact missing")
|
||||
}
|
||||
if schema.Kind != "schema" {
|
||||
t.Errorf("kind = %q, want schema", schema.Kind)
|
||||
}
|
||||
if schema.ContentHash == "" {
|
||||
t.Error("content hash not computed")
|
||||
}
|
||||
// The schema text names User and CreateOrder.
|
||||
if len(schema.References) != 2 {
|
||||
t.Errorf("expected 2 references, got %v", schema.References)
|
||||
}
|
||||
|
||||
// The artifact node and its EdgeReferences edges are in the graph.
|
||||
node := g.GetNode("artifact::db/schema.sql")
|
||||
if node == nil || node.Kind != graph.KindArtifact {
|
||||
t.Fatalf("artifact node missing or wrong kind: %+v", node)
|
||||
}
|
||||
refEdges := 0
|
||||
for _, e := range g.GetOutEdges(node.ID) {
|
||||
if e.Kind == graph.EdgeReferences {
|
||||
refEdges++
|
||||
}
|
||||
}
|
||||
if refEdges != 2 {
|
||||
t.Errorf("expected 2 EdgeReferences out of the artifact, got %d", refEdges)
|
||||
}
|
||||
|
||||
// The .md artifact auto-detects kind "doc".
|
||||
for _, a := range arts {
|
||||
if a.Path == "docs/adr/0001-choice.md" && a.Kind != "doc" {
|
||||
t.Errorf("adr kind = %q, want doc", a.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialize_RecursiveGlob(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "docs/a/one.md", "alpha")
|
||||
writeFile(t, root, "docs/a/b/two.md", "beta")
|
||||
writeFile(t, root, "docs/note.txt", "skip me")
|
||||
|
||||
g := graph.New()
|
||||
arts := Materialize(g, root, []config.ArtifactEntry{{Path: "docs/**/*.md"}}, "")
|
||||
if len(arts) != 2 {
|
||||
t.Fatalf("recursive glob should match 2 .md files, got %d: %+v", len(arts), arts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialize_RepoPrefix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "schema.sql", "CREATE TABLE t (id int);")
|
||||
g := graph.New()
|
||||
arts := Materialize(g, root, []config.ArtifactEntry{{Path: "schema.sql"}}, "myrepo")
|
||||
if len(arts) != 1 {
|
||||
t.Fatalf("expected 1 artifact, got %d", len(arts))
|
||||
}
|
||||
if arts[0].ID != "artifact::myrepo/schema.sql" {
|
||||
t.Errorf("prefixed ID = %q", arts[0].ID)
|
||||
}
|
||||
if g.GetNode("artifact::myrepo/schema.sql") == nil {
|
||||
t.Error("prefixed artifact node missing from graph")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectKind(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"db/schema.sql": "schema",
|
||||
"prisma/schema.prisma": "schema",
|
||||
"api/service.proto": "api",
|
||||
"api/openapi-v2.yaml": "api",
|
||||
"infra/main.tf": "infra",
|
||||
"k8s/kustomization.yaml": "infra",
|
||||
"docs/adr/0001-thing.md": "doc",
|
||||
"notes.txt": "doc",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := detectKind(path); got != want {
|
||||
t.Errorf("detectKind(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialize_EmptyManifest(t *testing.T) {
|
||||
if arts := Materialize(graph.New(), t.TempDir(), nil, ""); arts != nil {
|
||||
t.Errorf("empty manifest should yield nil, got %+v", arts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user