Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/backlinks/backlinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ func Backlinks(b *bundle.Bundle, conceptID string) []string {
if c.ID == conceptID {
continue // don't self-report
}
links := validate.ExtractLinks(c.Body)
for _, link := range links {
bodyLinks := validate.ExtractLinks(c.Body)
fmLinks := validate.ExtractFrontmatterLinks(c.Frontmatter.Links)
for _, link := range append(bodyLinks, fmLinks...) {
target := validate.ResolveLink(c.ID, link)
if target == conceptID {
if !seen[c.ID] {
Expand Down
15 changes: 15 additions & 0 deletions internal/backlinks/backlinks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ func TestBacklinks_DeduplicatesLinks(t *testing.T) {
}
}

func TestBacklinks_FrontmatterLinks(t *testing.T) {
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\nlinks:\n - /b\n---\n\nNo body links here.",
"b.md": "---\ntype: T\ntitle: B\n---\n\nbody",
})

links := Backlinks(b, "b")
if len(links) != 1 {
t.Fatalf("got %d backlinks, want 1 (from frontmatter link)", len(links))
}
if links[0] != "a" {
t.Errorf("got %s, want a", links[0])
}
}

// --- helpers ---

func testBundle(t *testing.T, files map[string]string) *bundle.Bundle {
Expand Down
42 changes: 42 additions & 0 deletions internal/concept/concept.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,51 @@ type Frontmatter struct {
Resource string `yaml:"resource"`
Tags []string `yaml:"tags"`
Timestamp time.Time `yaml:"timestamp"`
Links StringList `yaml:"links"`
Extensions map[string]any `yaml:",inline"`
}

// StringList is a []string that unmarshals from YAML flexibly: it accepts both
// a single scalar string (links: /b) and a sequence of strings (links: [/b, /c]).
// A missing/empty value stays a nil/empty slice, and a malformed shape (e.g. a
// mapping) is tolerated as an empty slice rather than failing the whole parse —
// so one badly-shaped concept cannot take down an entire bundle load.
type StringList []string

// UnmarshalYAML implements yaml.Unmarshaler, accepting scalar-or-sequence.
func (s *StringList) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
// A single string, or an explicit null. Treat null as empty.
if value.Tag == "!!null" {
*s = nil
return nil
}
var single string
if err := value.Decode(&single); err != nil {
// Non-string scalar (e.g. a number) — tolerate as empty.
*s = nil
return nil
}
*s = StringList{single}
return nil
case yaml.SequenceNode:
var list []string
if err := value.Decode(&list); err != nil {
// A sequence of non-strings — tolerate as empty rather than
// failing the entire bundle load.
*s = nil
return nil
}
*s = StringList(list)
return nil
default:
// Mapping or any other unexpected shape — tolerate as empty.
*s = nil
return nil
}
}

// Concept is a parsed concept document.
type Concept struct {
// ID is the file path within the bundle with .md removed, e.g. "tables/users".
Expand Down
54 changes: 54 additions & 0 deletions internal/concept/concept_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package concept

import (
"reflect"
"testing"
)

func TestParse_LinksScalar(t *testing.T) {
raw := []byte("---\ntype: T\ntitle: A\nlinks: /b\n---\n\nbody")
c, err := ParseBytes(raw, "a.md", "/tmp/a.md")
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
want := StringList{"/b"}
if !reflect.DeepEqual(c.Frontmatter.Links, want) {
t.Errorf("Links = %v, want %v", c.Frontmatter.Links, want)
}
}

func TestParse_LinksSequence(t *testing.T) {
raw := []byte("---\ntype: T\ntitle: A\nlinks:\n - /b\n - /c\n---\n\nbody")
c, err := ParseBytes(raw, "a.md", "/tmp/a.md")
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
want := StringList{"/b", "/c"}
if !reflect.DeepEqual(c.Frontmatter.Links, want) {
t.Errorf("Links = %v, want %v", c.Frontmatter.Links, want)
}
}

func TestParse_LinksMissing(t *testing.T) {
raw := []byte("---\ntype: T\ntitle: A\n---\n\nbody")
c, err := ParseBytes(raw, "a.md", "/tmp/a.md")
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
if len(c.Frontmatter.Links) != 0 {
t.Errorf("Links = %v, want empty", c.Frontmatter.Links)
}
}

func TestParse_LinksMalformedShapeTolerated(t *testing.T) {
// A mapping (or otherwise unexpected) shape must not fail the parse — it is
// tolerated as an empty list so one bad concept cannot kill a bundle load.
raw := []byte("---\ntype: T\ntitle: A\nlinks:\n target: /b\n---\n\nbody")
c, err := ParseBytes(raw, "a.md", "/tmp/a.md")
if err != nil {
t.Fatalf("ParseBytes should tolerate malformed links, got error: %v", err)
}
if len(c.Frontmatter.Links) != 0 {
t.Errorf("Links = %v, want empty for malformed shape", c.Frontmatter.Links)
}
}
12 changes: 9 additions & 3 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,17 @@ func Build(b *bundle.Bundle) *Graph {

edgeSet := make(map[string]bool) // "from\x00to" dedup
for _, c := range b.Concepts {
links := validate.ExtractLinks(c.Body)
// Collect body links and frontmatter links, then process them
// uniformly. The edgeSet dedup handles overlaps.
bodyLinks := validate.ExtractLinks(c.Body)
fmLinks := validate.ExtractFrontmatterLinks(c.Frontmatter.Links)
links := make([]validate.Link, 0, len(bodyLinks)+len(fmLinks))
links = append(links, bodyLinks...)
links = append(links, fmLinks...)
for _, link := range links {
target := resolveLink(c.ID, link)
if target == "" || !b.HasConcept(target) {
continue
if target == "" || target == c.ID || !b.HasConcept(target) {
continue // skip external, unresolved, self, and dangling links
}
key := c.ID + "\x00" + target
if !edgeSet[key] {
Expand Down
127 changes: 127 additions & 0 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package graph

import (
"os"
"path/filepath"
"testing"

"github.com/okfcli/okf/internal/bundle"
)

func TestBuild_BodyLinks(t *testing.T) {
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\n---\n\nLinks to [B](b.md).",
"b.md": "---\ntype: T\ntitle: B\n---\n\nbody",
})

g := Build(b)
if len(g.Edges) != 1 {
t.Fatalf("got %d edges, want 1", len(g.Edges))
}
if g.Edges[0].From != "a" || g.Edges[0].To != "b" {
t.Errorf("edge = %v, want a->b", g.Edges[0])
}
}

func TestBuild_FrontmatterLinksOnly(t *testing.T) {
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\nlinks:\n - /b\n---\n\nNo body links here.",
"b.md": "---\ntype: T\ntitle: B\n---\n\nbody",
})

g := Build(b)
if len(g.Edges) != 1 {
t.Fatalf("got %d edges, want 1 (from frontmatter link)", len(g.Edges))
}
if g.Edges[0].From != "a" || g.Edges[0].To != "b" {
t.Errorf("edge = %v, want a->b", g.Edges[0])
}
// Backlinks should also be populated.
if len(g.Backlinks["b"]) != 1 || g.Backlinks["b"][0] != "a" {
t.Errorf("backlinks[b] = %v, want [a]", g.Backlinks["b"])
}
}

func TestBuild_FrontmatterAndBodyDeduped(t *testing.T) {
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\nlinks:\n - /b\n---\n\nAlso links in [body](b.md).",
"b.md": "---\ntype: T\ntitle: B\n---\n\nbody",
})

g := Build(b)
if len(g.Edges) != 1 {
t.Fatalf("got %d edges, want 1 (deduped frontmatter+body)", len(g.Edges))
}
if g.Edges[0].From != "a" || g.Edges[0].To != "b" {
t.Errorf("edge = %v, want a->b", g.Edges[0])
}
}

func TestBuild_FrontmatterLinkScalar(t *testing.T) {
// A single-string `links: /b` (scalar, not a sequence) must load fine and
// produce the same edge as the sequence form.
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\nlinks: /b\n---\n\nNo body links here.",
"b.md": "---\ntype: T\ntitle: B\n---\n\nbody",
})

