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
8 changes: 6 additions & 2 deletions internal/archdocs/graph2md/graph2md.go
Original file line number Diff line number Diff line change
Expand Up @@ -1578,8 +1578,12 @@ func (c *renderContext) writeGraphData(sb *strings.Builder) {
lineCount := 0
startLine := getNum(n.Properties, "startLine")
endLine := getNum(n.Properties, "endLine")
if startLine > 0 && endLine > 0 {
lineCount = endLine - startLine + 1
if endLine > 0 {
effectiveStart := startLine
if effectiveStart <= 0 {
effectiveStart = 1
}
lineCount = endLine - effectiveStart + 1
}
lang := getStr(n.Properties, "language")
callCount := len(c.calls[nodeID])
Expand Down
113 changes: 113 additions & 0 deletions internal/archdocs/graph2md/graph2md_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@ import (
"testing"
)

// parseGraphData extracts the "graph_data" JSON from a rendered markdown file's
// frontmatter and returns the parsed graphData struct.
func parseGraphData(t *testing.T, content string) struct {
Nodes []struct {
ID string `json:"id"`
LC int `json:"lc"`
} `json:"nodes"`
} {
t.Helper()
const key = `graph_data: "`
idx := strings.Index(content, key)
if idx < 0 {
t.Fatal("graph_data key not found in output")
}
start := idx + len(key)
// graph_data value is a quoted Go string — find the closing unescaped "
end := strings.Index(content[start:], "\"\n")
if end < 0 {
t.Fatal("graph_data closing quote not found")
}
// Unquote the embedded JSON
raw := strings.ReplaceAll(content[start:start+end], `\"`, `"`)
var gd struct {
Nodes []struct {
ID string `json:"id"`
LC int `json:"lc"`
} `json:"nodes"`
}
if err := json.Unmarshal([]byte(raw), &gd); err != nil {
t.Fatalf("unmarshal graph_data: %v\nraw: %s", err, raw)
}
return gd
}

// buildGraphJSON serialises nodes and relationships into a Graph JSON file
// that loadGraph can parse.
func buildGraphJSON(t *testing.T, nodes []Node, rels []Relationship) string {
Expand Down Expand Up @@ -140,3 +174,82 @@ func TestLineCountMissingStartLine(t *testing.T) {
t.Errorf("expected line_count: 50 in output, got:\n%s", content)
}
}

// TestGraphDataLineCountMissingStartLine verifies that the graph_data JSON
// embedded in the markdown frontmatter uses the same effectiveStart=1 logic
// as the text line_count field. Before the fix, a node with endLine=50 but
// no startLine would have lc=0 (condition startLine>0 was false), while the
// frontmatter line_count correctly showed 50.
//
// A DEFINES_FUNCTION relationship to a file is included so that the function
// node has at least one neighbor; writeGraphData skips output when len(nodes)<2.
func TestGraphDataLineCountMissingStartLine(t *testing.T) {
nodes := []Node{
{
ID: "file:src/foo.go",
Labels: []string{"File"},
Properties: map[string]interface{}{
"path": "src/foo.go",
"lineCount": float64(100),
},
},
{
ID: "fn:src/foo.go:bar",
Labels: []string{"Function"},
Properties: map[string]interface{}{
"name": "bar",
"filePath": "src/foo.go",
"endLine": float64(50), // startLine intentionally absent
},
},
}
rels := []Relationship{
{
ID: "r1",
Type: "DEFINES_FUNCTION",
StartNode: "file:src/foo.go",
EndNode: "fn:src/foo.go:bar",
},
}

graphFile := buildGraphJSON(t, nodes, rels)
outDir := t.TempDir()

if err := Run(graphFile, outDir, "testrepo", "", 0); err != nil {
t.Fatalf("Run: %v", err)
}

// Find the function's markdown file
entries, _ := os.ReadDir(outDir)
var fnFile string
for _, e := range entries {
if strings.HasPrefix(e.Name(), "fn-") {
fnFile = filepath.Join(outDir, e.Name())
break
}
}
if fnFile == "" {
t.Fatal("function markdown file not found")
}

content, err := os.ReadFile(fnFile)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}

gd := parseGraphData(t, string(content))
// Find the function node in graph_data
var fnLC int = -1
for _, n := range gd.Nodes {
if n.ID == "fn:src/foo.go:bar" {
fnLC = n.LC
break
}
}
if fnLC == -1 {
t.Fatalf("function node not found in graph_data nodes: %v", gd.Nodes)
}
if fnLC != 50 {
t.Errorf("graph_data lc = %d, want 50 (endLine=50, effectiveStart=1)", fnLC)
}
}
5 changes: 3 additions & 2 deletions internal/graph/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ func writeDOT(w io.Writer, g *api.Graph, filter string) error {
}

func dotEscape(s string) string {
if len(s) > 40 {
s = "…" + s[len(s)-39:]
runes := []rune(s)
if len(runes) > 40 {
return "…" + string(runes[len(runes)-39:])
}
return s
}
28 changes: 28 additions & 0 deletions internal/graph/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"strings"
"testing"
"unicode/utf8"

"github.com/supermodeltools/cli/internal/api"
)
Expand Down Expand Up @@ -307,3 +308,30 @@ func TestPrintGraph_HumanDefault(t *testing.T) {
t.Errorf("human output should contain table headers:\n%s", buf.String())
}
}

// TestWriteDOT_LongNameTruncated_MultiByteUTF8 verifies that dotEscape does
// not split a multi-byte UTF-8 character when truncating long node names.
// Before the fix, s[len(s)-39:] used byte indexing, which could land in the
// middle of a multi-byte character and produce invalid UTF-8 in the DOT file.
func TestWriteDOT_LongNameTruncated_MultiByteUTF8(t *testing.T) {
// 41 × "é" (2 bytes each) = 82 bytes, 41 runes.
// byte-based slice: s[82-39:] = s[43:] — byte 43 is the second byte of "é"
// (U+00E9 encodes as 0xC3 0xA9), producing invalid UTF-8 without the fix.
longName := strings.Repeat("é", 41)
g := &api.Graph{
Nodes: []api.Node{
{ID: "n1", Labels: []string{"Function"}, Properties: map[string]any{"name": longName}},
},
}
var buf bytes.Buffer
if err := writeDOT(&buf, g, ""); err != nil {
t.Fatalf("writeDOT: %v", err)
}
out := buf.String()
if !utf8.ValidString(out) {
t.Errorf("writeDOT output contains invalid UTF-8 (byte-based truncation of multi-byte name)")
}
if strings.Contains(out, longName) {
t.Errorf("long multi-byte name should be truncated in DOT output")
}
}
Loading