Skip to content

Commit a13dbf6

Browse files
authored
feat(config): let one config name a whole multi-repo cluster (#156)
Cross-repo linking was reachable only from an MCP session: `append` is a generate_snapshot tool parameter and both CLIs hardcoded false, so a CI job or a developer not driving an agent saw only the single-repo subset — no service nodes, no cross-repo edges, no coverage_report, no unused-routes. A config may now name the cluster, and one --generate run indexes it, first repository fresh and the rest appended: repos: - ../api - ../web - ../sdk Entries resolve against the config file's own directory rather than the working directory, so a checked-in cluster config means the same thing on a laptop and in CI; `repo:` keeps its cwd-relative behaviour. Order is semantic and duplicates are dropped — a repository listed twice would be indexed twice, the second pass appending a duplicate of every fact the first contributed. --explain takes a cluster config too. Its positional argument was previously always a repository path, so `--explain cluster.yaml` analysed the YAML file as if it were a repo. A repository is a directory and a config is a file, which separates them without a flag.
1 parent 3842a4a commit a13dbf6

7 files changed

Lines changed: 350 additions & 37 deletions

File tree

ARCHITECTURE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,11 @@ The config file is **optional**. Every field has a built-in default (see `config
664664

665665
```yaml
666666
repo: "."
667+
# …or name a whole cluster instead, resolved relative to THIS file:
668+
# repos:
669+
# - ../api
670+
# - ../web
671+
# - ../sdk
667672
ignore:
668673
- "vendor/**"
669674
- "node_modules/**"
@@ -711,7 +716,8 @@ The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list (
711716

712717
| Field | Description | Default |
713718
|-------|-------------|---------|
714-
| `repo` | Repository root path | `"."` |
719+
| `repo` | Repository root path, relative to the **working directory** | `"."` |
720+
| `repos` | Ordered list of repository roots forming a multi-repo cluster; supersedes `repo`. One `--generate` run indexes them all (the first fresh, the rest appended), producing the service nodes and cross-repo edges a single-repo snapshot cannot have. Entries resolve relative to the **config file's own directory**, so a checked-in cluster config means the same thing wherever it is run from. Order is semantic; duplicates are dropped | *(unset)* |
715721
| `ignore` | Glob patterns for files/dirs to skip | vendor, node_modules, .git, tests, build dirs, minified JS (`*.min.js`/`*.bundle.js`), docs, config data, … |
716722
| `extractors` | Enabled extractors | `["cpp", "go", "grpc", "java", "kotlin", "openapi", "php", "python", "typescript", "swift", "ruby", "rust"]` |
717723
| `explainers` | Enabled explainers | `["cycles", "layers", "crossrepo", "coverage", "unused-routes", "god-class", "hotspots", "dependency-depth", "exported-surface", "complexity-outliers"]` |

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,22 @@ enola --generate [config_path] # config_path is optional; defaults to mcp-arch
433433

434434
Artifacts are written to the configured `output.dir` (default `.enola/`). The config file is optional - see **[ARCHITECTURE.md → Configuration](ARCHITECTURE.md#configuration)** for the full field reference and defaults.
435435

436+
**Indexing a whole cluster in one command.** Cross-repo linking needs several repositories in one graph. Name them with `repos:` and a single run indexes them all - the first fresh, the rest appended - producing the service nodes, cross-repo edges, `coverage_report` and unused-route findings that a single-repo snapshot cannot have:
437+
438+
```yaml
439+
# ci/cluster.yaml
440+
repos:
441+
- ../api
442+
- ../web
443+
- ../sdk
444+
```
445+
446+
```bash
447+
enola --generate ci/cluster.yaml
448+
```
449+
450+
Entries resolve **relative to the config file**, not to your working directory, so a cluster config can be checked in and means the same thing on a laptop and in CI. (`repo:` is unchanged: a single repository, relative to the working directory.) Order matters - the first entry resets the graph and the rest are added to it.
451+
436452
---
437453

438454
## Explain a repository at a glance
@@ -453,8 +469,13 @@ enola --explain
453469

454470
# Analyze a specific repository path
455471
enola --explain /path/to/repo
472+
473+
# Report over a whole cluster, from a config that names it with `repos:`
474+
enola --explain ci/cluster.yaml
456475
```
457476

477+
The argument is a **repository** when it is a directory and a **config file** when it is a file, so both forms work without a flag to tell them apart.
478+
458479
**The report covers nine sections:**
459480
- **Overview** - path, analysis time, active languages, total fact count
460481
- **Architectural kinds** - counts of modules, symbols, routes, storage, dependencies, services
@@ -589,8 +610,8 @@ Run `enola --help` for the full text. With no flags, enola starts the MCP server
589610

590611
| Flag | What it does |
591612
|------|--------------|
592-
| `--generate [config_path]` | Generate a snapshot and exit - no MCP server. Artifacts go to `output.dir` (default `.enola/`). |
593-
| `--explain [repo_path]` | Print the statistics report above and exit. Read-only: nothing is written to `.enola/`. |
613+
| `--generate [config_path]` | Generate a snapshot and exit - no MCP server. Artifacts go to `output.dir` (default `.enola/`). With `repos:` in the config, indexes the whole cluster in one run. |
614+
| `--explain [repo_path\|config_path]` | Print the statistics report above and exit. Read-only: nothing is written to `.enola/`. A directory is a repository; a file is a config, so a `repos:` config reports over the whole cluster. |
594615
| `--list` | List the MCP tools this build serves, with one-line summaries. |
595616
| `--status` | List every enola server running right now - PID, repos, uptime, calls, dashboard URL - plus per-tool call counts and an estimate of the reconstruction those calls saved, in time and tokens. |
596617
| `--status --all` | The same usage, broken down per repository. |

cmd/enola/main.go

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/enola-labs/enola/internal/config"
14+
"github.com/enola-labs/enola/internal/facts"
1415
"github.com/enola-labs/enola/internal/upgrade"
1516
"github.com/enola-labs/enola/internal/version"
1617
"github.com/enola-labs/enola/pkg/bootstrap"
@@ -68,10 +69,16 @@ func main() {
6869
noDashboard = true
6970
default:
7071
// In --explain mode the positional argument is the repository path;
71-
// otherwise it is the config file path.
72-
if explainMode {
72+
// otherwise it is the config file path. A repository is a directory and
73+
// a config is a file, so `--explain cluster.yaml` is unambiguous — and
74+
// without this it would analyse the YAML file as if it were a repo and
75+
// report on nothing.
76+
switch {
77+
case explainMode && isDirectory(arg):
7378
explainRepo = arg
74-
} else {
79+
case explainMode:
80+
cfgPath = arg
81+
default:
7582
cfgPath = arg
7683
}
7784
}
@@ -101,18 +108,24 @@ func main() {
101108
}
102109

103110
if generateMode {
104-
repoPath, err := filepath.Abs(cfg.Repo)
111+
repoPaths, err := cfg.RepoPaths()
105112
if err != nil {
106113
log.Fatalf("failed to resolve repo path: %v", err)
107114
}
108115

109-
snapshot, err := eng.GenerateSnapshot(ctx, repoPath, false)
110-
if err != nil {
111-
log.Fatalf("snapshot generation failed: %v", err)
112-
}
113-
114-
if err := eng.WriteArtifacts(repoPath); err != nil {
115-
log.Fatalf("failed to write artifacts: %v", err)
116+
var snapshot *facts.Snapshot
117+
for i, repoPath := range repoPaths {
118+
// The first repository resets the store; the rest append to it, which is
119+
// what makes one process produce one linked graph.
120+
snapshot, err = eng.GenerateSnapshot(ctx, repoPath, i > 0)
121+
if err != nil {
122+
log.Fatalf("snapshot generation failed for %s: %v", repoPath, err)
123+
}
124+
// In multi-repo mode WriteArtifacts writes the whole store to each
125+
// repo's output dir, matching what the MCP server does per generate.
126+
if err := eng.WriteArtifacts(repoPath); err != nil {
127+
log.Fatalf("failed to write artifacts for %s: %v", repoPath, err)
128+
}
116129
}
117130

118131
// Refresh the graph-wide receipt at ~/.enola/receipt.json. Non-fatal: a
@@ -122,12 +135,19 @@ func main() {
122135
}
123136

124137
fmt.Fprintf(os.Stderr, "\nSnapshot complete:\n")
125-
fmt.Fprintf(os.Stderr, " Repository: %s\n", snapshot.Meta.RepoPath)
138+
if len(repoPaths) > 1 {
139+
fmt.Fprintf(os.Stderr, " Repositories: %d\n", len(repoPaths))
140+
for _, p := range repoPaths {
141+
fmt.Fprintf(os.Stderr, " - %s\n", p)
142+
}
143+
} else {
144+
fmt.Fprintf(os.Stderr, " Repository: %s\n", snapshot.Meta.RepoPath)
145+
}
126146
fmt.Fprintf(os.Stderr, " Facts: %d\n", snapshot.Meta.FactCount)
127147
fmt.Fprintf(os.Stderr, " Insights: %d\n", snapshot.Meta.InsightCount)
128148
fmt.Fprintf(os.Stderr, " Artifacts: %d\n", len(snapshot.Artifacts))
129149
fmt.Fprintf(os.Stderr, " Duration: %s\n", snapshot.Meta.Duration)
130-
fmt.Fprintf(os.Stderr, " Output: %s\n", filepath.Join(repoPath, cfg.Output.Dir))
150+
fmt.Fprintf(os.Stderr, " Output: %s\n", filepath.Join(repoPaths[len(repoPaths)-1], cfg.Output.Dir))
131151
os.Exit(0)
132152
}
133153

@@ -216,23 +236,40 @@ func helpSpec() cli.HelpSpec {
216236
// runExplain indexes the given repository (defaulting to the configured repo)
217237
// and prints a human-readable statistical summary to stdout.
218238
func runExplain(ctx context.Context, eng *bootstrap.Engine, cfg *config.Config, repoArg string) {
219-
repo := repoArg
220-
if repo == "" {
221-
repo = cfg.Repo
222-
}
223-
repoPath, err := filepath.Abs(repo)
224-
if err != nil {
225-
log.Fatalf("failed to resolve repo path: %v", err)
239+
// A positional argument names one repository and overrides the config; with no
240+
// argument the config decides, which is what lets `repos:` produce a report over
241+
// the whole cluster rather than its first member.
242+
var repoPaths []string
243+
if repoArg != "" {
244+
abs, err := filepath.Abs(repoArg)
245+
if err != nil {
246+
log.Fatalf("failed to resolve repo path: %v", err)
247+
}
248+
repoPaths = []string{abs}
249+
} else {
250+
var err error
251+
if repoPaths, err = cfg.RepoPaths(); err != nil {
252+
log.Fatalf("failed to resolve repo path: %v", err)
253+
}
226254
}
227255

228-
fmt.Fprintf(os.Stderr, "Analyzing %s …\n", repoPath)
229256
// --explain is a read-only, no-artifacts mode: reuse a cache if one exists,
230257
// but never write to .enola.
231258
eng.SetPersistCache(false)
232-
if _, err := eng.GenerateSnapshot(ctx, repoPath, false); err != nil {
233-
log.Fatalf("snapshot generation failed: %v", err)
259+
for i, repoPath := range repoPaths {
260+
fmt.Fprintf(os.Stderr, "Analyzing %s …\n", repoPath)
261+
if _, err := eng.GenerateSnapshot(ctx, repoPath, i > 0); err != nil {
262+
log.Fatalf("snapshot generation failed for %s: %v", repoPath, err)
263+
}
234264
}
235265

236266
report := explain.Compute(eng)
237267
fmt.Print(report.Render())
238268
}
269+
270+
// isDirectory reports whether path names an existing directory. Used to tell a
271+
// repository argument from a config-file argument.
272+
func isDirectory(path string) bool {
273+
fi, err := os.Stat(path)
274+
return err == nil && fi.IsDir()
275+
}

examples/multi-repo.yaml

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
11
# enola configuration for cross-repo analysis.
22
#
33
# This config enables all extractors so it works regardless of which
4-
# languages each repository uses. Use append mode to build a combined
5-
# snapshot across multiple repositories.
4+
# languages each repository uses. There are two ways to build the combined
5+
# snapshot; they produce the same graph.
66
#
7-
# Workflow:
8-
# 1. Generate the first repo:
9-
# > "Generate an architectural snapshot of /path/to/ruby-monolith"
7+
# A. Name the cluster here, and index it in one command:
108
#
11-
# 2. Append additional repos:
12-
# > "Generate a snapshot of /path/to/go-service with append mode"
9+
# enola --generate examples/multi-repo.yaml
1310
#
14-
# 3. Query across repos:
11+
# Uncomment `repos:` below. Entries resolve relative to THIS FILE, not to
12+
# your working directory, so the config can be checked in and means the
13+
# same thing on a laptop and in CI. Order is semantic: the first entry
14+
# resets the graph and the rest are added to it.
15+
#
16+
# B. Drive it from your agent, one repo at a time:
17+
#
18+
# 1. > "Generate an architectural snapshot of /path/to/ruby-monolith"
19+
# 2. > "Generate a snapshot of /path/to/go-service with append mode"
20+
#
21+
# Then query across repos:
1522
# > "Query all route facts" (all repos)
1623
# > "Query all symbols in go-service" (single repo)
1724
#
1825
# Each repo is tagged with a label derived from its directory name
1926
# (e.g. /path/to/go-service -> "go-service"). File paths in the
2027
# fact store are prefixed with the repo label in multi-repo mode.
28+
#
29+
# Cross-repo edges, `coverage_report` and unused-route findings only exist
30+
# with two or more repos loaded — which is what either route above gives you.
2131

2232
repo: "."
33+
34+
# repos:
35+
# - ../ruby-monolith
36+
# - ../go-service
37+
# - ../web-client
2338
ignore:
2439
# Dependencies and tooling
2540
- "vendor/**"

internal/config/config.go

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,38 @@ package config
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
7+
"strings"
68

79
"gopkg.in/yaml.v3"
810
)
911

1012
// Config represents the mcp-arch.yaml configuration.
1113
type Config struct {
12-
Repo string `yaml:"repo"`
14+
Repo string `yaml:"repo"`
15+
16+
// Repos is the ordered list of repositories that form a multi-repo cluster.
17+
// When set it supersedes Repo, and the whole cluster is indexed in one run —
18+
// the first repository fresh, the rest appended.
19+
//
20+
// It exists because cross-repo linking was previously reachable only from an
21+
// MCP session: `append` is a generate_snapshot tool parameter, and both CLIs
22+
// hardcoded false, so a CI job or a developer not driving an agent could only
23+
// ever see the single-repo subset (no service nodes, no cross-repo edges, no
24+
// coverage_report, no unused-routes). Naming the cluster in the config also
25+
// makes its composition a reviewable file rather than a property of the order
26+
// somebody happened to issue tool calls in.
27+
//
28+
// Entries are resolved relative to the DIRECTORY OF THIS FILE, not the working
29+
// directory, so a checked-in cluster config means the same thing wherever it is
30+
// run from. (Repo keeps its historical cwd-relative behaviour.)
31+
Repos []string `yaml:"repos"`
32+
33+
// SourcePath is the file this config was read from, or "" when the built-in
34+
// defaults are in force. Not read from YAML — set by Load, and used to resolve
35+
// Repos entries against the config's own directory.
36+
SourcePath string `yaml:"-"`
37+
1338
Ignore []string `yaml:"ignore"`
1439
TestGlobs []string `yaml:"test_globs"`
1540
Extractors []string `yaml:"extractors"`
@@ -202,6 +227,13 @@ func Load(path string) (*Config, error) {
202227
if err := yaml.Unmarshal(data, cfg); err != nil {
203228
return nil, fmt.Errorf("parsing config %s: %w", path, err)
204229
}
230+
// Recorded so Repos entries resolve against the config's own directory; see
231+
// RepoPaths.
232+
if abs, err := filepath.Abs(path); err == nil {
233+
cfg.SourcePath = abs
234+
} else {
235+
cfg.SourcePath = path
236+
}
205237

206238
// Ensure required defaults
207239
if cfg.Output.Dir == "" {
@@ -214,6 +246,66 @@ func Load(path string) (*Config, error) {
214246
return cfg, nil
215247
}
216248

249+
// RepoPaths returns the absolute repository paths this run covers, in the order
250+
// they should be indexed: the first fresh, the rest appended.
251+
//
252+
// This is the single definition of "which repositories does this config describe",
253+
// so the CLI, --explain and any wrapper agree. Two resolution rules, and the
254+
// difference is deliberate:
255+
//
256+
// - Repos entries resolve against the config file's own directory, because a
257+
// cluster config is meant to be checked in and to mean the same thing wherever
258+
// it is run from. With no config file (built-in defaults) there is no such
259+
// directory, so they fall back to the working directory.
260+
// - Repo resolves against the working directory, unchanged, because that is what
261+
// `repo: "."` has always meant.
262+
//
263+
// Returns exactly one path when Repos is empty, so a single-repo caller needs no
264+
// special case.
265+
func (c *Config) RepoPaths() ([]string, error) {
266+
if len(c.Repos) == 0 {
267+
abs, err := filepath.Abs(c.Repo)
268+
if err != nil {
269+
return nil, fmt.Errorf("resolving repo %q: %w", c.Repo, err)
270+
}
271+
return []string{abs}, nil
272+
}
273+
274+
base := ""
275+
if c.SourcePath != "" {
276+
base = filepath.Dir(c.SourcePath)
277+
}
278+
out := make([]string, 0, len(c.Repos))
279+
seen := map[string]bool{}
280+
for _, r := range c.Repos {
281+
// Trimmed before the emptiness check: a whitespace-only entry is a YAML
282+
// slip, and filepath.Abs would happily turn it into the working directory.
283+
r = strings.TrimSpace(r)
284+
if r == "" {
285+
continue
286+
}
287+
p := r
288+
if !filepath.IsAbs(p) && base != "" {
289+
p = filepath.Join(base, p)
290+
}
291+
abs, err := filepath.Abs(p)
292+
if err != nil {
293+
return nil, fmt.Errorf("resolving repos entry %q: %w", r, err)
294+
}
295+
// A repository listed twice would be indexed twice, the second pass
296+
// appending a duplicate of every fact it already contributed.
297+
if seen[abs] {
298+
continue
299+
}
300+
seen[abs] = true
301+
out = append(out, abs)
302+
}
303+
if len(out) == 0 {
304+
return nil, fmt.Errorf("repos is set but contains no usable paths")
305+
}
306+
return out, nil
307+
}
308+
217309
// IsExtractorEnabled returns true if the named extractor is enabled.
218310
func (c *Config) IsExtractorEnabled(name string) bool {
219311
return contains(c.Extractors, name)

0 commit comments

Comments
 (0)