g := Build(b)
if len(g.Edges) != 1 {
t.Fatalf("got %d edges, want 1 (from scalar frontmatter link)", len(g.Edges))
}
if g.Edges[0].From != "a" || g.Edges[0].To != "b" {
t.Errorf("edge = %v, want a->b", g.Edges[0])
}
}

func TestBuild_FrontmatterLinksDeduped(t *testing.T) {
// Duplicate frontmatter links collapse to a single edge.
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\nlinks:\n - /b\n - /b\n---\n\nbody",
"b.md": "---\ntype: T\ntitle: B\n---\n\nbody",
})

g := Build(b)
if len(g.Edges) != 1 {
t.Fatalf("got %d edges, want 1 (duplicate frontmatter links deduped)", len(g.Edges))
}
if len(g.Backlinks["b"]) != 1 {
t.Errorf("backlinks[b] = %v, want a single entry", g.Backlinks["b"])
}
}

func TestBuild_NoSelfEdge(t *testing.T) {
// A concept linking to itself must not produce an a->a self-edge.
b := testBundle(t, map[string]string{
"a.md": "---\ntype: T\ntitle: A\nlinks:\n - /a\n---\n\nAlso [self](a.md).",
})

g := Build(b)
if len(g.Edges) != 0 {
t.Fatalf("got %d edges, want 0 (no self-edge): %+v", len(g.Edges), g.Edges)
}
if len(g.Backlinks["a"]) != 0 {
t.Errorf("backlinks[a] = %v, want empty (no self-backlink)", g.Backlinks["a"])
}
}

