Skip to content

Commit dde8a4f

Browse files
committed
Enhancing cross repo edges for C++
1 parent 4ed8fc1 commit dde8a4f

5 files changed

Lines changed: 267 additions & 3 deletions

File tree

ARCHITECTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,16 @@ enola can analyze multiple repositories together. Use `append` mode to increment
262262

263263
### Linking, not just co-locating
264264

265-
Appending several repos does more than pool their facts — a linking pass connects the per-repo graphs using two signals the extractors already capture:
265+
Appending several repos does more than pool their facts — a linking pass connects the per-repo graphs using three signals the extractors already capture:
266266

267267
- **HTTP route role matching** — a route a repo *calls* (`role:"client"`, e.g. from a generated OpenAPI client) is matched to a route another repo *serves* (`role:"server"`, or a framework route) by normalized path + method. The caller is recorded as depending on the servee.
268268
- **Import / shared-lib references** — an import whose scope or leading segment names another loaded repo (e.g. `@app-web/lib-api`, `lib-core/money`) records a dependency on that repo.
269+
- **Shared symbol surface** — when two repos declare enough of the same distinctive types (e.g. a vendored protocol header copied between them — the `onelab::*` / `GmshClient` / `GmshServer` classes shared by *gmsh* and *getdp*), they are coupled. The match is on each type's portable identity (the namespace-qualified name with the repo-specific directory prefix stripped), filtered to type-like symbols (class/struct/interface/enum) and to distinctive names — namespaced identities always count; bare names must be non-generic and reasonably long. A pair links only above a small shared-type threshold, so an incidental `Config`/`JsonParser` collision can't fabricate a dependency. This relationship is symmetric, so it is emitted as a **bidirectional** pair of edges marked `via:"shared_symbols"`.
269270

270271
These become real, queryable facts:
271272

272273
- A `service` node per repo (`query_facts kind=service`), named by its repo label.
273-
- A cross-repo dependency edge per `consumer → provider` pair, carrying the matched endpoints and import samples.
274+
- A cross-repo dependency edge per `consumer → provider` pair, carrying the matched endpoints, import samples, and shared-symbol samples.
274275

275276
Because they're ordinary graph nodes and edges, the traversal tools become cross-repo aware with no extra steps — `traverse`, `find_path`, and `impact_analysis` all reach across repo boundaries. The cross-repo dependencies also appear as a **Cross-Repo Dependencies** section in `llm_context.md`, so an agent reading the snapshot sees them without running a tool.
276277

