Skip to content

Commit e571b79

Browse files
committed
Add CCG refs for cross-namespace annotations
1 parent 12eff69 commit e571b79

16 files changed

Lines changed: 487 additions & 13 deletions

File tree

guide/annotations.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,19 @@ func AuthenticateUser(username, password string) (string, error) {
5050
| `@ensures` | Postcondition | `@ensures session != nil` |
5151
| `@param` | Parameter description | `@param username the login ID` |
5252
| `@return` | Return description | `@return JWT token on success` |
53-
| `@see` | Related function | `@see SessionManager.Create` |
53+
| `@see` | Related function or CCG ref | `@see SessionManager.Create`, `@see ccg://auth-svc/internal/auth/token.go#ValidateToken` |
5454

5555
`@intent` is especially important because a symbol with annotations but no `@intent` is reported as `incomplete` by `ccg lint`. `@see` tags are also linted and can produce `dead-ref` findings if they point to non-existent symbols.
5656

57+
Use `ccg://{namespace}/{path}#{symbol}` in `@see` when a behavior depends on code in another namespace. Keep the reason in the semantic tag and the concrete target in `@see`:
58+
59+
```go
60+
// @sideEffect records token validation audit in auth-svc.
61+
// @see ccg://auth-svc/internal/audit/token_audit.go#RecordTokenAudit
62+
```
63+
64+
The path and symbol are optional, so `ccg://auth-svc/internal/auth` can point at a package/path scope and `ccg://auth-svc/` can point at a whole namespace.
65+
5766
## AI-Driven Annotation
5867

5968
Coding agents with the `/ccg-annotate` skill can analyze your codebase and

guide/ko/annotations.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,19 @@ func AuthenticateUser(username, password string) (string, error) {
4646
| `@ensures` | 사후 조건 | `@ensures session != nil` |
4747
| `@param` | 파라미터 설명 | `@param username 로그인 ID` |
4848
| `@return` | 반환 값 설명 | `@return 성공 시 JWT 토큰` |
49-
| `@see` | 관련 함수 | `@see SessionManager.Create` |
49+
| `@see` | 관련 함수 또는 CCG ref | `@see SessionManager.Create`, `@see ccg://auth-svc/internal/auth/token.go#ValidateToken` |
5050

5151
`@intent`는 특히 중요합니다. 어노테이션은 있지만 `@intent`가 없는 심볼은 `ccg lint`에서 `incomplete`로 보고되기 때문입니다. `@see` 태그 또한 린트 대상이며, 존재하지 않는 심볼을 가리키는 경우 `dead-ref` 결과가 발생할 수 있습니다.
5252

53+
다른 네임스페이스의 코드를 함께 봐야 하는 동작은 `@see``ccg://{namespace}/{path}#{symbol}` 형식으로 기록합니다. 이유는 의미 태그에 두고, 구체적인 대상은 `@see`에 둡니다.
54+
55+
```go
56+
// @sideEffect auth-svc에 토큰 검증 감사 기록을 남긴다.
57+
// @see ccg://auth-svc/internal/audit/token_audit.go#RecordTokenAudit
58+
```
59+
60+
path와 symbol은 선택 사항입니다. `ccg://auth-svc/internal/auth`는 패키지/경로 범위, `ccg://auth-svc/`는 네임스페이스 전체를 가리킬 수 있습니다.
61+
5362
## AI 기반 어노테이션 (AI-Driven Annotation)
5463

5564
`/ccg-annotate` 스킬을 사용할 수 있는 코딩 에이전트는 코드베이스를 분석하여

internal/annotation/parser_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ func TestParse_See(t *testing.T) {
160160
}
161161
}
162162

163+
func TestParse_CCGSeeRef(t *testing.T) {
164+
p := NewParser()
165+
ann, _ := p.Parse("@see ccg://auth-svc/internal/auth/token.go#ValidateToken")
166+
if len(ann.Tags) != 1 {
167+
t.Fatalf("expected 1 tag, got %d", len(ann.Tags))
168+
}
169+
if ann.Tags[0].Kind != model.TagSee {
170+
t.Errorf("Kind = %q, want %q", ann.Tags[0].Kind, model.TagSee)
171+
}
172+
if ann.Tags[0].Value != "ccg://auth-svc/internal/auth/token.go#ValidateToken" {
173+
t.Errorf("Value = %q", ann.Tags[0].Value)
174+
}
175+
}
176+
163177
func TestParse_MultipleSee(t *testing.T) {
164178
p := NewParser()
165179
ann, _ := p.Parse("@see LoginHandler.Handle\n@see SessionManager.Create")

internal/ccgref/ref.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// @index CCG reference parser for cross-namespace annotation links.
2+
package ccgref
3+
4+
import (
5+
"fmt"
6+
"net/url"
7+
"path"
8+
"strings"
9+
)
10+
11+
const Scheme = "ccg"
12+
13+
// Ref is the structured form of a ccg:// annotation reference.
14+
// @intent represent cross-namespace @see links without coupling annotations to graph storage.
15+
type Ref struct {
16+
Raw string `json:"raw"`
17+
Namespace string `json:"namespace"`
18+
Path string `json:"path,omitempty"`
19+
Symbol string `json:"symbol,omitempty"`
20+
Scope string `json:"scope"`
21+
}
22+
23+
// Is reports whether value uses the CCG reference scheme.
24+
// @intent let callers branch between local @see values and cross-namespace CCG refs cheaply.
25+
func Is(value string) bool {
26+
return strings.HasPrefix(strings.TrimSpace(value), Scheme+"://")
27+
}
28+
29+
// Parse validates and normalizes a ccg:// reference.
30+
// @intent decode ccg://{namespace}/{path}#{symbol} values used by @see annotations.
31+
// @domainRule namespace is required and must be a single safe path segment.
32+
// @domainRule path and symbol are optional; a path with no symbol represents a file or package path.
33+
func Parse(value string) (*Ref, error) {
34+
raw := strings.TrimSpace(value)
35+
if raw == "" {
36+
return nil, fmt.Errorf("empty ref")
37+
}
38+
u, err := url.Parse(raw)
39+
if err != nil {
40+
return nil, fmt.Errorf("parse ref: %w", err)
41+
}
42+
if u.Scheme != Scheme {
43+
return nil, fmt.Errorf("ref scheme must be %q", Scheme)
44+
}
45+
if u.User != nil || u.RawQuery != "" || u.Opaque != "" {
46+
return nil, fmt.Errorf("ccg ref must not contain userinfo, query, or opaque data")
47+
}
48+
namespace := strings.TrimSpace(u.Host)
49+
if err := validateNamespace(namespace); err != nil {
50+
return nil, err
51+
}
52+
refPath, err := normalizeRefPath(u.EscapedPath())
53+
if err != nil {
54+
return nil, err
55+
}
56+
symbol, err := url.PathUnescape(u.EscapedFragment())
57+
if err != nil {
58+
return nil, fmt.Errorf("decode symbol: %w", err)
59+
}
60+
symbol = strings.TrimSpace(symbol)
61+
if strings.ContainsAny(symbol, "\r\n") {
62+
return nil, fmt.Errorf("symbol must be a single line")
63+
}
64+
return &Ref{
65+
Raw: raw,
66+
Namespace: namespace,
67+
Path: refPath,
68+
Symbol: symbol,
69+
Scope: scopeFor(refPath, symbol),
70+
}, nil
71+
}
72+
73+
// MustParse is a test helper for constructing refs.
74+
// @intent keep tests concise while still using the production parser.
75+
func MustParse(value string) Ref {
76+
ref, err := Parse(value)
77+
if err != nil {
78+
panic(err)
79+
}
80+
return *ref
81+
}
82+
83+
// Display returns a compact human-readable form for UI labels and logs.
84+
// @intent shorten ccg refs while preserving namespace, path, and symbol identity.
85+
func (r Ref) Display() string {
86+
var b strings.Builder
87+
b.WriteString(r.Namespace)
88+
if r.Path != "" {
89+
b.WriteString("/")
90+
b.WriteString(r.Path)
91+
}
92+
if r.Symbol != "" {
93+
b.WriteString("#")
94+
b.WriteString(r.Symbol)
95+
}
96+
return b.String()
97+
}
98+
99+
// @intent reject namespace values that could escape namespace storage roots.
100+
func validateNamespace(namespace string) error {
101+
if namespace == "" {
102+
return fmt.Errorf("namespace is required")
103+
}
104+
if namespace == "." || namespace == ".." || strings.ContainsAny(namespace, `/\`) || strings.HasPrefix(namespace, "..") {
105+
return fmt.Errorf("invalid namespace: must be a single safe name")
106+
}
107+
return nil
108+
}
109+
110+
// @intent normalize the URI path part into the same slash-separated file paths used by graph nodes.
111+
func normalizeRefPath(escapedPath string) (string, error) {
112+
if escapedPath == "" || escapedPath == "/" {
113+
return "", nil
114+
}
115+
decoded, err := url.PathUnescape(escapedPath)
116+
if err != nil {
117+
return "", fmt.Errorf("decode path: %w", err)
118+
}
119+
decoded = strings.TrimPrefix(decoded, "/")
120+
if decoded == "" {
121+
return "", nil
122+
}
123+
if strings.Contains(decoded, `\`) || strings.ContainsAny(decoded, "\r\n") {
124+
return "", fmt.Errorf("invalid path: path traversal not allowed")
125+
}
126+
clean := path.Clean(decoded)
127+
if clean == "." {
128+
return "", nil
129+
}
130+
if strings.HasPrefix(clean, "../") || clean == ".." || strings.HasPrefix(clean, "/") {
131+
return "", fmt.Errorf("invalid path: path traversal not allowed")
132+
}
133+
return clean, nil
134+
}
135+
136+
// @intent classify refs for clients that want to render namespace, path, and symbol scopes differently.
137+
func scopeFor(refPath, symbol string) string {
138+
if symbol != "" {
139+
return "symbol"
140+
}
141+
if refPath != "" {
142+
return "path"
143+
}
144+
return "namespace"
145+
}

internal/ccgref/ref_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package ccgref
2+
3+
import "testing"
4+
5+
func TestParseSymbolRef(t *testing.T) {
6+
ref, err := Parse("ccg://auth-svc/internal/auth/token.go#ValidateToken")
7+
if err != nil {
8+
t.Fatalf("Parse: %v", err)
9+
}
10+
if ref.Namespace != "auth-svc" || ref.Path != "internal/auth/token.go" || ref.Symbol != "ValidateToken" || ref.Scope != "symbol" {
11+
t.Fatalf("unexpected ref: %+v", ref)
12+
}
13+
if ref.Display() != "auth-svc/internal/auth/token.go#ValidateToken" {
14+
t.Fatalf("Display = %q", ref.Display())
15+
}
16+
}
17+
18+
func TestParseNamespaceRef(t *testing.T) {
19+
ref, err := Parse("ccg://common-lib/")
20+
if err != nil {
21+
t.Fatalf("Parse: %v", err)
22+
}
23+
if ref.Namespace != "common-lib" || ref.Path != "" || ref.Symbol != "" || ref.Scope != "namespace" {
24+
t.Fatalf("unexpected ref: %+v", ref)
25+
}
26+
}
27+
28+
func TestParseRejectsTraversal(t *testing.T) {
29+
for _, raw := range []string{
30+
"ccg://../internal/auth.go",
31+
"ccg://auth-svc/../secret.go",
32+
"ccg://auth-svc/internal/../../secret.go",
33+
"ccg://auth-svc/internal\\auth.go",
34+
} {
35+
t.Run(raw, func(t *testing.T) {
36+
if _, err := Parse(raw); err == nil {
37+
t.Fatal("expected parse error")
38+
}
39+
})
40+
}
41+
}

internal/docs/lint.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"sort"
88
"strings"
99

10+
"github.com/tae2089/code-context-graph/internal/ccgref"
1011
"github.com/tae2089/code-context-graph/internal/model"
1112
"github.com/tae2089/code-context-graph/internal/pathutil"
1213
)
@@ -220,6 +221,27 @@ func (g *Generator) Lint() (*LintReport, error) {
220221
if tag.Kind != model.TagSee {
221222
continue
222223
}
224+
if ccgref.Is(tag.Value) {
225+
ref, err := ccgref.Parse(tag.Value)
226+
if err != nil {
227+
report.DeadRefs = append(report.DeadRefs, DeadRef{
228+
QualifiedName: n.QualifiedName,
229+
SeeTarget: tag.Value,
230+
})
231+
continue
232+
}
233+
ok, err := g.ccgRefExists(*ref)
234+
if err != nil {
235+
return nil, fmt.Errorf("query dead ccg ref for %q → %q: %w", n.QualifiedName, tag.Value, err)
236+
}
237+
if !ok {
238+
report.DeadRefs = append(report.DeadRefs, DeadRef{
239+
QualifiedName: n.QualifiedName,
240+
SeeTarget: tag.Value,
241+
})
242+
}
243+
continue
244+
}
223245
var count int64
224246
refQ := g.DB.Model(&model.Node{}).Where("qualified_name = ?", tag.Value)
225247
if g.Namespace != "" {
@@ -278,3 +300,31 @@ func (g *Generator) Lint() (*LintReport, error) {
278300

279301
return report, nil
280302
}
303+
304+
// ccgRefExists checks whether a parsed ccg:// @see ref resolves to graph data.
305+
// @intent let docs lint validate cross-namespace refs while keeping local @see lookup semantics unchanged.
306+
func (g *Generator) ccgRefExists(ref ccgref.Ref) (bool, error) {
307+
q := g.DB.Model(&model.Node{}).Where("namespace = ?", ref.Namespace)
308+
if ref.Path != "" {
309+
if ref.Symbol != "" {
310+
suffixDot := "%." + ref.Symbol
311+
suffixColon := "%::" + ref.Symbol
312+
q = q.Where(
313+
"file_path = ? AND (name = ? OR qualified_name = ? OR qualified_name LIKE ? OR qualified_name LIKE ?)",
314+
ref.Path, ref.Symbol, ref.Symbol, suffixDot, suffixColon,
315+
)
316+
} else {
317+
prefix := strings.TrimSuffix(ref.Path, "/") + "/%"
318+
q = q.Where("file_path = ? OR file_path LIKE ?", ref.Path, prefix)
319+
}
320+
} else if ref.Symbol != "" {
321+
suffixDot := "%." + ref.Symbol
322+
suffixColon := "%::" + ref.Symbol
323+
q = q.Where("name = ? OR qualified_name = ? OR qualified_name LIKE ? OR qualified_name LIKE ?", ref.Symbol, ref.Symbol, suffixDot, suffixColon)
324+
}
325+
var count int64
326+
if err := q.Count(&count).Error; err != nil {
327+
return false, err
328+
}
329+
return count > 0, nil
330+
}

internal/docs/lint_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,79 @@ func TestLint_ValidRef_NotDeadRef(t *testing.T) {
399399
}
400400
}
401401

402+
func TestLint_CCGSeeRefResolvesAcrossNamespace(t *testing.T) {
403+
db := newLintTestDB(t)
404+
outDir := t.TempDir()
405+
406+
target := model.Node{
407+
Namespace: "auth-svc",
408+
QualifiedName: "auth.ValidateToken",
409+
Kind: model.NodeKindFunction,
410+
Name: "ValidateToken",
411+
FilePath: "internal/auth/token.go",
412+
StartLine: 1, EndLine: 5,
413+
Hash: "h1", Language: "go",
414+
}
415+
db.Create(&target)
416+
417+
source := model.Node{
418+
Namespace: "payment-svc",
419+
QualifiedName: "payment.Charge",
420+
Kind: model.NodeKindFunction,
421+
Name: "Charge",
422+
FilePath: "internal/payment/charge.go",
423+
StartLine: 1, EndLine: 10,
424+
Hash: "h2", Language: "go",
425+
}
426+
db.Create(&source)
427+
db.Create(&model.Annotation{
428+
NodeID: source.ID,
429+
Tags: []model.DocTag{
430+
{Kind: model.TagSee, Value: "ccg://auth-svc/internal/auth/token.go#ValidateToken", Ordinal: 0},
431+
},
432+
})
433+
434+
gen := &Generator{DB: db, OutDir: outDir, Namespace: "payment-svc"}
435+
report, err := gen.Lint()
436+
if err != nil {
437+
t.Fatalf("unexpected error: %v", err)
438+
}
439+
if len(report.DeadRefs) != 0 {
440+
t.Fatalf("expected 0 dead refs, got %v", report.DeadRefs)
441+
}
442+
}
443+
444+
func TestLint_CCGSeeRefMissingTargetIsDeadRef(t *testing.T) {
445+
db := newLintTestDB(t)
446+
outDir := t.TempDir()
447+
448+
source := model.Node{
449+
Namespace: "payment-svc",
450+
QualifiedName: "payment.Charge",
451+
Kind: model.NodeKindFunction,
452+
Name: "Charge",
453+
FilePath: "internal/payment/charge.go",
454+
StartLine: 1, EndLine: 10,
455+
Hash: "h2", Language: "go",
456+
}
457+
db.Create(&source)
458+
db.Create(&model.Annotation{
459+
NodeID: source.ID,
460+
Tags: []model.DocTag{
461+
{Kind: model.TagSee, Value: "ccg://auth-svc/internal/auth/token.go#ValidateToken", Ordinal: 0},
462+
},
463+
})
464+
465+
gen := &Generator{DB: db, OutDir: outDir, Namespace: "payment-svc"}
466+
report, err := gen.Lint()
467+
if err != nil {
468+
t.Fatalf("unexpected error: %v", err)
469+
}
470+
if len(report.DeadRefs) != 1 {
471+
t.Fatalf("expected 1 dead ref, got %d: %v", len(report.DeadRefs), report.DeadRefs)
472+
}
473+
}
474+
402475
func TestLint_NoContradiction_WhenAnnotationFresh(t *testing.T) {
403476
db := newLintTestDB(t)
404477
outDir := t.TempDir()

0 commit comments

Comments
 (0)