// --- helpers ---

func testBundle(t *testing.T, files map[string]string) *bundle.Bundle {
t.Helper()
dir := t.TempDir()
for path, content := range files {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0644); err != nil {
t.Fatalf("write: %v", err)
}
}
b, err := bundle.Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
return b
}
24 changes: 23 additions & 1 deletion internal/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,14 @@ func validateBody(r *Report, c *concept.Concept) {
// are checked.
func validateLinks(r *Report, b *bundle.Bundle) {
for _, c := range b.Concepts {
links := extractLinks(c.Body)
// Validate both body links and frontmatter links, mirroring how graph
// and backlinks consume them, so a frontmatter link to a nonexistent
// concept is reported instead of being silently dropped.
bodyLinks := extractLinks(c.Body)
fmLinks := ExtractFrontmatterLinks(c.Frontmatter.Links)
links := make([]Link, 0, len(bodyLinks)+len(fmLinks))
links = append(links, bodyLinks...)
links = append(links, fmLinks...)
for _, link := range links {
target := resolveLink(c.ID, link)
if target == "" {
Expand All @@ -121,6 +128,21 @@ func ExtractLinks(body string) []Link {
return extractLinks(body)
}

// ExtractFrontmatterLinks converts a frontmatter "links:" list (concept IDs or
// absolute /paths) into Link structs. Empty strings are skipped. The Text is
// empty (frontmatter links have no link text); the Target is preserved as-is,
// including any leading "/" — ResolveLink handles the stripping.
func ExtractFrontmatterLinks(links []string) []Link {
out := make([]Link, 0, len(links))
for _, l := range links {
if strings.TrimSpace(l) == "" {
continue
}
out = append(out, Link{Text: "", Target: l})
}
return out
}

// extractLinks parses all markdown links [text](target) from a body.
func extractLinks(body string) []Link {
var links []Link
Expand Down
Loading
Loading