internal/explainers/crossrepo/crossrepo.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ func edgeDetail(d facts.Fact) string {
7474
if n := propInt(d, "import_count"); n > 0 {
7575
parts = append(parts, fmt.Sprintf("%d import(s)", n))
7676
}
77+
if n := propInt(d, "symbol_count"); n > 0 {
78+
parts = append(parts, fmt.Sprintf("%d shared symbol(s)", n))
79+
}
7780
if len(parts) == 0 {
7881
return "cross-repo dependency"
7982
}

internal/linkers/crossrepo/crossrepo.go

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
// - Import / shared-lib references: a dependency whose import target names
1313
// another loaded repo (by @scope or leading path segment) means the
1414
// importer depends on that repo.
15+
// - Shared symbol surface: when two repos declare enough of the same
16+
// distinctive types (a vendored/shared protocol header, e.g. the onelab
17+
// GmshClient/GmshServer classes copied between repos), they are coupled.
18+
// This signal is symmetric, so it is emitted as a bidirectional pair of
19+
// edges marked via="shared_symbols".
1520
//
1621
// The result is expressed as synthetic facts: one KindService node per repo and
1722
// one KindDependency edge per (consumer -> provider) pair. Because these are
@@ -47,9 +52,10 @@ const minSharedSegments = 2
4752
type edge struct {
4853
consumer string
4954
provider string
50-
via map[string]bool // "http", "import"
55+
via map[string]bool // "http", "import", "shared_symbols"
5156
endpoints map[string]bool // "METHOD /path"
5257
imports map[string]bool // sample import targets
58+
symbols map[string]bool // sample shared type identities
5359
}
5460

5561
func (e *edge) note(via string) {
@@ -72,6 +78,7 @@ func ComputeLinks(all []facts.Fact) []facts.Fact {
7278
edges := map[string]*edge{}
7379
linkHTTP(all, edges)
7480
linkImports(all, normToLabel, edges)
81+
linkSharedSymbols(all, edges)
7582

7683
return materialize(edges, repoLabels(normToLabel))
7784
}
@@ -273,6 +280,152 @@ func importCandidates(target string) []string {
273280
return out
274281
}
275282

283+
// --- signal (C): shared symbol surface ---
284+
285+
// minSharedSymbols is the fewest distinct distinctive type identities two repos
286+
// must share before a shared-symbol edge is drawn. Set above 2 so an incidental
287+
// name collision (a `JsonParser` both repos happen to define) cannot fabricate a
288+
// dependency, while genuinely shared/vendored code (a protocol header copied
289+
// between repos) shares many.
290+
const minSharedSymbols = 3
291+
292+
// genericTypeNames are common unqualified type names too generic to link on by
293+
// themselves. Namespaced identities (e.g. "onelab::number") bypass this list —
294+
// sharing a namespace across repos is itself meaningful.
295+
var genericTypeNames = map[string]bool{
296+
"config": true, "error": true, "manager": true, "options": true,
297+
"result": true, "base": true, "impl": true, "utils": true, "util": true,
298+
"common": true, "exception": true, "context": true, "data": true,
299+
"info": true, "item": true, "node": true, "entry": true, "helper": true,
300+
"settings": true, "logger": true, "test": true, "main": true, "model": true,
301+
"request": true, "response": true, "status": true, "value": true,
302+
}
303+
304+
// linkSharedSymbols connects repos that declare enough of the same distinctive
305+
// types. The relationship is symmetric (shared/vendored code, not a one-way
306+
// dependency), so qualifying pairs get a bidirectional pair of edges.
307+
func linkSharedSymbols(all []facts.Fact, edges map[string]*edge) {
308+
repoModules := moduleNamesByRepo(all)
309+
310+
// identity -> set of repos that declare a type with that identity.
311+
idToRepos := map[string]map[string]bool{}
312+
for _, f := range all {
313+
if f.Kind != facts.KindSymbol || f.Repo == "" || !isTypeSymbol(f) {
314+
continue
315+
}
316+
id := typeIdentity(f.Name, repoModules[f.Repo])
317+
if !isDistinctiveIdentity(id) {
318+
continue
319+
}
320+
if idToRepos[id] == nil {
321+
idToRepos[id] = map[string]bool{}
322+
}
323+
idToRepos[id][f.Repo] = true
324+
}
325+
326+
// For each identity shared by 2+ repos, record it against every repo pair.
327+
// pairShared["a\x00b"] (a<b) -> set of shared identities.
328+
pairShared := map[string]map[string]bool{}
329+
for id, repos := range idToRepos {
330+
if len(repos) < 2 {
331+
continue
332+
}
333+
rs := make([]string, 0, len(repos))
334+
for r := range repos {
335+
rs = append(rs, r)
336+
}
337+
sort.Strings(rs)
338+
for i := 0; i < len(rs); i++ {
339+
for j := i + 1; j < len(rs); j++ {
340+
key := rs[i] + "\x00" + rs[j]
341+
if pairShared[key] == nil {
342+
pairShared[key] = map[string]bool{}
343+
}
344+
pairShared[key][id] = true
345+
}
346+
}
347+
}
348+
349+
// Materialize a bidirectional edge for each pair over the threshold.
350+
for key, ids := range pairShared {
351+
if len(ids) < minSharedSymbols {
352+
continue
353+
}
354+
a, b, _ := strings.Cut(key, "\x00")
355+
for _, pair := range [2][2]string{{a, b}, {b, a}} {
356+
e := edgeFor(edges, pair[0], pair[1])
357+
e.note("shared_symbols")
358+
if e.symbols == nil {
359+
e.symbols = map[string]bool{}
360+
}
361+
for id := range ids {
362+
e.symbols[id] = true
363+
}
364+
}
365+
}
366+
}
367+
368+
// isTypeSymbol reports whether a symbol fact is a type-like declaration (the
369+
// portable "contract surface"), excluding functions, methods, variables, etc.
370+
func isTypeSymbol(f facts.Fact) bool {
371+
switch propString(f, "symbol_kind") {
372+
case facts.SymbolClass, facts.SymbolStruct, facts.SymbolInterface, facts.SymbolEnum:
373+
return true
374+
}
375+
return false
376+
}
377+
378+
// moduleNamesByRepo returns, per repo, the module (directory) names sorted
379+
// longest-first, so the longest matching prefix can be stripped from a symbol.
380+
func moduleNamesByRepo(all []facts.Fact) map[string][]string {
381+
byRepo := map[string][]string{}
382+
for _, f := range all {
383+
if f.Kind != facts.KindModule || f.Repo == "" {
384+
continue
385+
}
386+
byRepo[f.Repo] = append(byRepo[f.Repo], f.Name)
387+
}
388+
for r := range byRepo {
389+
ms := byRepo[r]
390+
sort.Slice(ms, func(i, j int) bool { return len(ms[i]) > len(ms[j]) })
391+
}
392+
return byRepo
393+
}
394+
395+
// typeIdentity strips the repo-specific "<module>." directory prefix from a
396+
// symbol's name, returning the portable namespace/type-qualified remainder that
397+
// is shared across repos (e.g. "src/common.onelab::Foo" -> "onelab::Foo",
398+
// "Common.onelab::Foo" -> "onelab::Foo"). The repo's own module names are used so
399+
// the differing directory layouts of two repos do not defeat the match.
400+
func typeIdentity(name string, modules []string) string {
401+
for _, m := range modules { // longest first
402+
if len(name) > len(m)+1 && strings.HasPrefix(name, m+".") {
403+
return name[len(m)+1:]
404+
}
405+
}
406+
// Fallback: strip up to the first "." when no module matched.
407+
if i := strings.IndexByte(name, '.'); i >= 0 && i+1 < len(name) {
408+
return name[i+1:]
409+
}
410+
return name
411+
}
412+
413+
// isDistinctiveIdentity filters out identities too generic to safely link on. A
414+
// namespaced identity (containing "::" or ".") is always kept; an unqualified one
415+
// is kept only if it is reasonably long and not a common generic type name.
416+
func isDistinctiveIdentity(id string) bool {
417+
if id == "" {
418+
return false
419+
}
420+
if strings.Contains(id, "::") || strings.Contains(id, ".") {
421+
return true
422+
}
423+
if len(id) < 5 {
424+
return false
425+
}
426+
return !genericTypeNames[strings.ToLower(id)]
427+
}
428+
276429
// --- materialization ---
277430

278431
func edgeFor(edges map[string]*edge, consumer, provider string) *edge {
@@ -323,6 +476,11 @@ func materialize(edges map[string]*edge, allRepos []string) []facts.Fact {
323476
props["import_count"] = len(imps)
324477
props["import_samples"] = cap25(imps)
325478
}
479+
if len(e.symbols) > 0 {
480+
syms := sortedKeys(e.symbols)
481+
props["symbol_count"] = len(syms)
482+
props["symbol_samples"] = cap25(syms)
483+
}
326484

327485
depFacts = append(depFacts, facts.Fact{
328486
Kind: facts.KindDependency,

internal/linkers/crossrepo/crossrepo_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,102 @@ func TestComputeLinks_PerRepoServiceNodes(t *testing.T) {
374374
}
375375
}
376376
}
377+
378+
// --- (C) shared symbol surface ---
379+
380+
func module(repo, name string) facts.Fact {
381+
return facts.Fact{Kind: facts.KindModule, Name: name, Repo: repo}
382+
}
383+
384+
func typeSym(repo, name, kind string) facts.Fact {
385+
return facts.Fact{
386+
Kind: facts.KindSymbol,
387+
Name: name,
388+
Repo: repo,
389+
Props: map[string]any{"symbol_kind": kind},
390+
}
391+
}
392+
393+
func TestComputeLinks_SharedSymbolsMatch(t *testing.T) {
394+
// getdp and gmsh both declare the vendored onelab/GmshSocket types, under
395+
// different directory prefixes (src/common vs Common). Enough distinctive
396+
// shared types must link them, bidirectionally and via shared_symbols.
397+
in := []facts.Fact{
398+
module("getdp", "src/common"),
399+
typeSym("getdp", "src/common.GmshClient", facts.SymbolClass),
400+
typeSym("getdp", "src/common.GmshServer", facts.SymbolClass),
401+
typeSym("getdp", "src/common.onelab::remoteNetworkClient", facts.SymbolClass),
402+
module("gmsh", "Common"),
403+
typeSym("gmsh", "Common.GmshClient", facts.SymbolClass),
404+
typeSym("gmsh", "Common.GmshServer", facts.SymbolClass),
405+
typeSym("gmsh", "Common.onelab::remoteNetworkClient", facts.SymbolClass),
406+
}
407+
out := ComputeLinks(in)
408+
409+
for _, pair := range [][2]string{{"getdp", "gmsh"}, {"gmsh", "getdp"}} {
410+
e := findEdge(out, pair[0], pair[1])
411+
if e == nil {
412+
t.Fatalf("missing shared-symbol edge %s -> %s; out=%+v", pair[0], pair[1], out)
413+
}
414+
if via, _ := e.Props["via"].([]string); !reflect.DeepEqual(via, []string{"shared_symbols"}) {
415+
t.Errorf("via = %v, want [shared_symbols]", e.Props["via"])
416+
}
417+
if c, _ := e.Props["symbol_count"].(int); c != 3 {
418+
t.Errorf("symbol_count = %v, want 3", e.Props["symbol_count"])
419+
}
420+
if !hasServiceEdge(out, pair[0], pair[1]) {
421+
t.Errorf("%s service node missing depends_on %s", pair[0], pair[1])
422+
}
423+
}
424+
}
425+
426+
func TestComputeLinks_SharedSymbolsBelowThreshold(t *testing.T) {
427+
// Only one distinctive shared type (below minSharedSymbols) → no link.
428+
in := []facts.Fact{
429+
module("alpha", "core"),
430+
typeSym("alpha", "core.WidgetRegistry", facts.SymbolClass),
431+
module("beta", "lib"),
432+
typeSym("beta", "lib.WidgetRegistry", facts.SymbolClass),
433+
}
434+
if edges := crossRepoEdges(ComputeLinks(in)); len(edges) != 0 {
435+
t.Errorf("single shared type should not link: %+v", edges)
436+
}
437+
}
438+
439+
func TestComputeLinks_SharedSymbolsGenericNamesIgnored(t *testing.T) {
440+
// Common generic/short unqualified type names are not distinctive enough to
441+
// link two otherwise-unrelated repos, even at count >= threshold.
442+
in := []facts.Fact{
443+
module("alpha", "core"),
444+
typeSym("alpha", "core.Config", facts.SymbolClass),
445+
typeSym("alpha", "core.Error", facts.SymbolStruct),
446+
typeSym("alpha", "core.Node", facts.SymbolClass),
447+
typeSym("alpha", "core.Item", facts.SymbolClass),
448+
module("beta", "lib"),
449+
typeSym("beta", "lib.Config", facts.SymbolClass),
450+
typeSym("beta", "lib.Error", facts.SymbolStruct),
451+
typeSym("beta", "lib.Node", facts.SymbolClass),
452+
typeSym("beta", "lib.Item", facts.SymbolClass),
453+
}
454+
if edges := crossRepoEdges(ComputeLinks(in)); len(edges) != 0 {
455+
t.Errorf("generic shared names should not link: %+v", edges)
456+
}
457+
}
458+
459+
func TestComputeLinks_SharedSymbolsNonTypesIgnored(t *testing.T) {
460+
// Functions/methods/variables are not the contract surface; sharing them
461+
// (even many) must not link repos.
462+
in := []facts.Fact{
463+
module("alpha", "core"),
464+
typeSym("alpha", "core.processRequest", facts.SymbolFunc),
465+
typeSym("alpha", "core.parseHeader", facts.SymbolFunc),
466+
typeSym("alpha", "core.computeChecksum", facts.SymbolFunc),
467+
module("beta", "lib"),
468+
typeSym("beta", "lib.processRequest", facts.SymbolFunc),
469+
typeSym("beta", "lib.parseHeader", facts.SymbolFunc),
470+
typeSym("beta", "lib.computeChecksum", facts.SymbolFunc),
471+
}
472+
if edges := crossRepoEdges(ComputeLinks(in)); len(edges) != 0 {
473+
t.Errorf("shared non-type symbols should not link: %+v", edges)
474+
}
475+
}

internal/renderers/llmcontext/llm.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ func crossRepoDetail(e facts.Fact) string {
224224
if n := propInt(e, "import_count"); n > 0 {
225225
parts = append(parts, fmt.Sprintf("%d import(s): %s", n, samplePreview(propStrSlice(e, "import_samples"))))
226226
}
227+
if n := propInt(e, "symbol_count"); n > 0 {
228+
parts = append(parts, fmt.Sprintf("%d shared symbol(s): %s", n, samplePreview(propStrSlice(e, "symbol_samples"))))
229+
}
227230
if len(parts) == 0 {
228231
return "cross-repo dependency"
229232
}

0 commit comments

Comments
 (0)