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: 4 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Repository

Stage by stage:

1. **File walker** — enumerates files under the repo, applying the `ignore` globs from config so build output, vendored code, tests, and generated files never reach the parsers. A few extractors (OpenAPI, and PHP's Symfony route config) deliberately scan specific config-format files (YAML/JSON) directly from disk, bypassing these globs — because the globs exist to suppress config/data noise, not to hide those architecturally meaningful files (see [Supported languages](#supported-languages)).
1. **File walker** — enumerates files under the repo, applying the `ignore` globs from config so build output, vendored code, tests, and generated files never reach the parsers. Beyond the path globs, the TypeScript extractor additionally detects **minified/bundled** files by content (any line longer than ~2000 chars) and skips them, so a hash-named vendor bundle checked in outside a build directory (e.g. a minified third-party bundle served from a static assets dir) does not pollute the fact graph with obfuscated symbols and spurious complexity/hotspot findings. A few extractors (OpenAPI, and PHP's Symfony route config) deliberately scan specific config-format files (YAML/JSON) directly from disk, bypassing these globs — because the globs exist to suppress config/data noise, not to hide those architecturally meaningful files (see [Supported languages](#supported-languages)).
2. **Extractors** — each enabled language extractor first *detects* whether it applies (e.g. Go runs when there's a `go.mod`), then *parses* the matching files and emits facts. This is pure parsing; see [Supported languages](#supported-languages) for what each one understands.
3. **Fact store** — facts land in an in-memory store ([`internal/facts/store.go`](internal/facts/store.go)) indexed by kind, file, name, and repo for fast queries. In append mode, facts are tagged with a repo label and file paths are repo-prefixed.
4. **Cross-repo linker** — only when two or more repos are loaded. It connects the per-repo graphs by matching HTTP client/server routes and shared-library imports, emitting `service` nodes and cross-repo dependency edges. The link set is recomputed from scratch on every append, so it always reflects exactly the repos currently loaded.
Expand Down Expand Up @@ -478,7 +478,7 @@ The bundled [`mcp-arch.yaml`](mcp-arch.yaml) ships a much fuller `ignore` list (
| Field | Description | Default |
|-------|-------------|---------|
| `repo` | Repository root path | `"."` |
| `ignore` | Glob patterns for files/dirs to skip | vendor, node_modules, .git, tests, build dirs, docs, config data, … |
| `ignore` | Glob patterns for files/dirs to skip | vendor, node_modules, .git, tests, build dirs, minified JS (`*.min.js`/`*.bundle.js`), docs, config data, … |
| `extractors` | Enabled extractors | `["cpp", "go", "java", "kotlin", "openapi", "python", "typescript", "swift", "ruby"]` |
| `explainers` | Enabled explainers | `["cycles", "layers", "crossrepo", "coverage", "unused-routes", "god-class", "hotspots", "dependency-depth", "exported-surface", "complexity-outliers"]` |
| `renderers` | Enabled renderers | `["llm_context"]` |
Expand Down Expand Up @@ -510,11 +510,11 @@ Each extractor is detected by characteristic project files and then parses what

**Go** uses the standard-library parser directly, so symbols, methods, interfaces, imports, and call edges are exact.

**TypeScript** (tree-sitter) includes Next.js route detection (App Router and Pages Router), monorepo detection one level deep, and parsing of `openapi-typescript`-generated client files — each operation is emitted as a `route` fact with `role:"client"`. App Router route groups like `(standard)` are stripped from URLs.
**TypeScript** (tree-sitter) includes Next.js route detection (App Router and Pages Router), monorepo detection one level deep, and parsing of `openapi-typescript`-generated client files — each operation is emitted as a `route` fact with `role:"client"`. App Router route groups like `(standard)` are stripped from URLs. Like the other AST extractors it walks function/method bodies for the complexity metrics (`cyclomatic`, `loop_depth`, `loop_count`, `calls_in_loop`, `recursive_self`) the enterprise `analyze_performance` tool consumes, and — mirroring Swift — it tags a body `io_direct` when it directly invokes a network/file primitive (`fetch`, `axios`, `fs.readFile`, `navigator.sendBeacon`, `new WebSocket`/`XMLHttpRequest`/`EventSource`) **or** calls a binding imported from a network module (a known HTTP-client package, or any path with a `network` segment — e.g. a `request` helper from a `.../lib/network/request` module). A serial post-pass (`computeTSPerformsIO`) then propagates that flag transitively over the `calls` graph into a `performs_io` prop via a cycle-safe monotone fixpoint, so a function reaching the network only through wrapper helpers is still flagged — letting the analyzer catch a per-iteration network call (an N+1) hidden behind a wrapper. *Limitation:* default-imported internal wrappers aren't resolved by the call-edge pass, so the fixpoint does not cross that hop; in practice most wrappers call their I/O sink directly (so they are seeded `io_direct` without needing the edge), and the analyzer's short-name I/O index still matches the in-loop call.

**Vue** support is integrated within the TypeScript extractor. `.vue` Single File Components are handled natively — the extractor parses each SFC's `<script>` and `<script setup>` blocks (case-insensitive, with `lang` attribute detection for TypeScript vs JavaScript) and feeds them through the existing tree-sitter TypeScript pipeline. Detection checks for a `"vue"` dependency in `package.json` (TypeScript root first, then repo root fallback); **Nuxt** is additionally detected by `nuxt.config.js/ts/mjs` or a `"nuxt"` package dependency. Each `.vue` file emits a `symbol` fact for the component, named `<dir>.<ComponentName>` (kebab-case converted to PascalCase), carrying `web_component: "component"`, `framework: "vue"` or `"nuxt"`, and `vue_setup: true` when using `<script setup>`. Functions named `use*` anywhere in the project are automatically classified as composables (`web_component: "composable"`). **Nuxt file-based routing** emits one `route` fact per file under `pages/`, with the URL derived from the file path — index files resolve to `/`, dynamic segments like `[id].vue` are preserved — each with `method: "GET"` and `router: "pages"`. Files containing a `createRouter()` call are emitted as a route fact with `type: "router_config"`. Import statements in all script blocks become `dependency` facts, and call edges from method bodies participate in `traverse`, `find_path`, and `impact_analysis`. Vue detection runs automatically inside the `typescript` extractor — no separate entry is needed under `extractors:` in config.

**JavaScript** (`.js`/`.jsx`) is handled by the TypeScript extractor. Tree-sitter's TypeScript parser natively parses JavaScript (JS is a subset of TS), so all extraction features — imports, declarations, call graphs, JSX component detection — work identically for `.js` and `.jsx` files. No separate configuration is needed; any project detected by the TypeScript extractor will have its JavaScript files processed automatically alongside TypeScript files.
**JavaScript** (`.js`/`.jsx`) is handled by the TypeScript extractor. Tree-sitter's TypeScript parser natively parses JavaScript (JS is a subset of TS), so all extraction features — imports, declarations, call graphs, JSX component detection — work identically for `.js` and `.jsx` files. No separate configuration is needed; any project detected by the TypeScript extractor will have its JavaScript files processed automatically alongside TypeScript files. **Minified/bundled JS is skipped**: before parsing, any file with a line longer than ~2000 characters is treated as a generated artifact and produces no facts (a directory containing only such files emits no module either), so checked-in vendor bundles do not distort the graph or the enterprise complexity/performance analyses.

**Svelte** support is integrated within the TypeScript extractor, following the same pattern as Vue. `.svelte` Single File Components are parsed by extracting `<script>` and `<script module>` blocks (Svelte 5 syntax; the older `<script context="module">` form from Svelte 4 is also supported) and feeding them through tree-sitter. Detection checks for a `"svelte"` dependency in `package.json`; **SvelteKit** is additionally detected by `svelte.config.js/ts/mjs` or a `"@sveltejs/kit"` package dependency. Each `.svelte` file emits a component fact with `web_component: "component"`, `framework: "svelte"` or `"sveltekit"`. **SvelteKit file-based routing** emits route facts for `+page.svelte`, `+layout.svelte`, `+error.svelte`, and `+server.ts` files under `src/routes/` — route groups in parentheses like `(groupName)` are stripped from URLs, dynamic segments like `[slug]` and catch-all `[...rest]` are preserved. Server-side load files (`+page.server.ts`, `+layout.server.ts`) are not emitted as routes. The SvelteKit `$lib` path alias is automatically resolved to `src/lib/`, ensuring imports like `$lib/utils` appear as internal dependency edges rather than unresolved externals.

Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ func Default() *Config {
"__pycache__/**",
"**/Pods/**",
"**/.gradle/**",
// Minified / bundled JS by name. The extractor also detects minified
// content heuristically (very long lines), but these globs cheaply skip
// the common named cases before a file is ever read. Keep in sync with
// the bundled mcp-arch.yaml ignore list.
"**/*.min.js",
"**/*.bundle.js",
},
// TestGlobs identify test/spec files. They stay ignored for normal indexing
// (still listed in Ignore above) — production architecture facts must not
Expand Down
31 changes: 30 additions & 1 deletion internal/engine/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,36 @@ import (
// (Java & Kotlin); a Dagger @Component interface is no longer mislabeled a Spring component
// (disambiguated by interface-vs-class). Lets package-metrics exclude DI wiring from
// abstractness/type counts (a Dagger component package was falsely "useless").
const cacheVersion = "v60"
// v61: TS/JS extractor now emits file-scope reference facts (KindFileRef) for JSX
// component rendering (<Foo/>), imported-identifier values (route configs like
// `{ component: Foo }`), namespace member access (`ns.foo`), require()-bound names,
// and `export … from` re-exports — plus require()/dynamic-import() dependency edges.
// Fixes massive dead-code false positives on React/CommonJS codebases, where a
// component used only via JSX or a route table previously had no incoming edge.
// v62: the TS/JS file-scope reference pass now also records same-module use
// positions — a bare call callee and identifier call arguments — so a function used
// only at module scope (`startSession()` at file top level) or passed as a value to
// an HOC (`connect(mapStateToProps)`) is no longer falsely reported dead.
// v63: a default import now also references the target module's default-export symbol
// (resolved via the known-files set + fileSymbolName), so an anonymous folder-index
// default like `export default connect(...)(X)` — named "<Folder>Index" — is no longer
// falsely reported dead when imported by the component's own name.
// v64: a `this.<member>` reference inside a class method now records a use of that
// member, so a React class-component event handler bound as a prop value
// (onClick={this.handleClick}) — never called by name — is no longer falsely dead.
// v65: the TypeScript extractor now skips minified/bundled files (any line longer
// than ~2000 chars), so checked-in vendor bundles emit no facts — invalidates
// caches that still hold the obfuscated symbols.
// v66: the TypeScript extractor now emits io_direct (body calls a network/file
// primitive or a network-module import binding) and a transitively-propagated
// performs_io prop, so cached TS facts must be re-extracted to carry them.
// v67: tightened TS io_direct — only DEFAULT/NAMESPACE network-module imports are
// I/O bindings (not named imports), and types/utils submodules are excluded, so pure
// helpers (e.g. `resolved` from network/types) no longer mislabel callers.
// v68: the TypeScript extractor now sets abstract:true on `abstract class`
// declarations (was previously indistinguishable from a concrete class), so
// package-metrics abstractness for TS must re-extract to pick up the flag.
const cacheVersion = "v68"

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
4 changes: 3 additions & 1 deletion internal/engine/testdata/golden/ts_sample.facts.jsonl
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{"kind":"dependency","name":"src -\u003e src/repo","file":"src/index.ts","line":1,"repo":"ts_sample","props":{"language":"typescript","source":"internal"},"relations":[{"kind":"imports","target":"src/repo"}]}
{"kind":"dependency","name":"src -\u003e src/repo","file":"src/svc.ts","line":1,"repo":"ts_sample","props":{"language":"typescript","source":"internal"},"relations":[{"kind":"imports","target":"src/repo"}]}
{"kind":"dependency","name":"src -\u003e src/svc","file":"src/index.ts","line":2,"repo":"ts_sample","props":{"language":"typescript","source":"internal"},"relations":[{"kind":"imports","target":"src/svc"}]}
{"kind":"file_ref","name":"src/index.ts","file":"src/index.ts","line":1,"repo":"ts_sample","props":{"language":"typescript"},"relations":[{"kind":"calls","target":"src.Repo"},{"kind":"calls","target":"src.Service"}]}
{"kind":"file_ref","name":"src/svc.ts","file":"src/svc.ts","line":1,"repo":"ts_sample","props":{"language":"typescript"},"relations":[{"kind":"calls","target":"src.Repo"}]}
{"kind":"module","name":"src","file":"src","repo":"ts_sample","props":{"language":"typescript"}}
{"kind":"symbol","name":"src.Repo","file":"src/repo.ts","line":2,"repo":"ts_sample","props":{"exported":true,"language":"typescript","symbol_kind":"class"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.Repo.all","file":"src/repo.ts","line":3,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","receiver":"Repo","symbol_kind":"method"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.Service","file":"src/svc.ts","line":4,"repo":"ts_sample","props":{"exported":true,"language":"typescript","symbol_kind":"class"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.Service.list","file":"src/svc.ts","line":7,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","receiver":"Service","symbol_kind":"method"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.Service.list","file":"src/svc.ts","line":7,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","receiver":"Service","symbol_kind":"method"},"relations":[{"kind":"calls","target":"src.Service.repo"},{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.main","file":"src/index.ts","line":5,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
56 changes: 56 additions & 0 deletions internal/extractors/tsextractor/minified_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package tsextractor

import (
"strings"
"testing"

"github.com/enola-labs/enola/internal/facts"
)

func TestIsMinifiedSource(t *testing.T) {
longLine := "var x = \"" + strings.Repeat("z", minifiedLineThreshold+100) + "\";"
if !isMinifiedSource([]byte(longLine)) {
t.Errorf("isMinifiedSource(one very long line) = false, want true")
}
// A build banner on line 1 followed by a huge minified chunk (the common shape).
bundle := "/* Build */\n" + longLine + "\nfunction f(){}\n"
if !isMinifiedSource([]byte(bundle)) {
t.Errorf("isMinifiedSource(banner + long line) = false, want true")
}

ordinary := "export function add(a, b) {\n return a + b;\n}\n"
if isMinifiedSource([]byte(ordinary)) {
t.Errorf("isMinifiedSource(ordinary source) = true, want false")
}
// Many short lines, none over the threshold, even if the file is large overall.
manyLines := strings.Repeat("const x = compute();\n", 500)
if isMinifiedSource([]byte(manyLines)) {
t.Errorf("isMinifiedSource(many short lines) = true, want false")
}
}

func TestExtract_SkipsMinifiedBundle(t *testing.T) {
longLine := "var bundledLibrary = \"" + strings.Repeat("z", minifiedLineThreshold+100) + "\";"
files := map[string]string{
"src/util.ts": "export function realHelper() {\n return 1;\n}\n",
"assets/vendor/bundle.js": longLine,
}
got := extractAll(t, files, false)

// The hand-written symbol is extracted.
if _, ok := findFact(got, "src.realHelper"); !ok {
t.Errorf("expected symbol fact for src.realHelper; got %+v", got)
}
// The minified bundle contributes no facts at all — no symbols and no module
// fact for its directory.
for _, f := range got {
if strings.Contains(f.File, "assets/vendor") || strings.Contains(f.Name, "assets/vendor") {
t.Errorf("minified bundle produced a fact it should have been skipped: %+v", f)
}
}
for _, m := range findFactsByKind(got, facts.KindModule) {
if m.Name == "assets/vendor" {
t.Errorf("minified-only directory should not emit a module fact; got %+v", m)
}
}
}
Loading
Loading