From 248dd030ff0899a67a5529185c18347d3d083eae Mon Sep 17 00:00:00 2001 From: forhappy Date: Sun, 23 Aug 2026 21:49:43 -0700 Subject: [PATCH 1/4] feat(cli): support explicit semantic provider selection --- CHANGELOG.md | 5 +++ COMPATIBILITY.md | 6 +++ crates/compass-cli/src/help.rs | 6 +-- crates/compass-cli/src/lib.rs | 52 ++++++++++++++++++++++- crates/compass-cli/tests/dedup_extract.rs | 4 +- docs/reference/commands.md | 14 +++++- docs/reference/configuration.md | 29 ++++++++++++- 7 files changed, 107 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93aa1ced..fd209606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ target; native document processing remains available and managed OCR reports an explicit unsupported-platform error before downloading model weights. +- Make semantic provider choice explicit and repeatable with `--backend`, + `--model`, `COMPASS_BACKEND`, and `COMPASS_MODEL`. Credential guidance now + covers all built-in providers and custom provider environment keys without + exposing secrets in Compass configuration or artifacts. + - Hard-cut Swift, Dart, Scala, and Groovy/Gradle onto version-1 qualifying universal evidence pipelines. The bounded AST-first producer publishes declarations, scopes, bindings, occurrences, and conservative relationship diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 3d16db63..bff316f0 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -77,6 +77,12 @@ with exact source owner, geometry, confidence, and model provenance. Partial visual coverage is never labeled complete or finalized as a complete document cache entry. +Semantic enrichment accepts explicit provider/model selection through +`--backend`/`--model` or the non-secret `COMPASS_BACKEND`/`COMPASS_MODEL` +environment defaults. Provider credentials remain provider-specific +environment or secret-store values and are excluded from graph artifacts, +history profiles, and cache identities. + ## Evolving contracts The Grounded Agent Graph feature is additive and opt-in. It does not change diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index 43d71c62..f44f58a4 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -749,9 +749,9 @@ const PAGES: &[Page] = &[ ), page!( "provider", - "Manage custom semantic model providers", + "Choose built-in or custom semantic model providers", ["compass provider "], - "Examples:\n compass provider list\n compass provider show local\n\nTips:\n Run `compass help provider add` for endpoint and credential configuration." + "Examples:\n compass provider list\n compass provider show local\n COMPASS_BACKEND=gemini GEMINI_API_KEY=… compass extract ./docs\n\nBuilt-in providers include claude, kimi, ollama, gemini, openai, deepseek, azure, bedrock, and claude-cli. Use `--backend ` (or `COMPASS_BACKEND`) when more than one credential is available.\n\nTips:\n Run `compass help provider add` for custom OpenAI-compatible endpoints and credential configuration. Keys are read from environment variables only; they are not stored in `.compass/config.toml`, provider registries, history profiles, or graph artifacts." ), page!( "provider add", @@ -765,7 +765,7 @@ const PAGES: &[Page] = &[ "provider list", "List registered custom providers", ["compass provider list"], - "Examples:\n compass provider list" + "Examples:\n compass provider list\n\nNotes:\n This lists custom providers registered in `~/.compass/providers.json`. Built-in providers are selected with `--backend` or `COMPASS_BACKEND`; see `compass help extract` for the supported credential variables." ), page!( "provider show", diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index 469d6535..1559ea11 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -2097,6 +2097,13 @@ fn command_build_with_validation_inner( "error: must specify a path to scan or a --postgres DSN".to_owned(), ); } + if extract { + // Explicit flags win over environment configuration. The generic + // selector is intentionally non-secret: provider transports still + // read only their documented provider-specific credential variable. + let environment = std::env::vars().collect::>(); + apply_provider_environment_defaults(&mut backend, &mut model, &environment); + } let root = if extract && !has_explicit_root { PathBuf::from(".") } else { @@ -2508,11 +2515,32 @@ fn no_llm_api_key_message(semantic_count: usize, dedup_llm: bool) -> String { "" }; format!( - "no LLM API key found ({}). Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY (kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only corpus needs no key.{hint}", + "no LLM API key found ({}). Choose a provider with --backend or COMPASS_BACKEND and set its credential: GEMINI_API_KEY/GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY (kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), DEEPSEEK_API_KEY (deepseek), AZURE_OPENAI_API_KEY plus AZURE_OPENAI_ENDPOINT (azure), AWS credentials (bedrock), OLLAMA_BASE_URL for local Ollama, or the env key registered with `compass provider add`. A code-only corpus needs no key.{hint}", reasons.join("; ") ) } +fn apply_provider_environment_defaults( + backend: &mut Option, + model: &mut Option, + environment: &HashMap, +) { + if backend.is_none() { + *backend = environment_value(environment, "COMPASS_BACKEND"); + } + if model.is_none() { + *model = environment_value(environment, "COMPASS_MODEL"); + } +} + +fn environment_value(environment: &HashMap, key: &str) -> Option { + environment + .get(key) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + fn command_hook_refresh(frontend: Frontend, args: &[String]) -> Outcome { let launch_root = args .iter() @@ -3002,7 +3030,7 @@ fn executable_on_path(name: &str) -> bool { } fn extract_help() -> String { - "Usage: compass extract [PATH] [--program] [--program-artifact PATH] [--no-program] [--store json|sqlite] [--inference-level low|medium|high|max] [--code-only] [--cargo] [--google-workspace] [--postgres DSN] [--backend NAME] [--model MODEL] [--mode deep] [--ocr off|auto|always] [--ocr-profile NAME] [--ocr-language BCP47] [--token-budget N] [--max-concurrency N] [--max-workers N] [--max-source-bytes N] [--api-timeout SECONDS] [--allow-partial] [--dedup-llm] [--timing] [--out DIR] [--no-cluster] [--force] [--no-viz] [--no-gitignore] [--exclude PATTERN] [--resolution N] [--exclude-hubs N]".to_owned() + "Usage: compass extract [PATH] [--program] [--program-artifact PATH] [--no-program] [--store json|sqlite] [--inference-level low|medium|high|max] [--code-only] [--cargo] [--google-workspace] [--postgres DSN] [--backend NAME] [--model MODEL] [--mode deep] [--ocr off|auto|always] [--ocr-profile NAME] [--ocr-language BCP47] [--token-budget N] [--max-concurrency N] [--max-workers N] [--max-source-bytes N] [--api-timeout SECONDS] [--allow-partial] [--dedup-llm] [--timing] [--out DIR] [--no-cluster] [--force] [--no-viz] [--no-gitignore] [--exclude PATTERN] [--resolution N] [--exclude-hubs N]\nProvider selection: --backend/--model override COMPASS_BACKEND/COMPASS_MODEL. Built-ins: claude, kimi, ollama, gemini, openai, deepseek, azure, bedrock, claude-cli. Set the selected provider's documented credential variable; custom providers use `compass provider add`. Credentials are never written to Compass artifacts.".to_owned() } fn saved_graph_root() -> Option { @@ -6580,4 +6608,24 @@ mod mcp_option_tests { assert!(extract_help().contains("--inference-level low|medium|high|max")); Ok(()) } + + #[test] + fn provider_environment_defaults_are_non_secret_and_flags_win() { + let environment = HashMap::from([ + ("COMPASS_BACKEND".to_owned(), " gemini ".to_owned()), + ("COMPASS_MODEL".to_owned(), " gemini-test ".to_owned()), + ]); + let mut backend = None; + let mut model = None; + apply_provider_environment_defaults(&mut backend, &mut model, &environment); + assert_eq!(backend.as_deref(), Some("gemini")); + assert_eq!(model.as_deref(), Some("gemini-test")); + + let mut backend = Some("openai".to_owned()); + let mut model = Some("gpt-test".to_owned()); + apply_provider_environment_defaults(&mut backend, &mut model, &environment); + assert_eq!(backend.as_deref(), Some("openai")); + assert_eq!(model.as_deref(), Some("gpt-test")); + assert!(extract_help().contains("COMPASS_BACKEND/COMPASS_MODEL")); + } } diff --git a/crates/compass-cli/tests/dedup_extract.rs b/crates/compass-cli/tests/dedup_extract.rs index 817e8c95..041bf37e 100644 --- a/crates/compass-cli/tests/dedup_extract.rs +++ b/crates/compass-cli/tests/dedup_extract.rs @@ -23,6 +23,8 @@ const BACKEND_ENVIRONMENT: &[&str] = &[ "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", "OLLAMA_BASE_URL", + "COMPASS_BACKEND", + "COMPASS_MODEL", ]; #[test] @@ -49,7 +51,7 @@ fn dedup_llm_without_backend_has_actionable_diagnostic() -> Result<(), Box or COMPASS_BACKEND and set its credential: GEMINI_API_KEY/GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY (kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), DEEPSEEK_API_KEY (deepseek), AZURE_OPENAI_API_KEY plus AZURE_OPENAI_ENDPOINT (azure), AWS credentials (bedrock), OLLAMA_BASE_URL for local Ollama, or the env key registered with `compass provider add`. A code-only corpus needs no key.\n" )); assert!(stderr.contains("Compass extract failed after ")); Ok(()) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 2886a225..0c8225c5 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -118,6 +118,15 @@ compass extract [PATH] [--exclude-hubs N] ``` +`--backend` and `--model` select the semantic provider and model. The +non-secret `COMPASS_BACKEND` and `COMPASS_MODEL` environment values are used +when the flags are omitted; explicit flags win. Built-ins are `claude`, `kimi`, +`ollama`, `gemini`, `openai`, `deepseek`, `azure`, `bedrock`, and `claude-cli`. +Set only the selected provider's documented credential variable, or register a +custom OpenAI-compatible provider with `compass provider add`. OCR remains +local and does not require an LLM credential; document semantic enrichment may +use the selected provider. + Use `--code-only` for an explicit fully local structural profile; it limits structural node and edge extraction to code-classified files while retaining the scanned file inventory. Program IR is opt-in with `--program`; @@ -783,7 +792,10 @@ compass provider add NAME compass provider remove NAME ``` -Built-in provider names cannot be overridden. +Built-in provider names cannot be overridden. The built-ins are `claude`, +`kimi`, `ollama`, `gemini`, `openai`, `deepseek`, `azure`, `bedrock`, and +`claude-cli`; credentials are read from each provider's documented environment +variable and are never written to the registry. ### `add` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 00b7b3dc..33444e6a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -191,17 +191,35 @@ Current built-in backend code recognizes families including: | Backend | Key variables | Endpoint/model examples | | --- | --- | --- | | Anthropic/Claude | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | +| Kimi/Moonshot | `MOONSHOT_API_KEY` | `KIMI_BASE_URL` | | Gemini | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | `GEMINI_BASE_URL`, `COMPASS_GEMINI_MODEL` | | OpenAI | `OPENAI_API_KEY` | `OPENAI_BASE_URL`, `OPENAI_MODEL`, `COMPASS_OPENAI_MODEL` | +| DeepSeek | `DEEPSEEK_API_KEY` | `DEEPSEEK_BASE_URL`, `COMPASS_DEEPSEEK_MODEL` | | Azure OpenAI | `AZURE_OPENAI_API_KEY` | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT` | | Ollama-compatible | optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`, `OLLAMA_MODEL` | | Bedrock | AWS credential chain | `COMPASS_BEDROCK_MODEL` | +| Claude CLI | local `claude` login | `COMPASS_CLAUDE_CLI_MODEL` | Some Compass variables retain `COMPASS_` names. Their presence does not change the public executable name. -Use `compass extract --help` and current provider documentation before -deployment; backend support and model defaults can evolve. +Choose a provider explicitly when more than one credential is present: + +```bash +COMPASS_BACKEND=openai OPENAI_API_KEY="$OPENAI_API_KEY" \ + COMPASS_MODEL=gpt-4.1-mini compass extract . +``` + +`--backend` and `--model` take precedence over `COMPASS_BACKEND` and +`COMPASS_MODEL`. If neither is supplied, Compass detects the first configured +built-in provider in this order: Gemini, Kimi, Claude, OpenAI, DeepSeek, Azure, +Bedrock, then Ollama; custom providers are considered after built-ins. The +selector and model are not secrets; `COMPASS_MODEL` also takes precedence over +a provider-specific model variable. Provider credentials remain in the +provider-specific environment variable or secret store and are never written +to graph artifacts or history profiles. Use `compass extract --help` and +current provider documentation before deployment; backend support and model +defaults can evolve. ## Custom provider registry @@ -239,6 +257,13 @@ compass provider list compass provider show internal ``` +Use the registered provider without putting its secret in the registry: + +```bash +COMPASS_BACKEND=internal INTERNAL_MODEL_API_KEY="$INTERNAL_MODEL_API_KEY" \ + compass extract . +``` + Remove: ```bash From cd943ee36b1968b13d83077ab1b9825ce55f7b2d Mon Sep 17 00:00:00 2001 From: forhappy Date: Sun, 23 Aug 2026 22:40:47 -0700 Subject: [PATCH 2/4] feat: publish remaining session document and framework improvements --- .cargo/config.toml | 9 +- CHANGELOG.md | 14 + COMPATIBILITY.md | 23 +- MIGRATION.md | 21 + ...-react-frontend-framework-graph-quality.md | 2148 +++++++++++++++++ advisor-plans/README.md | 13 + .../compass-cli/src/task_context_commands.rs | 15 + .../compass-core/src/document_processing.rs | 12 + crates/compass-core/src/lib.rs | 11 +- crates/compass-core/src/pipeline.rs | 47 +- crates/compass-core/src/task_context.rs | 702 +++++- .../code_graph_v1_publication_resilience.rs | 5 +- crates/compass-core/tests/task_context.rs | 123 +- crates/compass-files/src/detect.rs | 43 +- crates/compass-files/src/file_set.rs | 278 +++ crates/compass-files/src/lib.rs | 10 + crates/compass-graph/src/v1.rs | 70 +- .../tests/graph_v1_normalization.rs | 10 + .../compass-graph/tests/markdown_identity.rs | 31 +- .../tests/rust_method_identity.rs | 25 +- crates/compass-languages/src/engine.rs | 8 + .../src/evidence/typescript.rs | 36 +- .../src/frameworks/csharp.rs | 21 + .../src/frameworks/file_routes.rs | 882 ++++++- crates/compass-languages/src/frameworks/go.rs | 1 + .../compass-languages/src/frameworks/mod.rs | 504 +++- .../compass-languages/src/frameworks/model.rs | 392 ++- .../compass-languages/src/frameworks/next.rs | 4 +- .../compass-languages/src/frameworks/pack.rs | 85 + .../compass-languages/src/frameworks/php.rs | 4 + .../compass-languages/src/frameworks/play.rs | 1 + .../src/frameworks/python.rs | 2 + .../compass-languages/src/frameworks/react.rs | 428 ++++ .../compass-languages/src/frameworks/remix.rs | 4 +- .../compass-languages/src/frameworks/ruby.rs | 1 + .../compass-languages/src/frameworks/rust.rs | 6 + .../src/frameworks/spring.rs | 30 + .../compass-languages/src/frameworks/swift.rs | 1 + .../src/frameworks/tanstack.rs | 470 ++++ .../src/frameworks/typescript.rs | 352 ++- .../src/frameworks/typescript_syntax.rs | 559 +++++ .../compass-languages/src/frameworks/vite.rs | 525 +++- crates/compass-languages/src/lib.rs | 11 +- .../compass-languages/src/project_evidence.rs | 202 +- .../tests/engine_edge_coverage.rs | 15 +- .../tests/next_universal_pack.rs | 224 ++ .../tests/php_universal_conformance.rs | 12 +- .../tests/react_router_universal_pack.rs | 132 + .../tests/react_universal_pack.rs | 55 + .../tests/tanstack_universal_pack.rs | 179 ++ .../tests/vite_universal_pack.rs | 140 ++ crates/compass-mcp/src/lib.rs | 2 +- crates/compass-model/src/code_graph.rs | 41 + crates/compass-model/src/validation.rs | 46 +- crates/compass-model/tests/code_graph_v1.rs | 23 +- .../tests/code_graph_validation.rs | 13 + crates/compass-output/assets/viewer/graph.js | 62 +- .../assets/viewer/manifest.json | 8 +- .../compass-output/assets/viewer/viewer.css | 2 +- crates/compass-output/src/html.rs | 104 + crates/compass-output/src/lenses.rs | 11 + crates/compass-output/src/lib.rs | 7 +- crates/compass-output/src/viewer_model.rs | 114 + crates/compass-query/src/affected.rs | 6 + crates/compass-query/src/code_query.rs | 2 + crates/compass-query/src/discovery.rs | 1 + crates/compass-query/tests/code_impact.rs | 59 + .../compass-resolve/src/frameworks/aspnet.rs | 1 + crates/compass-resolve/src/frameworks/axum.rs | 6 +- .../compass-resolve/src/frameworks/domain.rs | 733 +++++- crates/compass-resolve/src/frameworks/mod.rs | 35 +- crates/compass-resolve/src/frameworks/node.rs | 6 +- .../src/frameworks/qualification.rs | 191 ++ .../compass-resolve/src/frameworks/react.rs | 517 ++++ .../src/frameworks/relations.rs | 441 ++++ .../compass-resolve/src/frameworks/routes.rs | 672 +++++- .../compass-resolve/src/frameworks/spring.rs | 8 + .../src/frameworks/target_index.rs | 75 + crates/compass-resolve/src/lib.rs | 116 +- .../tests/framework_resolution_scale.rs | 1 + .../compass-resolve/tests/framework_routes.rs | 1 + crates/compass-resolve/tests/native_routes.rs | 61 +- .../tests/php_ruby_jvm_routes.rs | 28 +- crates/compass-resolve/tests/python_routes.rs | 7 +- .../compass-resolve/tests/react_frontend.rs | 294 +++ .../tests/spring_universal_pack.rs | 7 +- .../tests/typescript_routes.rs | 40 +- .../tests/universal_resolution/javascript.rs | 57 + crates/compass-semantic-diff/src/topology.rs | 3 + docs/README.md | 1 + docs/design/code-graph-v1-qualification.md | 2 +- docs/guides/task-context.md | 20 +- docs/reference/commands.md | 11 +- docs/reference/react-framework-graph.md | 143 ++ domain/database.expected.json | 23 + domain/database.sql | 33 + domain/jobs/aspnet.cs | 6 + domain/jobs/celery.py | 11 + domain/jobs/dynamic-near-matches.py | 9 + domain/jobs/spring.java | 6 + domain/messaging/dynamic-near-matches.ts | 9 + domain/messaging/nest.ts | 22 + domain/messaging/spring.java | 16 + domain/orm/active-record.rb | 3 + domain/orm/database.sql | 10 + domain/orm/diesel.rs | 7 + domain/orm/django.py | 5 + domain/orm/dynamic-near-matches.ts | 6 + domain/orm/eloquent.php | 7 + domain/orm/entity-framework.cs | 4 + domain/orm/gorm.go | 11 + domain/orm/jpa.java | 6 + domain/orm/sqlalchemy.py | 7 + domain/orm/typeorm.ts | 4 + .../vscode/src/views/initializationPanel.ts | 132 +- .../src/views/taskContextClient.test.ts | 18 + editors/vscode/src/views/taskContextClient.ts | 48 + editors/vscode/src/webviews/initialize.tsx | 18 +- .../frontend-react-negative/src/app/page.tsx | 3 + .../frontend-react/app/routes/users.$id.tsx | 9 + .../code-graph/frontend-react/next.config.mjs | 1 + .../code-graph/frontend-react/package.json | 14 + .../frontend-react/src/app/admin/page.tsx | 5 + .../src/app/admin/settings/page.tsx | 5 + .../frontend-react/src/app/page.tsx | 5 + .../frontend-react/src/components/Card.tsx | 3 + .../frontend-react/src/routes/home.tsx | 10 + .../frontend-react/src/routes/tanstack.tsx | 11 + .../code-graph/frontend-react/vite.config.ts | 9 + .../code-graph/routes/typescript/package.json | 6 + .../contracts/compass-query-v1.fingerprint | 2 +- .../contracts/compass-query-v1.manifest.json | 4 +- packages/compass-viewer/package.json | 1 + .../src/contracts/codeQuery.test.ts | 30 + .../compass-viewer/src/contracts/codeQuery.ts | 34 +- .../src/contracts/graph.test.ts | 57 + .../compass-viewer/src/contracts/graph.ts | 36 +- .../src/contracts/taskContext.test.ts | 52 + .../src/contracts/taskContext.ts | 186 ++ .../src/graph/DocumentOcrPanel.test.tsx | 145 ++ .../src/graph/DocumentOcrPanel.tsx | 552 +++++ .../src/graph/GraphInspector.tsx | 12 + packages/compass-viewer/src/index.ts | 2 + packages/compass-viewer/src/initialize.css | 153 ++ .../initialize/InitializationWizard.test.tsx | 27 + .../src/initialize/InitializationWizard.tsx | 185 +- packages/compass-viewer/src/theme.css | 452 ++++ qualification/MissingReference.csproj | 5 + qualification/Rich.cs | 21 + qualification/empty.py | 1 + qualification/export.dart | 5 + qualification/guide.md | 3 + qualification/mcp.json | 11 + qualification/migrations/001_init.sql | 8 + qualification/package.json | 9 + qualification/recovery.py | 4 + qualification/rich.dart | 1 + qualification/rich.php | 19 + qualification/rich.rb | 45 + qualification/rich.rs | 42 + qualification/rich.ts | 16 + qualification/semantic-doc.md | 3 + qualification/semantic-documented.rs | 1 + routes/csharp/AdvancedController.cs | 19 + routes/csharp/AspNetController.cs | 26 + routes/csharp/NearMatches.cs | 7 + routes/go/chi.go | 12 + routes/go/gin.go | 13 + routes/go/gorilla.go | 9 + routes/go/near_matches.go | 10 + routes/jvm/NearMatches.java | 10 + routes/jvm/PlayController.java | 6 + routes/jvm/PlayController.scala | 5 + routes/jvm/SpringController.java | 20 + routes/jvm/SpringController.kt | 12 + routes/jvm/play/conf/routes | 3 + routes/php/drupal.module | 14 + routes/php/drupal.routing.yml | 16 + routes/php/laravel.php | 29 + routes/php/near_matches.php | 11 + routes/python/django/project/urls.py | 10 + routes/python/django/project/views.py | 12 + routes/python/django/qualification/urls.py | 10 + routes/python/django_urls.py | 10 + routes/python/fastapi_app.py | 19 + routes/python/flask_app.py | 15 + routes/python/near_matches.py | 25 + routes/ruby/near_matches.rb | 6 + routes/ruby/rails.rb | 23 + routes/rust/actix.rs | 7 + routes/rust/axum.rs | 11 + routes/rust/near_matches.rs | 6 + routes/rust/rocket.rs | 4 + routes/swift/NearMatches.swift | 9 + routes/swift/VaporRoutes.swift | 11 + routes/typescript/AccountPage.tsx | 3 + routes/typescript/UserPage.tsx | 3 + routes/typescript/UserPage.vue | 1 + routes/typescript/astro/package.json | 6 + routes/typescript/astro/src/pages/about.astro | 1 + .../astro/src/pages/api/items/[id].ts | 7 + .../astro/src/pages/blog/[slug].astro | 4 + .../astro/src/pages/files/[...rest].astro | 5 + .../typescript/astro/src/pages/users/[id].ts | 3 + routes/typescript/express.ts | 15 + routes/typescript/fastify.ts | 9 + routes/typescript/hono.ts | 9 + routes/typescript/near-matches.ts | 10 + routes/typescript/nest.ts | 35 + routes/typescript/nuxt/middleware/auth.ts | 3 + routes/typescript/nuxt/package.json | 6 + routes/typescript/nuxt/pages/users/[id].vue | 1 + .../typescript/nuxt/server/api/users.get.ts | 1 + .../nuxt/server/api/users/[id].post.ts | 1 + routes/typescript/owner-mismatch.ts | 11 + routes/typescript/react-router.tsx | 20 + .../typescript/remix/app/routes/users.$id.tsx | 7 + routes/typescript/sveltekit/package.json | 6 + .../src/routes/api/users/[id]/+server.ts | 5 + .../src/routes/files/[...rest]/+page.svelte | 1 + .../src/routes/users/[id]/+page.svelte | 5 + .../src/routes/users/[id]/+server.ts | 3 + routes/typescript/vue-router-qualified.ts | 15 + routes/typescript/vue-router.ts | 12 + scripts/code_graph_v1_oracle.py | 27 +- scripts/qualify_code_graph_v1.sh | 15 +- scripts/qualify_react_frontend_graph.py | 1036 ++++++++ scripts/qualify_react_frontend_graph.sh | 111 + scripts/qualify_react_frontend_pinned.py | 577 +++++ scripts/react_frontend_source_oracle.mjs | 1136 +++++++++ scripts/tests/test_code_graph_v1_oracle.py | 7 + .../qualification/code-graph-v1-semantic.json | 13 +- .../react-frontend-expectations.json | 81 + .../react-frontend-repositories.toml | 128 + 234 files changed, 18757 insertions(+), 482 deletions(-) create mode 100644 advisor-plans/021-react-frontend-framework-graph-quality.md create mode 100644 crates/compass-files/src/file_set.rs create mode 100644 crates/compass-languages/src/frameworks/react.rs create mode 100644 crates/compass-languages/src/frameworks/tanstack.rs create mode 100644 crates/compass-languages/src/frameworks/typescript_syntax.rs create mode 100644 crates/compass-languages/tests/next_universal_pack.rs create mode 100644 crates/compass-languages/tests/react_router_universal_pack.rs create mode 100644 crates/compass-languages/tests/react_universal_pack.rs create mode 100644 crates/compass-languages/tests/tanstack_universal_pack.rs create mode 100644 crates/compass-languages/tests/vite_universal_pack.rs create mode 100644 crates/compass-resolve/src/frameworks/react.rs create mode 100644 crates/compass-resolve/src/frameworks/relations.rs create mode 100644 crates/compass-resolve/tests/react_frontend.rs create mode 100644 docs/reference/react-framework-graph.md create mode 100644 domain/database.expected.json create mode 100644 domain/database.sql create mode 100644 domain/jobs/aspnet.cs create mode 100644 domain/jobs/celery.py create mode 100644 domain/jobs/dynamic-near-matches.py create mode 100644 domain/jobs/spring.java create mode 100644 domain/messaging/dynamic-near-matches.ts create mode 100644 domain/messaging/nest.ts create mode 100644 domain/messaging/spring.java create mode 100644 domain/orm/active-record.rb create mode 100644 domain/orm/database.sql create mode 100644 domain/orm/diesel.rs create mode 100644 domain/orm/django.py create mode 100644 domain/orm/dynamic-near-matches.ts create mode 100644 domain/orm/eloquent.php create mode 100644 domain/orm/entity-framework.cs create mode 100644 domain/orm/gorm.go create mode 100644 domain/orm/jpa.java create mode 100644 domain/orm/sqlalchemy.py create mode 100644 domain/orm/typeorm.ts create mode 100644 editors/vscode/src/views/taskContextClient.test.ts create mode 100644 editors/vscode/src/views/taskContextClient.ts create mode 100644 fixtures/code-graph/frontend-react-negative/src/app/page.tsx create mode 100644 fixtures/code-graph/frontend-react/app/routes/users.$id.tsx create mode 100644 fixtures/code-graph/frontend-react/next.config.mjs create mode 100644 fixtures/code-graph/frontend-react/package.json create mode 100644 fixtures/code-graph/frontend-react/src/app/admin/page.tsx create mode 100644 fixtures/code-graph/frontend-react/src/app/admin/settings/page.tsx create mode 100644 fixtures/code-graph/frontend-react/src/app/page.tsx create mode 100644 fixtures/code-graph/frontend-react/src/components/Card.tsx create mode 100644 fixtures/code-graph/frontend-react/src/routes/home.tsx create mode 100644 fixtures/code-graph/frontend-react/src/routes/tanstack.tsx create mode 100644 fixtures/code-graph/frontend-react/vite.config.ts create mode 100644 fixtures/code-graph/routes/typescript/package.json create mode 100644 packages/compass-viewer/src/contracts/taskContext.test.ts create mode 100644 packages/compass-viewer/src/contracts/taskContext.ts create mode 100644 packages/compass-viewer/src/graph/DocumentOcrPanel.test.tsx create mode 100644 packages/compass-viewer/src/graph/DocumentOcrPanel.tsx create mode 100644 qualification/MissingReference.csproj create mode 100644 qualification/Rich.cs create mode 100644 qualification/empty.py create mode 100644 qualification/export.dart create mode 100644 qualification/guide.md create mode 100644 qualification/mcp.json create mode 100644 qualification/migrations/001_init.sql create mode 100644 qualification/package.json create mode 100644 qualification/recovery.py create mode 100644 qualification/rich.dart create mode 100644 qualification/rich.php create mode 100644 qualification/rich.rb create mode 100644 qualification/rich.rs create mode 100644 qualification/rich.ts create mode 100644 qualification/semantic-doc.md create mode 100644 qualification/semantic-documented.rs create mode 100644 routes/csharp/AdvancedController.cs create mode 100644 routes/csharp/AspNetController.cs create mode 100644 routes/csharp/NearMatches.cs create mode 100644 routes/go/chi.go create mode 100644 routes/go/gin.go create mode 100644 routes/go/gorilla.go create mode 100644 routes/go/near_matches.go create mode 100644 routes/jvm/NearMatches.java create mode 100644 routes/jvm/PlayController.java create mode 100644 routes/jvm/PlayController.scala create mode 100644 routes/jvm/SpringController.java create mode 100644 routes/jvm/SpringController.kt create mode 100644 routes/jvm/play/conf/routes create mode 100644 routes/php/drupal.module create mode 100644 routes/php/drupal.routing.yml create mode 100644 routes/php/laravel.php create mode 100644 routes/php/near_matches.php create mode 100644 routes/python/django/project/urls.py create mode 100644 routes/python/django/project/views.py create mode 100644 routes/python/django/qualification/urls.py create mode 100644 routes/python/django_urls.py create mode 100644 routes/python/fastapi_app.py create mode 100644 routes/python/flask_app.py create mode 100644 routes/python/near_matches.py create mode 100644 routes/ruby/near_matches.rb create mode 100644 routes/ruby/rails.rb create mode 100644 routes/rust/actix.rs create mode 100644 routes/rust/axum.rs create mode 100644 routes/rust/near_matches.rs create mode 100644 routes/rust/rocket.rs create mode 100644 routes/swift/NearMatches.swift create mode 100644 routes/swift/VaporRoutes.swift create mode 100644 routes/typescript/AccountPage.tsx create mode 100644 routes/typescript/UserPage.tsx create mode 100644 routes/typescript/UserPage.vue create mode 100644 routes/typescript/astro/package.json create mode 100644 routes/typescript/astro/src/pages/about.astro create mode 100644 routes/typescript/astro/src/pages/api/items/[id].ts create mode 100644 routes/typescript/astro/src/pages/blog/[slug].astro create mode 100644 routes/typescript/astro/src/pages/files/[...rest].astro create mode 100644 routes/typescript/astro/src/pages/users/[id].ts create mode 100644 routes/typescript/express.ts create mode 100644 routes/typescript/fastify.ts create mode 100644 routes/typescript/hono.ts create mode 100644 routes/typescript/near-matches.ts create mode 100644 routes/typescript/nest.ts create mode 100644 routes/typescript/nuxt/middleware/auth.ts create mode 100644 routes/typescript/nuxt/package.json create mode 100644 routes/typescript/nuxt/pages/users/[id].vue create mode 100644 routes/typescript/nuxt/server/api/users.get.ts create mode 100644 routes/typescript/nuxt/server/api/users/[id].post.ts create mode 100644 routes/typescript/owner-mismatch.ts create mode 100644 routes/typescript/react-router.tsx create mode 100644 routes/typescript/remix/app/routes/users.$id.tsx create mode 100644 routes/typescript/sveltekit/package.json create mode 100644 routes/typescript/sveltekit/src/routes/api/users/[id]/+server.ts create mode 100644 routes/typescript/sveltekit/src/routes/files/[...rest]/+page.svelte create mode 100644 routes/typescript/sveltekit/src/routes/users/[id]/+page.svelte create mode 100644 routes/typescript/sveltekit/src/routes/users/[id]/+server.ts create mode 100644 routes/typescript/vue-router-qualified.ts create mode 100644 routes/typescript/vue-router.ts create mode 100755 scripts/qualify_react_frontend_graph.py create mode 100755 scripts/qualify_react_frontend_graph.sh create mode 100644 scripts/qualify_react_frontend_pinned.py create mode 100644 scripts/react_frontend_source_oracle.mjs create mode 100644 tests/qualification/react-frontend-expectations.json create mode 100644 tests/qualification/react-frontend-repositories.toml diff --git a/.cargo/config.toml b/.cargo/config.toml index 1480e19c..9575ffa8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,8 +3,9 @@ # libraries are disabled in Cargo.toml, preserving the local-only code path. TSLP_LANGUAGES = "apex,astro,bash,blade,c,cpp,csharp,dart,elixir,fortran,go,groovy,hcl,java,javascript,json,julia,kotlin,lua,objc,pascal,php,powershell,python,razor,ruby,rust,scala,sql,svelte,swift,tsx,typescript,verilog,vue,zig" TSLP_LINK_MODE = "static" -# These language-pack switches are debug escape hatches that can silently -# produce a binary with advertised-but-missing grammars. Force production -# semantics even when a caller's shell happens to export them. -TSLP_OFFLINE = { value = "0", force = true } +# Keep the normal build default online for a fresh source checkout, but allow +# release/qualification jobs to force a pre-provisioned parser bundle with +# `TSLP_OFFLINE=1`. The runtime remains statically linked and never downloads +# grammars. +TSLP_OFFLINE = "0" TSLP_ALLOW_FAILED_GRAMMARS = { value = "0", force = true } diff --git a/CHANGELOG.md b/CHANGELOG.md index fd209606..68136331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,12 +35,26 @@ Preserve Intel macOS builds by excluding the unavailable ONNX runtime on that target; native document processing remains available and managed OCR reports an explicit unsupported-platform error before downloading model weights. + The graph viewer now exposes combined/native/OCR evidence tabs, confidence + and page/slide/sheet locators, bounded OCR region previews, coverage warnings, + and a managed model status/install card during initialization. - Make semantic provider choice explicit and repeatable with `--backend`, `--model`, `COMPASS_BACKEND`, and `COMPASS_MODEL`. Credential guidance now covers all built-in providers and custom provider environment keys without exposing secrets in Compass configuration or artifacts. +- Add versioned `compass.task-context/2` framework evidence for React and + frontend workflows, including pack versions, route stages, render direction, + runtime boundaries, configuration dependencies, provenance, and bounded + qualification states. + +- Add the first provisional React frontend graph slice: exact JSX `renders` + relationships, component/hook/client/server role evidence, strict viewer and + query contracts, and framework-pack cache invalidation. The slice remains + `Qualifying` until the independent React/Next/TanStack/React Router/Remix/Vite + corpus and precision/recall gates are complete. + - Hard-cut Swift, Dart, Scala, and Groovy/Gradle onto version-1 qualifying universal evidence pipelines. The bounded AST-first producer publishes declarations, scopes, bindings, occurrences, and conservative relationship diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index bff316f0..0382584f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -178,6 +178,22 @@ The additive `compass.graph/1` endpoint matrix accepts exact `calls` edges to and object properties without changing node or edge identity; consumers that validate endpoint kinds should accept this existing-major widening. +The frontend graph vocabulary is also additive within the pre-release +`compass.graph/1` contract. React-oriented builds may publish the typed +`renders` relationship and the `ui_component`, `hook`, `client_boundary`, +`client_component`, `server_component`, `server_function`, and `data_loader` +node roles. A reader that validates closed edge or role enums must update to a +release containing these values before consuming such a graph; older readers +must fail closed rather than silently dropping them. These values do not make +the corresponding framework pack a completed quality claim: promotion remains +gated by the independent qualification corpus, precision/recall, ambiguity, +limit, and determinism checks in the frontend graph plan. + +The checked-in `compass.query/1` enum manifest and fingerprint widen in lockstep +with this vocabulary. Strict query, MCP, CLI, VS Code, and viewer consumers +must use the matching manifest; they must reject an unknown `edgeKind` or +`nodeRole` instead of filtering it into an older response shape. + Large universal-evidence collections now degrade explicitly instead of silently publishing file scaffolding. Compass retains source declarations and safe exact relationships in deterministic bounded partitions, records omitted @@ -199,6 +215,11 @@ Standalone HTML may additionally embed optional, presentation-only source navigation metadata for a recognized Git forge and full source commit. This metadata is outside `compass.viewer.workbench/1`; `workbench-json` and the versioned graph/view contracts are unchanged. +Rich document nodes may carry an optional additive `document` object in +`compass.viewer.graph/1`. It contains bounded native/OCR provenance, typed +page/slide/sheet locators, confidence, and OCR geometry for the viewer; older +readers may ignore the field, while strict readers should preserve unknown +nested fields and fail closed only on an unknown contract major. Structural builds publish a validated `store.sqlite3` sidecar and typed `store.ref` selector by default; `--store json` explicitly opts out. Typed code queries prefer that validated sidecar by default, while `--engine json` @@ -252,7 +273,7 @@ instead of publishing a partial semantic result. Natural discovery results additionally expose the same query-owned `semanticResultDigest` in this transport envelope, enabling direct/persistent result parity checks without requiring an agent client to invent a digest. -Task-oriented results use strict `compass.task-context/1` and +Task-oriented results use strict `compass.task-context/2` and `compass.task-context-profile/1` contracts through `compass context` and MCP `task_context`. Exact identity resolution, digest-verified source, provenance, omissions, and domain truncation remain inside the result; fuzzy candidates diff --git a/MIGRATION.md b/MIGRATION.md index ebfc9191..525e8396 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,27 @@ sidecars. Its output root now preserves the familiar flat artifact shape so file-based workflows can transition while Compass's snapshot and store layout remains visible and clearly owned. +## Frontend graph vocabulary + +Recent pre-release builds can add React-oriented `renders` edges and UI/server +roles to `compass.graph/1`. Consumers with strict enum validation must upgrade +their graph reader before opening a graph produced by a frontend-enabled +build; the old reader must reject the new values rather than treating them as +unknown calls or silently omitting them. Rebuild cached graph and query +artifacts after upgrading so the frontend extraction semantics and framework +pack digest are applied consistently. The matching `compass.query/1` manifest +and fingerprint must be deployed with the reader as well. + +## Framework task context + +Frontend-enabled builds emit `compass.task-context/2` (the previous +`compass.task-context/1` packet is intentionally rejected). Upgrade CLI, MCP, +and agent readers together, then rebuild cached context artifacts. The new +typed `framework` section carries pack/version and qualification state, route +stages, render direction, runtime boundaries, configuration dependencies, and +explicit ambiguity or truncation; unknown pack IDs and qualification states +must fail closed. + ## Ruby universal evidence rebuild The current release publishes Ruby through the version-1 universal evidence diff --git a/advisor-plans/021-react-frontend-framework-graph-quality.md b/advisor-plans/021-react-frontend-framework-graph-quality.md new file mode 100644 index 00000000..0beca5c4 --- /dev/null +++ b/advisor-plans/021-react-frontend-framework-graph-quality.md @@ -0,0 +1,2148 @@ +# Plan 021: Make React frontend framework graphs enterprise-ready + +> **Executor instructions**: Follow this plan phase by phase. Run every +> verification command and confirm the expected result before moving on. If a +> STOP condition occurs, stop and report it; do not improvise. This is a +> multi-PR program, not one oversized patch. Keep each phase independently +> reviewable and leave the production graph coherent after every merge. When +> the program is complete, update this plan and its row in +> `advisor-plans/README.md`. +> +> **Drift check (run first)**: +> +> ```bash +> git diff --stat 4e12ca92..HEAD -- \ +> COMPATIBILITY.md CHANGELOG.md MIGRATION.md SECURITY.md PERFORMANCE.md \ +> crates/compass-model crates/compass-files crates/compass-languages crates/compass-resolve \ +> crates/compass-graph crates/compass-core crates/compass-query crates/compass-cypher \ +> crates/compass-output crates/compass-cli crates/compass-mcp \ +> crates/compass-history crates/compass-semantic-diff \ +> packages/compass-viewer editors/vscode tests/viewer \ +> crates/compass-output/assets/viewer fixtures tests/qualification \ +> benchmarks/performance scripts docs .github/workflows/compass-ci.yml +> ``` +> +> If an in-scope file changed, compare this plan's “Current state” with the +> live code. Update paths and tests mechanically when ownership is unchanged. +> STOP when a public contract, evidence boundary, or ownership assumption no +> longer holds. + +## Status + +- **Priority**: P1 +- **Effort**: XXL (ten phases; ship as multiple PRs) +- **Risk**: HIGH +- **Depends on**: Plan 013's TypeScript/JavaScript production hard cut; the + final release claim should consume Plan 005 or an equivalent + exact-production qualification gate +- **Category**: migration, tests, dx, direction +- **Planned at**: commit `4e12ca92`, 2026-08-22 +- **Plan audit**: first completed against live commit `de41139b` on 2026-08-22 + and superseded by the fifth-pass execution audit against `20cf959e` plus the + current worktree on 2026-08-23; `Planned at` remains the drift-check + baseline `4e12ca92`. The audits cover strict schemas, pack ownership, + AST/config seams, cache invalidation, downstream consumers, security, + performance, and qualification. Intervening commits must still pass the + drift check before execution. + +## Live implementation audit (2026-08-22) + +The first vertical slice has been reviewed against every contract boundary in +this plan. The following items are implemented and covered by focused tests: + +- `Renders`, typed render details, frontend roles, endpoint validation, graph + normalization, query/discovery enumeration, inbound impact policy, and + viewer Zod/label/appearance contracts; +- package/runtime-gated `react-ui` detection over the borrowed TypeScript/TSX + tree, including self-closing JSX, fragments, exact JSX references, + occurrence-preserving render projection, conservative hook closure, and + exported client/server directive roles; +- deterministic framework-pack registry identity in the build profile, so a + framework activation/descriptor/limit change cannot reuse stale extraction + facts; +- downstream policy is explicit for the provisional edge: callers/callees and + callflow remain call-only, code-query impact and default affected traversal + include inbound `renders`, the dependency lens exposes it, semantic/history + deltas retain it generically, and dependency-cycle topology intentionally + excludes it as UI topology; +- negative non-React activation, exact anchor, repeated-occurrence, + directive-scope, hook false-positive, impact, serialization, viewer, and + cold/repeated-resolution tests. + +The audit also confirms that the plan still has no implicit “done” path. The +remaining release blockers are explicit and tracked by their phases: the +parser-backed shared syntax/config view, typed route-stage migration and +hierarchy, Next/TanStack/React Router/Remix/Vite packs, expanded resource +limits and per-pack cache semantics, the evidence-sufficiency matrix and +versioned expectation schema, task-context/CLI/MCP versioning, pinned +independent qualification corpora and oracle, performance baselines, and the +exact production release gate. Existing Next/Vite/route behavior is therefore +not counted as covered by the React slice, and the plan/index row remains +`TODO` until Phases 0–9 pass. + +The audit found one unrelated pre-existing graph test mismatch in +`crates/compass-graph/tests/markdown_identity.rs` (duplicate Markdown heading +URI expectation); it is not changed or used as frontend evidence. It must be +resolved or explicitly dispositioned before the repository-wide baseline can +be called green. + +### Follow-up audit dispositions + +The follow-up audit also closed the concrete gaps found while exercising the +vertical slice: strict render-kind decoding now rejects unknown values, +source-scoped node collision remapping updates UI-role references, the legacy +affected traversal includes inbound `renders`, the dependency lens exposes +`renders`, and the compatibility/migration/changelog notes describe the +pre-release enum widening. React fact volume now uses the descriptor's checked +pack budget instead of a silent detector-local truncation. Model, language, +output, resolver, query, core +determinism, semantic-diff, history, product-boundary, and fixture +qualification checks were rerun; the fixture gate produced byte-stable output. +These checks validate the slice only. They do not waive the remaining +framework, route, task-context, independent-corpus, performance, or release +gates listed above. + +### Audit gap register (2026-08-22) + +The second-pass audit checked the plan against the live closed-enum consumers, +package/config resolution boundaries, qualification math, safety limits, +parallel extraction behavior, and generated-client surfaces. Four plan gaps +were found and closed below; none changes the product scope or permits a +release claim early: + +| Audit area | Gap found | Disposition | +|---|---|---| +| Closed consumers | The minimum inventory named the principal crates but did not name the query-contract model, task-context CLI/MCP owners, VS Code transport, viewer fixture generator, or qualification graph builder. | Step 1.1 now names those files and requires an explicit policy/test for each; Phase 8 and Phase 9 repeat the transport and qualification checks. | +| Corpus floor | “2,000 total” plus “400 per stable family” was ambiguous because the support matrix has more than five stable rows. | The quality budget now defines the floor as `max(2,000, 400 × N_stable_families)` and records the current `N_stable_families` explicitly. | +| Resolution and limits | Workspace/package-manager variants, compiler-option inheritance, regex/resource bounds, all-collection budgets, and detector-version invalidation were implied rather than enumerated. | Phase 0.5, Step 2.3, and Step 2.6 now list the supported/unsupported resolution forms, explicit per-pack version bumps, and named limits, including bounded regex handling. | +| Operational determinism | Cold/warm tests did not require equivalent output across worker counts or interrupted/resumed extraction. | The shared quality budget, cross-phase tests, and Phase 9 gate now compare single/default/max worker runs and interruption recovery, with no partial-success escape. | + +The plan still intentionally excludes React Native/Expo, Storybook semantics, +runtime portals/hydration, and dynamic configuration; those exclusions are now +explicit in Scope and the evidence matrix rather than silently counted as +recall gaps. The phase ledger and plan index remain `TODO` until the full +release gates pass; the implemented React slice is not a completed phase. + +### Third-pass implementation audit (2026-08-22 worktree) + +The live worktree now contains additional bounded implementation slices. They +close extraction-level gaps but do not change the release status: + +| Area | Closed in this pass | Still required before phase exit | +|---|---|---| +| Shared syntax and identity | Borrowed TypeScript syntax view, static-value limits, import-alias identity, config-object recovery, per-pack semantics versions, and accumulator validation | Package-manager/workspace precedence, `tsconfig` inheritance, generated-file policy, and cache reopen/invalidation qualification | +| React render graph | Exact `jsx`, `create_element`, `root`, and statically linked `lazy` projections; repeated anchors and non-React negatives | Dynamic/Next factory policy, cross-file ambiguity fixtures, graph/query/task-context snapshots, and corpus recall evidence | +| Route packs | Next App/Pages stages and hierarchy, React Router ownership and loader/action stages, TanStack Router/Start aliases and server roles | Version matrix, generated-tree precedence, Remix universal cutover, route hierarchy qualification, and old-cache compatibility | +| Vite | AST config aliases/order, shadowed-factory negative, and bounded `import.meta.glob` file-set facts | File-set consumer/matching contract, plugin call identity, nested config/package precedence, and Vite hard-cut qualification | +| Generic facts | Typed relation/file-set facts, strict validation, and source-file relation lookup | Public relation resolver contract tests, unsupported/ambiguous diagnostics, and downstream graph publication expectations | +| Qualification | Focused universal-pack regression fixtures now exist for React, Next, TanStack, React Router, and Vite | Versioned expectation schema/loader, independent oracle, pinned corpus manifest, `qualify_react_frontend_graph.sh`, Wilson/anchor/multiplicity metrics, worker/interruption rows, and external artifact publication | +| Agent workflow | Existing impact/render policy remains covered | Versioned FrameworkContext task-context section, CLI/MCP/VS Code strict transport contracts, query aliases/examples, trust-boundary tests, and docs example runner | + +Consequently, the “no gaps” audit result is **not yet a release-ready plan**: +the remaining rows are concrete gates, not assumptions. The phase ledger and +`advisor-plans/README.md` row must remain `TODO` until those rows have checked-in +tests and the exact production qualification command passes twice with a +byte-stable artifact. The unrelated Markdown identity mismatch recorded above +also remains a repository-baseline disposition item. + +### Fourth-pass audit: verification findings and newly explicit residuals (2026-08-22) + +The focused pack/resolver checks found and fixed two correctness holes rather +than silently accepting them: aliased Vite `defineConfig` calls now match the +actual imported local binding, and a truncated generic framework-relation +candidate set can no longer publish as an exact edge (the diagnostic records +the truncation). The corresponding Vite regression and resolver unit tests are +checked in. + +The same run also exposed residuals that must stay on the execution ledger: + +| Finding | Why it remains a gate | Required disposition | +|---|---|---| +| Established source/config/template packs still use an implicit semantics version of `1` in the registry digest | Detector changes outside universal descriptors can reuse cached facts without a reviewed per-pack bump | Add an explicit nonzero version field to every runtime pack and reject stale cache entries | +| `TypeScriptSyntax::is_incomplete` does not yet reject every node overlapping a parser recovery region, and static-value/regex budgets are helper constants rather than descriptor-observed limits | Recovery-overlapping configuration or route syntax can be mistaken for complete evidence, and named `FrameworkLimits` are not all enforced before allocation | Add recovery-range tests, descriptor-driven syntax budgets, retained-literal accounting, regex complexity checks, and typed limit diagnostics | +| Next/Remix file-route endpoint discovery still uses bounded source regexes and convention-wide file anchors; Next/Remix version and root precedence are not qualified | This violates the parser-backed exact-anchor contract for exported handlers and can drift across framework majors | Replace with the shared AST view or mark the form incomplete, then qualify version/root precedence and old-cache behavior | +| TanStack currently recognizes code-based factories but not the planned file-route/generated-tree policy; Vite plugin identity is still name/substring based, and Vite glob facts have no `compass-files` matcher/publication consumer | The advertised route/config relationships are not yet project-wide or independently attributable | Implement bounded file-route/generated-tree handling, exact plugin import/call identity, and file-set matching/invalidation before promotion | +| Generic role/configuration/file-set facts are validated and cached, but only roles/domains and generic relations have resolver publication paths; configuration and file-set facts do not yet reach graph/query consumers | Agents can observe a raw fact without a typed downstream answer or an explicit unsupported state | Add central semantic publication, consumer contracts, and negative/ambiguous diagnostics for each fact family | +| The existing `typescript_routes` integration suite currently has three failures after the typed-stage/React Router cutover (legacy page-stage expectation, loader compatibility field, and component-target ordering) | A frontend migration cannot claim baseline safety while established route behavior is red, even when the deltas are intentional | Either preserve the old public behavior through a compatibility projection or update the contract/tests with an explicit migration note and parity disposition | +| Build verification emitted a tree-sitter language-pack parser-source download warning | Release/fixture qualification must remain offline and cannot depend on an uncached network fetch | Qualify from pre-provisioned parser artifacts and add an offline/no-network check to the Phase 9 gate | +| The production fixture gate now reaches the independent oracle and stops on the new `renders` edge kind; its vocabulary/manifest still only describe the pre-React edge contract, and its route-stage enum still only accepts `handler`/`middleware` | A qualification oracle that cannot parse a published edge or typed route stage cannot prove the React/React Router contract; changing the producer without changing the independent expectation schema leaves a false sense of coverage | Version the qualification expectation schema, add `renders`/render detail and typed route-stage expectations with exact producer/origin rules, add the nearest-package activation fixture, and rerun clean/warm/rebuild/checkout/lifecycle byte comparisons | +| React Router’s new required activation pack was initially absent from the fixture graph because `fixtures/code-graph/routes/typescript/react-router.tsx` had no nearest package marker; adding the marker exposes the stale oracle rather than proving parity | Activation is intentionally package-scoped, so a corpus without an owning manifest tests a disabled pack and can silently undercount framework recall | Keep the package-local dependency fixture, assert enabled/disabled sibling packages, and include activation changes in cache invalidation and semantic-manifest coverage | +| Route-hierarchy publication first emitted `compass.languages.unknown` provenance and required an independent-oracle route-to-route `contains` allowance; the edge is now explicitly convention-owned, but this path is not yet covered by the frontend qualification corpus | Hierarchy is a graph edge with its own provenance and endpoint policy; an unowned or unqualified edge is not safe for agent traversal | Add a hierarchy flow/negative expectation covering parent selection, ambiguity, route-to-route containment, producer/origin/rule, and no unknown-producer fallback | + +These findings do not add new product scope; they make the already implied +ownership, recovery, cache, downstream-consumer, and offline requirements +executable. The plan is now audited against both the implementation and the +negative verification path. It still must not be marked `DONE`, and the +Markdown identity mismatch remains a separate repository-baseline failure. + +Historical fourth-pass verification ledger (superseded by the fifth-pass audit +below): `cargo fmt --all -- --check`, the complete +`compass-languages` test suite, focused Next/TanStack/Vite/React Router packs, +focused resolver route/frontend tests, the Python oracle unit suite, the +product-boundary script, and `git diff --check` pass. The complete +`compass-resolve` suite is still red in three existing +`typescript_routes.rs` cases (generic file-route stage compatibility, the +legacy React Router loader field, and component-target ordering). The complete +fixture gate compiles and passes its scale/determinism lifecycle checks, with +each production update reporting a bounded omission summary of five nodes and +eight edges, but it currently stops in the independent oracle on the +unversioned `renders` edge vocabulary/typed-stage contract; clippy/workspace- +wide tests, JS contracts, and the plan-specific frontend qualification command +have not been run to a release conclusion. These are evidence states, not +waived requirements. + +### Fifth-pass live execution audit (2026-08-23) + +This pass supersedes the red/unknown verification states recorded in the +2026-08-22 ledger above. It re-audited the phase ledger and all four residual tables against the +live worktree at `20cf959e` plus its uncommitted implementation changes. It +also ran the production paths rather than treating focused unit tests as a +release substitute. The earlier `typescript_routes` and Markdown concerns are +now dispositioned: the complete resolver suite and +`crates/compass-graph/tests/markdown_identity.rs` both pass. The inference +policy was additionally corrected after the first workspace run exposed a +real low-vs-max graph mismatch; the special-case was removed and the existing +coherence regression now passes. + +Closed implementation/contract gaps from the fourth pass: + +| Area | Evidence now present | +|---|---| +| Pack/cache identity | Every runtime framework descriptor has an explicit non-zero version; the pack registry test, framework semantics digest, cache-profile digest, and stale-version checks pass. | +| Shared syntax and limits | The borrowed TypeScript/TSX syntax view is recovery-aware and bounded; static values, regex/call/fact budgets, file-set validation, and typed limit diagnostics are covered by language tests. | +| Framework extraction | React renders/roles, Next App/Pages stages, React Router/Remix handlers, TanStack code/file routes and generated-tree negative, and Vite AST config/plugin/glob facts have focused positive/negative tests and resolver publication. | +| Generic facts and consumers | Typed relation/configuration/file-set facts reach graph publication; route hierarchy carries convention provenance; query/impact/lenses, semantic diff, task context, CLI/MCP, VS Code, and viewer schemas have strict tests. | +| Qualification wiring | The independent oracle recognizes `renders`, typed route stages, route hierarchy, configuration, file-set imports, negative activation, and rejects any positive partial-publication diagnostic. The existing code-graph gate invokes the frontend gate. | +| Repository gates | Workspace clippy, workspace library/binary tests, task-context integration, CompassQL/OpenCypher TCKs, JS typecheck/unit/Playwright (87/87), viewer asset determinism, product boundary, Python oracle, broad code-graph lifecycle, and the dedicated production frontend fixture gate pass. | + +The exact frontend fixture result is deterministic across its repeated +production update: 112 nodes, 128 edges, 12 framework nodes, 8 routes, 2 +route-hierarchy edges, 4 renders, 2 configuration fields, 1 file-set resource, +6 file-set imports, and 4 negative-corpus nodes. The broad code-graph gate also +passes all lifecycle byte comparisons and the independent summary. Its known +mixed-language fixture still reports bounded omissions (4 nodes/4 edges); that +is an accepted diagnostic in that broader gate, not evidence that a positive +frontend corpus may be partial—the dedicated frontend oracle fails on any such +diagnostic. + +The following are the only remaining gaps found by this audit. They are +release-evidence gaps, not unassigned design holes, and each has an owner and +acceptance condition in Phase 0.5/9: + +| Remaining gap | Concrete evidence | Required closure before `DONE` | +|---|---|---| +| Pinned representative corpus | `tests/qualification/react-frontend-repositories.toml` is explicitly `mode = "fixtures-only"`; it contains no immutable repository revisions, checksums, licenses, or reviewed external expectations. `/Volumes/Workspace/Github` has no pinned React-family qualification set. | Add read-only immutable corpora covering the seven stable target families (React, Next App, Next Pages, React Router, Remix, TanStack Router, Vite), keep TanStack Start separately labelled, and validate mutable refs/checksums/licenses/scope. | +| Independent precision/recall metrics | `scripts/qualify_react_frontend_graph.py` currently validates schema, endpoint/provenance vocabulary, selected counts, hierarchy, renders, config, file-set imports, and negatives; it does not match every expectation by identity/range/direction/role/provenance or compute precision, recall, ambiguity, anchor, multiplicity, or Wilson bounds. | Implement the versioned expectation matcher and machine-readable result artifact; fail on skipped capabilities, zero denominators, fabricated targets, truncation-as-success, or thresholds below the quality budget. | +| Pinned-mode production command | `scripts/qualify_react_frontend_graph.sh` accepts only `--fixtures-only`; its banner mentions pinned mode but no pinned runner, established/candidate comparison, external artifact path, binary digest, or read-only checkout guard exists. | Add pinned mode and exact-release-binary identity, external result publication, network/process checks, source-tree immutability checks, and register it with the exact-production release gate. | +| Performance baseline | `PERFORMANCE.md` has no Plan 021 frontend baseline/result. Existing scale tests prove bounded behavior but do not record the required cold, unchanged-warm, semantic edit, manifest/config edit, restore, alternate-checkout, duration, and peak-RSS rows. | Freeze the Phase 0.5 release baseline with revision, binary digest, machine profile, corpus manifest, and explicit 10%/20% decisions. | +| Worker and interruption evidence | Fixture qualification repeats a default-worker `--force` build; broad lifecycle checks cover delete/rename/restore, but no frontend corpus rows compare one/default/max workers or cancellation/interruption cleanup and resume. | Add one/default/max byte comparisons and an interrupted run that leaves no publishable partial artifact and resumes to the uncanceled digest. | +| Package/config precedence matrix | Current project evidence is package-local and bounded, but the release corpus does not exercise npm/yarn/pnpm workspaces, nested/mixed versions, lockfile invalidation, `tsconfig` `extends`/references/`jsxImportSource`, aliases, or dependency-section policy. | Add the Phase 0.5 matrix and prove affected-package-only re-extraction plus fail-closed unsupported forms and cache invalidation. | +| Capability-level promotion | The fixture expectations cover one sentinel per family and the positive checker only requires aggregate minimums; Next App vs Pages, React Router vs Remix, TanStack Start, plugin identity, route hierarchy ambiguity, dynamic/unsupported forms, and multiplicity are not independently measured on pinned data. | Stratify reviewed records by framework/capability/declaration/negative/ambiguous state and keep support claims provisional until every advertised capability meets its own threshold. | + +No additional hidden ownership, schema, parser, resolver, consumer, safety, or +determinism gap was found. The correct audit result is therefore “implementation +slice closed; release qualification still open,” not a premature promotion: +the phase ledger and `advisor-plans/README.md` row remain `TODO` until the +pinned corpus, metric/artifact, performance, worker/interruption, and +package/config evidence above are checked in and the exact production gate +passes twice. The reference documentation intentionally continues to describe +the non-React packs as provisional and the matrix as a release target. + +Verification ledger for this fifth pass: `cargo fmt --all -- --check`, +`cargo clippy --workspace --lib --bins --locked -- -D warnings`, +`cargo test --workspace --lib --bins --locked --quiet`, the complete resolver, +language, graph, query, history, output, CLI, semantic-diff, task-context, +CompassQL, and OpenCypher gates, `npm run typecheck:js`, `npm run test:js`, +`node scripts/check_viewer_assets.mjs`, `sh scripts/check_product_boundary.sh`, +the Python oracle tests, `git diff --check`, +`./scripts/qualify_react_frontend_graph.sh --fixtures-only`, and +`./scripts/qualify_code_graph_v1.sh --fixtures-only` all exit 0. The standard +macOS linker emits its existing `__eh_frame section too large` warning; it does +not change any test or qualification result. + +## Why this matters + +Compass already extracts useful TypeScript/JavaScript and JSX evidence, but +its frontend framework layer is uneven: generic JSX is represented only as +references, Next.js is primarily inferred from a few file paths, Vite config +uses text and regular-expression matching, and TanStack Router/Start has no +dedicated pack. The resulting graph can answer language questions but cannot +reliably answer the daily questions an enterprise coding agent needs: + +1. Which component renders this component, and from which JSX occurrence? +2. Which URL, layout, loader, action, error boundary, or server handler owns + this code? +3. Does this symbol execute on the browser, server, build tool, or across an + explicit client/server boundary? +4. Which tests, routes, and upstream components are affected by a change? +5. Which Vite alias, plugin, or eager glob controls discovery and bundling? + +This program adds those answers without executing user JavaScript, requiring +Node.js, downloading grammars, contacting framework services, or inventing +meaning when resolution is ambiguous. The result is a deterministic, +provenance-preserving graph that agents can safely use for navigation, impact +analysis, review, and change planning. + +## Outcome and support contract + +The shipped support matrix after this plan is: + +| Surface | Target support | Required graph evidence | +|---|---|---| +| React | Qualifying | components, custom hooks, exact render occurrences, root render entry points | +| Next.js App Router | Qualifying | route hierarchy, route groups, slots, intercepting routes, layouts/templates/boundaries, route handlers, client/server directives, server functions | +| Next.js Pages Router | Qualifying | pages, API routes, `_app`, `_document`, `_error`, dynamic/catch-all segments | +| React Router framework/data mode | Qualifying | route tree, components, loaders, actions, middleware/error boundaries where statically declared | +| Remix | Qualifying | file/config routes, route modules, loaders, actions, error boundaries, and established flat-route parity | +| TanStack Router | Qualifying | file- and code-based route trees, parentage, path/id, loaders and route components | +| TanStack Start | Qualifying, separately labelled pre-stable | server routes/functions and client/server boundary evidence supported by pinned fixtures | +| Vite | Qualifying | config roots, ordered aliases, plugins, and bounded literal `import.meta.glob` expansion | + +“Qualifying” is not a marketing synonym for complete. Documentation must list +the exact supported declaration forms, unsupported dynamic forms, limits, and +the versioned evidence producer. Promotion beyond Qualifying requires the +quality gate in Phase 9 and a separate maintainer decision. + +## Current state + +### Existing architecture to preserve + +- `crates/compass-model/src/code_graph.rs` owns graph records, `NodeRole`, + `EdgeKind`, route stages, validation, and the serialized + `compass.graph/1` contract. +- `crates/compass-languages/src/lib.rs` and `Cargo.toml` define the language + crate boundary and dependencies. +- `crates/compass-files` owns source discovery, ignore/scope policy, path + containment, manifests, and atomic/cache I/O. Vite glob expansion must reuse + that boundary rather than walking the filesystem from a language pack. +- `crates/compass-languages/src/evidence/typescript.rs` emits native + TypeScript/JavaScript syntax evidence. JSX tags already emit exact + `CandidateRelation::References` occurrences with `context = "jsx"`; JSX + props, spreads, and child expressions have distinct contexts. Preserve + these language facts even when adding framework relations. +- `crates/compass-languages/src/evidence_pipeline.rs` and + `typescript_universal_evidence.rs` register TypeScript and JavaScript as + hard-cut universal pipelines. TSX is classified as TypeScript. Do not + restore a legacy fallback. +- `crates/compass-languages/src/frameworks/pack.rs` owns framework pack + identity, detection, capabilities, and emitted raw framework facts. +- `crates/compass-languages/src/frameworks/model.rs` owns the serialized raw + framework fact variants and `FrameworkLimits`. `RawRouteFact` currently has + one `handler_reference`, one route-wide anchor, and a string vector of + `middleware_references`; it cannot faithfully represent independently + anchored layout, loader, action, boundary, or per-method stages. +- `crates/compass-languages/src/frameworks/mod.rs` still registers + `typescript-web`, `nextjs-routes`, `remix-routes`, and `vite-config` as + established source adapters. This plan migrates each affected pack once, + atomically, after candidate parity. +- `crates/compass-languages/src/frameworks/next.rs` delegates to + `file_routes::detect_next`. `file_routes.rs` recognizes basic App Router + `page`/`route` files and Pages Router pages/API routes, but does not model + layouts, templates, loading/error/not-found boundaries, route groups, + parallel slots, intercepting routes, or server directives. +- `crates/compass-languages/src/frameworks/typescript.rs` contains the current + React Router/Remix-style route extraction and its nearest tests. +- `crates/compass-languages/src/frameworks/vite.rs` currently uses + `body.contains(...)` and regular expressions for plugins and aliases and + attaches broad whole-file anchors. It is the behavior to freeze, replace, + and then delete—not a pattern to extend. +- `crates/compass-languages/src/project_evidence.rs::parse_vite_configuration` + and `parse_next_configuration` run before per-file framework detection and + currently use `contains`/regex helpers. Vite aliases are collapsed into a + `BTreeMap`, which loses array order, regex-vs-string kind, + duplicate entries, anchors, and completeness. Replacing only `vite.rs` + would leave heuristic project resolution in production. +- There is no generic React component framework pack to hard-cut. JSX + references are language evidence. `typescript-web` currently owns React + Router alongside Angular, Nest, and Vue; removing that entire pack would be + an unrelated regression. `nextjs-routes`, `remix-routes`, and `vite-config` + are the established pack IDs that can be migrated in place. +- `crates/compass-languages/src/frameworks/mod.rs::UniversalDetectionContext` + already exposes source bytes, the prepared tree-sitter `root`, project + evidence, and `SemanticEvidenceBatch`. The missing piece is a shared, + bounded TypeScript/JavaScript AST helper with consistent recovery, + completeness, and literal rules; packs must use that existing one-parse seam + rather than adding ad-hoc walkers or source-string matching. +- `crates/compass-resolve` owns project-wide target selection, ambiguity, and + identity. `crates/compass-resolve/tests/typescript_routes.rs` has the closest + end-to-end Next/Vite framework test. +- `crates/compass-graph` owns deduplication and publication. Framework facts + must pass through the same validation, provenance, and deterministic sort + boundaries as other graph evidence. +- `crates/compass-core/src/task_context.rs` builds agent-facing context + sections under strict schema `compass.task-context/1`. It currently has no + dedicated framework context section, and adding a new enum value can break + old strict readers unless the schema is versioned or the feature is exposed + through a compatible typed extension. +- `crates/compass-query` owns impact, traversal, natural-query discovery, and + CompassQL execution. Its default impact relation set includes structural + relations such as calls, references, imports, and embeds. A render edge must + be integrated deliberately; it must not be reclassified as a call. +- `packages/compass-viewer/src/contracts/codeQuery.ts` contains closed Zod + lists for graph edge kinds and node roles. Viewer source must change before + regenerating `crates/compass-output/assets/viewer/{graph.js,viewer.css,manifest.json}`. +- `crates/compass-semantic-diff`, `crates/compass-history`, graph inference, + query discovery/ranking, CLI/MCP contracts, and the viewer are downstream + policy consumers of new graph values even where Rust exhaustiveness does not + force a compile error. +- `crates/compass-core/src/pipeline.rs` serializes and compacts + `RawFrameworkFact`, normalizes its paths, fingerprints fact-neutral output, + and reuses it from the AST cache. New variants and changed pack semantics + must update all exhaustive matches and cache identity together. + +### Load-bearing current excerpts + +Confirm these shapes during the drift check; line numbers are from planned-at +commit `4e12ca92`. + +`crates/compass-model/src/code_graph.rs:189` has a closed semantic-role enum +ending at `Generated`, and `:208` has a closed `EdgeKind` ending at `MapsTo`: + +```rust +pub enum NodeRole { + Controller, + // ... + Generated, +} + +pub enum EdgeKind { + Contains, + // ... + MapsTo, +} +``` + +`crates/compass-languages/src/frameworks/model.rs:51,141` demonstrates the raw +route limitation and closed fact union: + +```rust +pub struct RawRouteFact { + // ... + pub anchor: RawFrameworkAnchor, + pub handler_reference: String, + pub middleware_references: Vec, + // ... +} + +pub enum RawFrameworkFact { + Route(RawRouteFact), + Domain(RawDomainFact), + Annotation(RawFrameworkAnnotationFact), +} +``` + +`crates/compass-languages/src/frameworks/mod.rs:57` proves universal packs +already share the prepared parse: + +```rust +struct UniversalDetectionContext<'source, 'tree> { + path: &'source Path, + source: &'source [u8], + root: Node<'tree>, + project: Option<&'source ProjectEvidence>, + evidence: &'source SemanticEvidenceBatch, +} +``` + +`crates/compass-core/src/task_context.rs:14,81` shows why Phase 8 requires a +schema decision: + +```rust +pub const TASK_CONTEXT_SCHEMA: &str = "compass.task-context/1"; + +pub enum TaskContextSectionKind { + DeclarationSource, + ExactCallers, + ExactCallees, + ImplementationType, + RelatedTests, + TransitiveImpact, +} +``` + +### Contract and design constraints + +Read these files before implementing any phase: + +- `AGENTS.md` +- the affected crate's `src/lib.rs`, `Cargo.toml`, and nearest tests +- `COMPATIBILITY.md` +- `docs/implementation/workspace-tour.md` +- `docs/implementation/extending-compass.md` +- `docs/design/principles.md` +- `docs/design/language-architecture.md` +- `docs/reference/universal-semantic-evidence.md` + +The load-bearing rules are: + +- Per-file extractors emit syntax evidence; project-wide target selection + belongs in `compass-resolve`. +- Discovery, identities, ordering, encoding, and output must be deterministic. +- Ambiguous targets remain explicit. Never select the first candidate. +- Preserve relationship direction, multiplicity, source anchors, and + provenance. One syntactic render occurrence is one relationship occurrence. +- All source, graph, archive, query, and subprocess work is bounded. A limit + error is not an empty result. +- Normal extraction remains native and offline. No Node.js runtime, TypeScript + compiler, framework CLI, build execution, package install, network service, + runtime grammar download, or Graphify dependency is allowed. +- Tests use checked-in fixtures or bounded local fakes, never real services or + credentials. + +### Proposed graph vocabulary + +Freeze this vocabulary in Phase 0; do not let individual framework phases +invent incompatible synonyms. + +1. Add an additive `EdgeKind::Renders` relation, serialized consistently with + existing edge naming. Direction is renderer/owner → rendered component. + Each JSX element, fragment-owned component reference, or supported + `createElement` occurrence gets its own source anchor and provenance. +2. Keep the existing JSX `References` edge. `Renders` is a framework semantic + projection over the same syntax, not a replacement for language evidence. + Publish `Renders` only for an exact target. Unresolved or ambiguous render + facts retain bounded candidates/diagnostics and the underlying language + reference; they do not create a convenient placeholder render edge. +3. Add typed roles only after the compatibility decision: + `ui_component`, `hook`, `client_boundary`, `client_component`, + `server_component`, `server_function`, and `data_loader`. Roles may coexist + when facts justify them; a client component is still a UI component. + A React function/class keeps its structural `NodeKind::Function` or + `NodeKind::Class`; `ui_component` is a semantic role. Do not retype it as + `NodeKind::Component`, which is already used for synthetic framework/domain + components such as plugins and bean definitions. + `client_boundary` means a valid directive boundary. `client_component` + attaches to the file/module boundary and `client_component` means the + component is declared in that directly evidenced boundary; + importing a component below the boundary does not automatically relabel its + declaration. `server_component` is limited to a directly qualifying + framework convention and is not a deployment-topology claim. +4. Extend typed route-stage vocabulary only where downstream consumers need + it: `layout`, `template`, `loading`, `default`, `error_boundary`, + `not_found`, `data_loader`, and `action`, in addition to + middleware/handler. +5. Ordinary hook invocation remains `Calls`. “Uses hook” is derived from a + call whose resolved target has the `hook` role; do not create a redundant + edge kind. +6. Framework-owned logical route nodes may be synthetic only when their stable + identity, source provenance, and collision rules are specified and tested. + Prefer attaching route evidence to declared symbols/files when that + preserves the framework's actual model. +7. Route hierarchy uses an explicitly validated `Contains` edge from parent + `NodeKind::Route` to child `NodeKind::Route`; do not overload `RoutesTo`. + Inherited layouts/boundaries remain attached once to their owning route + node instead of being copied onto every descendant. Add only the exact + route→route endpoint allowance, not a blanket “route is a container” rule. +8. Freeze render-owner attribution: ordinary JSX is owned by its innermost + declared callable; a class component's `render` method normalizes to the + owning class component; top-level JSX uses the smallest exact declared + value or file owner; render props/component-valued parameters remain + unresolved symbolic targets unless project resolution proves a concrete + declaration. Tests and stories retain their test/file owner so component + impact can find them. +9. Decide and version typed `RenderEdgeDetails` rather than hiding durable + semantics in free-form context strings. At minimum distinguish JSX, + `createElement`, root render, and statically proven lazy indirection while + keeping the relationship site at the actual render occurrence. +10. Add typed import details for Vite glob projections (mechanism, eager/lazy, + selected import, query, ordered pattern identity) through the same + compatibility decision. Do not encode these durable fields into one + colon-delimited context string. + +`compass.graph/1` uses typed serialized enums. Before adding any enum value, +prove whether existing readers tolerate it. If they do not, use the repository +compatibility process and bump the appropriate contract/version; do not call +an incompatible enum addition “additive.” + +## Quality budget + +All phases share these non-negotiable gates: + +- Reviewed synthetic fixtures: 100% expected facts present, zero unexpected + framework facts. +- Negative fixtures: zero false activations when a package name, JSX-shaped + string, route-like filename, or config keyword occurs without the required + syntactic/project evidence. +- Repeated and cold/warm-cache runs: byte-for-byte equivalent normalized graph + output. +- Source anchors: exact token/expression range when the parser provides it; + never a whole-file anchor merely because extraction is easier. +- Ambiguity: zero silently chosen ambiguous targets. +- Safety: zero execution of project configs or generated code; all glob and + route expansion obey explicit count/path/byte limits. +- Final pinned corpus: at least 2,000 independently reviewed relationship and + role records, at least 400 records per stable framework family, and at least + 100 per advertised relation/capability. The total floor is + `max(2,000, 400 × N_stable_families)`, where + `N_stable_families` counts each support-matrix row promoted as stable (the + current target matrix has seven: React, Next App Router, Next Pages Router, + React Router, Remix, TanStack Router, and Vite; TanStack Start is separately + pre-stable). Aggregate observed precision must be at least 99.5%, Wilson + lower bound at least 99%, and each advertised capability at least 99% + precision and 95% recall. There must be zero fabricated targets, unsafe path + escapes, or nondeterministic results. +- Performance: record cold, unchanged-warm, one-file semantic edit, + manifest/config edit, restore, and peak RSS using the methodology in + `PERFORMANCE.md`. Any median latency or peak-RSS regression above 10% against + the frozen Phase 0 baseline requires an explained maintainer decision; a + regression above 20% is a failed gate. Correctness thresholds are never + traded away to meet performance. +- Parallel determinism: the same fixture and pinned corpus must produce + byte-identical normalized graphs with one worker, the default worker count, + and the configured maximum; cancellation/interruption followed by a clean + resume must either publish the complete equivalent artifact or a typed + failure, never a plausible partial success. + +## Commands you will need + +Every Cargo command must use the external per-checkout target directory. Run +the preflight in each new shell before the first build: + +```bash +test -d /Volumes/Workspace && test -w /Volumes/Workspace +mkdir -p /Volumes/Workspace/crabbuild-target/compass-021-react-frontend +``` + +Expected: both commands exit 0. If the volume is absent or not writable, STOP; +do not create a local `target/` directory. + +| Purpose | Command | Expected on success | +|---|---|---| +| Format | `cargo fmt --all -- --check` | exit 0, no diff | +| Model tests | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-model --locked` | all pass | +| Language tests | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --locked` | all pass | +| Resolver tests | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-resolve --locked` | all pass | +| Query tests | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-query --locked` | all pass | +| Semantic diff | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-semantic-diff --locked` | all pass | +| History | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-history --locked` | all pass | +| CLI contract | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-cli --test compass_product --locked` | all pass | +| Product boundary | `sh scripts/check_product_boundary.sh` | exit 0 | +| Code-graph fixture gate | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_code_graph_v1.sh --fixtures-only` | exit 0, deterministic fixture qualification passes | +| Clippy baseline | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo clippy --workspace --lib --bins --locked -- -D warnings` | exit 0, no warnings | +| Test baseline | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test --workspace --lib --bins --locked` | all pass | +| JS contracts | `npm ci && npm run typecheck:js && npm run test:js` | all exit 0 | +| Generated viewer | `node scripts/build_viewer_assets.mjs && node scripts/check_viewer_assets.mjs` | generated assets match source and check exits 0 | + +When a phase changes CompassQL grammar/support, also run: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-cypher --test tck --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-query --test opencypher_tck --locked +python3 scripts/check_compassql_support.py +``` + +Expected: all exit 0. Add a plan-specific qualification command in Phase 9; +that command becomes the authoritative final gate. + +## Suggested executor toolkit + +- Use the `diagnose` skill, if available, when a precision/recall regression + cannot be reduced to one fixture. +- Use official framework documentation as the semantic source of truth. Pin + the documentation/framework versions represented by qualification fixtures. +- Primary semantic references at planning time: + - React rules and hooks: and + + - Next.js App Router and directives: + , + , and + + - Next.js route conventions: + , + , + and + + - TanStack Router route trees, file routing, and loading: + , + , and + + - TanStack Start server routes/functions: + + and + + - React Router framework routes and route modules: + and + + - Vite aliases and glob imports: + and + +- Use external public repositories only under + `/Volumes/Workspace/Github//`, and treat existing + checkouts as read-only. Never clone qualification repositories into this + checkout or `/tmp`. + +## Scope + +**In scope** (only as required by the phase being executed): + +- `crates/compass-model/` — typed roles, relationships, route stages, schemas +- `crates/compass-files/` — bounded, ignored-aware file-set evaluation used by + literal Vite glob facts +- `crates/compass-languages/` — React/framework evidence and pack migration +- `crates/compass-resolve/` — project semantics, identity, ambiguity +- `crates/compass-graph/` — validation, deduplication, publication +- `crates/compass-core/` — orchestration and framework task context +- `crates/compass-query/` and `crates/compass-cypher/` — traversal, impact, + natural and structured query support +- `crates/compass-history/` and `crates/compass-semantic-diff/` — compatibility, + fingerprints, and relationship-change semantics for new typed values +- `crates/compass-output/`, `crates/compass-cli/`, and `crates/compass-mcp/` — + contract-preserving presentation and agent-facing access +- `packages/compass-viewer/`, `editors/vscode/`, `tests/viewer/`, and generated + `crates/compass-output/assets/viewer/` — closed TypeScript contracts and + compatibility rendering only; no visual redesign +- `fixtures/`, `tests/qualification/`, `benchmarks/performance/`, and `scripts/` + — independent expectations, corpus manifests, measurements, and gates +- `docs/`, `COMPATIBILITY.md`, `CHANGELOG.md`, `MIGRATION.md`, `SECURITY.md`, + and `PERFORMANCE.md` when required +- `.github/workflows/compass-ci.yml` — wire the offline fixture gate and, when + Plan 005 is available, the exact-production release gate + +**Out of scope**: + +- A viewer redesign or new frontend UI. Update rendering only if the existing + viewer must recognize new typed graph values. +- Vue, Nuxt, Svelte, SvelteKit, Astro, Angular, Solid, Qwik, or non-React + framework expansion. Their existing behavior must not regress. +- TanStack Query, Table, Form, Store, and Virtual, plus Storybook and frontend + styling/data-flow ecosystems. In this plan “TanStack” means Router and Start; + the other packages need separate graph semantics and qualification rather + than being silently implied. +- Vitest, Jest, Playwright, Cypress, and Testing Library framework semantics. + Their source still receives ordinary language/test evidence; Vite ownership + must not silently turn a test-runner config or test helper into application + route, render, or runtime-boundary evidence. +- React Router's experimental RSC APIs and any unpinned prerelease form. Keep + them as ordinary language evidence until a separate maturity decision. +- React Native/Expo and platform-specific JSX/runtime semantics. This is a + web-framework graph plan; a future native plan must define its own package + activation, platform boundaries, and qualification corpus. +- React runtime portals/hydration, `cloneElement`/`Children` execution + semantics, and arbitrary runtime element factories unless Phase 0 adds an + exact parser fact, target rule, and independent expectation. They remain + ordinary language evidence or an explicit unsupported/incomplete result. +- Type-level prop flow, runtime state flow, taint analysis, React reconciliation + modeling, or inferred runtime bundle contents. +- Executing Vite/Next/TanStack configs, running framework CLIs, importing + arbitrary JavaScript, or requiring Node.js for normal graph extraction. +- A general-purpose package manager, lockfile resolver, or TypeScript compiler + service. Use existing project semantics and bounded native parsing. +- Treating file names, capitalization, `use` prefixes, or package strings alone + as proof of semantic meaning. +- Graphify runtime, fixtures, tests, configuration, artifacts, or fallbacks. +- Existing untracked roots such as `qualification/` or `routes/`; the checked-in + qualification owner is `tests/qualification/` and framework fixtures belong + under `fixtures/code-graph/`. + +## Git workflow + +- Use branch prefix `codex/`, for example `codex/021-react-graph-phase-1`. +- Land one phase or one independently green vertical slice per PR. Do not mix + unrelated cleanup with semantic changes. +- Match the repository's conventional commit style, for example + `feat(graph): add occurrence-preserving render relations`. +- Do not push, publish a release, or open a PR unless the operator requests it. +- At every handoff, record exact commands run and checks omitted. + +## Phase delivery ledger + +Each row is a separate review unit. Update this ledger as phases land; do not +mark the plan-level index row `DONE` until Phase 9 passes. + +| Phase | Deliverable | Depends on | Status | +|---:|---|---|---| +| 0 | support contract, evidence matrix, baselines | Plan 013 hard cut | TODO | +| 1 | graph/route vocabulary and downstream compatibility | 0 | TODO | +| 2 | parser-backed framework syntax/config/fact/cache substrate | 0, 1 | TODO | +| 3 | React pack and render graph | 2 | TODO | +| 4 | Next.js pack hard cut | 2, 3 | TODO | +| 5 | TanStack Router/Start packs | 2, 3 | TODO | +| 6 | React Router/Remix migration | 2, 3 | TODO | +| 7 | Vite hard cut | 2 | TODO | +| 8 | task context, impact, queries, trust boundary | 1, 3–7 | TODO | +| 9 | exact-production release qualification | 0–8 | TODO | + +## Per-phase verification commands + +When a step says “run the Phase N gate,” it means the exact commands below, +all of which must exit 0. Newly named test targets are created by that phase; +their absence is a failed/incomplete phase, not permission to skip them. + +**Phase 0** + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-resolve --test framework_qualification --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test registry --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-core --test code_graph_v1_determinism --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_code_graph_v1.sh --fixtures-only +``` + +**Phase 1** + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-model --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-graph --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-output --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-query --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-semantic-diff --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-history --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-cli --test compass_product --locked +npm run typecheck:js +npm run test:js +node scripts/build_viewer_assets.mjs +node scripts/check_viewer_assets.mjs +sh scripts/check_product_boundary.sh +``` + +**Phase 2** + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test typescript_universal_evidence --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test registry --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test package_manifest_coverage --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-resolve --test framework_routes --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-resolve --test framework_qualification --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-core --test code_graph_v1_determinism --locked +``` + +**Phases 3–7** + +```bash +# Phase 3 +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test react_universal_pack --locked +# Phase 4 +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test next_universal_pack --locked +# Phase 5 +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test tanstack_universal_pack --locked +# Phase 6 +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test react_router_universal_pack --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-resolve --test typescript_routes --locked +# Phase 7 +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-languages --test vite_universal_pack --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-files --locked +# Shared after each phase +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-resolve --test react_frontend --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-core --test code_graph_v1_determinism --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_code_graph_v1.sh --fixtures-only +``` + +**Phase 8** + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-core --test task_context --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-query --test code_impact --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-query --test code_traversal --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-query --test natural_intent --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-mcp --test code_query_tools --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test -p compass-cli --test task_context_cli --locked +npm run typecheck:js +npm run test:js +node scripts/build_viewer_assets.mjs +node scripts/check_viewer_assets.mjs +``` + +**Phase 9** + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_react_frontend_graph.sh --fixtures-only +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_code_graph_v1.sh --fixtures-only +cargo fmt --all -- --check +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo clippy --workspace --lib --bins --locked -- -D warnings +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend cargo test --workspace --lib --bins --locked +sh scripts/check_product_boundary.sh +npm run typecheck:js +npm run test:js +node scripts/check_viewer_assets.mjs +``` + +## Phase 0: Freeze the semantic contract and independent truth + +### Step 0.1: Write the support specification before changing production + +Create `docs/reference/react-framework-graph.md`. Define: + +- framework activation evidence and non-activation cases; +- stable node roles, edge direction, multiplicity, context, and provenance; +- route identity and display-path rules for every supported router; +- runtime domains (`browser`, `server`, `build`, `shared`, `unknown`) as + evidence labels, not guessed execution facts; +- precedence when file conventions and explicit declarations coexist; +- exact production pack IDs and migration ownership: + `react-ui` (new), `nextjs-routes` (migrated in place), + `react-router-routes` (new extraction owner removed surgically from + `typescript-web`), `remix-routes` (migrated in place), + `tanstack-router` (new), `tanstack-start` (new), and `vite-config` + (migrated in place); `typescript-web` remains for Angular/Nest/Vue; +- ambiguity, generated-file, symlink, path-containment, and limit behavior; +- the exact supported declaration forms and explicit unsupported dynamic forms. + +Add an ADR if `Renders`, new roles, route stages, or runtime-domain evidence +change a public contract. Update `COMPATIBILITY.md` in the same phase if the +decision affects compatibility policy. + +**Verify**: + +```bash +rg -n "Renders|multiplicity|ambigu|runtime|limit|unsupported" \ + docs/reference/react-framework-graph.md +``` + +Expected: every term appears in a normative section; no statement describes a +planned form as already shipped. + +### Step 0.2: Add a framework-neutral expectation schema + +Extend `crates/compass-resolve/src/frameworks/qualification.rs` rather than +building a competing harness. Add a versioned, serializable expectation format +under `tests/qualification/` that records source file, exact range, +source/target stable identity, role/relation, route metadata, provenance, and +expected ambiguity. The expectation loader must reject unknown major versions, +duplicate IDs, reversed/invalid ranges, out-of-root paths, and unbounded record +counts. Keep the existing Rust `FrameworkQualificationCase` API as a thin +fixture convenience over the same evaluator. + +The oracle must be independent of production extractors. It may use reviewed +manifests and separately implemented qualification-only parsers, but it must +not call the product function it is evaluating. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-resolve --test framework_qualification --locked +``` + +Expected: valid round-trip passes; each corrupt/over-limit fixture fails with a +typed actionable error, and all established route qualification cases pass. + +### Step 0.3: Freeze current behavior and negative fixtures + +Capture current normalized output for: + +- JSX intrinsic vs component tags, member tags, fragments, spread props, + aliases, namespace imports, default imports, and ambiguous targets; +- basic Next App/Pages routes and Vite aliases/plugins; +- existing React Router/Remix behavior; +- non-framework projects containing misleading strings and filenames. + +The baseline is evidence, not a requirement to preserve known false positives. +Classify each delta as preserve, intentional fix, or unsupported. + +**Verify**: run each fixture twice and through a warm cache. Expected: +byte-identical normalized outputs across all three runs. + +### Step 0.4: Prove the language evidence is sufficient + +Create a checked-in evidence-sufficiency matrix in +`docs/reference/react-framework-graph.md`. For every promised declaration form, +name the exact parser node/evidence fact, owner identity, literal fields, +completeness signal, and source range the pack will consume. At minimum cover: + +- directive prologues (`use client`/`use server`); +- JSX tags and owners, `createElement`, root render, `lazy`, and + `next/dynamic` imports; +- statically recoverable calls, call arguments, exports, object/array + properties, spreads, computed keys, regex/string aliases, and conditional + branches; +- file-convention evidence whose anchor is a path rather than a token. +- explicitly unsupported web-runtime forms such as portals/hydration, + `cloneElement`/`Children` execution, arbitrary runtime element factories, + React Native/Expo platform APIs, and Storybook-only semantics; each must + have a negative expectation rather than an accidental “not observed” result. + +`UniversalDetectionContext` already supplies the prepared syntax root. Phase 2 +must standardize one bounded TypeScript/JavaScript helper over that borrowed +root and source, shared by all frontend packs; do not reparse once per pack, +serialize parser node handles, or fall back to source-string/regular-expression +matching. The helper must mark dynamic/computed/spread/conditional regions +incomplete instead of flattening them into false literals. + +**Verify**: + +```bash +rg -n "directive|JSX|createElement|object|array|spread|computed|incomplete|anchor" \ + docs/reference/react-framework-graph.md +``` + +Expected: every promised framework form maps to an existing fact or a named +Phase 2 syntax-view addition; no row says “scan source text.” + +### Step 0.5: Freeze package-scope and performance baselines + +Specify framework activation and config precedence per nearest owning package, +not repository-wide. Cover npm/yarn/pnpm workspaces, nested package manifests, +workspace protocols, mixed framework versions, nested Next/Vite configs, +TypeScript project references, and `jsxImportSource`. Also decide and fixture +the dependency-section policy (`dependencies`, `devDependencies`, optional and +peer dependencies), npm aliases, pnpm catalogs, Yarn Plug'n'Play markers, +conditional `exports`/`imports`, and package-manager lockfile changes. A +lockfile is an invalidation input, not permission to run a package manager. +Resolve TypeScript `extends`, `baseUrl`, `paths`, module-resolution mode, +`jsx`, `allowJs`, and project-reference inheritance as bounded project +evidence; unsupported compiler options remain explicit diagnostics. A root +React dependency must not activate React semantics in an unrelated package +that uses another JSX runtime. + +Record cold, warm, semantic-edit, manifest/config-edit, restore, and RSS +baselines using `PERFORMANCE.md`. Include the exact revision, release binary, +machine profile, corpus manifest, and digests. + +**Verify**: add focused `ProjectEvidenceIndex` tests in +`crates/compass-languages` and an incremental fixture in +`crates/compass-core/tests/code_graph_v1_determinism.rs`; run both owning-crate +test suites. Expected: only files in the affected package are re-extracted +after a package/config change, restored output matches the original bytes, and +package-manager/compiler-option fixtures resolve or fail closed according to +the matrix, and the baseline artifact validates against its pinned schema. + +**Phase exit gate**: run every Phase 0 command in “Per-phase verification +commands.” Expected: all exit 0 before any public vocabulary change begins. + +## Phase 1: Add the graph vocabulary as a complete vertical slice + +### Step 1.1: Decide and test every affected public contract + +In `crates/compass-model/src/code_graph.rs` and its public-contract tests, +determine whether adding enum values to `compass.graph/1` is accepted by every +supported reader. Test JSON round-trips, unknown-value behavior, stable +ordering, GraphML/HTML/JSON rendering, history fingerprints, MCP schemas, and +CompassQL enumeration. + +Inventory and assign an explicit policy for every closed consumer before +editing the enum. The minimum inventory is: + +- `crates/compass-model/src/code_graph.rs` and `validation.rs`; +- `crates/compass-model/src/query_contract.rs`, `search.rs`, and + `identity.rs` (closed query/role/edge and stable-ID consumers); +- `crates/compass-graph/src/{v1.rs,inference.rs,snapshot.rs}`; +- `crates/compass-query/src/{code_query.rs,discovery.rs,index.rs,ranking.rs}`; +- `crates/compass-semantic-diff/src/topology.rs` and history publication/diff; +- `crates/compass-store-qualification/src/main.rs` and qualification graph + builders; +- `crates/compass-cli/src/{help.rs,task_context_commands.rs}`, + `crates/compass-mcp/src/lib.rs`, and `crates/compass-core/src/task_context.rs`; +- output viewer models, strict CLI/MCP schemas, and task context; +- `packages/compass-viewer/src/contracts/codeQuery.ts`, edge labels, semantic + appearance, `tests/viewer/fixtures/generate.ts`, VS Code transport/messages + and consumers, and generated viewer assets (ignored build outputs must be + regenerated, never hand-edited); +- code-graph coverage manifests, qualification oracles, and support docs. + +Freeze a downstream policy matrix: callers/callees exclude `Renders`; impact +includes inbound renderers with a reasoned path; semantic diff reports render +add/remove; history fingerprints it; discovery/query can filter both +directions; viewer labels it; topology/community/ranking either assign a +reviewed weight or explicitly exclude it. Never let a wildcard/default match +silently decide semantics. + +If existing readers reject new enum values, follow `COMPATIBILITY.md`: update +the graph contract/version, migration documentation, changelog, fixtures, and +consumer negotiation together. Do not silently weaken deserialization. + +**Verify**: run the model, graph, output, query, semantic-diff, history, JS +contract, generated-viewer, CLI contract, and product-boundary commands from +“Commands you will need.” Expected: new compatibility tests and all existing +tests pass, TypeScript Zod schemas accept the new values, and generated assets +match their source. + +### Step 1.2: Add `Renders` and frontend roles + +Add the vocabulary from “Proposed graph vocabulary” to the model and every +exhaustive consumer. Specify: + +- source → target direction; +- allowed endpoint kinds; +- exact occurrence multiplicity; +- stable serialized spelling; +- display label and query alias; +- validation for source anchors/provenance; +- preservation of unknown attributes where the contract permits them. +- the exact endpoint matrix: source is an owning file, callable, class + component, or existing synthetic component with direct render evidence; + target is an exactly resolved function/class/component declaration carrying + `ui_component`, or a source-scoped qualified external component endpoint + proven by an exact import binding and JSX use. Do not inspect or invent the + package's internal file. Parameters, import nodes themselves, unqualified + package guesses, and unresolved placeholders are not silently promoted to + concrete component targets. + +Do not emit production `Renders` edges in this step. First make the contract +and all consumers safe. + +**Verify**: run the same Phase 1 command set from Step 1.1. Expected: all pass, +including round-trip, typed render-details, exhaustive-consumer, and invalid- +endpoint tests. + +### Step 1.3: Replace the one-handler raw route shape with typed stages + +Add a bounded `RawRouteStageFact` in +`crates/compass-languages/src/frameworks/model.rs` with role, independently +anchored reference, optional operation, ordinal, origin/rule, and typed detail. +Migrate `RawRouteFact` from one route-wide `handler_reference` plus +`middleware_references` to an ordered `stages` vector so layout, template, +loading/default/error/not-found, loader, action, middleware/proxy, page, and +per-method route handlers do not borrow the wrong anchor. Update both public +route-stage types and `crates/compass-resolve/src/frameworks/routes.rs` so the +new stages survive raw fact → resolution → graph → query → output round trips. + +Treat this serialized raw-fact change as a cache-wire change: either reject old +entries and bump the owning cache/extraction semantics version, or add an +explicit validated migration. Do not deserialize an old handler into a new +stage without preserving its operation, position, and anchor. + +Audit `RoutesTo` endpoint validation for inline arrow/function expressions +used as loaders, actions, middleware, and route components. Add only +source-proven callable `Closure` or callable variable/property endpoint +widens required by fixtures, following the existing compatibility precedent +for additive endpoint-matrix changes. Never accept every variable/property as +a handler merely to make inline routes validate. + +**Verify**: run `cargo test -p compass-languages --locked`, +`cargo test -p compass-resolve --test framework_routes --locked`, and +`cargo test -p compass-model --locked` with the required target directory. +Expected: one integration fixture containing every stage serializes and +queries back in deterministic route order; old-cache handling is explicit. + +### Step 1.4: Define route hierarchy and identity + +Freeze separate identities for framework/package scope, router family, +normalized display URL, non-URL structural identity (groups/slots/intercepts), +operation, and source convention. Parent route → child route is a `Contains` +edge with cycle rejection and deterministic sibling order. A page and `GET` +handler at the same display URL must not collide; two parallel slots with the +same display URL must not collapse; route groups must not leak into the URL. + +Update `RouteNodeDetails` only through the Phase 1 compatibility decision. Do +not encode hierarchy solely in `declaring_scope` or display strings, and do +not duplicate inherited stages onto descendants. + +**Verify**: add public model/graph and resolver tests for same-URL operations, +groups, two parallel slots, an intercept, missing parents, and a cycle. +Expected: identities are distinct where semantics differ, stable across file +order/checkouts, and traversable through validated `Contains` edges. + +**Phase exit gate**: run every Phase 1 command. Expected: all Rust/TypeScript +consumers accept the chosen contracts and generated assets are current. + +## Phase 2: Build a universal framework evidence substrate + +### Step 2.1: Standardize the existing parser-backed frontend syntax view + +Create a shared helper module such as +`crates/compass-languages/src/frameworks/typescript_syntax.rs` over the +existing borrowed `UniversalDetectionContext.root` and source bytes. It must +provide exact ranges and explicit completeness for directive prologues, +exports, calls and arguments, JSX ownership, object/array properties, +literal/regex values, spreads, computed keys, and conditional branches. It +must not expose parser node handles beyond the extraction lifetime or persist +arbitrary source literals not selected by a pack. + +Make all React/Next/TanStack/React Router/Remix/Vite packs use this helper. +Reject nodes that overlap parser recovery ranges, and join AST shapes to +universal imports/bindings/owners before assigning meaning. Add a truthful +capability only if the descriptor registry needs to state this contract. Bump +the TypeScript/JavaScript producer version and evidence/cache schema only when +serialized evidence changes; framework-only helper semantics are covered by +the pack version/digest in Step 2.3. + +**Verify**: add malformed, recovery-overlap, Unicode, multiline, dynamic, +incomplete-spread, and maximum-fact tests in +`crates/compass-languages/tests/typescript_universal_evidence.rs`, then run: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-languages --test typescript_universal_evidence --locked +``` + +Expected: every syntax view item has an exact valid range, incomplete shapes +remain incomplete, parser recovery cannot be upgraded to framework meaning, +and framework-pack dispatch performs no additional parse. + +### Step 2.2: Add generic role and relation facts + +Extend the framework pack contract with typed, versioned facts equivalent to: + +- `RawFrameworkRoleFact`: exact subject anchor/identity hint, role, context, + provenance, confidence/evidence class; +- `RawFrameworkRelationFact`: exact source and target evidence, relation kind, + occurrence anchor, context, provenance, and ambiguity policy. +- `RawFrameworkConfigurationFact`: pack/config identity, typed static field, + exact anchor, ordinal, completeness, and bounded value selected by the pack; +- `RawFrameworkFileSetFact`: owning declaration/file, ordered literal and + negative patterns, eager/lazy/import/query options, anchor, package scope, + and explicit resolution limits. The language pack emits the pattern fact; + `compass-files`/`compass-resolve` own match evaluation and target selection. + +Do not add React-specific variants to a global enum. Reuse the universal +evidence occurrence and candidate identity types where ownership allows it. +The raw fact must be incapable of expressing a resolved target without the +resolver's evidence. + +Before adding variants, capture `rg -l 'RawFrameworkFact::' crates | sort` and +classify every exhaustive match: language emitters, engine sorting/limits, +core compaction/digest/path portability, resolver expansion/publication, and +tests. Update each intentionally; do not add wildcard arms that hide a new +fact family from cache normalization or publication. + +**Verify**: serialization, validation, bound, sort, duplicate, corrupt-fact, +and incomplete-configuration tests pass in `compass-languages` and +`compass-model`; file-set facts cannot contain resolved filesystem targets at +the per-file extraction boundary. + +### Step 2.3: Add explicit framework capabilities and pack versions + +Extend framework capabilities for component roles, render relations, runtime +boundaries, route hierarchy, route data stages, build configuration, and +bounded file sets. A +pack advertises a capability only when it emits and qualifies the corresponding +facts. Update manifests and public support reporting atomically. + +Add a nonzero framework-pack semantics version to universal descriptors and +established runtime entries. Include the ordered `(pack_id, version, +capabilities, activation rules, limits)` digest in extraction/cache identity. +The version is an explicit per-pack maintainer-controlled value, not a hash of +an opaque function pointer. Changing a detector implementation, syntax-view +contract, activation rule, fact shape, or limit requires a reviewed version +bump and must invalidate affected cached framework facts even when source bytes +and package manifests are unchanged. Registry tests must fail when a pack has +no nonzero version or when a changed fixture is accepted under a stale version. + +Extend `FrameworkLimits` with named nonzero bounds for source/config bytes and +AST nodes/depth, retained literal/string bytes, role and relation facts, +diagnostics, route nodes/stages, glob patterns, per-pattern matches, total +file-set edges, alias expansions, and regex pattern length/complexity. Apply +limits before allocation where possible and report observed/maximum values +through the existing typed limit error pattern. Aggregate project limits remain +separate from per-file limits; a limit error never becomes an empty successful +framework result. Regex literals may be retained as evidence but must not be +evaluated for arbitrary filesystem expansion or allowed to trigger unbounded +backtracking. + +**Verify**: support matrix tests fail when a pack advertises a capability with +no fixture expectations and pass for the completed candidate packs. + +### Step 2.4: Resolve framework facts centrally + +Create `crates/compass-resolve/src/frameworks/semantic.rs` and integrate it +through `frameworks::resolve_framework_facts` in +`crates/compass-resolve/src/lib.rs`, where route/domain facts are currently +expanded, resolved, and published. Resolve role and relation facts using exact language +evidence, import/export identity, project aliases, and framework-declared +parentage. Preserve unresolved and ambiguous candidates explicitly. Enforce +the same bounded fan-out and deterministic tie rules as universal relations. + +Stage roles, render relations, routes, and domains in a cloned/temporary +extraction, validate the complete set, then publish it coherently. A limit, +invalid endpoint, bad anchor, or resolution error in one new framework family +must not leave a partial graph that appears successful. Preserve the existing +typed diagnostic behavior for unrelated established packs. + +**Verify**: tests cover exact, re-exported, aliased, unresolved, ambiguous, +duplicate, cyclic, and over-limit targets. No test expects “first match wins.” + +### Step 2.5: Make package activation and incremental invalidation exact + +Extend `ProjectEvidenceIndex` and its per-file fingerprint only where Phase +0's matrix proves a gap. Framework packs must receive the nearest owning +package/config scope, resolved JSX runtime, and pack-semantics digest. A +manifest/config/generated-route-tree change invalidates all and only dependent +files; a source-only edit must not re-extract unrelated packages. + +Update every exhaustive `RawFrameworkFact` consumer in +`crates/compass-core/src/pipeline.rs`: compaction, digest normalization, +portable path rewriting, partial-extraction clearing, cache serialization, and +tests. New facts with more than one source path must normalize and validate +each path, not only a single common anchor. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-core --test code_graph_v1_determinism --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-languages --test registry --locked +``` + +Expected: cold/warm/forced/semantic-edit/manifest-edit/config-edit/restore +digests match their declared expectations, stale pack versions miss cache, +and unrelated packages remain cache hits. + +### Step 2.6: Replace heuristic frontend project-config indexing + +Refactor the project-evidence build into an explicit bounded frontend-config +prepass. Parse recognized Next/Vite/React Router/TanStack configuration files +with the same pinned TypeScript/JavaScript parser and shared AST helper, then +build `ProjectEvidenceIndex` before dependent source resolution. Store a typed +ordered alias rule list with source config, string/regex kind, exact anchor, +ordinal, replacement, and completeness; keep TypeScript/package alias families +separate so their precedence is not flattened. + +Delete Vite/Next `contains`, quoted-alias regex, and plugin-name substring +semantics from `project_evidence.rs`. Generic or other-language configuration +parsers may remain only for their existing owners and must not feed the new +frontend packs. Avoid duplicate config parsing by carrying the normalized +prepass facts into the later framework pack; if the language graph still needs +the config AST for declarations, measure and document that bounded second +parse rather than pretending it does not occur. + +Version the project-evidence schema/fingerprint and test old-cache rejection. +Bound manifest/config bytes, object/array entries, nesting depth, literal +retention, and diagnostic count before indexing. Define duplicate-key and +package-exports/conditional-branch behavior explicitly. Config parse +failure/incompleteness must remain a diagnostic and cannot be silently +replaced by regex facts; regex values are bounded, preserved as typed evidence, +and never evaluated as an untrusted filesystem matcher. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-languages --test package_manifest_coverage --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-core --test code_graph_v1_determinism --locked +! rg -n "parse_vite_configuration|parse_next_configuration" \ + crates/compass-languages/src/project_evidence.rs +``` + +Expected: tests pass; the negated search exits 0. A generic text helper may +remain only if every caller is an explicitly out-of-scope non-frontend +configuration owner; ordered aliases and config +incompleteness survive cold/warm/cache round trips. + +**Phase exit gate**: run every Phase 2 command. Expected: all exit 0 and no +framework pack reparses or uses text matching to compensate for missing facts. + +## Phase 3: Ship the universal React pack + +### Step 3.1: Detect React projects from converging evidence + +Create `crates/compass-languages/src/frameworks/react.rs` and register the new +universal `react-ui` pack. Activation requires supported syntax +plus project evidence such as a resolved React import/runtime or framework +dependency; a package string or JSX-looking text alone is insufficient. +Support classic and automatic JSX runtimes without assuming the import name +is literally `React`. Respect package-scoped `jsxImportSource`; Preact and +other JSX runtimes are negative cases unless a React-based framework provides +separate exact activation evidence. + +**Verify**: positive fixtures for both runtimes activate; negative fixtures +with comments, strings, similarly named local modules, and JSX in a non-React +runtime do not emit React semantic facts. + +### Step 3.2: Classify components conservatively + +Recognize declarations whose syntax and use provide direct evidence: + +- function/arrow/class components returning supported JSX; +- declarations exactly targeted by a JSX element or a framework route + component field, including components that legitimately return `null`; +- classes with an exact resolved React component base and a declared render + method; +- exported/default, aliased, namespace-member, `memo`, and `forwardRef` + wrappers when their callable target is statically recoverable; +- higher-order wrapper chains only while each link remains explicit and + bounded. +- statically recoverable `React.lazy` and `next/dynamic` component bindings; + retain lazy indirection provenance and leave computed loaders unresolved. + +Do not classify by uppercase naming alone. Do not infer a component from a +type signature without a rendered value. Attach roles to the declared symbol +and preserve wrapper occurrence provenance. + +**Verify**: fixtures cover nested/local components, same-name declarations, +anonymous defaults, wrappers, non-components returning strings/objects, and +ambiguous re-exports. + +### Step 3.3: Emit occurrence-preserving render relations + +Project resolved JSX component references to `Renders` edges. Support member +tags and statically resolved `createElement`/configured JSX factory calls. +Intrinsic DOM/custom-element tags remain language references but do not create +component render targets unless the project defines a supported component +identity for that exact tag form. + +An exactly imported external component may produce a qualified external +component endpoint with import and JSX provenance. Namespace/member imports +must retain the selected member. Wildcard or conditional package exports that +do not identify one external symbol remain unresolved. + +For conditional, looped, or repeated JSX, keep one edge per syntactic +occurrence; do not estimate runtime cardinality. Keep the existing JSX +`References` facts. + +JSX in a test or story-like file follows the owner rules from Phase 0 and can +therefore supply inbound render impact, but this plan does not add Storybook +framework semantics. A JSX component parameter/render prop stays a symbolic +reference unless exact project resolution identifies the passed declaration. + +**Verify**: assert edge direction, exact source range, target stable ID, +context, provenance, multiplicity, and stable sort order for every exact form; +unresolved/ambiguous forms retain candidate diagnostics and no `Renders` edge. + +### Step 3.4: Add hook and root-entry evidence + +Classify built-in hooks by resolved, version-pinned React export identity. +Classify a custom hook only when a declared `use*` callable has an exact call +path to a resolved built-in or already qualified custom hook; a `use*` name +alone is insufficient even inside a React project. Bound/cycle-check the +custom-hook closure. Hook invocations remain `Calls`. + +Recognize statically imported `createRoot(...).render(...)`, legacy +`ReactDOM.render`, and supported framework root APIs. Anchor the render edge at +the rendered component expression, not the entire call/file. + +**Verify**: aliased imports, namespace imports, shadowing, nested functions, +false `use` prefixes, and ambiguous roots are covered. + +### Step 3.5: Qualify and enable the new React pack + +There is no established generic React component pack to remove. Register one +`react-ui` owner in the same explicitly `Qualifying`/provisional registry used +by the other staged universal packs, but do not advertise it as a completed +support claim or promote it beyond that state until the independent gates pass. +Preserve TypeScript/JavaScript JSX `References` and do not remove +`typescript-web`, which still owns Angular/Nest/Vue and the pre-Phase-6 React +Router path. Prove that the new semantic projection does not duplicate any +existing graph edge identity. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-languages --test react_universal_pack --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-resolve --test react_frontend --locked +``` + +Expected: registry tests prove exactly one `react-ui` production pack; +repeated and cold/warm outputs match; JSX references remain; render edges are +not duplicated; product-boundary and fixture gates pass. + +**Phase exit gate**: run the Phase 3 and shared Phase 3–7 commands. Expected: +all exit 0. + +## Phase 4: Deepen Next.js App and Pages Router evidence + +### Step 4.1: Replace path-only route discovery with bounded project semantics + +Create a universal Next pack that combines exact file conventions, parsed +exports/directives, configuration roots, and project identity. Normalize route +groups without adding URL segments; preserve parallel slot and intercepting +route identity instead of flattening it. Support dynamic, catch-all, and +optional catch-all segments with deterministic identities. + +Pin the supported Next major(s) in fixtures and make version-sensitive +conventions explicit. Include `default` slot fallbacks and the applicable +`middleware`/`proxy` convention for each pinned major. Treat private folders, +metadata/instrumentation files, and unsupported future conventions as explicit +non-route or separately documented evidence, never pages by filename shape. + +Treat `src/app`, `app`, `src/pages`, and `pages` precedence as an explicit +project rule. Never walk outside the project root or follow unbounded symlink +cycles. + +**Verify**: table-driven fixtures cover groups, nested groups, slots, +intercepts, private folders, colocated non-route files, dynamic segments, +`default`, versioned middleware/proxy, page-vs-route conflicts, root +precedence, Windows separators, non-UTF-8-capable path handling where the test +harness supports it, and path traversal attempts. + +### Step 4.2: Model route stages and hierarchy + +Emit route evidence for `page`, `layout`, `template`, `loading`, `error`, +`global-error`, `not-found`, and `route` conventions that are supported by the +pinned Next version. Attach each stage to the correct route hierarchy and +declared handler/component. For Pages Router, include pages, API routes, +`_app`, `_document`, `_error`, and supported data functions. + +For App Router, include statically declared `generateStaticParams`, +`generateMetadata`, and other pinned route-module exports in typed route +context when their identity is useful, but do not claim they are request +handlers or execute them. HTTP method exports in `route.*` remain separate +operation stages with exact export anchors. + +Capture supported static route-segment exports such as deployment runtime, +dynamic rendering, revalidation, cache policy, and preferred-region metadata +only when the pinned Next contract and a literal value make their meaning +exact. Keep deployment runtime metadata distinct from React client/server +component boundaries: for example, an edge/server deployment target does not +by itself make a module a React server component. Computed or version-unknown +values remain typed incomplete/unsupported evidence rather than free-form +attributes. + +Do not fabricate a handler when an expected export is absent. Preserve one +route-handler occurrence per supported HTTP method export. + +**Verify**: query tests can move URL → hierarchy/stages → declared symbol and +back; missing or ambiguous exports remain explicit. Literal route-segment +metadata round-trips through JSON/history/task context, while computed values +stay incomplete and deployment/runtime labels never change component roles. + +### Step 4.3: Model client/server boundaries from directives + +Parse top-level directive prologues exactly. Add `client_component` only when +`'use client'` occurs in a valid directive position and the symbol is exported +through that boundary. Add server-function evidence for valid `'use server'` +module/function forms supported by the pinned Next semantics. Do not treat a +string elsewhere in the body as a directive. + +Treat `server-only`/`client-only` imports as direct boundary evidence when +resolved to the documented packages. Do not propagate `client_component` to +every transitive import: the directive marks a client boundary, while a +descendant's execution domain remains unknown unless direct framework evidence +supports it. Record boundary and component-runtime semantics separately in the +Phase 0 contract so agents cannot confuse “imported by client code” with “has +its own client directive.” + +Server-component classification is conservative: default App Router server +behavior is scoped by project/file convention and is overridden by a valid +client boundary. Record `unknown` rather than guessing through unsupported +dynamic re-export chains. + +**Verify**: fixtures cover quote styles, comments/shebangs, invalid directive +placement, nested directives, barrel re-exports, client imports, server +actions, shadowed names, and mixed boundaries. + +### Step 4.4: Parse Next config without executing it + +Extract only statically recoverable config relevant to graph identity, such as +supported route roots, extensions, base path, and aliases delegated to +existing project semantics. Preserve conditional alternatives as incomplete or +ambiguous rather than choosing the first branch. Record statically literal +redirect/rewrite declarations as configuration evidence only if Phase 0 +defines safe route/resource identities; otherwise report them as an explicit +unsupported capability. Reject or label computed/dynamic values as +unsupported; never import or run `next.config.*`. + +**Verify**: static ESM/CommonJS/TypeScript config fixtures pass; getters, +function calls, environment-dependent expressions, and malicious side effects +are never executed and produce bounded diagnostics. + +### Step 4.5: Perform the Next hard cut + +Compare candidate facts with `nextjs-routes`, document intentional deltas, +switch the registry atomically, and remove the established adapter. Do not +dual-publish duplicate routes. + +**Verify**: exactly one Next pack produces production facts; all old public +route cases remain covered; new route/boundary fixtures and determinism gates +pass. + +Run the new `crates/compass-languages/tests/next_universal_pack.rs` and the +Next-filtered cases in `crates/compass-resolve/tests/react_frontend.rs`, plus +`crates/compass-core/tests/code_graph_v1_determinism.rs`. Expected: the +`nextjs-routes` pack ID is preserved, the established adapter is absent, and +all cold/warm/config-edit/restore assertions pass. + +**Phase exit gate**: run the Phase 4 and shared Phase 3–7 commands. Expected: +all exit 0. + +## Phase 5: Add TanStack Router and TanStack Start packs + +### Step 5.1: Support code-based TanStack route trees + +Recognize statically resolved TanStack Router APIs (including supported +`create*Route` forms) from import identity, not callee spelling alone. Extract +route ID/path, parent getter, component, loader, pending/error/not-found +components, and child composition when values are statically recoverable. +Resolve tree parentage centrally and preserve cycles/missing parents as +diagnostics, not invented roots. + +Cover the pinned `createRootRoute`, `createRootRouteWithContext`, +`createRoute`, `createFileRoute`, `getParentRoute`, and `addChildren` forms, +plus supported lazy route/component APIs. Route masks, virtual file routes, and +future generator formats are advertised only when separately present in the +evidence matrix and qualification fixtures. + +**Verify**: aliased/namespace imports, shadowed functions, reordered options, +spread/merge ambiguity, nested route trees, cycles, and duplicate IDs are +covered. + +### Step 5.2: Support file-based TanStack routes + +Pin and document the file-route convention being supported. Model root/index, +pathless/layout, dynamic, splat, non-nested, and grouped forms without losing +file identity. Treat configuration tokens such as route directories/prefixes +as static only when parsed exactly. + +Generated route-tree files require an explicit policy: consume them only as +bounded generated evidence with provenance and deduplicate them against source +route files, or ignore them with a diagnostic. Never publish duplicate logical +routes from both inputs. + +Package-scoped router configuration controls route-token/prefix behavior. A +generated tree from another workspace package must never influence this one. + +**Verify**: route identity is stable across source ordering and generated-file +presence; malformed/generated drift is detected. + +### Step 5.3: Model loaders, actions, and render boundaries + +Attach route components and pending/error/not-found components as typed stages +or role/context evidence according to Phase 0. Loaders remain callable graph +targets with `data_loader` role; their invocations/dependencies retain normal +language call/import relations. + +**Verify**: impact tests demonstrate component ↔ route ↔ loader navigation +without conflating render and call edges. + +### Step 5.4: Add TanStack Start as a separate capability + +Recognize supported server routes and server-function declarations only from +pinned, exact API/import evidence. Keep Start's support claim and quality +metrics separate from stable TanStack Router support because its public API is +pre-stable. Unsupported versions/forms must degrade to language evidence, not +misclassified framework facts. + +Where the pinned API exposes validators or middleware as statically named +callables, preserve them as ordered stages with their own anchors. Do not run +validators, middleware builders, or server functions. + +**Verify**: versioned fixtures exercise supported and intentionally +unsupported forms; documentation labels the maturity accurately. + +### Step 5.5: Enable packs only after qualification + +TanStack has no established production pack to preserve. Keep candidate packs +qualification-only until their reviewed fixture and minimum per-capability +precision/recall thresholds pass. Enable each pack with its manifest, +capability report, docs, and registry test in one PR. + +**Verify**: before enablement, production discovery reports no TanStack pack; +after enablement, exactly the intended Router/Start pack and version activate. + +Run `crates/compass-languages/tests/tanstack_universal_pack.rs`, the TanStack +cases in `crates/compass-resolve/tests/react_frontend.rs`, and the core +determinism test. Expected: `tanstack-router` and `tanstack-start` activate only +in their owning package, generated/source routes deduplicate by explicit +identity, and Start failures cannot promote Router metrics or vice versa. + +**Phase exit gate**: run the Phase 5 and shared Phase 3–7 commands. Expected: +all exit 0. + +## Phase 6: Migrate React Router and Remix to universal evidence + +### Step 6.1: Freeze and port existing route behavior + +Inventory every React Router/Remix form in +`crates/compass-languages/src/frameworks/typescript.rs` and its tests. Port the +behavior to universal role/relation facts without changing public route +identity unintentionally. Add data-router and framework-mode forms supported +by pinned official docs, including loaders, actions, lazy route modules, and +error boundaries when statically declared. + +Where supported by the pinned React Router or Remix contract, recognize exact +client/server entry modules and documented `.client`/`.server` module +conventions as module-boundary evidence. Scope the convention to its owning +package and framework mode, keep it separate from React RSC semantics, and do +not infer a transitive execution domain for every importer. A matching suffix +outside an activated project is only ordinary language evidence. + +Create a dedicated universal `react-router-routes` pack. After parity, remove +only React Router activation/detection from `typescript-web`; keep that pack's +Angular, Nest, and Vue behavior and regression tests. Migrate `remix-routes` in +place so its public pack identity remains stable. + +**Verify**: established-vs-candidate comparison has an explained disposition +for every fact; reviewed negative fixtures have zero false activations. +Boundary fixtures cover framework/data/declarative modes, nested workspaces, +client/server entries, suffix lookalikes, barrels, and mixed Remix/React Router +packages without leaking roles across package roots. + +### Step 6.2: Perform one atomic registry cut + +Remove the established React Router branch and Remix adapter only after +candidate parity. Do not remove the whole `typescript-web` pack, and do not +leave a production fallback or dual publisher. + +**Verify**: registry enumeration shows one owner per pack ID; old and new +fixtures pass with byte-stable output. + +Run `crates/compass-languages/tests/react_router_universal_pack.rs`, the +React Router/Remix cases in +`crates/compass-resolve/tests/typescript_routes.rs`, and the mixed-framework +cases in `crates/compass-core/tests/code_graph_v1_determinism.rs`. Expected: +React Router facts come only from `react-router-routes`, Remix facts only from +`remix-routes`, and Angular/Nest/Vue established output is unchanged. + +**Phase exit gate**: run the Phase 6 and shared Phase 3–7 commands. Expected: +all exit 0. + +## Phase 7: Replace Vite regex extraction with parsed evidence + +### Step 7.1: Parse Vite config statically + +Replace `body.contains`/regex matching with exact syntax evidence for supported +`defineConfig`, plain export, function-export, and array forms. Parse only +literal/static values. Track config anchor and provenance precisely; never run +the config or a plugin. + +Use the shared Phase 2 syntax view. For function configs and conditional +branches, publish only common statically proven facts or explicit alternatives +with incompleteness; never assume a command, mode, SSR flag, environment, or +first branch. Pin supported `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts` forms to +the Vite versions in qualification. + +**Verify**: comments, strings, shadowed `defineConfig`, computed property +names, nested unrelated objects, malicious expressions, and syntax errors do +not produce false facts or side effects. + +### Step 7.2: Preserve ordered alias semantics + +Extract object and array alias forms with exact `find`/`replacement` anchors, +order, string-vs-regex distinction, and unresolved dynamic values. Feed only +safe literal path aliases into existing project resolution. Do not collapse +ordered entries into a map or interpret arbitrary regex as a filesystem path. + +Inventory `root`, `resolve.alias`, `resolve.dedupe`, `resolve.conditions`, and +`resolve.extensions` in the Phase 0 support contract. Only fields with +well-defined native resolution semantics may affect target selection; retain +the rest as bounded configuration evidence or declare them unsupported. Do +not let Vite aliases bypass the established TypeScript/project alias +precedence. + +**Verify**: overlapping aliases resolve by documented order, duplicate keys +remain reviewable, regex entries are preserved but not unsafely expanded, and +paths cannot escape configured containment. + +### Step 7.3: Extract plugin identity and config dependencies + +Resolve plugin factories through imports/re-exports when possible. Emit build +configuration dependency evidence with exact call anchors; never invoke the +plugin. Preserve unresolved/ambiguous factories and repeated plugin instances. + +Virtual module IDs and plugin-defined resolution remain unresolved external +configuration evidence unless a checked-in static declaration proves an exact +target. Detect the official React and React-SWC plugin imports without +assuming every plugin whose name contains “react” has React semantics. + +**Verify**: alias, namespace, shadow, duplicate, conditional, and array-spread +fixtures cover precision and multiplicity. + +### Step 7.4: Add bounded literal `import.meta.glob` evidence + +Recognize only the documented literal pattern forms. Resolve relative, +absolute-root, and supported alias patterns within project containment. Apply +explicit maximum pattern, match, path-length, traversal, and total-output +limits. Preserve eager/lazy and supported query/import options as context; do +not claim a runtime call edge to every lazy module. Project each exact matched +module as an `Imports` edge from the owning file/module with the glob literal +as relationship site and typed eager/lazy/options detail; target identity and +inventory provenance distinguish edges that share one pattern occurrence. + +The Vite pack emits an anchored raw glob-pattern fact; it does not walk the +filesystem. Evaluate the file set through `compass-files` discovery/ignore and +containment primitives, then resolve/project it collection-wide. Include the +matched file-set fingerprint in incremental state so adding, removing, or +renaming a matching file invalidates the dependent glob without rebuilding +unrelated packages. + +**Verify**: positive fixtures cover single/array/negative patterns and options; +negative fixtures cover variables, template expressions, path escapes, +symlink cycles, ignored files, add/delete/rename invalidation, huge matches, +unsupported options, and malformed globs. Limit failures are typed +errors/diagnostics, never empty success. + +### Step 7.5: Perform the Vite hard cut + +Compare against `vite-config`, switch atomically, and delete the regex adapter +and obsolete dependency if it has no other owner. + +**Verify**: + +```bash +! rg -n "body\.contains|Regex|regex" crates/compass-languages/src/frameworks/vite.rs +! rg -n "parse_vite_configuration|parse_next_configuration" \ + crates/compass-languages/src/project_evidence.rs +``` + +Expected: both negated searches exit 0; no extraction-by-text/regex +implementation remains (test names or comments documenting the removal may be +allowed), and no Vite/Next project-evidence caller reaches generic text +helpers. All Vite fixtures and product-boundary checks pass. + +Also run `crates/compass-languages/tests/vite_universal_pack.rs`, the Vite +cases in `crates/compass-resolve/tests/react_frontend.rs`, and the core +determinism test. Expected: the `vite-config` pack ID is preserved, no +established adapter remains, config/alias/glob edits invalidate the correct +package only, and no fixture executes JavaScript or a plugin. + +**Phase exit gate**: run the Phase 7 and shared Phase 3–7 commands. Expected: +all exit 0. + +## Phase 8: Make the evidence useful to agent workflows + +### Step 8.1: Version the task-context contract before adding a section + +`TaskContextSectionKind` is a closed enum inside strict +`compass.task-context/1`. Decide whether framework context is an opt-in typed +extension compatible with v1 or requires `compass.task-context/2`; test both +old-reader/new-writer and new-reader/old-writer behavior. Update +`TASK_CONTEXT_SCHEMA`, request/response validation, CLI JSON, MCP tool schema, +docs, changelog, and migration note atomically when the major changes. + +Do not silently add an enum value to v1 and assume old agents ignore it. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-core --test task_context --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend \ + cargo test -p compass-mcp --test code_query_tools --locked +``` + +Expected: version negotiation/strict rejection is deterministic and every +supported old/new pairing has an explicit test. + +### Step 8.2: Add deterministic framework task context + +Add a bounded `FrameworkContext` section in +`crates/compass-core/src/task_context.rs` and thread it through the owning CLI +and MCP contracts. For a focus symbol/file, report the smallest useful set: +framework identity, route and stages, runtime boundary, rendered-by/renders, +loaders/actions, config dependencies, provenance, ambiguity, and truncation. + +Update `crates/compass-cli/src/task_context_commands.rs`, +`crates/compass-mcp/src/lib.rs`, and the VS Code transport/query clients as +strict readers: unknown schema versions, roles, edge kinds, pack IDs, or +qualification states must fail closed with a typed diagnostic. The ignored +VS Code `dist/` output and viewer bundle are regenerated from source during +verification; they are not hand-edited or used as a second contract. + +Include pack ID/version, qualification state, graph/build identity, coverage +diagnostics, and explicit unsupported/incomplete capabilities. An agent must +be able to distinguish “no relation exists” from “the pack did not support or +finish extracting this form.” + +Use typed machine fields; do not force agents to parse prose. Enforce stable +ordering and explicit record/byte limits. + +**Verify**: snapshot/contract tests cover empty, normal, ambiguous, truncated, +and untrusted-label cases for CLI JSON and MCP responses. + +### Step 8.3: Integrate render relations into impact analysis + +Add inbound `Renders` to the appropriate default impact/traversal profiles so +a component change can find upstream renderers/routes. Preserve configurability +and document direction. Do not add a render edge to caller/callee results, +which remain call semantics. + +**Verify**: a fixture change to a leaf component reaches its direct renderer, +owning route, and relevant tests with a reasoned path; unrelated components do +not appear; limits and cycles terminate deterministically. + +### Step 8.4: Add query vocabulary and examples + +Expose stable query aliases/filters for component, hook, client/server, +renders/rendered-by, route stage, loader/action, and framework identity in +natural discovery and CompassQL where the existing architecture supports +them. Update support claims and TCK coverage for any grammar change. + +**Verify**: documented queries execute against checked-in fixtures and return +typed deterministic results. `check_compassql_support.py` passes when touched. + +### Step 8.5: Harden the agent trust and disclosure boundary + +Repository paths, labels, route text, config literals, comments, and source +snippets are untrusted data. Framework context must use typed fields, existing +label sanitization, verified source digests, and bounded strings; it must not +turn repository text into instructions, shell commands, Markdown/HTML control +content, or automatically executable actions. Avoid raw source/config values +unless the existing verified-source contract explicitly includes them. + +Update `SECURITY.md` and `docs/design/security-and-privacy.md` if the new +CLI/MCP surface changes disclosure, source-reading, or transport boundaries. +Keep local-only behavior and MCP response-size failure semantics. + +**Verify**: extend `crates/compass-core/tests/task_context.rs`, MCP contract +tests, CLI context tests, and viewer escaping tests with control characters, +markup, prompt-like text, oversized values, invalid UTF-8 paths where +supported, and truncation. Expected: output remains typed/escaped/bounded, +digests validate, and no semantic result is silently transport-truncated. + +### Step 8.6: Publish day-to-day workflow recipes + +Add guide/cookbook documentation for: + +- “What renders this component?” +- “What route owns this file?” +- “What crosses the Next client/server boundary?” +- “What loaders/actions and tests are affected?” +- “Which Vite aliases/plugins/globs explain this dependency?” +- “How should an agent treat unresolved, ambiguous, or truncated evidence?” +- “Which pack/version produced this answer, and was that capability + qualifying, incomplete, or unsupported?” + +Separate current behavior from future plans. Include machine-readable CLI/MCP +examples and expected schema versions. + +**Verify**: every command in docs runs against a checked-in fixture and is +covered by a doc/example test or a qualification script. + +Run the task-context, query impact/traversal/natural-intent, MCP +`code_query_tools`, CLI code-query/context, JS contract, and generated-viewer +commands. Expected: all documented examples match typed snapshots and no +callers/callees response contains `Renders` unless explicitly queried as a +relationship. + +**Phase exit gate**: run every Phase 8 command. Expected: all exit 0 and the +selected task-context compatibility behavior matches its documentation. + +## Phase 9: Qualify, document, and gate the production claim + +### Step 9.1: Build pinned representative corpora + +Select immutable revisions representing monorepos and standalone projects for +each stable framework family. Store only repository URL, commit, license, +scope, checksums, and reviewed expectations in Compass; keep checkouts under +`/Volumes/Workspace/Github`. Include generated code, aliases, re-exports, +monorepo boundaries, Windows-style cases, large graphs, and adversarial files. + +Create the checked-in manifest at +`tests/qualification/react-frontend-repositories.toml` and reviewed +expectations at +`tests/qualification/react-frontend-expectations.json`. Stratify samples by +framework, capability, declaration form, positive/negative outcome, +resolved/unresolved/ambiguous state, and relationship multiplicity. Count +route identity, target identity, direction, anchor, role, and provenance as +separate correctness dimensions so a partially correct edge does not count as +a true positive. + +Do not use a framework's own generated manifest as unquestioned truth. Review +sampled facts against source and independent semantics. + +**Verify**: manifest validation rejects mutable refs, checksum drift, +out-of-root paths, duplicate corpus IDs, missing licenses, and excess size. + +### Step 9.2: Add a dedicated qualification command + +Add the exact production gate `scripts/qualify_react_frontend_graph.sh` with: + +- `--fixtures-only` for offline CI; +- pinned-corpus mode for release qualification; +- established/candidate comparison during migrations; +- per-framework and per-capability precision, recall, Wilson bound, ambiguity, + anchor, multiplicity, determinism, cold/warm-cache, duration, and peak-memory + reporting; +- cold, unchanged-warm, semantic-edit, manifest/config-edit, restore, and + alternate-checkout performance rows compared with Phase 0 thresholds; +- one/default/max worker determinism and cancellation/interruption-resume + rows; a canceled run must leave no publishable partial artifact and a clean + rerun must match the uncanceled digest; +- a versioned machine-readable result artifact published outside source trees. + +The gate must evaluate the exact production registry/configuration. It must +fail on skipped capability classes, zero denominators, truncation disguised as +success, unexpected network/process use, or thresholds below “Quality budget.” +It must run with qualification corpora mounted read-only and fail if a +checkout, source fixture, cache, or generated artifact inside a source tree is +modified. All temporary output and result artifacts belong under the designated +external qualification volume. +Build one release-mode `compass-cli` binary in the mandated external target +directory and use that exact binary for all candidate observations; record its +path, commit, features, and digest so a debug/test-only path cannot qualify the +release claim. + +Reuse `scripts/code_graph_v1_oracle.py`, the existing universal-language audit +schema/runner, and `crates/compass-resolve/src/frameworks/qualification.rs` +where their truth boundaries fit. Do not fork a second canonical graph loader, +Wilson implementation, path validator, or production CLI invocation model. + +Make `./scripts/qualify_code_graph_v1.sh --fixtures-only` invoke or otherwise +cover the new frontend fixture assertions so the existing product gate cannot +pass while this surface fails. Add the same offline command to +`.github/workflows/compass-ci.yml` if it is not already reached by an existing +job. When Plan 005 exists, register the pinned exact-production gate there; +otherwise document the equivalent release-candidate invocation and keep the +Plan 021 index dependency explicit. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_react_frontend_graph.sh --fixtures-only +``` + +Expected: exit 0; every advertised stable framework/capability has a non-zero +sample, all thresholds pass, and two runs produce identical normalized output. + +### Step 9.3: Run release gates and update public claims + +Run every command in “Commands you will need,” plus the pinned qualification +when its corpora are available. Update the language/framework support matrix, +reference docs, cookbook, `CHANGELOG.md`, and `MIGRATION.md` if users must act. +Document unsupported dynamic cases and exact limits. + +Do not promote a framework/capability if another one passed aggregate metrics +on its behalf. TanStack Start remains separately labelled until its own gate +and maturity decision pass. + +Run `compass update .` with the exact release candidate after code and docs are +final, as required by the universal-evidence implementation guide. Ensure any +generated Compass artifacts remain outside tracked source and are not added to +the patch. + +**Verify**: all gates exit 0; `git diff --check` is clean; `git status --short` +contains only intended files; the published qualification artifact identifies +the exact commit, producer versions, fixture/corpus manifests, and production +configuration. + +**Phase exit gate**: run the complete repository baseline, every applicable +surface gate, the frontend fixture qualification twice, and the pinned-corpus +qualification when promotion is requested. Expected: all required checks exit +0, normalized fixture outputs are byte-identical, every advertised capability +passes independently, the production binary is identified exactly, and only +then may the support matrix or Plan 021 index row be promoted. + +## Cross-phase test plan + +Add tests at the lowest useful layer and public-contract tests for visible +behavior. At minimum, the completed program covers: + +- **Activation**: dependency plus syntax, aliases, nearest package/workspace + scope, mixed versions, `jsxImportSource`, false package strings, non-React + JSX runtimes, and multiple frameworks in one repository. +- **Identity**: imports, exports, barrels, aliases, namespaces, shadowing, + anonymous defaults, same-name declarations, symlinks, and monorepo roots. +- **Relations**: direction, occurrence multiplicity, exact range, context, + provenance, unresolved/ambiguous targets, deduplication, stable ordering. +- **React**: intrinsic/member tags, fragments, conditional/list JSX, + `memo`, `forwardRef`, `lazy`, `next/dynamic`, factories, component-valued + parameters, test owners, hooks, and root renders. +- **Next**: App/Pages conventions, groups, slots, intercepts, dynamic segments, + `default`, versioned middleware/proxy, layouts/templates/boundaries, HTTP + handlers, directives, route-segment runtime/deployment metadata, re-exports, + package scope, and static/incomplete config without conflating deployment + runtime and React component boundaries. +- **TanStack**: file/code trees, parent cycles, generated-tree policy, + components/loaders/boundaries, server routes/functions, version drift. +- **React Router/Remix**: legacy parity plus data/framework route forms, + package-scoped client/server entries, and `.client`/`.server` lookalike + negatives outside an activated framework project. +- **Vite**: static config forms, ordered aliases, plugins, literal globs, + negative patterns, path containment, limits, and non-execution. +- **Consumers**: JSON/GraphML/HTML, CompassQL, natural discovery, impact, + strict task-context versioning, MCP, CLI, history/fingerprints, semantic + diff, TypeScript Zod contracts, generated viewer assets, and cache reopen. +- **Operational**: cold/warm/repeated equivalence, bounded large files and + graphs, pack-version/manifest/config invalidation, package-local incremental + work, dependency-section/package-manager/compiler-option matrix, + cancellation/interruption cleanup, corrupt cache/input handling, equivalent + output across one/default/max worker counts, Linux/macOS/Windows path + behavior, latency/RSS thresholds, and untrusted agent-facing text. + +Use `crates/compass-resolve/tests/typescript_routes.rs` as the nearest existing +end-to-end framework pattern and the TypeScript universal evidence tests as the +nearest JSX occurrence pattern. Do not encode qualification truth by calling +the production extractor from expected-result generation. + +## Done criteria + +All must hold: + +- [ ] The support specification and any required ADR/compatibility decision + are merged before new public graph values are emitted. +- [ ] Exactly one production owner exists for `react-ui`, `nextjs-routes`, + `react-router-routes`, `remix-routes`, `tanstack-router`, `tanstack-start`, + and `vite-config`; no legacy fallback or dual publish path remains, while + unrelated `typescript-web` Angular/Nest/Vue behavior is unchanged. +- [ ] The shared frontend syntax view is parser-backed, bounded, incomplete- + aware, shared by every pack without per-pack reparsing, and has no + source-string/regex semantic fallback. +- [ ] Raw route stages have independent anchors and survive cache, resolution, + graph, history, query, and output round trips; route hierarchy has stable, + collision-free identities and validated route→route containment. +- [ ] Framework pack semantics versions participate in cache identity; source, + manifest, config, generated-tree, and pack-version edits invalidate exactly + their dependent package scope. +- [ ] React component/hook roles and occurrence-preserving `Renders` edges have + exact anchors, stable identity, provenance, ambiguity, and deterministic + ordering. +- [ ] Next App/Pages route hierarchy, stages, and supported client/server + boundaries pass positive, negative, ambiguity, and versioned fixtures. +- [ ] TanStack Router and separately labelled TanStack Start pass their own + capability thresholds before activation/promotion. +- [ ] Vite production extraction contains no text/regex semantic matching, + never executes configs/plugins, and bounds literal glob expansion. +- [ ] Framework context, impact, CLI, MCP, and query contracts expose the new + evidence without conflating render and call semantics; strict task-context + compatibility and untrusted-text tests pass. +- [ ] `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-021-react-frontend ./scripts/qualify_react_frontend_graph.sh --fixtures-only` passes twice + with byte-identical normalized results. +- [ ] The pinned audit satisfies every threshold in “Quality budget,” with + zero fabricated targets, unsafe path escapes, or nondeterministic outputs, + and the performance thresholds have an explicit pass/approved decision. +- [ ] `cargo fmt`, targeted tests, workspace clippy/tests, product boundary, + code-graph fixtures, CLI contract, and applicable CompassQL gates pass using + the required external `CARGO_TARGET_DIR`. +- [ ] Reference, guide/cookbook, support matrix, changelog, compatibility, and + migration docs accurately distinguish shipped, qualifying, unsupported, and + pre-stable behavior. +- [ ] `git diff --check` passes and no unrelated/user files are modified. +- [ ] Plan 021's row in `advisor-plans/README.md` is `DONE` only after all + phases and final production qualification complete. + +## STOP conditions + +Stop and report; do not improvise if: + +- `/Volumes/Workspace` is absent or not writable before any Cargo build or + external-corpus operation. +- Plan 013's TypeScript/JavaScript production hard cut is absent, has been + reverted, or a legacy fallback is again active. +- Adding roles, relations, or route stages is incompatible with a supported + reader and no contract migration/version decision has been approved. +- A framework fact requires executing project JavaScript/config, running a + framework CLI, downloading code, or making Node.js a normal-runtime + dependency. +- Exact framework semantics require source-text/regex matching, any + framework-pack-owned reparse, or parser node handles that outlive their + bounded extraction context; return to Phase 0 and revise the shared helper + contract over `UniversalDetectionContext.root` instead. +- Pack behavior can change without a deterministic cache-identity change, or a + package/config edit cannot invalidate dependent files without rebuilding + unrelated packages. +- Correct resolution would require selecting the first/nearest ambiguous + candidate, guessing from capitalization/file name/`use` prefix alone, or + dropping occurrence/provenance data. +- A route/glob/config operation cannot be bounded or contained within the + project root with existing safe primitives. +- Candidate and established outputs disagree without an independently reviewed + disposition, or two production packs would publish the same fact. +- Implementing React Router would require deleting or changing unrelated + Angular/Nest/Vue behavior in `typescript-web`. +- Route group/slot/intercept/operation identities collide, hierarchy requires + duplicating inherited stages, or the raw fact cannot preserve each stage's + own anchor. +- The qualification oracle shares the production extractor/resolver logic it + is meant to evaluate. +- A required change crosses into an out-of-scope framework or a viewer redesign + rather than a compatibility update for typed values. +- An applicable quality threshold fails twice after a focused diagnosis, or a + capability has too few independent records to make the advertised claim. +- A performance regression exceeds 20%, or exceeds 10% without an explicit + reviewed explanation recorded in `PERFORMANCE.md`. +- Existing unrelated work overlaps an in-scope file and cannot be preserved + cleanly. + +## Maintenance notes + +- Reviewers should scrutinize evidence boundaries more than framework-name + coverage. A small exact support surface is more valuable than broad false + confidence in agent workflows. +- Framework versions and file conventions evolve. Add a pinned fixture and + independent expectation before extending a supported declaration form. +- `Renders` is occurrence evidence, not React runtime cardinality. Keep that + distinction in docs, queries, and future execution-flow work. +- Client/server labels express statically evidenced framework boundaries, not + deployment topology or data-flow guarantees. +- Vite glob output describes statically selected module dependencies and + eager/lazy context; it must not become an excuse for unbounded filesystem + discovery. +- Future Vue/Svelte/Astro framework work should reuse the universal role and + relation substrate, but must have its own support contract and qualification + rather than inheriting React claims. +- Plan 017 may consume route/render evidence for ranked execution flows after + this plan, but this plan must not depend on speculative flow inference. diff --git a/advisor-plans/README.md b/advisor-plans/README.md index b1b5b510..24619298 100644 --- a/advisor-plans/README.md +++ b/advisor-plans/README.md @@ -60,6 +60,13 @@ promotion decision. The mounted qualification target records the pinned SwiftSyntax, Dart Analyzer, scala.meta, and Groovy CompilationUnit toolchains and the immutable audit results. +Plan 021 hardens the React frontend framework graph after Plan 013's +TypeScript/JavaScript production hard cut. It adds occurrence-preserving render +evidence, conservative component and runtime-boundary roles, deep Next.js and +TanStack route semantics, parsed Vite configuration, and agent-facing context +and impact workflows. Each framework capability remains qualifying until its +independent precision, recall, determinism, and safety gates pass. + Plan 022 extends the native document program with selective, local OCR for scanned PDF pages and images embedded in DOCX, PPTX, and XLSX. Native package and PDF text remains authoritative. OCR is off by default, model/profile and @@ -91,6 +98,7 @@ Compass-owned corpus before support or quality claims ship. | 018 | Expose five native MCP workflow prompts | P2 | M | — | TODO | | 019 | Hard-cut Ruby to a qualifying universal evidence pipeline | P1 | XL | —; final gate should consume 005 or equivalent | IN PROGRESS | | 020 | Hard-cut Swift, Dart, Scala, and Groovy to universal evidence | P1 | XXL | —; final gate should consume 005 or equivalent | DONE | +| 021 | Make React frontend framework graphs enterprise-ready | P1 | XXL | 013 production hard cut; final gate should consume 005 or equivalent | TODO | | 022 | Add bounded, quality-gated OCR to document processing | P1 | XL | 006, 007, 008, 010 | IN PROGRESS | Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`. @@ -138,6 +146,11 @@ Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`. reuse the exact-language JVM boundary established for Scala. Each language has a separate candidate and atomic hard-cut phase, and the mixed-language release gate runs only after all four cuts. +- Plan 021 builds on Plan 013's hard-cut TypeScript/JavaScript evidence rather + than creating another parser path. It lands the public graph vocabulary and + universal framework substrate first, then qualifies React, Next.js, + TanStack, React Router/Remix, and Vite independently. Its final claim should + consume Plan 005's exact-production-evidence model or an equivalent gate. - Plan 022 starts only after the document safety, artifact, slicing/fusion, and OOXML plans. It adds a separate OCR qualification gate rather than weakening Plan 012's credential-free native document gate. Plan 012 and Plan 022 may diff --git a/crates/compass-cli/src/task_context_commands.rs b/crates/compass-cli/src/task_context_commands.rs index ef566cbe..3d6e6a0f 100644 --- a/crates/compass-cli/src/task_context_commands.rs +++ b/crates/compass-cli/src/task_context_commands.rs @@ -231,6 +231,21 @@ fn render_text(context: &TaskContext) -> String { if agent.truncated { " (truncated)" } else { "" } ); } + if let Some(framework) = &context.framework { + let _ = writeln!( + output, + "Framework context: {} packs, {} routes, {} renders, {} config dependencies{}", + framework.packs.len(), + framework.routes.len(), + framework.rendered_by.len() + framework.renders.len(), + framework.config_dependencies.len(), + if framework.truncated { + " (truncated)" + } else { + "" + } + ); + } for section in &context.sections { let _ = writeln!( output, diff --git a/crates/compass-core/src/document_processing.rs b/crates/compass-core/src/document_processing.rs index 2c1c5d24..9acc684a 100644 --- a/crates/compass-core/src/document_processing.rs +++ b/crates/compass-core/src/document_processing.rs @@ -42,6 +42,7 @@ pub struct PreparedDocument { pub artifact: Arc, pub semantic_text: Arc, pub cache_identity: String, + pub ocr_mode: OcrMode, } #[derive(Clone, Debug)] @@ -152,6 +153,7 @@ pub fn prepare_document_set( artifact: Arc::new(artifact), semantic_text: Arc::::from(semantic_text), cache_identity: cache_identity.clone(), + ocr_mode: options.ocr_mode, }, ); } @@ -252,6 +254,7 @@ pub(crate) fn project_document( path: &Path, artifact: &DocumentArtifact, cache_identity: &str, + ocr_mode: OcrMode, ) -> Result { artifact.validate().map_err(|error| error.to_string())?; let file_id = compass_languages::make_id(&[source_file]); @@ -294,6 +297,10 @@ pub(crate) fn project_document( "document_visual_coverage".to_owned(), serde_json::to_value(artifact.visual_coverage).map_err(|error| error.to_string())?, ); + root.insert( + "document_ocr_mode".to_owned(), + serde_json::to_value(ocr_mode).map_err(|error| error.to_string())?, + ); if !artifact.metadata.is_empty() { root.insert( "document_metadata".to_owned(), @@ -486,6 +493,7 @@ mod tests { Path::new("report.docx"), &artifact, "fixture-identity", + OcrMode::Off, )?; assert_eq!(extraction.nodes.len(), 2); assert_eq!(extraction.edges.len(), 1); @@ -496,6 +504,10 @@ mod tests { .and_then(serde_json::Value::as_str), Some("Projected sentinel") ); + assert_eq!( + extraction.nodes[0].attributes.get("document_ocr_mode"), + Some(&serde_json::json!("off")) + ); assert_eq!( extraction.extensions.get("document_cache_identity"), Some(&serde_json::json!("fixture-identity")) diff --git a/crates/compass-core/src/lib.rs b/crates/compass-core/src/lib.rs index 70bc7a6f..1a787228 100644 --- a/crates/compass-core/src/lib.rs +++ b/crates/compass-core/src/lib.rs @@ -51,10 +51,13 @@ pub use review::{ }; pub use task_context::{ AgentKnowledgeAssertion, AgentKnowledgeChallenge, AgentKnowledgeSection, - TASK_CONTEXT_PROFILE_SCHEMA, TASK_CONTEXT_SCHEMA, TaskContext, TaskContextError, - TaskContextIntent, TaskContextKnowledge, TaskContextLimits, TaskContextOmission, - TaskContextRequest, TaskContextSection, TaskContextSectionKind, TaskContextTarget, - TaskContextWork, attach_agent_knowledge, build_task_context, + FRAMEWORK_CONTEXT_SCHEMA, FrameworkAmbiguity, FrameworkBoundaryContext, + FrameworkCapabilityStatus, FrameworkContext, FrameworkPackContext, FrameworkQualificationState, + FrameworkRelationContext, FrameworkRouteContext, FrameworkStageContext, + TASK_CONTEXT_PROFILE_SCHEMA, TASK_CONTEXT_SCHEMA, TASK_CONTEXT_SCHEMA_V1, TaskContext, + TaskContextError, TaskContextIntent, TaskContextKnowledge, TaskContextLimits, + TaskContextOmission, TaskContextRequest, TaskContextSection, TaskContextSectionKind, + TaskContextTarget, TaskContextWork, attach_agent_knowledge, build_task_context, }; pub use watch::{ WatchBackend, WatchBuildReason, WatchError, WatchOptions, WatchStatus, watch_local_graph, diff --git a/crates/compass-core/src/pipeline.rs b/crates/compass-core/src/pipeline.rs index 4c2d4caa..8a0557f1 100644 --- a/crates/compass-core/src/pipeline.rs +++ b/crates/compass-core/src/pipeline.rs @@ -286,6 +286,23 @@ fn compact_extraction(extraction: &mut Extraction) { compact_json_map(&mut annotation.arguments); compact_json_map(&mut annotation.detail); } + compass_languages::RawFrameworkFact::Role(role) => { + compact_json_map(&mut role.detail); + } + compass_languages::RawFrameworkFact::Relation(relation) => { + compact_json_map(&mut relation.detail); + } + compass_languages::RawFrameworkFact::Configuration(configuration) => { + if let Some(value) = configuration.value.as_mut() { + compact_json_value(value); + } + compact_json_map(&mut configuration.detail); + } + compass_languages::RawFrameworkFact::FileSet(file_set) => { + compact_vec(&mut file_set.patterns); + compact_vec(&mut file_set.negative_patterns); + compact_json_map(&mut file_set.detail); + } } } if let Some(evidence) = extraction.semantic_evidence.as_mut() { @@ -462,11 +479,15 @@ fn build_profile_digest(profile: &BuildProfile, output_dir: &Path) -> Result &mut route.anchor.source_file, RawFrameworkFact::Domain(domain) => &mut domain.anchor.source_file, RawFrameworkFact::Annotation(annotation) => &mut annotation.anchor.source_file, + RawFrameworkFact::Role(role) => &mut role.anchor.source_file, + RawFrameworkFact::Relation(relation) => &mut relation.anchor.source_file, + RawFrameworkFact::Configuration(configuration) => &mut configuration.anchor.source_file, + RawFrameworkFact::FileSet(file_set) => &mut file_set.anchor.source_file, }; *source_file = normalize_portable_origin_path( source_file, @@ -6783,7 +6809,18 @@ fn prepare_extraction_for_publication( partials.insert(identity, portable_diagnostic_reason(reason, path, root)); extraction.edges.clear(); extraction.hyperedges.clear(); - extraction.framework_facts.clear(); + // Parser recovery invalidates ordinary AST edges, but a filesystem route + // declaration remains a bounded convention fact. Preserve only those + // convention-owned route facts so Next/Remix/React Router inventories and + // hierarchy stay useful; target resolution remains fail-closed when the + // recovered syntax does not expose a unique declaration. + extraction.framework_facts.retain(|fact| { + matches!( + fact, + RawFrameworkFact::Route(route) + if route.origin == compass_languages::RawFrameworkOrigin::Convention + ) + }); extraction.raw_calls = None; } @@ -6796,6 +6833,10 @@ fn make_framework_fact_sources_portable(extraction: &mut Extraction, root: &Path RawFrameworkFact::Route(route) => &mut route.anchor.source_file, RawFrameworkFact::Domain(domain) => &mut domain.anchor.source_file, RawFrameworkFact::Annotation(annotation) => &mut annotation.anchor.source_file, + RawFrameworkFact::Role(role) => &mut role.anchor.source_file, + RawFrameworkFact::Relation(relation) => &mut relation.anchor.source_file, + RawFrameworkFact::Configuration(configuration) => &mut configuration.anchor.source_file, + RawFrameworkFact::FileSet(file_set) => &mut file_set.anchor.source_file, }; let path = Path::new(source_file); if !path.is_absolute() { @@ -8320,6 +8361,10 @@ mod tests { RawFrameworkFact::Route(route) => &route.anchor.source_file, RawFrameworkFact::Domain(domain) => &domain.anchor.source_file, RawFrameworkFact::Annotation(annotation) => &annotation.anchor.source_file, + RawFrameworkFact::Role(role) => &role.anchor.source_file, + RawFrameworkFact::Relation(relation) => &relation.anchor.source_file, + RawFrameworkFact::Configuration(configuration) => &configuration.anchor.source_file, + RawFrameworkFact::FileSet(file_set) => &file_set.anchor.source_file, }; framework_source == expected })); diff --git a/crates/compass-core/src/task_context.rs b/crates/compass-core/src/task_context.rs index 6db66912..ba7d770b 100644 --- a/crates/compass-core/src/task_context.rs +++ b/crates/compass-core/src/task_context.rs @@ -1,12 +1,16 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use compass_agent_graph::{ ChallengeId, CompositionOmissions, EffectiveGraph, GroundingEvidence, OverlayState, }; -use compass_model::code_graph::EdgeKind; +use compass_model::code_graph::{ + EdgeDetails, EdgeKind, NodeDetails, NodeKind, NodeRole, RouteStage, +}; +use compass_model::provenance::{ResolutionState, SourceAnchor}; use compass_model::query_contract::{ CallRequest, CodeQueryLimits, CodeQueryOperation, CodeQueryResponse, ExploreRequest, - ImpactRequest, QueryDiagnosticCode, QueryEdge, QueryNode, SearchHit, SearchRequest, + ImpactRequest, QueryDiagnosticCode, QueryEdge, QueryEvidence, QueryNode, SearchHit, + SearchRequest, }; use compass_query::CodeQueryEngine; use compass_reflect::MemoryDoc; @@ -14,8 +18,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; -pub const TASK_CONTEXT_SCHEMA: &str = "compass.task-context/1"; +pub const TASK_CONTEXT_SCHEMA: &str = "compass.task-context/2"; +pub const TASK_CONTEXT_SCHEMA_V1: &str = "compass.task-context/1"; pub const TASK_CONTEXT_PROFILE_SCHEMA: &str = "compass.task-context-profile/1"; +pub const FRAMEWORK_CONTEXT_SCHEMA: &str = "compass.framework-context/1"; const MAX_TASK_TARGET_BYTES: usize = 16 * 1024; const MAX_REPOSITORY_ROOT_BYTES: usize = 32 * 1024; const MAX_KNOWLEDGE_ITEMS: u32 = 100; @@ -23,6 +29,9 @@ const MAX_MEMORY_DOCS: usize = 10_000; const MAX_MEMORY_SOURCE_NODES: usize = 100_000; const MAX_MEMORY_INPUT_BYTES: usize = 16 * 1024 * 1024; const MAX_TASK_CONTEXT_BYTES: usize = 64 * 1024 * 1024; +const MAX_FRAMEWORK_CONTEXT_RECORDS: usize = 256; +const MAX_FRAMEWORK_CONTEXT_BYTES: usize = 256 * 1024; +const MAX_FRAMEWORK_TEXT_BYTES: usize = 16 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -88,6 +97,7 @@ pub enum TaskContextSectionKind { ImplementationType, RelatedTests, TransitiveImpact, + Framework, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -97,6 +107,220 @@ pub struct TaskContextSection { pub evidence: CodeQueryResponse, } +/// Qualification state is deliberately closed. Consumers must reject a +/// future state rather than treating it as a successful framework claim. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FrameworkQualificationState { + Qualified, + Qualifying, + Incomplete, + Unsupported, + Ambiguous, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkPackContext { + pub id: String, + pub version: u32, + pub qualification: FrameworkQualificationState, + pub capabilities: Vec, + pub observed_nodes: u32, + pub observed_relations: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkStageContext { + pub stage: RouteStage, + pub position: u32, + pub reference: String, + pub resolution: ResolutionState, + pub source: Option, + pub target: Option, + pub provenance: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkRouteContext { + pub node_id: String, + pub framework: String, + pub operation: String, + pub path: String, + pub declaring_scope: String, + pub resolution: ResolutionState, + pub stages: Vec, + pub provenance: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkRelationContext { + pub id: String, + pub relation: EdgeKind, + pub source: String, + pub target: String, + pub details: Option, + pub relationship_site: Option, + pub provenance: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkBoundaryContext { + pub node_id: String, + pub framework: String, + pub roles: Vec, + pub source: Option, + pub provenance: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkCapabilityStatus { + pub framework: String, + pub capability: String, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkAmbiguity { + pub kind: String, + pub reference: String, + pub candidates: Vec, +} + +/// Typed framework evidence attached to one task-context response. The +/// section contains only graph evidence already admitted by the query engine; +/// it never reparses source or executes a framework configuration. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FrameworkContext { + pub schema: String, + pub graph_identity: String, + pub build_generation_identity: String, + pub focus_node_id: Option, + pub packs: Vec, + pub routes: Vec, + pub relations: Vec, + pub rendered_by: Vec, + pub renders: Vec, + pub config_dependencies: Vec, + pub runtime_boundaries: Vec, + pub unsupported: Vec, + pub incomplete: Vec, + pub ambiguities: Vec, + pub truncated: bool, + pub record_limit: u32, + pub byte_limit: u64, +} + +impl FrameworkContext { + fn trim_one(&mut self) -> bool { + self.relations.pop().is_some() + || self.rendered_by.pop().is_some() + || self.renders.pop().is_some() + || self.config_dependencies.pop().is_some() + || self.routes.pop().is_some() + || self.runtime_boundaries.pop().is_some() + || self.ambiguities.pop().is_some() + || self.incomplete.pop().is_some() + || self.unsupported.pop().is_some() + || self.packs.pop().is_some() + } + + fn validate(&self) -> Result<(), TaskContextError> { + if self.schema != FRAMEWORK_CONTEXT_SCHEMA { + return Err(TaskContextError::UnsupportedSchema(self.schema.clone())); + } + if self.graph_identity.is_empty() || self.build_generation_identity.is_empty() { + return Err(TaskContextError::InvalidResult( + "framework context is missing graph or build identity".to_owned(), + )); + } + if let Some(focus) = &self.focus_node_id { + validate_framework_strings(std::slice::from_ref(focus))?; + } + if self.record_limit == 0 + || usize::try_from(self.record_limit).unwrap_or(usize::MAX) + > MAX_FRAMEWORK_CONTEXT_RECORDS + || self.byte_limit == 0 + || usize::try_from(self.byte_limit).unwrap_or(usize::MAX) > MAX_FRAMEWORK_CONTEXT_BYTES + { + return Err(TaskContextError::InvalidResult( + "framework context limits are outside the supported bounds".to_owned(), + )); + } + for pack in &self.packs { + let Some(expected) = compass_languages::framework_pack_semantics_version(&pack.id) + else { + return Err(TaskContextError::InvalidResult(format!( + "unknown framework pack ID {:?}", + pack.id + ))); + }; + if pack.version != expected || pack.id.trim().is_empty() { + return Err(TaskContextError::InvalidResult(format!( + "invalid version for framework pack {:?}", + pack.id + ))); + } + validate_framework_strings(&pack.capabilities)?; + } + validate_framework_strings( + &self + .routes + .iter() + .flat_map(|route| { + [ + route.node_id.clone(), + route.framework.clone(), + route.operation.clone(), + route.path.clone(), + route.declaring_scope.clone(), + ] + }) + .collect::>(), + )?; + for route in &self.routes { + validate_framework_strings( + &route + .stages + .iter() + .flat_map(|stage| { + [ + stage.reference.clone(), + stage.target.clone().unwrap_or_default(), + ] + }) + .collect::>(), + )?; + } + for status in self.unsupported.iter().chain(self.incomplete.iter()) { + validate_framework_strings(&[ + status.framework.clone(), + status.capability.clone(), + status.reason.clone(), + ])?; + } + for ambiguity in &self.ambiguities { + validate_framework_strings(&[ambiguity.kind.clone(), ambiguity.reference.clone()])?; + validate_framework_strings(&ambiguity.candidates)?; + } + if framework_record_count(self) > usize::try_from(self.record_limit).unwrap_or(usize::MAX) + || canonical_bytes(self)?.len() > usize::try_from(self.byte_limit).unwrap_or(usize::MAX) + { + return Err(TaskContextError::InvalidResult( + "framework context exceeds its declared record or byte limit".to_owned(), + )); + } + Ok(()) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct TaskContextKnowledge { @@ -131,6 +355,8 @@ pub struct TaskContextWork { pub agent_knowledge_records: u64, #[serde(default)] pub agent_knowledge_bytes: u64, + pub framework_records: u64, + pub framework_bytes: u64, pub response_bytes: u64, } @@ -188,6 +414,8 @@ pub struct TaskContext { pub sections: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_knowledge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub framework: Option, pub project_knowledge: Vec, pub omissions: Vec, pub truncated: bool, @@ -216,6 +444,15 @@ impl TaskContext { self.work.schema.clone(), )); } + if let Some(framework) = &self.framework { + framework.validate()?; + let encoded = canonical_bytes(framework)?; + if encoded.len() > MAX_FRAMEWORK_CONTEXT_BYTES { + return Err(TaskContextError::InvalidResult( + "framework context exceeds its byte bound".to_owned(), + )); + } + } let expected_digest = task_context_digest(self)?; if self.result_digest != expected_digest { return Err(TaskContextError::InvalidResult(format!( @@ -432,6 +669,12 @@ pub fn build_task_context( .then_with(|| left.reason.cmp(&right.reason)) }); omissions.dedup(); + let framework = build_framework_context( + &target, + §ions, + engine.graph_identity(), + engine.build_generation_identity(), + )?; let mut context = TaskContext { schema: TASK_CONTEXT_SCHEMA.to_owned(), intent: request.intent, @@ -441,6 +684,7 @@ pub fn build_task_context( build_generation_identity: engine.build_generation_identity().to_owned(), sections, agent_knowledge: None, + framework: Some(framework), project_knowledge: knowledge, omissions, truncated: preliminary_truncated @@ -691,6 +935,436 @@ fn validate_memory(memory: &[MemoryDoc]) -> Result<(), TaskContextError> { Ok(()) } +fn validate_framework_strings(values: &[String]) -> Result<(), TaskContextError> { + if values.iter().any(|value| { + value.is_empty() + || value.len() > MAX_FRAMEWORK_TEXT_BYTES + || value.chars().any(char::is_control) + }) { + return Err(TaskContextError::InvalidResult( + "framework context contains an empty, oversized, or control-bearing value".to_owned(), + )); + } + Ok(()) +} + +fn framework_pack_id(framework: &str) -> Option<&'static str> { + let normalized = framework.trim().to_ascii_lowercase(); + let mapped = match normalized.as_str() { + "react" | "react-dom" => "react-ui", + "next" | "nextjs" => "nextjs-routes", + "react-router" | "react-router-dom" => "react-router-routes", + "remix" => "remix-routes", + "tanstack" | "tanstack-router" => "tanstack-router", + "tanstack-start" => "tanstack-start", + "vite" => "vite-config", + _ => return None, + }; + Some(mapped) +} + +fn framework_capabilities(pack_id: &str) -> Vec { + let values = match pack_id { + "react-ui" => ["ui", "renders", "hooks", "client_server_boundary"].as_slice(), + "nextjs-routes" => ["routes", "stages", "client_server_boundary", "config"].as_slice(), + "react-router-routes" => ["routes", "loaders", "actions", "stages"].as_slice(), + "remix-routes" => ["routes", "loaders", "actions", "stages"].as_slice(), + "tanstack-router" => ["routes", "loaders", "components", "stages"].as_slice(), + "tanstack-start" => ["routes", "loaders", "actions", "server_functions"].as_slice(), + "vite-config" => ["aliases", "plugins", "file_sets", "config"].as_slice(), + _ => ["framework_evidence"].as_slice(), + }; + values.iter().map(|value| (*value).to_owned()).collect() +} + +fn framework_node_id(node: &QueryNode) -> Option<&str> { + node.framework + .as_deref() + .filter(|value| !value.trim().is_empty()) +} + +fn is_framework_relation(kind: EdgeKind) -> bool { + matches!( + kind, + EdgeKind::Renders + | EdgeKind::RoutesTo + | EdgeKind::Handles + | EdgeKind::Registers + | EdgeKind::Produces + | EdgeKind::Consumes + | EdgeKind::Publishes + | EdgeKind::Subscribes + | EdgeKind::DependsOn + | EdgeKind::MapsTo + | EdgeKind::Decorates + ) +} + +fn is_config_relation(kind: EdgeKind) -> bool { + matches!( + kind, + EdgeKind::Imports | EdgeKind::DependsOn | EdgeKind::Aliases + ) +} + +fn evidence_sort_key(evidence: &QueryEvidence) -> String { + let anchor = evidence.anchor.as_ref().map_or_else(String::new, |anchor| { + format!( + "{}:{:020}:{:020}", + anchor.file, anchor.start_byte, anchor.end_byte + ) + }); + format!( + "{}|{:?}|{:?}|{:?}|{}|{}", + evidence.extractor, + evidence.origin, + evidence.confidence, + evidence.resolution, + evidence.rule.as_deref().unwrap_or_default(), + anchor + ) +} + +fn sorted_provenance(mut values: Vec) -> Vec { + values.sort_by_key(evidence_sort_key); + values.dedup_by(|left, right| evidence_sort_key(left) == evidence_sort_key(right)); + values +} + +fn build_framework_context( + target: &TaskContextTarget, + sections: &[TaskContextSection], + graph_identity: &str, + build_generation_identity: &str, +) -> Result { + let mut nodes = BTreeMap::::new(); + let mut edges = BTreeMap::::new(); + let mut truncated = false; + for section in sections { + truncated |= section.evidence.truncated; + for node in §ion.evidence.nodes { + nodes.entry(node.id.clone()).or_insert_with(|| node.clone()); + } + for edge in §ion.evidence.edges { + edges.entry(edge.id.clone()).or_insert_with(|| edge.clone()); + } + } + + let focus_node_id = match target { + TaskContextTarget::Exact { node_id } => Some(node_id.clone()), + TaskContextTarget::Ambiguous { .. } | TaskContextTarget::NotFound { .. } => None, + }; + let focus_id = focus_node_id.as_deref(); + + let mut framework_names = BTreeSet::new(); + for node in nodes.values() { + if let Some(framework) = framework_node_id(node) { + framework_names.insert(framework.to_owned()); + } + } + if edges.values().any(|edge| edge.kind == EdgeKind::Renders) { + framework_names.insert("react".to_owned()); + } + + let mut packs = Vec::new(); + let mut unsupported = Vec::new(); + let mut incomplete = Vec::new(); + for framework in framework_names { + let Some(pack_id) = framework_pack_id(&framework).or_else(|| { + compass_languages::framework_pack_semantics_version(&framework) + .map(|_| framework.as_str()) + }) else { + unsupported.push(FrameworkCapabilityStatus { + framework, + capability: "framework_pack".to_owned(), + reason: "the graph names a framework without a registered pack".to_owned(), + }); + continue; + }; + let Some(version) = compass_languages::framework_pack_semantics_version(pack_id) else { + unsupported.push(FrameworkCapabilityStatus { + framework, + capability: "framework_pack".to_owned(), + reason: "the graph names a pack whose semantics version is unavailable".to_owned(), + }); + continue; + }; + let observed_nodes = nodes + .values() + .filter(|node| { + framework_node_id(node) + .is_some_and(|value| framework_pack_id(value).unwrap_or(value) == pack_id) + }) + .count(); + let observed_relations = edges + .values() + .filter(|edge| is_framework_relation(edge.kind)) + .filter(|edge| { + nodes.get(&edge.source).is_some_and(|node| { + framework_node_id(node) + .is_some_and(|value| framework_pack_id(value).unwrap_or(value) == pack_id) + }) || nodes.get(&edge.target).is_some_and(|node| { + framework_node_id(node) + .is_some_and(|value| framework_pack_id(value).unwrap_or(value) == pack_id) + }) + }) + .count(); + let has_ambiguity = nodes.values().any(|node| { + node.framework + .as_deref() + .is_some_and(|value| framework_pack_id(value).unwrap_or(value) == pack_id) + && node.evidence.iter().any(|evidence| { + matches!( + evidence.confidence, + compass_model::provenance::EvidenceConfidence::Ambiguous + ) || matches!(evidence.resolution, ResolutionState::Ambiguous) + }) + }) || edges.values().any(|edge| { + is_framework_relation(edge.kind) + && edge.evidence.iter().any(|evidence| { + matches!( + evidence.confidence, + compass_model::provenance::EvidenceConfidence::Ambiguous + ) || matches!(evidence.resolution, ResolutionState::Ambiguous) + }) + }); + let qualification = if has_ambiguity { + FrameworkQualificationState::Ambiguous + } else if observed_nodes == 0 && observed_relations == 0 { + FrameworkQualificationState::Unsupported + } else if truncated { + FrameworkQualificationState::Incomplete + } else { + // A checked-in task context is evidence from the current graph; + // promotion to `qualified` remains the independent corpus gate's + // responsibility. + FrameworkQualificationState::Qualifying + }; + packs.push(FrameworkPackContext { + id: pack_id.to_owned(), + version, + qualification, + capabilities: framework_capabilities(pack_id), + observed_nodes: u32::try_from(observed_nodes).unwrap_or(u32::MAX), + observed_relations: u32::try_from(observed_relations).unwrap_or(u32::MAX), + }); + } + + let mut routes = Vec::new(); + let mut runtime_boundaries = Vec::new(); + for node in nodes.values() { + if node.kind == NodeKind::Route + && focus_id.is_none_or(|focus| { + node.id == focus + || edges.values().any(|edge| { + is_framework_relation(edge.kind) + && (edge.source == focus && edge.target == node.id + || edge.target == focus && edge.source == node.id) + }) + }) + && let Some(NodeDetails::Route(details)) = node.details.as_ref() + { + routes.push(FrameworkRouteContext { + node_id: node.id.clone(), + framework: node + .framework + .clone() + .unwrap_or_else(|| "unknown".to_owned()), + operation: details.operation.clone(), + path: details.path.clone(), + declaring_scope: details.declaring_scope.clone(), + resolution: details.resolution, + stages: details + .stages + .iter() + .map(|stage| FrameworkStageContext { + stage: stage.stage, + position: stage.position, + reference: stage.reference.clone(), + resolution: stage.resolution, + source: stage.source_anchor.clone(), + target: stage.target.clone(), + provenance: sorted_provenance(node.evidence.clone()), + }) + .collect(), + provenance: sorted_provenance(node.evidence.clone()), + }); + } + let boundary_roles = node + .roles + .iter() + .copied() + .filter(|role| { + matches!( + role, + NodeRole::ClientBoundary + | NodeRole::ClientComponent + | NodeRole::ServerComponent + | NodeRole::ServerFunction + ) + }) + .collect::>(); + if !boundary_roles.is_empty() + && focus_id.is_none_or(|focus| { + node.id == focus + || edges.values().any(|edge| { + (edge.source == focus && edge.target == node.id) + || (edge.target == focus && edge.source == node.id) + }) + }) + { + runtime_boundaries.push(FrameworkBoundaryContext { + node_id: node.id.clone(), + framework: node + .framework + .clone() + .unwrap_or_else(|| "unknown".to_owned()), + roles: boundary_roles, + source: node.source.clone(), + provenance: sorted_provenance(node.evidence.clone()), + }); + } + } + + let mut relations = Vec::new(); + let mut rendered_by = Vec::new(); + let mut renders = Vec::new(); + let mut config_dependencies = Vec::new(); + let mut ambiguities = Vec::new(); + for edge in edges + .values() + .filter(|edge| is_framework_relation(edge.kind) || is_config_relation(edge.kind)) + { + let touches_focus = + focus_id.is_none_or(|focus| edge.source == focus || edge.target == focus); + if !touches_focus { + continue; + } + let provenance = sorted_provenance(edge.evidence.clone()); + let relation = FrameworkRelationContext { + id: edge.id.clone(), + relation: edge.kind, + source: edge.source.clone(), + target: edge.target.clone(), + details: edge.details.clone(), + relationship_site: edge.relationship_site.clone(), + provenance: provenance.clone(), + }; + if edge.kind == EdgeKind::Renders { + if focus_id == Some(edge.target.as_str()) { + rendered_by.push(relation.clone()); + } + if focus_id == Some(edge.source.as_str()) { + renders.push(relation.clone()); + } + } + if is_config_relation(edge.kind) { + config_dependencies.push(relation.clone()); + } + if is_framework_relation(edge.kind) { + relations.push(relation); + } + for evidence in &provenance { + if !matches!(evidence.resolution, ResolutionState::Exact) + || !evidence.candidates.is_empty() + { + ambiguities.push(FrameworkAmbiguity { + kind: edge.kind.as_str().to_owned(), + reference: edge.id.clone(), + candidates: evidence + .candidates + .iter() + .map(|candidate| candidate.node_id.clone()) + .collect(), + }); + } + } + } + + if let TaskContextTarget::Ambiguous { candidates } + | TaskContextTarget::NotFound { candidates } = target + { + ambiguities.push(FrameworkAmbiguity { + kind: "target".to_owned(), + reference: "requested_target".to_owned(), + candidates: candidates + .iter() + .map(|candidate| candidate.node_id.clone()) + .collect(), + }); + } + for section in sections { + if section.evidence.diagnostics.iter().any(|diagnostic| { + diagnostic.code == QueryDiagnosticCode::IncompleteCoverage + || diagnostic.code == QueryDiagnosticCode::BoundedTruncation + }) { + incomplete.push(FrameworkCapabilityStatus { + framework: "graph".to_owned(), + capability: "coverage".to_owned(), + reason: "the bounded query response reported incomplete coverage".to_owned(), + }); + } + } + + routes.sort_by(|left, right| left.node_id.cmp(&right.node_id)); + runtime_boundaries.sort_by(|left, right| left.node_id.cmp(&right.node_id)); + relations.sort_by(|left, right| left.id.cmp(&right.id)); + rendered_by.sort_by(|left, right| left.id.cmp(&right.id)); + renders.sort_by(|left, right| left.id.cmp(&right.id)); + config_dependencies.sort_by(|left, right| left.id.cmp(&right.id)); + ambiguities.sort_by(|left, right| { + left.kind + .cmp(&right.kind) + .then_with(|| left.reference.cmp(&right.reference)) + }); + ambiguities.dedup(); + + let mut context = FrameworkContext { + schema: FRAMEWORK_CONTEXT_SCHEMA.to_owned(), + graph_identity: graph_identity.to_owned(), + build_generation_identity: build_generation_identity.to_owned(), + focus_node_id, + packs, + routes, + relations, + rendered_by, + renders, + config_dependencies, + runtime_boundaries, + unsupported, + incomplete, + ambiguities, + truncated, + record_limit: MAX_FRAMEWORK_CONTEXT_RECORDS as u32, + byte_limit: MAX_FRAMEWORK_CONTEXT_BYTES as u64, + }; + let record_count = framework_record_count(&context); + if record_count > MAX_FRAMEWORK_CONTEXT_RECORDS { + context.truncated = true; + while framework_record_count(&context) > MAX_FRAMEWORK_CONTEXT_RECORDS && context.trim_one() + { + } + } + while canonical_bytes(&context)?.len() > MAX_FRAMEWORK_CONTEXT_BYTES && context.trim_one() { + context.truncated = true; + } + context.validate()?; + Ok(context) +} + +fn framework_record_count(context: &FrameworkContext) -> usize { + context.packs.len() + + context.routes.len() + + context.relations.len() + + context.rendered_by.len() + + context.renders.len() + + context.config_dependencies.len() + + context.runtime_boundaries.len() + + context.unsupported.len() + + context.incomplete.len() + + context.ambiguities.len() +} + fn resolve_target(requested: &str, search: &CodeQueryResponse) -> TaskContextTarget { let exact_ids = search .nodes @@ -929,6 +1603,17 @@ fn update_work(context: &mut TaskContext) { .and_then(|agent| canonical_bytes(agent).ok()) .map(|bytes| u64::try_from(bytes.len()).unwrap_or(u64::MAX)) .unwrap_or(0); + context.work.framework_records = context + .framework + .as_ref() + .map(|framework| u64::try_from(framework_record_count(framework)).unwrap_or(u64::MAX)) + .unwrap_or(0); + context.work.framework_bytes = context + .framework + .as_ref() + .and_then(|framework| canonical_bytes(framework).ok()) + .map(|bytes| u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .unwrap_or(0); } fn update_response_bytes(context: &mut TaskContext) -> Result<(), TaskContextError> { @@ -955,6 +1640,15 @@ fn enforce_response_bound(context: &mut TaskContext, limit: u64) -> Result<(), T "omitted lower-priority Agent Graph evidence after reaching maxResponseBytes", )); update_work(context); + } else if let Some(framework) = context.framework.as_mut() + && framework.trim_one() + { + context.truncated = true; + context.omissions.push(omission( + "framework_context_budget", + "omitted lower-priority framework evidence after reaching maxResponseBytes", + )); + update_work(context); } else if let Some(section) = context.sections.pop() { context.truncated = true; context.omissions.push(omission( diff --git a/crates/compass-core/tests/code_graph_v1_publication_resilience.rs b/crates/compass-core/tests/code_graph_v1_publication_resilience.rs index a470ecf9..aafbc760 100644 --- a/crates/compass-core/tests/code_graph_v1_publication_resilience.rs +++ b/crates/compass-core/tests/code_graph_v1_publication_resilience.rs @@ -665,7 +665,10 @@ fn real_framework_limit_failure_isolated_from_healthy_file() -> Result<(), Box Result<(), Box> { + let directory = tempfile::tempdir()?; + let source = b"component"; + fs::create_dir_all(directory.path().join("src"))?; + fs::write(directory.path().join("src/ui.tsx"), source)?; + let mut graph = GraphDocument::empty_v1(BuildMetadata { + builder_version: "test".to_owned(), + schema_fingerprint: "sha256:schema".to_owned(), + source_tree_digest: "sha256:tree".to_owned(), + configuration_digest: "sha256:config".to_owned(), + generation_id: "sha256:generation".to_owned(), + source_commit: None, + }); + graph.graph.files.push(FileRecord { + id: file_id("src/ui.tsx"), + path: "src/ui.tsx".to_owned(), + language: Some("tsx".to_owned()), + content_digest: format!("sha256:{:x}", Sha256::digest(source)), + byte_size: source.len() as u64, + generated: false, + extraction_status: ExtractionStatus::Extracted, + extractor_versions: vec!["test".to_owned()], + coverage: Vec::new(), + diagnostics: Vec::new(), + }); + let mut component = node("component", "Card", "Card", "src/ui.tsx"); + component.kind = NodeKind::Component; + component.framework = Some("react".to_owned()); + component.roles = vec![compass_model::code_graph::NodeRole::UiComponent]; + let mut route = node("route", "Page", "Page", "src/ui.tsx"); + route.kind = NodeKind::Route; + route.framework = Some("next".to_owned()); + route.details = Some(NodeDetails::Route(RouteNodeDetails { + operation: "GET".to_owned(), + path: "/".to_owned(), + original_path: None, + declaring_scope: "src/app/page.tsx".to_owned(), + resolution: ResolutionState::Exact, + middleware_count: 0, + stages: vec![RouteStageDetails { + stage: RouteStage::RouteComponent, + position: 0, + reference: "Page".to_owned(), + resolution: ResolutionState::Exact, + source_anchor: Some(anchor("src/ui.tsx")), + target: Some("component".to_owned()), + candidates: Vec::new(), + }], + })); + let mut page_component = node( + "page-component", + "PageComponent", + "PageComponent", + "src/ui.tsx", + ); + page_component.kind = NodeKind::Component; + page_component.framework = Some("react".to_owned()); + let mut render = edge( + "component", + EdgeKind::Renders, + "page-component", + "src/ui.tsx", + ); + render.details = Some(EdgeDetails::Render(RenderEdgeDetails { + render_kind: RenderKind::Jsx, + boundary: None, + })); + graph.nodes = vec![component, route, page_component]; + graph.links = vec![ + render, + edge("route", EdgeKind::RoutesTo, "page-component", "src/ui.tsx"), + ]; + let graph_path = directory.path().join("graph.json"); + let engine = open_with_document(graph, &graph_path, None, &directory.path().join("cache"))?; + let context = build_task_context( + &engine, + &TaskContextRequest { + intent: TaskContextIntent::Modify, + target: "page-component".to_owned(), + repository_root: directory.path().to_string_lossy().into_owned(), + limits: TaskContextLimits::default(), + }, + &[], + )?; + let framework = context + .framework + .as_ref() + .ok_or("framework context missing")?; + assert!(framework.packs.iter().any(|pack| pack.id == "react-ui")); + assert!(framework + .packs + .iter() + .any(|pack| pack.qualification == compass_core::FrameworkQualificationState::Qualifying)); + assert!(framework.renders.is_empty()); + assert_eq!(framework.rendered_by.len(), 1); + assert_eq!(framework.routes.len(), 1); + assert_eq!( + framework.routes[0].stages[0].stage, + RouteStage::RouteComponent + ); + + let mut encoded = serde_json::to_value(&context)?; + encoded["schema"] = serde_json::Value::String(compass_core::TASK_CONTEXT_SCHEMA_V1.to_owned()); + let error = + TaskContext::from_json(&serde_json::to_vec(&encoded)?).expect_err("v1 must be rejected"); + assert!(matches!( + error, + compass_core::TaskContextError::UnsupportedSchema(_) + )); + Ok(()) +} diff --git a/crates/compass-files/src/detect.rs b/crates/compass-files/src/detect.rs index 8353b5cb..9bcda322 100644 --- a/crates/compass-files/src/detect.rs +++ b/crates/compass-files/src/detect.rs @@ -481,7 +481,12 @@ fn is_noise_dir(path: &Path, output_name: &str) -> bool { .file_name() .and_then(|value| value.to_str()) .unwrap_or_default(); - if (SKIP_DIRS.contains(&name) && !is_java_package_build_dir(path)) + // `target` is normally a Rust build directory, but it is also a valid + // route segment in Next.js/React Router file conventions (for example + // `app/target/page.tsx`). Keep the broad discovery guard everywhere else + // while allowing that frontend-specific spelling through. + let frontend_route_target = name == "target" && has_frontend_route_ancestor(path); + if SKIP_DIRS.contains(&name) && !frontend_route_target && !is_java_package_build_dir(path) || Path::new(output_name) .file_name() .is_some_and(|value| value == name) @@ -514,6 +519,18 @@ fn is_noise_dir(path: &Path, output_name: &str) -> bool { false } +fn has_frontend_route_ancestor(path: &Path) -> bool { + path.parent() + .into_iter() + .flat_map(Path::components) + .any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| matches!(name, "app" | "pages" | "routes")) + }) +} + fn is_java_package_build_dir(path: &Path) -> bool { if path.file_name().and_then(|value| value.to_str()) != Some("build") { return false; @@ -670,6 +687,7 @@ fn looks_like_paper(path: &Path) -> bool { pub fn classify_file(path: &Path) -> Option { if is_package_manifest(path) || is_play_routes_file(path) + || is_drupal_routing_file(path) || path .file_name() .and_then(|value| value.to_str()) @@ -724,6 +742,7 @@ fn graphable_source(path: &Path) -> bool { } if is_package_manifest(path) || is_play_routes_file(path) + || is_drupal_routing_file(path) || path .file_name() .and_then(|value| value.to_str()) @@ -746,6 +765,15 @@ fn is_play_routes_file(path: &Path) -> bool { .is_some_and(|parent| parent.eq_ignore_ascii_case("conf")) } +fn is_drupal_routing_file(path: &Path) -> bool { + path.file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| { + let name = name.to_ascii_lowercase(); + name.ends_with(".routing.yml") || name.ends_with(".routing.yaml") + }) +} + fn generic_keyword_hit(name: &str) -> bool { static KEYWORDS: OnceLock = OnceLock::new(); let stem = name @@ -1114,9 +1142,20 @@ fn collect_memory_files(directory: &Path, files: &mut Vec, errors: &mut #[cfg(test)] mod tests { - use super::{FileType, classify_file}; + use super::{FileType, classify_file, is_noise_dir}; use std::path::Path; + #[test] + fn frontend_route_target_directory_is_not_filtered_as_build_output() { + assert!(!is_noise_dir(Path::new("repo/app/target"), "compass-out")); + assert!(!is_noise_dir( + Path::new("repo/src/routes/target"), + "compass-out" + )); + assert!(is_noise_dir(Path::new("repo/target"), "compass-out")); + assert!(is_noise_dir(Path::new("repo/build/target"), "compass-out")); + } + #[test] fn office_documents_are_discovered_as_documents() { for name in ["report.docx", "workbook.xlsx", "deck.pptx"] { diff --git a/crates/compass-files/src/file_set.rs b/crates/compass-files/src/file_set.rs new file mode 100644 index 00000000..ce59c4c2 --- /dev/null +++ b/crates/compass-files/src/file_set.rs @@ -0,0 +1,278 @@ +use std::path::{Component, Path, PathBuf}; + +use glob::{MatchOptions, Pattern}; + +use crate::FileError; + +/// A bounded, ignore-neutral matcher for a statically declared file set. +/// +/// The matcher never walks the filesystem. Callers provide the already +/// discovered, ignore-filtered candidates, which keeps file-set semantics in +/// the filesystem boundary without allowing a language pack to perform an +/// unbounded scan. +#[derive(Clone, Debug)] +pub struct FileSetMatcher { + root: PathBuf, + includes: Vec, + excludes: Vec, +} + +#[derive(Clone, Debug)] +struct FileSetPattern { + raw: String, + pattern: Pattern, +} + +impl FileSetMatcher { + pub fn new( + root: &Path, + includes: &[String], + excludes: &[String], + max_patterns: usize, + ) -> Result { + if includes.is_empty() { + return Err(FileError::InvalidFileSet { + pattern: String::new(), + reason: "at least one include pattern is required".to_owned(), + }); + } + let pattern_count = includes.len().saturating_add(excludes.len()); + if pattern_count > max_patterns { + return Err(FileError::FileSetLimit { + kind: "patterns", + observed: pattern_count, + maximum: max_patterns, + }); + } + let root = std::fs::canonicalize(root).map_err(|source| crate::io_error(root, source))?; + Ok(Self { + root, + includes: includes + .iter() + .map(|pattern| FileSetPattern::new(pattern)) + .collect::>()?, + excludes: excludes + .iter() + .map(|pattern| FileSetPattern::new(pattern)) + .collect::>()?, + }) + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn matches(&self, path: &Path) -> bool { + let Ok(relative) = relative_path(&self.root, path) else { + return false; + }; + self.includes + .iter() + .any(|pattern| pattern.matches(&relative)) + && !self + .excludes + .iter() + .any(|pattern| pattern.matches(&relative)) + } + + /// Filter a bounded candidate set in deterministic relative-path order. + /// `max_matches_per_pattern` applies before de-duplication, while + /// `max_total` bounds the published dependency fan-out. + pub fn match_paths<'a>( + &self, + paths: impl IntoIterator, + max_matches_per_pattern: usize, + max_total: usize, + ) -> Result, FileError> { + let mut candidates = paths + .into_iter() + .filter_map(|path| { + relative_path(&self.root, path) + .ok() + .map(|relative| (relative, path.to_path_buf())) + }) + .collect::>(); + candidates.sort_by(|left, right| left.0.cmp(&right.0)); + candidates.dedup_by(|left, right| left.0 == right.0); + + let mut matched = Vec::new(); + for include in &self.includes { + let mut count = 0usize; + for (relative, path) in &candidates { + if !include.matches(relative) + || self + .excludes + .iter() + .any(|exclude| exclude.matches(relative)) + { + continue; + } + count = count.saturating_add(1); + if count > max_matches_per_pattern { + return Err(FileError::FileSetLimit { + kind: "matches_per_pattern", + observed: count, + maximum: max_matches_per_pattern, + }); + } + if !matched.iter().any(|existing: &PathBuf| existing == path) { + matched.push(path.clone()); + if matched.len() > max_total { + return Err(FileError::FileSetLimit { + kind: "total_edges", + observed: matched.len(), + maximum: max_total, + }); + } + } + } + } + matched.sort_by_key(|path| { + relative_path(&self.root, path).unwrap_or_else(|_| path.to_string_lossy().into_owned()) + }); + Ok(matched) + } +} + +impl FileSetPattern { + fn new(raw: &str) -> Result { + let original = raw.to_owned(); + let mut value = raw.trim().replace('\\', "/"); + while let Some(rest) = value.strip_prefix("./") { + value = rest.to_owned(); + } + if value.is_empty() { + return Err(FileError::InvalidFileSet { + pattern: original, + reason: "pattern is empty".to_owned(), + }); + } + let path = Path::new(&value); + if path.is_absolute() + || is_windows_absolute(&value) + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(FileError::InvalidFileSet { + pattern: original, + reason: "pattern must stay within the project root".to_owned(), + }); + } + let pattern = Pattern::new(&value).map_err(|error| FileError::InvalidFileSet { + pattern: original, + reason: error.to_string(), + })?; + Ok(Self { + raw: value, + pattern, + }) + } + + fn matches(&self, relative: &str) -> bool { + let options = MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: true, + }; + if self.pattern.matches_with(relative, options) + || self + .pattern + .matches_with(relative.trim_end_matches('/'), options) + { + return true; + } + // `glob::Pattern` treats `**/` as requiring one directory while + // Vite's documented glob convention treats it as zero or more. + // Remove one or more `**/` segments and retry the bounded variants. + let mut variant = self.raw.clone(); + while let Some(index) = variant.find("**/") { + variant.replace_range(index..index.saturating_add(3), ""); + let Ok(pattern) = Pattern::new(&variant) else { + break; + }; + if pattern.matches_with(relative, options) { + return true; + } + } + false + } +} + +fn relative_path(root: &Path, path: &Path) -> Result { + let path = if path.is_absolute() { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + } else { + root.join(path) + }; + let relative = path.strip_prefix(root).map_err(|_| ())?; + let relative = relative.to_string_lossy().replace('\\', "/"); + (!relative.is_empty() && !relative.split('/').any(|part| part == "..")) + .then_some(relative) + .ok_or(()) +} + +fn is_windows_absolute(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn matches_bounded_candidates_with_negative_patterns() -> Result<(), Box> + { + let directory = tempdir()?; + fs::create_dir_all(directory.path().join("src"))?; + fs::write(directory.path().join("src/a.tsx"), "")?; + fs::write(directory.path().join("src/b.test.tsx"), "")?; + let matcher = FileSetMatcher::new( + directory.path(), + &["src/**/*.tsx".to_owned()], + &["src/**/*.test.tsx".to_owned()], + 8, + )?; + let paths = [ + directory.path().join("src/a.tsx"), + directory.path().join("src/b.test.tsx"), + ]; + assert!(matcher.matches(&paths[0])); + assert!(!matcher.matches(&paths[1])); + let matches = matcher.match_paths(paths.iter().map(PathBuf::as_path), 8, 8)?; + assert_eq!(matches, vec![paths[0].clone()]); + Ok(()) + } + + #[test] + fn rejects_escape_and_match_limits() -> Result<(), Box> { + let directory = tempdir()?; + assert!(matches!( + FileSetMatcher::new(directory.path(), &["../secret".to_owned()], &[], 8), + Err(FileError::InvalidFileSet { .. }) + )); + let matcher = FileSetMatcher::new(directory.path(), &["**/*.tsx".to_owned()], &[], 8)?; + let paths = (0..2) + .map(|index| directory.path().join(format!("{index}.tsx"))) + .collect::>(); + for path in &paths { + fs::write(path, "")?; + } + assert!(matches!( + matcher.match_paths(paths.iter().map(PathBuf::as_path), 1, 8), + Err(FileError::FileSetLimit { + kind: "matches_per_pattern", + .. + }) + )); + Ok(()) + } +} diff --git a/crates/compass-files/src/lib.rs b/crates/compass-files/src/lib.rs index f67221b8..61e8b809 100644 --- a/crates/compass-files/src/lib.rs +++ b/crates/compass-files/src/lib.rs @@ -5,6 +5,7 @@ mod build_guard; mod cache; mod detect; mod encoding; +mod file_set; mod generated; mod hash; mod manifest; @@ -26,6 +27,7 @@ pub use detect::{ DetectOptions, Detection, FileType, IgnorePolicy, WatchPathFilter, classify_file, detect, }; pub use encoding::{read_bytes_bounded, read_source_lossy}; +pub use file_set::FileSetMatcher; pub use generated::source_is_generated; pub use hash::{StatHashIndex, body_content, file_hash, md5_file, prompt_fingerprint}; pub use manifest::{IncrementalDetection, Manifest, ManifestEntry, ManifestKind}; @@ -98,6 +100,14 @@ pub enum FileError { ProjectConfigOutsideRoot { path: PathBuf, root: PathBuf }, #[error("invalid build scope entry '{entry}': {reason}")] InvalidScope { entry: String, reason: String }, + #[error("invalid framework file-set pattern '{pattern}': {reason}")] + InvalidFileSet { pattern: String, reason: String }, + #[error("framework file-set {kind} limit exceeded: observed {observed}, maximum {maximum}")] + FileSetLimit { + kind: &'static str, + observed: usize, + maximum: usize, + }, } pub(crate) fn io_error(path: impl Into, source: std::io::Error) -> FileError { diff --git a/crates/compass-graph/src/v1.rs b/crates/compass-graph/src/v1.rs index 7cd2e77f..107d9e0a 100644 --- a/crates/compass-graph/src/v1.rs +++ b/crates/compass-graph/src/v1.rs @@ -10,8 +10,9 @@ use compass_model::code_graph::{ DatabaseNodeDetails, DiagnosticSeverity, EdgeDetails, EdgeKind, EdgeRecord, ExtractionStatus, FileNodeDetails, FileRecord, GraphDiagnostic, GraphDocument, GraphMetadata, ImportExportNodeDetails, MessagingNodeDetails, NodeDetails, NodeKind, NodeRecord, NodeRole, - QueryNodeDetails, ResourceKind, ResourceNodeDetails, RouteEdgeDetails, RouteNodeDetails, - RouteStage, RouteStageDetails, SchemaNodeDetails, SymbolNodeDetails, + QueryNodeDetails, RenderEdgeDetails, RenderKind, ResourceKind, ResourceNodeDetails, + RouteEdgeDetails, RouteNodeDetails, RouteStage, RouteStageDetails, SchemaNodeDetails, + SymbolNodeDetails, }; use compass_model::identity::{ database_entity_id, domain_id, edge_id, file_id, messaging_id, normalize_repository_path, @@ -3373,6 +3374,13 @@ fn insert_raw_edge_details(attributes: &mut Map, details: &EdgeDe Value::String(details.mapping_kind.clone()), ); } + EdgeDetails::Render(details) => { + attributes.insert( + "render_kind".to_owned(), + serde_json::to_value(details.render_kind).unwrap_or(Value::Null), + ); + insert_optional_string(attributes, "boundary", details.boundary.as_ref()); + } } } @@ -3748,16 +3756,48 @@ fn normalize_edge( relationship_site.as_ref(), occurrence_rule.as_ref().map(OccurrenceRule::as_str), ); - let details = (kind == EdgeKind::RoutesTo).then(|| { - EdgeDetails::Route(RouteEdgeDetails { + let details = match kind { + EdgeKind::RoutesTo => Some(EdgeDetails::Route(RouteEdgeDetails { stage: match optional_string(&raw.attributes, "stage").as_deref() { + None | Some("handler") => RouteStage::Handler, Some("middleware") => RouteStage::Middleware, - _ => RouteStage::Handler, + Some("layout") => RouteStage::Layout, + Some("template") => RouteStage::Template, + Some("loading") => RouteStage::Loading, + Some("default") => RouteStage::Default, + Some("error_boundary") => RouteStage::ErrorBoundary, + Some("not_found") => RouteStage::NotFound, + Some("boundary") => RouteStage::Boundary, + Some("loader") => RouteStage::Loader, + Some("action") => RouteStage::Action, + Some("data_loader") => RouteStage::DataLoader, + Some("route_component") => RouteStage::RouteComponent, + Some(value) => { + return Err(raw_error(&owner, &format!("unknown route stage {value:?}"))); + } }, position: optional_u32(&raw.attributes, "position"), operation: optional_string(&raw.attributes, "operation"), - }) - }); + })), + EdgeKind::Renders => { + let render_kind = match optional_string(&raw.attributes, "render_kind").as_deref() { + None => RenderKind::Jsx, + Some("jsx") => RenderKind::Jsx, + Some("create_element") => RenderKind::CreateElement, + Some("root") => RenderKind::Root, + Some("lazy") => RenderKind::Lazy, + Some("dynamic") => RenderKind::Dynamic, + Some(value) => { + return Err(raw_error(&owner, &format!("unknown render kind {value:?}"))); + } + }; + Some(EdgeDetails::Render(RenderEdgeDetails { + render_kind, + boundary: optional_string(&raw.attributes, "boundary"), + })) + } + _ => None, + }; Ok(EdgeRecord { key: id.clone(), id, @@ -4131,6 +4171,7 @@ fn route_stage_details( let mut stages = serde_json::from_value::>(stages.clone()) .map_err(|error| raw_error(record, &format!("invalid route stages: {error}")))?; for stage in &mut stages { + normalize_optional_anchor(&mut stage.source_anchor, root)?; for candidate in &mut stage.candidates { normalize_optional_anchor(&mut candidate.anchor, root)?; } @@ -4277,6 +4318,7 @@ fn map_edge_kind(raw: &str) -> Option<(EdgeKind, Option<&'static str>, bool)> { "depends_on" => (EdgeKind::DependsOn, None, false), "documents" => (EdgeKind::Documents, None, false), "maps_to" => (EdgeKind::MapsTo, None, false), + "renders" => (EdgeKind::Renders, None, false), "imports_from" => (EdgeKind::Imports, Some("raw-relation:imports_from"), false), "re_exports" => (EdgeKind::Exports, Some("raw-relation:re_exports"), false), "navigates" => (EdgeKind::References, Some("raw-relation:navigates"), false), @@ -4467,7 +4509,12 @@ fn node_identity( let target_namespace = route .stages .iter() - .find(|stage| stage.stage == RouteStage::Handler) + .find(|stage| { + matches!( + stage.stage, + RouteStage::Handler | RouteStage::RouteComponent + ) + }) .map(|stage| { if stage.reference.is_empty() { stage.target.as_deref().unwrap_or_default() @@ -4865,6 +4912,13 @@ fn map_role(value: &str) -> Option { "test" => Some(NodeRole::Test), "fixture" => Some(NodeRole::Fixture), "generated" => Some(NodeRole::Generated), + "ui_component" => Some(NodeRole::UiComponent), + "hook" => Some(NodeRole::Hook), + "client_boundary" => Some(NodeRole::ClientBoundary), + "client_component" => Some(NodeRole::ClientComponent), + "server_component" => Some(NodeRole::ServerComponent), + "server_function" => Some(NodeRole::ServerFunction), + "data_loader" => Some(NodeRole::DataLoader), _ => None, } } diff --git a/crates/compass-graph/tests/graph_v1_normalization.rs b/crates/compass-graph/tests/graph_v1_normalization.rs index 87f6e63b..00b09163 100644 --- a/crates/compass-graph/tests/graph_v1_normalization.rs +++ b/crates/compass-graph/tests/graph_v1_normalization.rs @@ -1392,6 +1392,16 @@ fn normalization_rejects_unknown_aliases_and_missing_wiring_sites() { missing_site.edges[0].attributes.remove("source_anchor"); let evidence = build_evidence(root).unwrap_or_else(|_| std::process::abort()); assert!(normalize_v1(missing_site, evidence).is_err()); + + let mut unknown_render = extraction(root); + unknown_render.edges[0] + .attributes + .insert("relation".to_owned(), json!("renders")); + unknown_render.edges[0] + .attributes + .insert("render_kind".to_owned(), json!("future_kind")); + let evidence = build_evidence(root).unwrap_or_else(|_| std::process::abort()); + assert!(normalize_v1(unknown_render, evidence).is_err()); } #[test] diff --git a/crates/compass-graph/tests/markdown_identity.rs b/crates/compass-graph/tests/markdown_identity.rs index 0c42815d..12852237 100644 --- a/crates/compass-graph/tests/markdown_identity.rs +++ b/crates/compass-graph/tests/markdown_identity.rs @@ -26,12 +26,31 @@ Second problem. let extraction = Engine::default().extract(path)?; let flexible = build_from_extraction(&extraction, true, Some(root)); let graph = normalize_document_v1(&flexible, root, "sha256:test", None)?; - assert!(graph.nodes.iter().filter(|node| { - node.kind == NodeKind::Resource && node.name == "Problem" - }).all(|node| matches!( - node.details.as_ref(), - Some(NodeDetails::Resource(resource)) if resource.uri.as_deref() == Some("#problem") - ))); + let uris = graph + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Resource && node.name == "Problem") + .map(|node| { + let uri = match node.details.as_ref() { + Some(NodeDetails::Resource(resource)) => resource.uri.clone(), + _ => None, + }; + (node.qualified_name.clone(), uri) + }) + .collect::>(); + assert_eq!( + uris, + BTreeMap::from([ + ( + "Cookbook::Recipe 1::Problem".to_owned(), + Some("#problem".to_owned()) + ), + ( + "Cookbook::Recipe 2::Problem".to_owned(), + Some("#problem-1".to_owned()) + ), + ]) + ); Ok(graph .nodes .iter() diff --git a/crates/compass-graph/tests/rust_method_identity.rs b/crates/compass-graph/tests/rust_method_identity.rs index 11f31cb0..7742f853 100644 --- a/crates/compass-graph/tests/rust_method_identity.rs +++ b/crates/compass-graph/tests/rust_method_identity.rs @@ -323,22 +323,29 @@ class SecondController { "#, )?; - let extraction = Engine::default().extract(&path)?; - let flexible = build_from_extraction(&extraction, true, Some(root)); - let graph = normalize_document_v1(&flexible, root, "sha256:test", None)?; - let methods = graph - .nodes + // PHP now uses the qualifying universal-evidence pipeline. The legacy + // graph projection is intentionally empty, so validate the same stable + // method identities at the evidence boundary instead of treating the + // retired generic graph as authoritative. + let source = fs::read(&path)?; + let evidence = Engine::default().extract_source_universal_evidence( + &path, + "routes/controllers.php", + &source, + )?; + let methods = evidence + .declarations .iter() - .filter(|node| node.kind == NodeKind::Method && node.name == ".index()") + .filter(|declaration| declaration.kind == "method" && declaration.name == "index") .collect::>(); - assert_eq!(methods.len(), 2, "nodes={:?}", graph.nodes); + assert_eq!(methods.len(), 2, "declarations={:?}", evidence.declarations); assert_eq!( methods .iter() - .map(|node| node.qualified_name.as_str()) + .map(|declaration| declaration.qualified_name.as_str()) .collect::>(), - ["FirstController::index", "SecondController::index"] + ["firstcontroller::index", "secondcontroller::index"] .into_iter() .collect() ); diff --git a/crates/compass-languages/src/engine.rs b/crates/compass-languages/src/engine.rs index e6cde5bd..205c7f8c 100644 --- a/crates/compass-languages/src/engine.rs +++ b/crates/compass-languages/src/engine.rs @@ -4191,6 +4191,10 @@ export class OrdersConsumer { crate::RawFrameworkFact::Route(route) => &route.anchor, crate::RawFrameworkFact::Domain(domain) => &domain.anchor, crate::RawFrameworkFact::Annotation(annotation) => &annotation.anchor, + crate::RawFrameworkFact::Role(role) => &role.anchor, + crate::RawFrameworkFact::Relation(relation) => &relation.anchor, + crate::RawFrameworkFact::Configuration(configuration) => &configuration.anchor, + crate::RawFrameworkFact::FileSet(file_set) => &file_set.anchor, }; anchor.source_file == "src/orders.ts" })); @@ -4232,6 +4236,10 @@ public class OrdersController : ControllerBase { crate::RawFrameworkFact::Route(route) => &route.anchor, crate::RawFrameworkFact::Domain(domain) => &domain.anchor, crate::RawFrameworkFact::Annotation(annotation) => &annotation.anchor, + crate::RawFrameworkFact::Role(role) => &role.anchor, + crate::RawFrameworkFact::Relation(relation) => &relation.anchor, + crate::RawFrameworkFact::Configuration(configuration) => &configuration.anchor, + crate::RawFrameworkFact::FileSet(file_set) => &file_set.anchor, }; anchor.source_file == "Controllers/OrdersController.cs" })); diff --git a/crates/compass-languages/src/evidence/typescript.rs b/crates/compass-languages/src/evidence/typescript.rs index 0cd04abd..cbce21fd 100644 --- a/crates/compass-languages/src/evidence/typescript.rs +++ b/crates/compass-languages/src/evidence/typescript.rs @@ -12291,14 +12291,40 @@ fn collect_import_bindings<'tree>( collect_nodes_of_kind(clause, "export_specifier", &mut specifiers); for specifier in specifiers { let identifiers = direct_identifier_children(specifier, source); - let Some(imported) = identifiers.first().copied() else { + if let Some(imported) = identifiers.first().copied() { + let local = identifiers.get(1).copied().unwrap_or(imported); + output.push(ImportBinding { + local_name: node_text(source, local), + imported_name: node_text(source, imported), + anchor: local, + type_only: statement_type_only + || node_text(source, specifier) + .trim_start() + .starts_with("type "), + }); + continue; + } + // Tree-sitter represents keyword-only specifiers such as + // `export { default } from "./component"` without named child + // identifier nodes. Recover the bounded spelling from the + // specifier text instead of silently dropping the re-export. + let specifier_text = node_text(source, specifier); + let spelling = specifier_text + .trim() + .strip_prefix("type ") + .unwrap_or_else(|| specifier_text.trim()); + let mut names = spelling.splitn(2, " as ").map(str::trim); + let Some(imported) = names.next().filter(|name| !name.is_empty()) else { continue; }; - let local = identifiers.get(1).copied().unwrap_or(imported); + let local = names + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(imported); output.push(ImportBinding { - local_name: node_text(source, local), - imported_name: node_text(source, imported), - anchor: local, + local_name: local.to_owned(), + imported_name: imported.to_owned(), + anchor: specifier, type_only: statement_type_only || node_text(source, specifier) .trim_start() diff --git a/crates/compass-languages/src/frameworks/csharp.rs b/crates/compass-languages/src/frameworks/csharp.rs index 8405e9dc..492a351f 100644 --- a/crates/compass-languages/src/frameworks/csharp.rs +++ b/crates/compass-languages/src/frameworks/csharp.rs @@ -233,6 +233,7 @@ fn collect_minimal_api_routes( anchor: source_anchor(context.path, context.source, start.start(), end), handler_reference: handler_reference.clone(), middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: None, detail: detail.clone(), @@ -489,6 +490,26 @@ fn fact_key(fact: &RawFrameworkFact) -> (&str, u64, &str) { domain.anchor.start_byte, domain.kind.as_str(), ), + RawFrameworkFact::Role(role) => ( + role.anchor.source_file.as_str(), + role.anchor.start_byte, + role.role.as_str(), + ), + RawFrameworkFact::Relation(relation) => ( + relation.anchor.source_file.as_str(), + relation.anchor.start_byte, + relation.relation.as_str(), + ), + RawFrameworkFact::Configuration(configuration) => ( + configuration.anchor.source_file.as_str(), + configuration.anchor.start_byte, + configuration.field.as_str(), + ), + RawFrameworkFact::FileSet(file_set) => ( + file_set.anchor.source_file.as_str(), + file_set.anchor.start_byte, + file_set.owner_reference.as_str(), + ), } } diff --git a/crates/compass-languages/src/frameworks/file_routes.rs b/crates/compass-languages/src/frameworks/file_routes.rs index 91878ef0..c5987a77 100644 --- a/crates/compass-languages/src/frameworks/file_routes.rs +++ b/crates/compass-languages/src/frameworks/file_routes.rs @@ -2,10 +2,13 @@ use std::path::Path; use regex::Regex; use serde_json::{Map, Value}; +use tree_sitter::Node; use super::evidence::{EvidenceKind, EvidenceSet}; +use super::typescript_syntax::TypeScriptSyntax; use super::{ RawDomainFact, RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, + RawRouteStageFact, RawRouteStageRole, }; use crate::{Extraction, ProjectEvidence, RawEdgeRecord, RawNodeRecord, make_id}; @@ -162,6 +165,7 @@ pub(super) fn detect( pub(super) fn detect_next( path: &Path, source: &[u8], + root: Node<'_>, project: Option<&ProjectEvidence>, extraction: &mut Extraction, ) -> Vec { @@ -180,10 +184,62 @@ pub(super) fn detect_next( .map(|path| path.to_string_lossy().replace('\\', "/")) .unwrap_or(portable); let Some((router, relative)) = next_route_file(&project_relative) else { - return Vec::new(); + return next_middleware_fact(&project_relative, path, source); }; let lower = relative.to_ascii_lowercase(); if router == "app" { + if let Some(app_file) = next_app_file(relative) { + if app_file.kind == "route" { + let handlers = + exported_endpoint_handlers_ast(TypeScriptSyntax::new(root, source), "next"); + return handlers + .into_iter() + .flat_map(|handler| { + one_route_with_options( + "next", + &handler.operation, + &app_file.raw_route, + path, + source, + extraction, + "next-app-route-convention", + Some(&handler), + Some(app_file.normalized_route.clone()), + app_file.detail.clone(), + Some(RawRouteStageRole::Handler), + Map::new(), + ) + }) + .collect(); + } + let syntax = TypeScriptSyntax::new(root, source); + let reference = + next_default_export_reference(syntax).unwrap_or_else(|| "default".to_owned()); + let handler = EndpointHandler { + operation: "PAGE".to_owned(), + reference, + module: None, + }; + let mut facts = one_route_with_options( + "next", + "PAGE", + &app_file.raw_route, + path, + source, + extraction, + "next-app-router-convention", + Some(&handler), + Some(app_file.normalized_route), + app_file.detail, + Some(app_file.stage_role), + Map::from_iter([( + "convention".to_owned(), + Value::String(app_file.file_kind.to_owned()), + )]), + ); + append_next_generated_stages(&mut facts, syntax, path, source); + return facts; + } if ["page.tsx", "page.ts", "page.jsx", "page.js"] .iter() .any(|name| next_file_is(&lower, name)) @@ -210,7 +266,36 @@ pub(super) fn detect_next( return next_pages_api_route(relative, path, source, extraction); } if matches!(page_route, "_app" | "_document" | "_error") { - return Vec::new(); + let (stage_role, stage_name) = match page_route { + "_app" => (RawRouteStageRole::RouteComponent, "_app"), + "_document" => (RawRouteStageRole::Template, "_document"), + _ => (RawRouteStageRole::ErrorBoundary, "_error"), + }; + let syntax = TypeScriptSyntax::new(root, source); + let reference = + next_default_export_reference(syntax).unwrap_or_else(|| "default".to_owned()); + let handler = EndpointHandler { + operation: "PAGE".to_owned(), + reference, + module: None, + }; + return one_route_with_options( + "next", + "PAGE", + "/", + path, + source, + extraction, + "next-pages-router-special-file", + Some(&handler), + Some("/".to_owned()), + Map::from_iter([( + "pages_special".to_owned(), + Value::String(stage_name.to_owned()), + )]), + Some(stage_role), + Map::new(), + ); } if lower.ends_with(".tsx") || lower.ends_with(".ts") @@ -229,6 +314,248 @@ pub(super) fn detect_next( Vec::new() } +#[derive(Clone, Debug)] +struct NextAppFile { + raw_route: String, + normalized_route: String, + file_kind: &'static str, + stage_role: RawRouteStageRole, + detail: Map, + kind: &'static str, +} + +fn next_app_file(relative: &str) -> Option { + let relative = relative.trim_matches('/'); + let mut pieces = relative.split('/').filter(|piece| !piece.is_empty()); + let file = pieces.next_back()?; + let stem = trim_known_extension(file); + let (file_kind, stage_role, kind) = match stem { + "page" => ("page", RawRouteStageRole::RouteComponent, "page"), + "layout" => ("layout", RawRouteStageRole::Layout, "layout"), + "template" => ("template", RawRouteStageRole::Template, "template"), + "loading" => ("loading", RawRouteStageRole::Loading, "loading"), + "error" => ("error", RawRouteStageRole::ErrorBoundary, "error"), + "global-error" => ( + "global-error", + RawRouteStageRole::ErrorBoundary, + "global-error", + ), + "not-found" => ("not-found", RawRouteStageRole::NotFound, "not-found"), + "default" => ("default", RawRouteStageRole::Default, "default"), + "route" => ("route", RawRouteStageRole::Handler, "route"), + _ => return None, + }; + let mut url_segments = Vec::new(); + let mut raw_segments = Vec::new(); + let mut groups = Vec::new(); + let mut slots = Vec::new(); + let mut intercepts = Vec::new(); + for segment in pieces { + if segment.starts_with('_') { + return None; + } + raw_segments.push(segment.to_owned()); + if let Some(intercept) = segment + .strip_prefix("(...)") + .or_else(|| segment.strip_prefix("(..)")) + .or_else(|| segment.strip_prefix("(.)")) + { + intercepts.push(Value::String(segment.to_owned())); + if intercept.is_empty() { + return None; + } + url_segments.push(intercept.to_owned()); + continue; + } + if segment.starts_with('(') && segment.ends_with(')') { + groups.push(Value::String(segment.to_owned())); + continue; + } + if let Some(slot) = segment.strip_prefix('@') { + if slot.is_empty() { + return None; + } + slots.push(Value::String(slot.to_owned())); + continue; + } + url_segments.push(segment.to_owned()); + } + let raw_route = format!("/{}", raw_segments.join("/")) + .trim_end_matches('/') + .to_owned(); + let raw_route = if raw_route == "/" { + "/".to_owned() + } else { + raw_route + }; + let normalized_route = normalize_dynamic_segments(&format!("/{}", url_segments.join("/"))); + let mut detail = Map::from_iter([ + ("router".to_owned(), Value::String("app".to_owned())), + ("file_kind".to_owned(), Value::String(file_kind.to_owned())), + ( + "route_segments".to_owned(), + Value::Array(url_segments.into_iter().map(Value::String).collect()), + ), + ]); + if !groups.is_empty() { + detail.insert("route_groups".to_owned(), Value::Array(groups)); + } + if !slots.is_empty() { + detail.insert("parallel_slots".to_owned(), Value::Array(slots)); + } + if !intercepts.is_empty() { + detail.insert("intercepting_segments".to_owned(), Value::Array(intercepts)); + } + Some(NextAppFile { + raw_route, + normalized_route, + file_kind, + stage_role, + detail, + kind, + }) +} + +fn next_middleware_fact(relative: &str, path: &Path, source: &[u8]) -> Vec { + let name = Path::new(relative) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!(name, "middleware" | "proxy") { + return Vec::new(); + } + vec![RawFrameworkFact::Domain(RawDomainFact { + framework: "next".to_owned(), + kind: "route_middleware".to_owned(), + name: name.to_owned(), + declaring_scope: relative.to_owned(), + anchor: file_anchor(path, source), + origin: RawFrameworkOrigin::Convention, + detail: Map::from_iter([("convention".to_owned(), Value::String(name.to_owned()))]), + })] +} + +fn next_default_export_reference(syntax: TypeScriptSyntax<'_, '_>) -> Option { + for statement in syntax + .descendants(syntax.root()) + .into_iter() + .filter(|node| node.kind() == "export_statement") + { + let is_default = syntax + .text(statement) + .is_some_and(|text| text.trim_start().starts_with("export default")); + if !is_default { + continue; + } + let mut cursor = statement.walk(); + for child in statement.named_children(&mut cursor) { + if let Some(name) = child + .child_by_field_name("name") + .and_then(|name| syntax.text(name)) + && (child.kind().contains("function") || child.kind().contains("class")) + { + return Some(name.to_owned()); + } + if matches!(child.kind(), "identifier" | "member_expression") { + return syntax.text(child).map(str::to_owned); + } + } + } + None +} + +fn append_next_generated_stages( + facts: &mut [RawFrameworkFact], + syntax: TypeScriptSyntax<'_, '_>, + path: &Path, + source: &[u8], +) { + let Some(RawFrameworkFact::Route(route)) = facts.first_mut() else { + return; + }; + let mut exports = Vec::new(); + for statement in syntax + .descendants(syntax.root()) + .into_iter() + .filter(|node| node.kind() == "export_statement") + { + let mut cursor = statement.walk(); + for child in statement.named_children(&mut cursor) { + let Some(name) = child + .child_by_field_name("name") + .and_then(|name| syntax.text(name)) + else { + continue; + }; + let role = match name { + "generateStaticParams" | "generateMetadata" => RawRouteStageRole::DataLoader, + _ => continue, + }; + let Some(range) = syntax.range(child) else { + continue; + }; + exports.push(RawRouteStageFact { + role, + position: u32::try_from(route.stages.len() + exports.len()).unwrap_or(u32::MAX), + reference: name.to_owned(), + anchor: range_anchor(path, range), + origin: RawFrameworkOrigin::Ast, + detail: Map::from_iter([("export".to_owned(), Value::String(name.to_owned()))]), + }); + } + } + // Tree-sitter represents `export const generateMetadata = ...` as an + // export statement containing a lexical declaration, so the declaration + // name is not exposed as a direct child of the export node. Recover that + // parser-backed form explicitly; do not fall back to source matching. + for declaration in syntax + .descendants(syntax.root()) + .into_iter() + .filter(|node| node.kind() == "variable_declarator") + { + let exported = declaration + .parent() + .filter(|parent| parent.kind() == "lexical_declaration") + .and_then(|parent| parent.parent()) + .is_some_and(|parent| parent.kind() == "export_statement"); + if !exported { + continue; + } + let Some(name) = declaration + .child_by_field_name("name") + .and_then(|name| syntax.text(name)) + else { + continue; + }; + if !matches!(name, "generateStaticParams" | "generateMetadata") { + continue; + } + let Some(node) = declaration.child_by_field_name("name") else { + continue; + }; + let Some(range) = syntax.range(node) else { + continue; + }; + let duplicate = exports.iter().any(|existing: &RawRouteStageFact| { + existing.reference == name + && existing.anchor.start_byte == u64::try_from(range.start_byte).unwrap_or(u64::MAX) + }); + if duplicate { + continue; + } + exports.push(RawRouteStageFact { + role: RawRouteStageRole::DataLoader, + position: u32::try_from(route.stages.len() + exports.len()).unwrap_or(u32::MAX), + reference: name.to_owned(), + anchor: range_anchor(path, range), + origin: RawFrameworkOrigin::Ast, + detail: Map::from_iter([("export".to_owned(), Value::String(name.to_owned()))]), + }); + } + route.stages.extend(exports); + let _ = source; +} + /// Detect Remix flat-route and nested route modules. Remix keeps page /// components and resource/data handlers in the same `app/routes` tree, so a /// route file can publish more than one operation (`PAGE`, `LOADER`, and @@ -236,6 +563,7 @@ pub(super) fn detect_next( pub(super) fn detect_remix( path: &Path, source: &[u8], + root: Node<'_>, project: Option<&ProjectEvidence>, extraction: &mut Extraction, ) -> Vec { @@ -246,6 +574,7 @@ pub(super) fn detect_remix( "@remix-run/react", "@remix-run/router", "@remix-run/serve", + "remix", ]) || project.has_any_configuration(&[ "remix.config.cjs", "remix.config.js", @@ -261,10 +590,13 @@ pub(super) fn detect_remix( .and_then(|project| path.strip_prefix(project.project_root()).ok()) .map(|path| path.to_string_lossy().replace('\\', "/")) .unwrap_or(portable); + if is_remix_route_config(&project_relative) { + return detect_remix_route_config(path, source, root, extraction); + } let Some(relative) = remix_route_file(&project_relative) else { return Vec::new(); }; - let handlers = remix_endpoint_handlers(std::str::from_utf8(source).unwrap_or_default()); + let handlers = exported_endpoint_handlers_ast(TypeScriptSyntax::new(root, source), "remix"); handlers .into_iter() .flat_map(|handler| { @@ -282,6 +614,320 @@ pub(super) fn detect_remix( .collect() } +fn is_remix_route_config(relative: &str) -> bool { + [ + "app/routes.ts", + "app/routes.tsx", + "app/routes.js", + "app/routes.jsx", + "app/routes.mts", + "app/routes.mjs", + ] + .iter() + .any(|suffix| relative == *suffix || relative.ends_with(&format!("/{suffix}"))) +} + +/// Extract Remix's object route DSL (`remix/routes`) without executing the +/// project. The DSL deliberately supports nested objects and static factory +/// calls only; computed keys, dynamic paths, and arbitrary expressions remain +/// unresolved rather than becoming invented route relationships. +fn detect_remix_route_config( + path: &Path, + source: &[u8], + root: Node<'_>, + extraction: &mut Extraction, +) -> Vec { + let syntax = TypeScriptSyntax::new(root, source); + let factories = [ + ("route", "route"), + ("get", "get"), + ("post", "post"), + ("put", "put"), + ("del", "del"), + ("form", "form"), + ("resources", "resources"), + ] + .into_iter() + .filter_map(|(canonical, _)| { + syntax + .imported_local_names("remix/routes", canonical) + .into_iter() + .next() + .map(|local| (local, canonical)) + }) + .collect::>(); + if factories.is_empty() { + return Vec::new(); + } + let mut facts = Vec::new(); + for call in syntax + .descendants(root) + .into_iter() + .filter(|node| node.kind() == "call_expression") + { + let Some(callee) = syntax.call_callee(call) else { + continue; + }; + if factories.get(&callee).copied() != Some("route") + || call + .parent() + .is_some_and(|parent| parent.kind() == "call_expression") + { + continue; + } + let Some(arguments) = call.child_by_field_name("arguments") else { + continue; + }; + let mut cursor = arguments.walk(); + let Some(first) = arguments.named_children(&mut cursor).next() else { + continue; + }; + if first.kind() != "object" || syntax.is_incomplete(first) { + continue; + } + collect_remix_route_config_object( + first, "", syntax, path, source, extraction, &factories, &mut facts, + ); + } + facts.sort_by_key(|fact| (fact.anchor().start_byte, fact.framework().to_owned())); + facts +} + +#[allow(clippy::too_many_arguments)] +fn collect_remix_route_config_object( + object: Node<'_>, + parent_path: &str, + syntax: TypeScriptSyntax<'_, '_>, + path: &Path, + source: &[u8], + extraction: &mut Extraction, + factories: &std::collections::BTreeMap, + facts: &mut Vec, +) { + if object.kind() != "object" || syntax.is_incomplete(object) { + return; + } + let mut cursor = object.walk(); + for property in object + .named_children(&mut cursor) + .filter(|node| node.kind() == "pair") + { + let Some(key) = syntax.property_name(property) else { + continue; + }; + let Some(value) = property + .child_by_field_name("value") + .or_else(|| property.named_child(1)) + else { + continue; + }; + collect_remix_route_config_value( + value, + parent_path, + &key, + syntax, + path, + source, + extraction, + factories, + facts, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn collect_remix_route_config_value( + value: Node<'_>, + parent_path: &str, + key: &str, + syntax: TypeScriptSyntax<'_, '_>, + path: &Path, + source: &[u8], + extraction: &mut Extraction, + factories: &std::collections::BTreeMap, + facts: &mut Vec, +) { + if syntax.is_incomplete(value) { + return; + } + if value.kind() == "object" { + collect_remix_route_config_object( + value, + &join_remix_config_path(parent_path, key), + syntax, + path, + source, + extraction, + factories, + facts, + ); + return; + } + if let Some(raw_path) = syntax.literal_string(value) { + push_remix_config_route( + "PAGE", + join_remix_config_path(parent_path, &raw_path), + value, + syntax, + path, + source, + extraction, + facts, + ); + return; + } + if value.kind() != "call_expression" { + return; + } + let Some(callee) = syntax.call_callee(value) else { + return; + }; + let Some(factory) = factories.get(&callee).copied() else { + return; + }; + let Some(arguments) = value.child_by_field_name("arguments") else { + return; + }; + let mut cursor = arguments.walk(); + let arguments = arguments.named_children(&mut cursor).collect::>(); + let first = arguments.first().copied(); + let second = arguments.get(1).copied(); + match factory { + "route" => { + let (segment, children) = match (first, second) { + (Some(first), Some(second)) => ( + syntax.literal_string(first), + (second.kind() == "object").then_some(second), + ), + (Some(first), None) if first.kind() == "object" => { + (Some(String::new()), Some(first)) + } + _ => (None, None), + }; + let Some(children) = children else { + return; + }; + let route_parent = segment.as_deref().map_or_else( + || parent_path.to_owned(), + |segment| join_remix_config_path(parent_path, segment), + ); + collect_remix_route_config_object( + children, + &route_parent, + syntax, + path, + source, + extraction, + factories, + facts, + ); + } + "resources" => { + let Some(segment) = first.and_then(|node| syntax.literal_string(node)) else { + return; + }; + push_remix_config_route( + "RESOURCE", + join_remix_config_path(parent_path, &segment), + value, + syntax, + path, + source, + extraction, + facts, + ); + } + "get" | "form" | "post" | "put" | "del" => { + let Some(segment) = first.and_then(|node| syntax.literal_string(node)) else { + return; + }; + let operation = match factory { + "get" => "GET", + "post" => "POST", + "put" => "PUT", + "del" => "DELETE", + // `form` describes a page/form route; its action metadata is + // retained in the route DSL but does not change the page + // relationship's component stage. + "form" => "PAGE", + _ => unreachable!(), + }; + push_remix_config_route( + operation, + join_remix_config_path(parent_path, &segment), + value, + syntax, + path, + source, + extraction, + facts, + ); + } + _ => {} + } +} + +fn join_remix_config_path(parent: &str, child: &str) -> String { + let child = child.trim_matches('/'); + if child.is_empty() { + return if parent.is_empty() { + "/".to_owned() + } else { + parent.to_owned() + }; + } + let parent = parent.trim_end_matches('/'); + if parent.is_empty() { + format!("/{child}") + } else { + format!("{parent}/{child}") + } +} + +fn push_remix_config_route( + operation: &str, + route_path: String, + anchor_node: Node<'_>, + syntax: TypeScriptSyntax<'_, '_>, + path: &Path, + source: &[u8], + extraction: &mut Extraction, + facts: &mut Vec, +) { + let relative = path.to_string_lossy().replace('\\', "/"); + let normalized = normalize_dynamic_segments(&route_path); + let mut generated = one_route_with_options( + "remix", + operation, + &relative, + path, + source, + extraction, + "remix-route-config", + None, + Some(normalized), + Map::from_iter([ + ("route_config".to_owned(), Value::Bool(true)), + ("config_path".to_owned(), Value::String(relative.clone())), + ]), + None, + Map::new(), + ); + if let Some(RawFrameworkFact::Route(route)) = generated.first_mut() { + if let Some(range) = syntax.range(anchor_node) { + route.anchor = range_anchor(path, range); + } + route.origin = RawFrameworkOrigin::Config; + if let Some(stage) = route.stages.first_mut() { + stage.origin = RawFrameworkOrigin::Config; + if let Some(range) = syntax.range(anchor_node) { + stage.anchor = range_anchor(path, range); + } + } + } + facts.extend(generated); +} + fn remix_route_file(relative: &str) -> Option<&str> { for marker in ["app/routes/", "src/routes/"] { if let Some(route) = relative.strip_prefix(marker) { @@ -341,74 +987,121 @@ fn remix_route_segment(segment: &str) -> String { } } -fn remix_endpoint_handlers(source: &str) -> Vec { - let Ok(named) = - Regex::new(r"(?m)^\s*export\s+(?:(?:async\s+)?function|const|let|var)\s+(loader|action)\b") - else { - return Vec::new(); +fn exported_endpoint_handlers_ast( + syntax: TypeScriptSyntax<'_, '_>, + framework: &str, +) -> Vec { + let accepted = if framework == "remix" { + ["loader", "action", "default"].as_slice() + } else { + [ + "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL", "fallback", + ] + .as_slice() }; - let mut handlers = named - .captures_iter(source) - .filter_map(|capture| capture.get(1).map(|name| name.as_str().to_owned())) - .map(|name| EndpointHandler { - operation: name.to_ascii_uppercase(), - reference: name, - module: None, - }) - .collect::>(); - if let Ok(reexports) = - Regex::new(r#"(?m)^\s*export\s*\{([^}]*)\}(?:\s*from\s*["']([^"']+)["'])?"#) + let mut handlers = Vec::new(); + for statement in syntax + .descendants(syntax.root()) + .into_iter() + .filter(|node| node.kind() == "export_statement") { - for capture in reexports.captures_iter(source) { - let Some(names) = capture.get(1) else { + let mut children = statement.walk(); + let has_default = statement + .children(&mut children) + .any(|child| child.kind() == "default"); + let module = statement + .child_by_field_name("source") + .and_then(|node| syntax.literal_string(node)); + let mut statement_cursor = statement.walk(); + let mut named = statement + .named_children(&mut statement_cursor) + .filter(|node| { + matches!( + node.kind(), + "function_declaration" + | "class_declaration" + | "lexical_declaration" + | "variable_declaration" + ) + }); + if let Some(declaration) = named.next() { + if has_default { + handlers.push(EndpointHandler { + operation: "PAGE".to_owned(), + reference: declaration_name(syntax, declaration) + .unwrap_or_else(|| "default".to_owned()), + module: module.clone(), + }); + } else if let Some(name) = declaration_name(syntax, declaration) + && accepted.iter().any(|candidate| *candidate == name) + { + handlers.push(EndpointHandler { + operation: normalize_http_operation(&name), + reference: name, + module: module.clone(), + }); + } + continue; + } + for specifier in syntax + .descendants(statement) + .into_iter() + .filter(|node| node.kind() == "export_specifier") + { + let Some(name_node) = specifier.child_by_field_name("name") else { continue; }; - let module = capture.get(2).map(|value| value.as_str().to_owned()); - for name in names.as_str().split(',').map(str::trim) { - let (local, exported) = name - .split_once(" as ") - .map(|(local, exported)| (local.trim(), exported.trim())) - .unwrap_or((name, name)); - if matches!(exported, "loader" | "action") { - handlers.push(EndpointHandler { - operation: exported.to_ascii_uppercase(), - reference: local.to_owned(), - module: module.clone(), - }); - } else if exported == "default" { - handlers.push(EndpointHandler { - operation: "PAGE".to_owned(), - reference: local.to_owned(), - module: module.clone(), - }); - } + let Some(local) = syntax.text(name_node).map(str::to_owned) else { + continue; + }; + let exported = specifier + .child_by_field_name("alias") + .and_then(|node| syntax.text(node)) + .unwrap_or(&local); + if accepted.contains(&exported) { + handlers.push(EndpointHandler { + operation: if exported == "default" { + "PAGE".to_owned() + } else { + normalize_http_operation(exported) + }, + reference: local, + module: module.clone(), + }); } } } - if Regex::new(r"(?m)^\s*export\s+default\b").is_ok_and(|pattern| pattern.is_match(source)) { - let reference = Regex::new( - r"(?m)^\s*export\s+default\s+(?:(?:async\s+)?function|class)\s+([A-Za-z_$][\w$]*)", - ) - .ok() - .and_then(|pattern| { - pattern - .captures(source) - .and_then(|capture| capture.get(1).map(|name| name.as_str().to_owned())) - }) - .unwrap_or_else(|| "default".to_owned()); - handlers.push(EndpointHandler { - operation: "PAGE".to_owned(), - reference, - module: None, - }); - } handlers.sort_by(|left, right| { - (&left.operation, &left.reference).cmp(&(&right.operation, &right.reference)) + (&left.operation, &left.reference, &left.module).cmp(&( + &right.operation, + &right.reference, + &right.module, + )) }); handlers.dedup(); handlers } +fn declaration_name(syntax: TypeScriptSyntax<'_, '_>, node: Node<'_>) -> Option { + if node.kind() == "lexical_declaration" || node.kind() == "variable_declaration" { + return syntax.descendants(node).into_iter().find_map(|child| { + (child.kind() == "variable_declarator") + .then(|| child.child_by_field_name("name")) + .flatten() + .and_then(|name| syntax.text(name).map(str::to_owned)) + }); + } + node.child_by_field_name("name") + .and_then(|name| syntax.text(name).map(str::to_owned)) +} + +fn normalize_http_operation(value: &str) -> String { + match value { + "ALL" | "fallback" => "ANY".to_owned(), + value => value.to_ascii_uppercase(), + } +} + fn next_pages_api_route( relative: &str, path: &Path, @@ -510,9 +1203,41 @@ fn one_route( extraction: &mut Extraction, rule: &str, handler: Option<&EndpointHandler>, +) -> Vec { + one_route_with_options( + framework, + operation, + relative, + path, + source, + extraction, + rule, + handler, + None, + Map::new(), + None, + Map::new(), + ) +} + +#[allow(clippy::too_many_arguments)] +fn one_route_with_options( + framework: &str, + operation: &str, + relative: &str, + path: &Path, + source: &[u8], + extraction: &mut Extraction, + rule: &str, + handler: Option<&EndpointHandler>, + normalized_override: Option, + mut route_detail: Map, + stage_role_override: Option, + stage_detail: Map, ) -> Vec { let original_path = convention_path(relative); - let normalized_path = normalize_dynamic_segments(&original_path); + let normalized_path = + normalized_override.unwrap_or_else(|| normalize_dynamic_segments(&original_path)); let handler_reference = match handler { Some(handler) if handler.reference == "default" => { ensure_default_export_handler(path, framework, operation, source, extraction); @@ -528,18 +1253,41 @@ fn one_route( extraction, ), }; + let stage_role = match operation.to_ascii_uppercase().as_str() { + "PAGE" | "ROOT" => RawRouteStageRole::RouteComponent, + "LAYOUT" => RawRouteStageRole::Layout, + "TEMPLATE" => RawRouteStageRole::Template, + "LOADING" => RawRouteStageRole::Loading, + "DEFAULT" => RawRouteStageRole::Default, + "ERROR" | "GLOBAL_ERROR" => RawRouteStageRole::ErrorBoundary, + "NOT_FOUND" => RawRouteStageRole::NotFound, + "LOADER" | "BEFORE_LOAD" => RawRouteStageRole::Loader, + "ACTION" => RawRouteStageRole::Action, + _ => RawRouteStageRole::Handler, + }; + let stage_reference = handler_reference.clone(); + let route_anchor = file_anchor(path, source); + route_detail.extend(endpoint_detail(handler, path)); vec![RawFrameworkFact::Route(RawRouteFact { framework: framework.to_owned(), operation: operation.to_owned(), raw_path: original_path, normalized_path, declaring_scope: path.to_string_lossy().replace('\\', "/"), - anchor: file_anchor(path, source), + anchor: route_anchor.clone(), handler_reference, middleware_references: Vec::new(), + stages: vec![RawRouteStageFact { + role: stage_role_override.unwrap_or(stage_role), + position: 0, + reference: stage_reference, + anchor: route_anchor, + origin: RawFrameworkOrigin::Convention, + detail: stage_detail, + }], origin: RawFrameworkOrigin::Convention, rule: Some(rule.to_owned()), - detail: endpoint_detail(handler, path), + detail: route_detail, })] } @@ -888,3 +1636,15 @@ fn file_anchor(path: &Path, source: &[u8]) -> RawFrameworkAnchor { end_column: 0, } } + +fn range_anchor(path: &Path, range: super::typescript_syntax::SyntaxRange) -> RawFrameworkAnchor { + RawFrameworkAnchor { + source_file: path.to_string_lossy().replace('\\', "/"), + start_byte: u64::try_from(range.start_byte).unwrap_or(u64::MAX), + end_byte: u64::try_from(range.end_byte).unwrap_or(u64::MAX), + start_line: range.start_line, + start_column: range.start_column, + end_line: range.end_line, + end_column: range.end_column, + } +} diff --git a/crates/compass-languages/src/frameworks/go.rs b/crates/compass-languages/src/frameworks/go.rs index 3a96a7ea..f4e114bb 100644 --- a/crates/compass-languages/src/frameworks/go.rs +++ b/crates/compass-languages/src/frameworks/go.rs @@ -221,6 +221,7 @@ fn collect_go_route_calls( anchor: anchor(path, source, node.start_byte(), node.end_byte()), handler_reference: handler.clone(), middleware_references: middleware.clone(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: Some(format!("{framework}-router-call")), detail: Map::from_iter([("receiver".into(), Value::String(receiver.clone()))]), diff --git a/crates/compass-languages/src/frameworks/mod.rs b/crates/compass-languages/src/frameworks/mod.rs index 6d86df0b..e25f8715 100644 --- a/crates/compass-languages/src/frameworks/mod.rs +++ b/crates/compass-languages/src/frameworks/mod.rs @@ -15,18 +15,23 @@ mod pack; mod php; mod play; mod python; +mod react; mod remix; mod ruby; mod rust; mod spring; mod swift; +mod tanstack; mod text; mod typescript; +pub(crate) mod typescript_syntax; mod vite; pub use model::{ FrameworkLimitError, FrameworkLimits, RawDomainFact, RawFrameworkAnchor, - RawFrameworkAnnotationFact, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, + RawFrameworkAnnotationFact, RawFrameworkConfigurationFact, RawFrameworkFact, + RawFrameworkFileSetFact, RawFrameworkOrigin, RawFrameworkRelationFact, RawFrameworkRoleFact, + RawRouteFact, RawRouteStageFact, RawRouteStageRole, }; pub use pack::{ FrameworkCapability, FrameworkManifestPolicy, FrameworkOccurrencePolicy, @@ -36,6 +41,8 @@ pub use pack::{ use std::path::Path; +use serde_json::Value; +use sha2::{Digest, Sha256}; use tree_sitter::Node; use crate::SemanticEvidenceBatch; @@ -71,6 +78,12 @@ type ConfigDetector = fn(&Path, &[u8]) -> Vec; type TemplateDetector = fn(&Path, &[u8], Option<&ProjectEvidence>, &mut Extraction) -> Vec; +/// Cache identity for the framework-pack registry. The value is deliberately +/// separate from the language producer version: changing framework activation, +/// descriptor capabilities, or resource limits must invalidate framework facts +/// without pretending that the parser/evidence producer changed. +pub const FRAMEWORK_PACK_SEMANTICS_VERSION: &str = "compass.framework-packs/2"; + /// The concrete implementation stored behind one framework-pack seam. /// /// The public descriptor describes the universal evidence contract. This @@ -94,6 +107,10 @@ enum FrameworkPackAdapter { #[derive(Clone, Copy)] struct FrameworkPack { id: &'static str, + /// Explicit maintainer-controlled semantics version for established + /// source/config/template adapters. Universal descriptors carry the same + /// contract directly in their descriptor. + semantics_version: u32, kind: FrameworkPackKind, languages: &'static [&'static str], dependency_markers: &'static [&'static str], @@ -104,14 +121,16 @@ struct FrameworkPack { } impl FrameworkPack { - const fn source( + const fn source_versioned( id: &'static str, languages: &'static [&'static str], dependency_markers: &'static [&'static str], + semantics_version: u32, detector: SourceDetector, ) -> Self { Self { id, + semantics_version, kind: FrameworkPackKind::Source, languages, dependency_markers, @@ -122,15 +141,17 @@ impl FrameworkPack { } } - const fn source_required( + const fn source_required_versioned( id: &'static str, languages: &'static [&'static str], dependency_markers: &'static [&'static str], configuration_markers: &'static [&'static str], + semantics_version: u32, detector: SourceDetector, ) -> Self { Self { id, + semantics_version, kind: FrameworkPackKind::Source, languages, dependency_markers, @@ -147,6 +168,7 @@ impl FrameworkPack { ) -> Self { Self { id: descriptor.id, + semantics_version: descriptor.semantics_version, kind: descriptor.kind, languages: descriptor.languages, dependency_markers: descriptor.dependency_markers, @@ -160,9 +182,15 @@ impl FrameworkPack { } } - const fn config(id: &'static str, matcher: ConfigMatcher, detector: ConfigDetector) -> Self { + const fn config_versioned( + id: &'static str, + matcher: ConfigMatcher, + semantics_version: u32, + detector: ConfigDetector, + ) -> Self { Self { id, + semantics_version, kind: FrameworkPackKind::Config, languages: &[], dependency_markers: &[], @@ -173,13 +201,15 @@ impl FrameworkPack { } } - const fn template( + const fn template_versioned( id: &'static str, dependency_markers: &'static [&'static str], + semantics_version: u32, detector: TemplateDetector, ) -> Self { Self { id, + semantics_version, kind: FrameworkPackKind::Template, languages: &[], dependency_markers, @@ -201,6 +231,16 @@ impl FrameworkPack { } fn matches_source(self, language: &str, project: Option<&ProjectEvidence>) -> bool { + // TSX uses the TypeScript universal evidence producer while the + // registry still preserves the source-language spelling for legacy + // adapters. Universal descriptors therefore match its canonical + // pipeline language here without making every descriptor duplicate a + // parser dialect entry. + let language = if language == "tsx" { + "typescript" + } else { + language + }; matches!(self.kind, FrameworkPackKind::Source) && self.languages.contains(&language) && self.enabled(project) @@ -219,6 +259,22 @@ impl FrameworkPack { context: &DetectionContext<'_, '_>, extraction: &mut Extraction, ) -> Result, String> { + self.limits + .check_source_bytes(context.source.len()) + .map_err(|error| { + format!( + "framework pack {:?} exceeded its source budget: {error}", + self.id + ) + })?; + let syntax = typescript_syntax::TypeScriptSyntax::new(context.root, context.source); + let (syntax_nodes, syntax_depth) = syntax.node_count_and_depth(); + self.limits + .check_syntax_nodes(syntax_nodes) + .map_err(|error| format!("framework pack {:?}: {error}", self.id))?; + self.limits + .check_syntax_depth(syntax_depth) + .map_err(|error| format!("framework pack {:?}: {error}", self.id))?; let facts = match self.adapter { FrameworkPackAdapter::Source(detector) => detector(context, extraction), FrameworkPackAdapter::Universal { @@ -244,11 +300,23 @@ impl FrameworkPack { self.check_fact_limit(facts.len()).map(|()| facts) } - fn collect_config(self, path: &Path, source: &[u8]) -> Option> { + fn collect_config( + self, + path: &Path, + source: &[u8], + ) -> Result>, String> { let FrameworkPackAdapter::Config { detector, .. } = self.adapter else { - return None; + return Ok(None); }; - Some(detector(path, source)) + self.limits + .check_config_bytes(source.len()) + .map_err(|error| { + format!( + "framework pack {:?} exceeded its config budget: {error}", + self.id + ) + })?; + Ok(Some(detector(path, source))) } fn collect_template( @@ -261,6 +329,14 @@ impl FrameworkPack { let FrameworkPackAdapter::Template(detector) = self.adapter else { return Ok(Vec::new()); }; + self.limits + .check_source_bytes(source.len()) + .map_err(|error| { + format!( + "framework pack {:?} exceeded its source budget: {error}", + self.id + ) + })?; let facts = detector(path, source, project, extraction); self.check_fact_limit(facts.len()).map(|()| facts) } @@ -280,20 +356,21 @@ impl FrameworkPack { /// adapter ownership remain deterministic and do not require a plugin ABI. const FRAMEWORK_PACKS: &[FrameworkPack] = &[ FrameworkPack::universal(&pack::ASPNET_CSHARP_DESCRIPTOR, csharp::detect), - FrameworkPack::source( + FrameworkPack::source_versioned( "aspnet-minimal-csharp", &["csharp"], &["microsoft.aspnetcore.app"], + 1, csharp::detect_minimal, ), FrameworkPack::universal(&pack::SPRING_JAVA_DESCRIPTOR, spring::detect), FrameworkPack::universal(&pack::SPRING_KOTLIN_DESCRIPTOR, spring::detect_kotlin), - FrameworkPack::source("python-web", &["python"], &[], detect_python), + FrameworkPack::source_versioned("python-web", &["python"], &[], 1, detect_python), FrameworkPack::universal(&pack::PHP_FRAMEWORKS_DESCRIPTOR, php::detect), FrameworkPack::universal(&pack::RAILS_RUBY_DESCRIPTOR, ruby::detect_universal), - FrameworkPack::source("go-web", &["go"], &[], detect_go), - FrameworkPack::source("axum-web", &["rust"], &["axum"], detect_axum), - FrameworkPack::source("rust-web", &["rust"], &[], detect_rust), + FrameworkPack::source_versioned("go-web", &["go"], &[], 1, detect_go), + FrameworkPack::source_versioned("axum-web", &["rust"], &["axum"], 1, detect_axum), + FrameworkPack::source_versioned("rust-web", &["rust"], &[], 1, detect_rust), FrameworkPack::universal(&pack::VAPOR_SWIFT_DESCRIPTOR, detect_swift_universal), FrameworkPack::universal( &pack::DART_FLUTTER_NAVIGATION_DESCRIPTOR, @@ -301,25 +378,29 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ ), FrameworkPack::universal(&pack::DART_BLOC_DESCRIPTOR, dart::detect_bloc), FrameworkPack::universal(&pack::DART_RIVERPOD_DESCRIPTOR, dart::detect_riverpod), - FrameworkPack::source( + FrameworkPack::universal(&pack::REACT_UI_DESCRIPTOR, react::detect), + FrameworkPack::source_versioned( "express-web", &["javascript", "typescript", "tsx"], &["express"], + 1, detect_express, ), - FrameworkPack::source( + FrameworkPack::source_versioned( "fastify-web", &["javascript", "typescript", "tsx"], &["fastify"], + 1, detect_fastify, ), - FrameworkPack::source( + FrameworkPack::source_versioned( "hono-web", &["javascript", "typescript", "tsx"], &["hono"], + 1, detect_hono, ), - FrameworkPack::source( + FrameworkPack::source_versioned( "typescript-web", &["javascript", "typescript", "tsx"], &[ @@ -329,16 +410,46 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ "react-router-dom", "vue-router", ], + 2, detect_typescript, ), - FrameworkPack::source_required( + FrameworkPack::source_required_versioned( + "react-router-routes", + &["javascript", "typescript", "tsx"], + &["react-router", "react-router-dom"], + &[], + 2, + detect_react_router, + ), + FrameworkPack::source_versioned( + "tanstack-router", + &["javascript", "typescript", "tsx"], + &[ + "@tanstack/react-router", + "@tanstack/react-start", + "@tanstack/router-core", + "@tanstack/router-generator", + "@tanstack/start", + ], + 2, + detect_tanstack, + ), + FrameworkPack::source_versioned( + "tanstack-start", + &["javascript", "typescript", "tsx"], + &["@tanstack/react-start", "@tanstack/start"], + 2, + detect_tanstack_start, + ), + FrameworkPack::source_required_versioned( "nextjs-routes", &["javascript", "typescript", "tsx"], &["next"], &["next.config.js", "next.config.mjs", "next.config.ts"], + 2, detect_next, ), - FrameworkPack::source_required( + FrameworkPack::source_required_versioned( "remix-routes", &["javascript", "typescript", "tsx"], &[ @@ -347,6 +458,7 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ "@remix-run/react", "@remix-run/router", "@remix-run/serve", + "remix", ], &[ "remix.config.cjs", @@ -354,9 +466,10 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ "remix.config.mjs", "remix.config.ts", ], + 3, detect_remix, ), - FrameworkPack::source_required( + FrameworkPack::source_required_versioned( "vite-config", &["javascript", "typescript", "tsx"], &["vite"], @@ -366,10 +479,12 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ "vite.config.mjs", "vite.config.ts", ], + 2, detect_vite, ), FrameworkPack { id: "filesystem-routes", + semantics_version: 2, kind: FrameworkPackKind::Source, languages: &["javascript", "typescript", "tsx"], dependency_markers: &["@sveltejs/kit", "nuxt", "astro"], @@ -380,6 +495,7 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ }, FrameworkPack { id: "enterprise-domain-facts", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &[ "python", @@ -398,19 +514,76 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ limits: FrameworkLimits::DEFAULT, adapter: FrameworkPackAdapter::Source(detect_enterprise), }, - FrameworkPack::config( + FrameworkPack::config_versioned( "drupal-routing-config", is_drupal_routing, + 1, php::detect_drupal_routing, ), - FrameworkPack::config("play-routes-config", is_play_routes, play::detect), - FrameworkPack::template( + FrameworkPack::config_versioned("play-routes-config", is_play_routes, 1, play::detect), + FrameworkPack::template_versioned( "filesystem-template-routes", &["@sveltejs/kit", "nuxt", "astro"], + 2, file_routes::detect, ), ]; +/// Return the deterministic cache identity for the complete runtime framework +/// registry. This includes both universal descriptor contracts and established +/// adapters, because either path can change the meaning of a source file. +#[must_use] +pub fn framework_semantics_digest() -> String { + let mut digest = Sha256::new(); + digest.update(FRAMEWORK_PACK_SEMANTICS_VERSION.as_bytes()); + digest.update(typescript_syntax::SYNTAX_VIEW_VERSION.as_bytes()); + for pack in FRAMEWORK_PACKS { + let mut fields = vec![ + pack.id.to_owned(), + format!("semantics_version:{}", pack.semantics_version), + format!("kind:{:?}", pack.kind), + format!("languages:{:?}", pack.languages), + format!("dependencies:{:?}", pack.dependency_markers), + format!("configuration:{:?}", pack.configuration_markers), + format!("manifest:{:?}", pack.manifest_policy), + format!("limits:{:?}", pack.limits), + ]; + if let FrameworkPackAdapter::Universal { descriptor, .. } = pack.adapter { + fields.push(format!("required:{:?}", descriptor.required_capabilities)); + fields.push(format!( + "descriptor_semantics_version:{}", + descriptor.semantics_version + )); + fields.push(format!("framework:{:?}", descriptor.framework_capabilities)); + fields.push(format!("activation:{:?}", descriptor.activation_rules)); + fields.push(format!("roles:{:?}", descriptor.accepted_roles)); + fields.push(format!( + "relations:{:?}", + descriptor.emitted_relation_families + )); + fields.push(format!("occurrence:{:?}", descriptor.occurrence_policy)); + } + let encoded = fields.join("\u{1f}"); + digest.update((encoded.len() as u64).to_le_bytes()); + digest.update(encoded.as_bytes()); + } + format!("sha256:{:x}", digest.finalize()) +} + +/// Return the reviewed semantics version for one registered framework pack. +/// +/// Agent-facing consumers publish this value beside a pack ID so a response +/// cannot silently mix evidence from different extractor contracts. Unknown +/// IDs deliberately return `None`; callers must fail closed instead of +/// inventing a version. +#[must_use] +pub fn framework_pack_semantics_version(pack_id: &str) -> Option { + FRAMEWORK_PACKS + .iter() + .find(|pack| pack.id == pack_id) + .map(|pack| pack.semantics_version) +} + struct FrameworkFactAccumulator { facts: Vec, } @@ -422,10 +595,139 @@ impl FrameworkFactAccumulator { fn add(&mut self, pack: FrameworkPack, facts: Vec) -> Result<(), String> { pack.check_fact_limit(facts.len())?; + for fact in &facts { + fact.validate().map_err(|error| { + format!( + "framework pack {:?} emitted invalid {} fact: {error}", + pack.id, + fact_variant_name(fact) + ) + })?; + } + let role_count = facts + .iter() + .filter(|fact| matches!(fact, RawFrameworkFact::Role(_))) + .count() + .saturating_add( + self.facts + .iter() + .filter(|fact| matches!(fact, RawFrameworkFact::Role(_))) + .count(), + ); + pack.limits + .check_role_facts(role_count) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + let relation_count = facts + .iter() + .filter(|fact| matches!(fact, RawFrameworkFact::Relation(_))) + .count() + .saturating_add( + self.facts + .iter() + .filter(|fact| matches!(fact, RawFrameworkFact::Relation(_))) + .count(), + ); + pack.limits + .check_relation_facts(relation_count) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + let route_count = facts + .iter() + .filter(|fact| matches!(fact, RawFrameworkFact::Route(_))) + .count() + .saturating_add( + self.facts + .iter() + .filter(|fact| matches!(fact, RawFrameworkFact::Route(_))) + .count(), + ); + pack.limits + .check_route_nodes(route_count) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + let route_stage_count = facts + .iter() + .filter_map(|fact| match fact { + RawFrameworkFact::Route(route) => Some(route.stages.len()), + _ => None, + }) + .sum::() + .saturating_add( + self.facts + .iter() + .filter_map(|fact| match fact { + RawFrameworkFact::Route(route) => Some(route.stages.len()), + _ => None, + }) + .sum::(), + ); + pack.limits + .check_route_stages(route_stage_count) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + let mut retained_literal_bytes = 0usize; + let mut regex_pattern_length = 0usize; + let mut regex_complexity = 0usize; + for fact in self.facts.iter().chain(facts.iter()) { + match fact { + RawFrameworkFact::Configuration(configuration) => { + if let Some(value) = configuration.value.as_ref() { + observe_json_budget( + value, + &mut retained_literal_bytes, + &mut regex_pattern_length, + &mut regex_complexity, + ); + } + observe_json_budget( + &Value::Object(configuration.detail.clone()), + &mut retained_literal_bytes, + &mut regex_pattern_length, + &mut regex_complexity, + ); + } + RawFrameworkFact::FileSet(file_set) => { + observe_json_budget( + &serde_json::json!({ + "patterns": &file_set.patterns, + "negative_patterns": &file_set.negative_patterns, + "detail": &file_set.detail, + }), + &mut retained_literal_bytes, + &mut regex_pattern_length, + &mut regex_complexity, + ); + } + _ => {} + } + } + pack.limits + .check_retained_literal_bytes(retained_literal_bytes) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + pack.limits + .check_regex_pattern_length(regex_pattern_length) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + pack.limits + .check_regex_complexity(regex_complexity) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; + let glob_patterns = self + .facts + .iter() + .chain(facts.iter()) + .filter_map(|fact| match fact { + RawFrameworkFact::FileSet(file_set) => Some( + file_set + .patterns + .len() + .saturating_add(file_set.negative_patterns.len()), + ), + _ => None, + }) + .sum::(); + pack.limits + .check_glob_patterns(glob_patterns) + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; let observed = self.facts.len().saturating_add(facts.len()); - FrameworkLimits::DEFAULT + pack.limits .check_facts(observed) - .map_err(|error| format!("framework fact budget exceeded: {error}"))?; + .map_err(|error| format!("framework pack {:?}: {error}", pack.id))?; self.facts.extend(facts); Ok(()) } @@ -435,12 +737,92 @@ impl FrameworkFactAccumulator { } } +fn observe_json_budget( + value: &Value, + retained_literal_bytes: &mut usize, + regex_pattern_length: &mut usize, + regex_complexity: &mut usize, +) { + match value { + Value::String(value) => { + *retained_literal_bytes = retained_literal_bytes.saturating_add(value.len()); + } + Value::Array(values) => { + for value in values { + observe_json_budget( + value, + retained_literal_bytes, + regex_pattern_length, + regex_complexity, + ); + } + } + Value::Object(values) => { + let regex = values.get("kind").and_then(Value::as_str) == Some("regex"); + if regex && let Some(pattern) = values.get("find").and_then(Value::as_str) { + *regex_pattern_length = regex_pattern_length.saturating_add(pattern.len()); + *regex_complexity = regex_complexity.saturating_add( + pattern + .chars() + .filter(|character| { + matches!( + character, + '*' | '+' | '?' | '{' | '}' | '(' | ')' | '[' | ']' | '|' + ) + }) + .count(), + ); + } + for value in values.values() { + observe_json_budget( + value, + retained_literal_bytes, + regex_pattern_length, + regex_complexity, + ); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn fact_variant_name(fact: &RawFrameworkFact) -> &'static str { + match fact { + RawFrameworkFact::Route(_) => "route", + RawFrameworkFact::Domain(_) => "domain", + RawFrameworkFact::Annotation(_) => "annotation", + RawFrameworkFact::Role(_) => "role", + RawFrameworkFact::Relation(_) => "relation", + RawFrameworkFact::Configuration(_) => "configuration", + RawFrameworkFact::FileSet(_) => "file_set", + } +} + fn record_framework_error(extraction: &mut Extraction, error: String) { extraction .error .get_or_insert_with(|| format!("framework extraction failed: {error}")); } +#[cfg(test)] +mod runtime_registry_tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn every_runtime_pack_has_explicit_nonzero_version_and_unique_id() { + let mut ids = BTreeSet::new(); + for pack in FRAMEWORK_PACKS { + assert!( + pack.semantics_version > 0, + "pack {} has no semantics version", + pack.id + ); + assert!(ids.insert(pack.id), "duplicate runtime pack {}", pack.id); + } + } +} + pub(crate) fn detect( path: &Path, source: &[u8], @@ -495,8 +877,13 @@ pub(crate) fn detect_config_file( else { return extraction; }; - let Some(facts) = pack.collect_config(path, source) else { - return extraction; + let facts = match pack.collect_config(path, source) { + Ok(Some(facts)) => facts, + Ok(None) => return extraction, + Err(error) => { + record_framework_error(&mut extraction, error); + return extraction; + } }; let mut accumulator = FrameworkFactAccumulator::new(); if let Err(error) = accumulator.add(*pack, facts) { @@ -579,6 +966,27 @@ fn detect_typescript( typescript::detect_non_express(context.path, context.source, context.root, extraction) } +fn detect_react_router( + context: &DetectionContext<'_, '_>, + extraction: &mut Extraction, +) -> Vec { + typescript::detect_react_router(context.path, context.source, context.root, extraction) +} + +fn detect_tanstack( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + tanstack::detect(context.path, context.source, context.root, context.project) +} + +fn detect_tanstack_start( + context: &DetectionContext<'_, '_>, + _extraction: &mut Extraction, +) -> Vec { + tanstack::detect_start(context.path, context.source, context.root, context.project) +} + fn detect_express( context: &DetectionContext<'_, '_>, extraction: &mut Extraction, @@ -604,21 +1012,39 @@ fn detect_next( context: &DetectionContext<'_, '_>, extraction: &mut Extraction, ) -> Vec { - next::detect(context.path, context.source, context.project, extraction) + next::detect( + context.path, + context.source, + context.root, + context.project, + extraction, + ) } fn detect_remix( context: &DetectionContext<'_, '_>, extraction: &mut Extraction, ) -> Vec { - remix::detect(context.path, context.source, context.project, extraction) + remix::detect( + context.path, + context.source, + context.root, + context.project, + extraction, + ) } fn detect_vite( context: &DetectionContext<'_, '_>, extraction: &mut Extraction, ) -> Vec { - vite::detect(context.path, context.source, context.project, extraction) + vite::detect( + context.path, + context.source, + context.root, + context.project, + extraction, + ) } fn detect_file_routes( @@ -688,10 +1114,14 @@ mod tests { "dart-flutter-navigation", "dart-bloc", "dart-riverpod", + "react-ui", "express-web", "fastify-web", "hono-web", "typescript-web", + "react-router-routes", + "tanstack-router", + "tanstack-start", "nextjs-routes", "remix-routes", "vite-config", @@ -708,6 +1138,11 @@ mod tests { #[test] fn runtime_registry_uses_one_manifest_policy_and_budget_source() { for pack in FRAMEWORK_PACKS { + assert!( + pack.semantics_version > 0, + "missing semantics version for {}", + pack.id + ); assert!(pack.limits.max_facts_per_file > 0); if pack.manifest_policy == super::FrameworkManifestPolicy::Required { assert!(!pack.dependency_markers.is_empty()); @@ -732,4 +1167,13 @@ mod tests { } assert_eq!(super::FrameworkPackRegistry::validate(), Ok(())); } + + #[test] + fn framework_semantics_digest_is_versioned_and_deterministic() { + let first = super::framework_semantics_digest(); + let second = super::framework_semantics_digest(); + assert_eq!(first, second); + assert!(first.starts_with("sha256:")); + assert_eq!(first.len(), "sha256:".len() + 64); + } } diff --git a/crates/compass-languages/src/frameworks/model.rs b/crates/compass-languages/src/frameworks/model.rs index 60962561..eba60e34 100644 --- a/crates/compass-languages/src/frameworks/model.rs +++ b/crates/compass-languages/src/frameworks/model.rs @@ -58,6 +58,8 @@ pub struct RawRouteFact { pub handler_reference: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub middleware_references: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stages: Vec, pub origin: RawFrameworkOrigin, #[serde(default, skip_serializing_if = "Option::is_none")] pub rule: Option, @@ -65,6 +67,36 @@ pub struct RawRouteFact { pub detail: Map, } +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RawRouteStageRole { + Middleware, + Layout, + Template, + Loading, + Default, + ErrorBoundary, + NotFound, + Boundary, + Loader, + Action, + Handler, + DataLoader, + RouteComponent, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RawRouteStageFact { + pub role: RawRouteStageRole, + pub position: u32, + pub reference: String, + pub anchor: RawFrameworkAnchor, + pub origin: RawFrameworkOrigin, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub detail: Map, +} + impl RawRouteFact { pub fn validate(&self) -> Result<(), &'static str> { if self.framework.trim().is_empty() { @@ -85,6 +117,13 @@ impl RawRouteFact { if !self.anchor.is_valid() { return Err("route anchor must be a non-empty valid range"); } + if self + .stages + .iter() + .any(|stage| stage.reference.trim().is_empty() || !stage.anchor.is_valid()) + { + return Err("route stages require non-empty references and valid anchors"); + } if matches!( self.origin, RawFrameworkOrigin::Convention | RawFrameworkOrigin::Heuristic @@ -136,12 +175,239 @@ pub struct RawFrameworkAnnotationFact { pub detail: Map, } +/// A project-neutral role assertion. `subject_reference` is only an identity +/// hint from the language evidence; it is never a resolved graph target. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RawFrameworkRoleFact { + pub pack_id: String, + pub framework: String, + pub role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_reference: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + pub anchor: RawFrameworkAnchor, + pub origin: RawFrameworkOrigin, + pub evidence_class: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub detail: Map, +} + +/// A project-neutral relation assertion. The target field is deliberately +/// named a hint: project-wide resolution must still prove the target or keep +/// the relation unresolved/ambiguous. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RawFrameworkRelationFact { + pub pack_id: String, + pub framework: String, + pub relation: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_reference: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + pub anchor: RawFrameworkAnchor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_anchor: Option, + pub origin: RawFrameworkOrigin, + pub evidence_class: String, + pub ambiguity_policy: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub detail: Map, +} + +/// A statically recovered configuration field selected by a framework pack. +/// The value is intentionally opaque JSON and must remain bounded by the pack +/// limits; dynamic or recovery-overlapping values use `complete = false`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RawFrameworkConfigurationFact { + pub pack_id: String, + pub framework: String, + pub config_id: String, + pub field: String, + pub anchor: RawFrameworkAnchor, + pub ordinal: u32, + pub complete: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + pub origin: RawFrameworkOrigin, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub detail: Map, +} + +/// An ordered, unresolved file-set pattern declaration. Matching and target +/// selection remain owned by `compass-files`/`compass-resolve`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RawFrameworkFileSetFact { + pub pack_id: String, + pub framework: String, + pub owner_reference: String, + pub patterns: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub negative_patterns: Vec, + pub anchor: RawFrameworkAnchor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_scope: Option, + pub eager: bool, + pub lazy: bool, + pub import_mode: bool, + pub query_mode: bool, + pub origin: RawFrameworkOrigin, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub detail: Map, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", content = "fact", rename_all = "snake_case")] pub enum RawFrameworkFact { Route(RawRouteFact), Domain(RawDomainFact), Annotation(RawFrameworkAnnotationFact), + Role(RawFrameworkRoleFact), + Relation(RawFrameworkRelationFact), + Configuration(RawFrameworkConfigurationFact), + FileSet(RawFrameworkFileSetFact), +} + +impl RawFrameworkFact { + #[must_use] + pub fn anchor(&self) -> &RawFrameworkAnchor { + match self { + Self::Route(fact) => &fact.anchor, + Self::Domain(fact) => &fact.anchor, + Self::Annotation(fact) => &fact.anchor, + Self::Role(fact) => &fact.anchor, + Self::Relation(fact) => &fact.anchor, + Self::Configuration(fact) => &fact.anchor, + Self::FileSet(fact) => &fact.anchor, + } + } + + #[must_use] + pub fn framework(&self) -> &str { + match self { + Self::Route(fact) => &fact.framework, + Self::Domain(fact) => &fact.framework, + Self::Annotation(fact) => &fact.framework, + Self::Role(fact) => &fact.framework, + Self::Relation(fact) => &fact.framework, + Self::Configuration(fact) => &fact.framework, + Self::FileSet(fact) => &fact.framework, + } + } + + pub fn validate(&self) -> Result<(), &'static str> { + if !self.anchor().is_valid() { + return Err("framework fact anchor must be a non-empty valid range"); + } + match self { + Self::Route(fact) => fact.validate(), + Self::Domain(fact) => { + if fact.framework.trim().is_empty() || fact.kind.trim().is_empty() { + Err("domain framework and kind must not be empty") + } else { + Ok(()) + } + } + Self::Annotation(fact) => { + if fact.pack_id.trim().is_empty() + || fact.framework.trim().is_empty() + || fact.annotation_name.trim().is_empty() + || fact.owner_declaration_id.trim().is_empty() + { + Err("annotation framework identity must not be empty") + } else { + Ok(()) + } + } + Self::Role(fact) => fact.validate(), + Self::Relation(fact) => fact.validate(), + Self::Configuration(fact) => fact.validate(), + Self::FileSet(fact) => fact.validate(), + } + } +} + +impl RawFrameworkRoleFact { + pub fn validate(&self) -> Result<(), &'static str> { + if self.pack_id.trim().is_empty() + || self.framework.trim().is_empty() + || self.role.trim().is_empty() + || self.evidence_class.trim().is_empty() + { + return Err("role identity and evidence class must not be empty"); + } + if self.subject_reference.as_deref().is_some_and(str::is_empty) { + return Err("role subject reference must not be empty"); + } + Ok(()) + } +} + +impl RawFrameworkRelationFact { + pub fn validate(&self) -> Result<(), &'static str> { + if self.pack_id.trim().is_empty() + || self.framework.trim().is_empty() + || self.relation.trim().is_empty() + || self.evidence_class.trim().is_empty() + || self.ambiguity_policy.trim().is_empty() + { + return Err("relation identity and resolution policy must not be empty"); + } + if self.source_reference.as_deref().is_some_and(str::is_empty) + || self.target_hint.as_deref().is_some_and(str::is_empty) + { + return Err("relation references must be absent or non-empty"); + } + if let Some(target_anchor) = self.target_anchor.as_ref() + && !target_anchor.is_valid() + { + return Err("relation target anchor must be valid"); + } + Ok(()) + } +} + +impl RawFrameworkConfigurationFact { + pub fn validate(&self) -> Result<(), &'static str> { + if self.pack_id.trim().is_empty() + || self.framework.trim().is_empty() + || self.config_id.trim().is_empty() + || self.field.trim().is_empty() + { + return Err("configuration identity must not be empty"); + } + if self.complete && self.value.is_none() { + return Err("complete configuration facts require a value"); + } + Ok(()) + } +} + +impl RawFrameworkFileSetFact { + pub fn validate(&self) -> Result<(), &'static str> { + if self.pack_id.trim().is_empty() + || self.framework.trim().is_empty() + || self.owner_reference.trim().is_empty() + || self.patterns.is_empty() + || self + .patterns + .iter() + .any(|pattern| pattern.trim().is_empty()) + || self + .negative_patterns + .iter() + .any(|pattern| pattern.trim().is_empty()) + { + return Err("file-set identity and patterns must not be empty"); + } + Ok(()) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -150,6 +416,21 @@ pub struct FrameworkLimits { pub max_include_depth: usize, pub max_alias_expansions: usize, pub max_facts_per_file: usize, + pub max_source_bytes: usize, + pub max_config_bytes: usize, + pub max_syntax_nodes: usize, + pub max_syntax_depth: usize, + pub max_retained_literal_bytes: usize, + pub max_role_facts: usize, + pub max_relation_facts: usize, + pub max_diagnostics: usize, + pub max_route_nodes: usize, + pub max_route_stages: usize, + pub max_glob_patterns: usize, + pub max_glob_matches_per_pattern: usize, + pub max_file_set_edges: usize, + pub max_regex_pattern_length: usize, + pub max_regex_complexity: usize, } impl Default for FrameworkLimits { @@ -168,17 +449,112 @@ impl FrameworkLimits { max_include_depth: 32, max_alias_expansions: 1_000, max_facts_per_file: 100_000, + max_source_bytes: 8 * 1024 * 1024, + max_config_bytes: 2 * 1024 * 1024, + max_syntax_nodes: 100_000, + max_syntax_depth: 256, + max_retained_literal_bytes: 64 * 1024, + max_role_facts: 100_000, + max_relation_facts: 100_000, + max_diagnostics: 10_000, + max_route_nodes: 50_000, + max_route_stages: 100_000, + max_glob_patterns: 2_048, + max_glob_matches_per_pattern: 100_000, + max_file_set_edges: 1_000_000, + max_regex_pattern_length: 1_024, + max_regex_complexity: 1_024, }; pub fn check_facts(self, count: usize) -> Result<(), FrameworkLimitError> { - if count > self.max_facts_per_file { - return Err(FrameworkLimitError { - limit: "max_facts_per_file", - maximum: self.max_facts_per_file, - observed: count, - }); - } - Ok(()) + self.check("max_facts_per_file", self.max_facts_per_file, count) + } + + pub fn check_source_bytes(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_source_bytes", self.max_source_bytes, count) + } + + pub fn check_config_bytes(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_config_bytes", self.max_config_bytes, count) + } + + pub fn check_syntax_nodes(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_syntax_nodes", self.max_syntax_nodes, count) + } + + pub fn check_syntax_depth(self, depth: usize) -> Result<(), FrameworkLimitError> { + self.check("max_syntax_depth", self.max_syntax_depth, depth) + } + + pub fn check_retained_literal_bytes(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check( + "max_retained_literal_bytes", + self.max_retained_literal_bytes, + count, + ) + } + + pub fn check_role_facts(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_role_facts", self.max_role_facts, count) + } + + pub fn check_relation_facts(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_relation_facts", self.max_relation_facts, count) + } + + pub fn check_diagnostics(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_diagnostics", self.max_diagnostics, count) + } + + pub fn check_route_nodes(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_route_nodes", self.max_route_nodes, count) + } + + pub fn check_route_stages(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_route_stages", self.max_route_stages, count) + } + + pub fn check_glob_patterns(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_glob_patterns", self.max_glob_patterns, count) + } + + pub fn check_glob_matches_per_pattern(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check( + "max_glob_matches_per_pattern", + self.max_glob_matches_per_pattern, + count, + ) + } + + pub fn check_file_set_edges(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_file_set_edges", self.max_file_set_edges, count) + } + + pub fn check_regex_pattern_length(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check( + "max_regex_pattern_length", + self.max_regex_pattern_length, + count, + ) + } + + pub fn check_regex_complexity(self, count: usize) -> Result<(), FrameworkLimitError> { + self.check("max_regex_complexity", self.max_regex_complexity, count) + } + + fn check( + self, + limit: &'static str, + maximum: usize, + observed: usize, + ) -> Result<(), FrameworkLimitError> { + (observed <= maximum) + .then_some(()) + .ok_or(FrameworkLimitError { + limit, + maximum, + observed, + }) } } diff --git a/crates/compass-languages/src/frameworks/next.rs b/crates/compass-languages/src/frameworks/next.rs index 1aa95329..a5900db1 100644 --- a/crates/compass-languages/src/frameworks/next.rs +++ b/crates/compass-languages/src/frameworks/next.rs @@ -2,14 +2,16 @@ use std::path::Path; use super::RawFrameworkFact; use crate::{Extraction, ProjectEvidence}; +use tree_sitter::Node; /// Next.js owns file-system route conventions; the shared file-route module /// supplies bounded path normalization and handler identity construction. pub(super) fn detect( path: &Path, source: &[u8], + root: Node<'_>, project: Option<&ProjectEvidence>, extraction: &mut Extraction, ) -> Vec { - super::file_routes::detect_next(path, source, project, extraction) + super::file_routes::detect_next(path, source, root, project, extraction) } diff --git a/crates/compass-languages/src/frameworks/pack.rs b/crates/compass-languages/src/frameworks/pack.rs index 5527289b..809cedf2 100644 --- a/crates/compass-languages/src/frameworks/pack.rs +++ b/crates/compass-languages/src/frameworks/pack.rs @@ -44,6 +44,7 @@ pub enum FrameworkCapability { Persistence, Transactions, Security, + Ui, } /// Closed framework relationship vocabulary advertised by a universal pack. @@ -65,6 +66,7 @@ pub enum FrameworkRelation { Triggers, DependsOn, MapsTo, + Renders, } impl FrameworkRelation { @@ -83,6 +85,7 @@ impl FrameworkRelation { Self::Triggers => "triggers", Self::DependsOn => "depends_on", Self::MapsTo => "maps_to", + Self::Renders => "renders", } } @@ -100,6 +103,7 @@ impl FrameworkRelation { Self::Schedules | Self::Triggers => Some(FrameworkCapability::Scheduling), Self::MapsTo => Some(FrameworkCapability::Persistence), Self::Decorates => None, + Self::Renders => Some(FrameworkCapability::Ui), }; required.map_or_else( || { @@ -115,6 +119,12 @@ impl FrameworkRelation { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct FrameworkPackDescriptor { pub id: &'static str, + /// Manually reviewed semantic version for this pack's evidence contract. + /// + /// The registry digest includes this value so detector changes cannot + /// silently reuse a cache entry. It is intentionally not derived from a + /// function pointer, which would be unstable across builds. + pub semantics_version: u32, pub kind: FrameworkPackKind, pub languages: &'static [&'static str], pub required_capabilities: &'static [LanguageCapability], @@ -132,6 +142,8 @@ pub struct FrameworkPackDescriptor { pub enum FrameworkPackRegistryError { #[error("framework pack ID must not be empty")] EmptyId, + #[error("framework pack {0:?} must declare a non-zero semantics version")] + InvalidSemanticsVersion(&'static str), #[error("duplicate framework pack ID {0:?}")] DuplicateId(&'static str), #[error("framework pack {0:?} must declare at least one language")] @@ -230,6 +242,11 @@ fn validate_descriptor( if descriptor.id.trim().is_empty() { return Err(FrameworkPackRegistryError::EmptyId); } + if descriptor.semantics_version == 0 { + return Err(FrameworkPackRegistryError::InvalidSemanticsVersion( + descriptor.id, + )); + } if descriptor.languages.is_empty() { return Err(FrameworkPackRegistryError::EmptyLanguages(descriptor.id)); } @@ -347,6 +364,33 @@ fn validate_descriptor( descriptor.limits.max_alias_expansions, ), ("max_facts_per_file", descriptor.limits.max_facts_per_file), + ("max_source_bytes", descriptor.limits.max_source_bytes), + ("max_config_bytes", descriptor.limits.max_config_bytes), + ("max_syntax_nodes", descriptor.limits.max_syntax_nodes), + ("max_syntax_depth", descriptor.limits.max_syntax_depth), + ( + "max_retained_literal_bytes", + descriptor.limits.max_retained_literal_bytes, + ), + ("max_role_facts", descriptor.limits.max_role_facts), + ("max_relation_facts", descriptor.limits.max_relation_facts), + ("max_diagnostics", descriptor.limits.max_diagnostics), + ("max_route_nodes", descriptor.limits.max_route_nodes), + ("max_route_stages", descriptor.limits.max_route_stages), + ("max_glob_patterns", descriptor.limits.max_glob_patterns), + ( + "max_glob_matches_per_pattern", + descriptor.limits.max_glob_matches_per_pattern, + ), + ("max_file_set_edges", descriptor.limits.max_file_set_edges), + ( + "max_regex_pattern_length", + descriptor.limits.max_regex_pattern_length, + ), + ( + "max_regex_complexity", + descriptor.limits.max_regex_complexity, + ), ] { if value == 0 { return Err(FrameworkPackRegistryError::ZeroLimit { @@ -369,6 +413,7 @@ fn validate_strings(values: &[&str]) -> Result<(), ()> { pub(super) const SPRING_JAVA_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "spring-java", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["java"], required_capabilities: &[ @@ -432,11 +477,13 @@ pub(super) const SPRING_JAVA_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPack max_include_depth: 32, max_alias_expansions: 1_000, max_facts_per_file: 100_000, + ..FrameworkLimits::DEFAULT }, }; pub(super) const SPRING_KOTLIN_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "spring-kotlin", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["kotlin"], required_capabilities: &[ @@ -501,11 +548,13 @@ pub(super) const SPRING_KOTLIN_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPa max_include_depth: 32, max_alias_expansions: 1_000, max_facts_per_file: 100_000, + ..FrameworkLimits::DEFAULT }, }; pub(super) const RAILS_RUBY_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "rails-ruby", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["ruby"], required_capabilities: &[ @@ -529,11 +578,13 @@ pub(super) const RAILS_RUBY_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackD max_include_depth: 32, max_alias_expansions: 1_000, max_facts_per_file: 100_000, + ..FrameworkLimits::DEFAULT }, }; pub(super) const ASPNET_CSHARP_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "aspnet-csharp", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["csharp"], required_capabilities: &[ @@ -566,11 +617,13 @@ pub(super) const ASPNET_CSHARP_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPa max_include_depth: 32, max_alias_expansions: 1_000, max_facts_per_file: 100_000, + ..FrameworkLimits::DEFAULT }, }; pub(super) const PHP_FRAMEWORKS_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "php-frameworks", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["php"], required_capabilities: &[ @@ -603,6 +656,7 @@ pub(super) const PHP_FRAMEWORKS_DESCRIPTOR: FrameworkPackDescriptor = FrameworkP pub(super) const VAPOR_SWIFT_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "vapor-swift", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["swift"], required_capabilities: &[ @@ -629,6 +683,7 @@ pub(super) const VAPOR_SWIFT_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPack pub(super) const DART_FLUTTER_NAVIGATION_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "dart-flutter-navigation", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["dart"], required_capabilities: &[ @@ -652,6 +707,7 @@ pub(super) const DART_FLUTTER_NAVIGATION_DESCRIPTOR: FrameworkPackDescriptor = pub(super) const DART_BLOC_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "dart-bloc", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["dart"], required_capabilities: &[ @@ -675,6 +731,7 @@ pub(super) const DART_BLOC_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDe pub(super) const DART_RIVERPOD_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "dart-riverpod", + semantics_version: 1, kind: FrameworkPackKind::Source, languages: &["dart"], required_capabilities: &[LanguageCapability::Calls, LanguageCapability::Members], @@ -688,6 +745,33 @@ pub(super) const DART_RIVERPOD_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPa limits: FrameworkLimits::DEFAULT, }; +pub(super) const REACT_UI_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { + id: "react-ui", + semantics_version: 1, + kind: FrameworkPackKind::Source, + languages: &["javascript", "typescript"], + required_capabilities: &[ + LanguageCapability::Declarations, + LanguageCapability::Calls, + LanguageCapability::Ownership, + ], + framework_capabilities: &[FrameworkCapability::Ui], + dependency_markers: &[ + "@vitejs/plugin-react", + "@vitejs/plugin-react-swc", + "react", + "react-dom", + "react-router", + "react-router-dom", + ], + manifest_policy: FrameworkManifestPolicy::Advisory, + activation_rules: &["jsx-component", "react-hook", "react-runtime-import"], + accepted_roles: &[SemanticRole::CallableReference], + emitted_relation_families: &[FrameworkRelation::Renders], + occurrence_policy: FrameworkOccurrencePolicy::ExactEvidence, + limits: FrameworkLimits::DEFAULT, +}; + const UNIVERSAL_FRAMEWORK_PACKS: &[FrameworkPackDescriptor] = &[ ASPNET_CSHARP_DESCRIPTOR, PHP_FRAMEWORKS_DESCRIPTOR, @@ -698,4 +782,5 @@ const UNIVERSAL_FRAMEWORK_PACKS: &[FrameworkPackDescriptor] = &[ DART_BLOC_DESCRIPTOR, DART_FLUTTER_NAVIGATION_DESCRIPTOR, DART_RIVERPOD_DESCRIPTOR, + REACT_UI_DESCRIPTOR, ]; diff --git a/crates/compass-languages/src/frameworks/php.rs b/crates/compass-languages/src/frameworks/php.rs index 13998dce..4da70965 100644 --- a/crates/compass-languages/src/frameworks/php.rs +++ b/crates/compass-languages/src/frameworks/php.rs @@ -136,6 +136,7 @@ pub(super) fn detect_drupal_routing(path: &Path, source: &[u8]) -> Vec) -> Vec Vec { anchor: line_anchor_at(path, source, offset, line, line_index.saturating_add(1)), handler_reference: handler, middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Config, rule: Some("play-conf-routes".to_owned()), detail: Map::new(), diff --git a/crates/compass-languages/src/frameworks/python.rs b/crates/compass-languages/src/frameworks/python.rs index bf6ddc74..8f0afd01 100644 --- a/crates/compass-languages/src/frameworks/python.rs +++ b/crates/compass-languages/src/frameworks/python.rs @@ -114,6 +114,7 @@ fn collect_django_routes( anchor: anchor(path, node), handler_reference, middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Config, rule: None, detail, @@ -197,6 +198,7 @@ fn collect_decorated_routes( anchor: anchor(path, decorator), handler_reference: name.clone(), middleware_references: middleware_references.clone(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: (!mount_prefix.is_empty()) .then(|| "python-mounted-router".to_owned()), diff --git a/crates/compass-languages/src/frameworks/react.rs b/crates/compass-languages/src/frameworks/react.rs new file mode 100644 index 00000000..575f9293 --- /dev/null +++ b/crates/compass-languages/src/frameworks/react.rs @@ -0,0 +1,428 @@ +//! Conservative React UI-role evidence over the prepared TypeScript/JavaScript +//! tree. This module deliberately emits project-neutral facts; target identity +//! and graph publication remain resolver responsibilities. + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; +use tree_sitter::Node; + +use crate::{ + RawDomainFact, RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawFrameworkRoleFact, +}; + +use super::UniversalDetectionContext; +use super::typescript_syntax::TypeScriptSyntax; + +const MAX_HOOK_CLOSURE: usize = 128; +const REACT_BUILTIN_HOOKS: &[&str] = &[ + "use", + "useActionState", + "useCallback", + "useContext", + "useDebugValue", + "useDeferredValue", + "useEffect", + "useEffectEvent", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useOptimistic", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", +]; + +pub(super) fn detect(context: &UniversalDetectionContext<'_, '_>) -> Vec { + let syntax = TypeScriptSyntax::new(context.root, context.source); + let has_project_runtime = context.project.is_some_and(|project| { + project.has_any_dependency(&[ + "react", + "react-dom", + "react-router", + "react-router-dom", + "@remix-run/react", + "remix", + "@tanstack/react-router", + "@vitejs/plugin-react", + "@vitejs/plugin-react-swc", + ]) + }); + let has_source_runtime = context.evidence.bindings.iter().any(|binding| { + let module = binding + .qualified_target + .split_once("::") + .map_or(binding.qualified_target.as_str(), |(module, _)| module); + matches!( + module, + "react" + | "react-dom" + | "react/jsx-runtime" + | "react/jsx-dev-runtime" + | "react-dom/client" + ) + }); + if !has_project_runtime && !has_source_runtime { + return Vec::new(); + } + + let factory_component_targets = react_factory_component_targets(context); + + let has_client_directive = syntax.top_level_directive("use client"); + let has_server_directive = syntax.top_level_directive("use server"); + let qualified_hooks = qualifying_hook_declarations(context); + let mut facts = Vec::new(); + let mut seen = BTreeSet::new(); + + for declaration in &context.evidence.declarations { + let node = smallest_covering_node( + context.root, + declaration.range.start_byte as usize, + declaration.range.end_byte as usize, + ) + .map(covering_declaration_node); + let has_jsx = node.is_some_and(|node| declaration_contains_jsx(&syntax, node)); + let is_component = declaration + .name + .chars() + .next() + .is_some_and(char::is_uppercase) + && is_component_declaration(&declaration.kind) + && (factory_component_targets.contains(&declaration.id) || has_jsx); + let is_hook = qualified_hooks.contains(&declaration.id); + + let mut roles = BTreeSet::new(); + if is_component { + roles.insert("ui_component"); + } + if is_hook { + roles.insert("hook"); + } + // A Next/React client module establishes a client boundary for every + // component declared in the module. Only declarations exported + // through that boundary receive the stronger client-component role. + if has_client_directive && is_component { + roles.insert("client_boundary"); + if node.is_some_and(is_exported_declaration) { + roles.insert("client_component"); + } + } + let has_local_server_directive = node + .and_then(|node| node.child_by_field_name("body")) + .is_some_and(|body| { + TypeScriptSyntax::new(body, context.source).top_level_directive("use server") + }); + if (has_server_directive || has_local_server_directive) + && declaration.kind == "function" + && node.is_some_and(|node| is_exported_declaration(node)) + { + roles.insert("server_function"); + } + if roles.is_empty() { + continue; + } + + for role in roles { + let key = (declaration.graph_node_id.clone(), role.to_owned()); + if !seen.insert(key) { + continue; + } + let mut detail = Map::new(); + detail.insert("pack_id".to_owned(), Value::String("react-ui".to_owned())); + detail.insert( + "source_reference".to_owned(), + Value::String(declaration.graph_node_id.clone()), + ); + detail.insert("role".to_owned(), Value::String(role.to_owned())); + detail.insert( + "declaration_id".to_owned(), + Value::String(declaration.id.clone()), + ); + let anchor = RawFrameworkAnchor { + source_file: declaration.range.source_file.clone(), + start_byte: declaration.range.start_byte, + end_byte: declaration.range.end_byte, + start_line: declaration.range.start_line, + start_column: declaration.range.start_column, + end_line: declaration.range.end_line, + end_column: declaration.range.end_column, + }; + facts.push(RawFrameworkFact::Domain(RawDomainFact { + framework: "react".to_owned(), + kind: "ui_role".to_owned(), + name: declaration.name.clone(), + declaring_scope: declaration.scope_id.clone().unwrap_or_default(), + anchor: anchor.clone(), + origin: RawFrameworkOrigin::Ast, + detail, + })); + facts.push(RawFrameworkFact::Role(RawFrameworkRoleFact { + pack_id: "react-ui".to_owned(), + framework: "react".to_owned(), + role: role.to_owned(), + subject_reference: Some(declaration.graph_node_id.clone()), + context: declaration.scope_id.clone(), + anchor, + origin: RawFrameworkOrigin::Ast, + evidence_class: "exact".to_owned(), + detail: Map::from_iter([( + "declaration_id".to_owned(), + Value::String(declaration.id.clone()), + )]), + })); + } + } + + facts.sort_by_key(fact_key); + facts +} + +fn react_factory_component_targets( + context: &UniversalDetectionContext<'_, '_>, +) -> BTreeSet { + let occurrences = context + .evidence + .occurrences + .iter() + .map(|occurrence| (occurrence.id.as_str(), occurrence)) + .collect::>(); + let mut targets = BTreeSet::new(); + for call in context.evidence.candidates.iter().filter(|candidate| { + candidate.relation == crate::CandidateRelation::Calls + && candidate.target_spelling == "createElement" + && candidate + .constraints + .module_or_package + .as_deref() + .is_some_and(|module| module == "react" || module.starts_with("react/")) + }) { + let Some(occurrence) = call + .occurrence_id + .as_deref() + .and_then(|id| occurrences.get(id)) + else { + continue; + }; + let Some(target) = context + .evidence + .candidates + .iter() + .filter(|candidate| { + candidate.relation == crate::CandidateRelation::References + && candidate.source_declaration_id == call.source_declaration_id + && candidate.constraints.exact_target_declaration_id.is_some() + && candidate + .occurrence_id + .as_deref() + .and_then(|id| occurrences.get(id)) + .is_some_and(|reference| { + reference.context.as_deref() == Some("value") + && reference.range.start_byte > occurrence.range.end_byte + }) + }) + .min_by_key(|candidate| { + candidate + .occurrence_id + .as_deref() + .and_then(|id| occurrences.get(id)) + .map_or(u64::MAX, |reference| reference.range.start_byte) + }) + .and_then(|candidate| candidate.constraints.exact_target_declaration_id.clone()) + else { + continue; + }; + targets.insert(target); + targets.insert(call.source_declaration_id.clone()); + } + targets +} + +fn fact_key(fact: &RawFrameworkFact) -> (String, String, String) { + match fact { + RawFrameworkFact::Domain(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + fact.detail + .get("role") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + ), + RawFrameworkFact::Route(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + "route".to_owned(), + ), + RawFrameworkFact::Annotation(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + "annotation".to_owned(), + ), + RawFrameworkFact::Role(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + fact.role.clone(), + ), + RawFrameworkFact::Relation(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + fact.relation.clone(), + ), + RawFrameworkFact::Configuration(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + fact.field.clone(), + ), + RawFrameworkFact::FileSet(fact) => ( + fact.anchor.source_file.clone(), + fact.anchor.start_byte.to_string(), + fact.owner_reference.clone(), + ), + } +} + +fn is_custom_hook(kind: &str, name: &str) -> bool { + matches!(kind, "function" | "variable" | "property") + && name + .strip_prefix("use") + .is_some_and(|suffix| suffix.chars().next().is_some_and(char::is_uppercase)) +} + +fn is_component_declaration(kind: &str) -> bool { + matches!( + kind, + "class" | "closure" | "function" | "method" | "variable" + ) +} + +fn declaration_contains_jsx(syntax: &TypeScriptSyntax<'_, '_>, node: Node<'_>) -> bool { + let has_jsx = |node: Node<'_>| { + ["jsx_element", "jsx_self_closing_element", "jsx_fragment"] + .iter() + .any(|kind| syntax.contains_kind(node, kind)) + }; + match node.kind() { + "variable_declarator" => node + .child_by_field_name("value") + .is_some_and(|value| match value.kind() { + "arrow_function" | "function_expression" => has_jsx(value), + "call_expression" => value + .named_children(&mut value.walk()) + .find(|child| child.kind() == "arguments") + .is_some_and(has_jsx), + _ => false, + }), + "function_declaration" | "function_expression" | "arrow_function" | "method_definition" => { + node.child_by_field_name("body").is_some_and(has_jsx) + } + "class_declaration" | "class_expression" => has_jsx(node), + _ => false, + } +} + +fn qualifying_hook_declarations(context: &UniversalDetectionContext<'_, '_>) -> BTreeSet { + let callable_ids = context + .evidence + .declarations + .iter() + .filter(|declaration| is_custom_hook(&declaration.kind, &declaration.name)) + .map(|declaration| declaration.id.clone()) + .collect::>(); + let mut qualified = BTreeSet::new(); + for _ in 0..MAX_HOOK_CLOSURE { + let mut changed = false; + for candidate in &context.evidence.candidates { + if candidate.relation != crate::CandidateRelation::Calls + || !callable_ids.contains(&candidate.source_declaration_id) + { + continue; + } + let builtin = candidate.constraints.module_or_package.as_deref() == Some("react") + && REACT_BUILTIN_HOOKS.contains(&candidate.target_spelling.as_str()); + let custom = candidate + .constraints + .exact_target_declaration_id + .as_ref() + .is_some_and(|target| qualified.contains(target)); + if (builtin || custom) && qualified.insert(candidate.source_declaration_id.clone()) { + changed = true; + } + } + if !changed { + break; + } + } + qualified +} + +fn smallest_covering_node<'tree>( + node: Node<'tree>, + start: usize, + end: usize, +) -> Option> { + if node.start_byte() > start || node.end_byte() < end { + return None; + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if let Some(found) = smallest_covering_node(child, start, end) { + return Some(found); + } + } + Some(node) +} + +fn covering_declaration_node<'tree>(node: Node<'tree>) -> Node<'tree> { + let mut current = node; + while let Some(parent) = current.parent() { + if matches!( + parent.kind(), + "function_declaration" + | "function_expression" + | "arrow_function" + | "class_declaration" + | "class_expression" + | "method_definition" + | "variable_declarator" + | "public_field_definition" + ) { + return parent; + } + current = parent; + } + node +} + +fn is_exported_declaration(node: Node<'_>) -> bool { + let mut current = node; + while let Some(parent) = current.parent() { + if parent.kind() == "export_statement" { + return true; + } + if matches!( + parent.kind(), + "program" | "statement_block" | "class_body" | "object" + ) { + return false; + } + current = parent; + } + false +} + +#[cfg(test)] +mod tests { + use super::is_custom_hook; + + #[test] + fn custom_hook_classification_requires_use_and_an_uppercase_suffix() { + assert!(is_custom_hook("function", "useOrders")); + assert!(!is_custom_hook("function", "use")); + assert!(!is_custom_hook("function", "user")); + assert!(!is_custom_hook("class", "useOrders")); + } +} diff --git a/crates/compass-languages/src/frameworks/remix.rs b/crates/compass-languages/src/frameworks/remix.rs index 43779ad6..b2f79ce4 100644 --- a/crates/compass-languages/src/frameworks/remix.rs +++ b/crates/compass-languages/src/frameworks/remix.rs @@ -2,6 +2,7 @@ use std::path::Path; use super::RawFrameworkFact; use crate::{Extraction, ProjectEvidence}; +use tree_sitter::Node; /// Remix route modules are file conventions with named data handlers. The /// shared file-route implementation owns path normalization, convention @@ -10,8 +11,9 @@ use crate::{Extraction, ProjectEvidence}; pub(super) fn detect( path: &Path, source: &[u8], + root: Node<'_>, project: Option<&ProjectEvidence>, extraction: &mut Extraction, ) -> Vec { - super::file_routes::detect_remix(path, source, project, extraction) + super::file_routes::detect_remix(path, source, root, project, extraction) } diff --git a/crates/compass-languages/src/frameworks/ruby.rs b/crates/compass-languages/src/frameworks/ruby.rs index 14289409..5034dc93 100644 --- a/crates/compass-languages/src/frameworks/ruby.rs +++ b/crates/compass-languages/src/frameworks/ruby.rs @@ -146,6 +146,7 @@ fn collect_routes( anchor: anchor.clone(), handler_reference: handler_reference.clone(), middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: Some("rails-routes-dsl".to_owned()), detail: Map::from_iter([( diff --git a/crates/compass-languages/src/frameworks/rust.rs b/crates/compass-languages/src/frameworks/rust.rs index 0fa6aba9..ed1c2c08 100644 --- a/crates/compass-languages/src/frameworks/rust.rs +++ b/crates/compass-languages/src/frameworks/rust.rs @@ -113,6 +113,7 @@ fn detect_selected( anchor: anchor(path, source, whole.start(), whole.end()), handler_reference: handler.clone(), middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: Some("rust-route-attribute".to_owned()), detail: Map::new(), @@ -124,6 +125,10 @@ fn detect_selected( RawFrameworkFact::Route(route) => route.framework.as_str(), RawFrameworkFact::Domain(domain) => domain.framework.as_str(), RawFrameworkFact::Annotation(annotation) => annotation.framework.as_str(), + RawFrameworkFact::Role(role) => role.framework.as_str(), + RawFrameworkFact::Relation(relation) => relation.framework.as_str(), + RawFrameworkFact::Configuration(configuration) => configuration.framework.as_str(), + RawFrameworkFact::FileSet(file_set) => file_set.framework.as_str(), }; if selected == "non-axum" { framework != "axum" @@ -1010,6 +1015,7 @@ fn route_fact( anchor: super::text::anchor(path, source, start, end), handler_reference: handler.replace("::", "."), middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: Some(rule.to_owned()), detail: Map::new(), diff --git a/crates/compass-languages/src/frameworks/spring.rs b/crates/compass-languages/src/frameworks/spring.rs index d168f115..11fef6bb 100644 --- a/crates/compass-languages/src/frameworks/spring.rs +++ b/crates/compass-languages/src/frameworks/spring.rs @@ -155,6 +155,12 @@ fn set_fact_pack(fact: &mut RawFrameworkFact, pack_id: &str) { Value::String(pack_id.to_owned()), ); } + RawFrameworkFact::Role(role) => role.pack_id = pack_id.to_owned(), + RawFrameworkFact::Relation(relation) => relation.pack_id = pack_id.to_owned(), + RawFrameworkFact::Configuration(configuration) => { + configuration.pack_id = pack_id.to_owned() + } + RawFrameworkFact::FileSet(file_set) => file_set.pack_id = pack_id.to_owned(), } } @@ -770,6 +776,30 @@ fn annotation_fact_key(fact: &RawFrameworkFact) -> (&str, u64, &str, &str) { fact.declaring_scope.as_str(), fact.kind.as_str(), ), + RawFrameworkFact::Role(fact) => ( + fact.anchor.source_file.as_str(), + fact.anchor.start_byte, + fact.subject_reference.as_deref().unwrap_or_default(), + fact.role.as_str(), + ), + RawFrameworkFact::Relation(fact) => ( + fact.anchor.source_file.as_str(), + fact.anchor.start_byte, + fact.source_reference.as_deref().unwrap_or_default(), + fact.relation.as_str(), + ), + RawFrameworkFact::Configuration(fact) => ( + fact.anchor.source_file.as_str(), + fact.anchor.start_byte, + fact.config_id.as_str(), + fact.field.as_str(), + ), + RawFrameworkFact::FileSet(fact) => ( + fact.anchor.source_file.as_str(), + fact.anchor.start_byte, + fact.owner_reference.as_str(), + fact.framework.as_str(), + ), } } diff --git a/crates/compass-languages/src/frameworks/swift.rs b/crates/compass-languages/src/frameworks/swift.rs index 4c725c30..2d6d9324 100644 --- a/crates/compass-languages/src/frameworks/swift.rs +++ b/crates/compass-languages/src/frameworks/swift.rs @@ -171,6 +171,7 @@ fn collect_vapor_route( anchor, handler_reference, middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: Some("vapor-route-call".to_owned()), detail: if opaque_handler { diff --git a/crates/compass-languages/src/frameworks/tanstack.rs b/crates/compass-languages/src/frameworks/tanstack.rs new file mode 100644 index 00000000..798a2cb6 --- /dev/null +++ b/crates/compass-languages/src/frameworks/tanstack.rs @@ -0,0 +1,470 @@ +//! AST-backed TanStack Router/Start route evidence. + +use std::path::Path; + +use serde_json::{Map, Value}; +use tree_sitter::Node; + +use super::typescript_syntax::{StaticValue, TypeScriptSyntax}; +use super::{ + RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, RawRouteStageFact, + RawRouteStageRole, +}; + +const TANSTACK_DEPENDENCIES: &[&str] = &[ + "@tanstack/react-router", + "@tanstack/router-core", + "@tanstack/router-generator", +]; +const TANSTACK_START_DEPENDENCIES: &[&str] = &["@tanstack/react-start", "@tanstack/start"]; + +pub(super) fn detect( + path: &Path, + source: &[u8], + root: Node<'_>, + project: Option<&crate::ProjectEvidence>, +) -> Vec { + if source.is_empty() + || !project.is_none_or(|project| project.has_any_dependency(TANSTACK_DEPENDENCIES)) + { + return Vec::new(); + } + let syntax = TypeScriptSyntax::new(root, source); + let imported_factories = syntax_imported_factory_names(syntax, TANSTACK_DEPENDENCIES); + let mut facts = Vec::new(); + for call in syntax + .descendants(root) + .into_iter() + .filter(|node| node.kind() == "call_expression") + { + let Some(local_factory) = syntax.call_callee(call) else { + continue; + }; + let Some(factory) = imported_factories + .iter() + .find_map(|(local, canonical)| (local == &local_factory).then_some(canonical)) + else { + continue; + }; + let route_path = call + .child_by_field_name("arguments") + .and_then(|arguments| { + let mut cursor = arguments.walk(); + arguments + .named_children(&mut cursor) + .find_map(|node| syntax.literal_string(node)) + }) + .unwrap_or_else(|| { + if factory == "createRootRoute" { + "/".to_owned() + } else { + String::new() + } + }); + if route_path.is_empty() && factory != "createRootRoute" { + continue; + } + let config_call = call + .parent() + .filter(|parent| { + parent.kind() == "call_expression" + && parent + .child_by_field_name("function") + .is_some_and(|function| { + function.start_byte() == call.start_byte() + && function.end_byte() == call.end_byte() + }) + }) + .unwrap_or(call); + let config = config_call + .child_by_field_name("arguments") + .and_then(|arguments| { + let mut cursor = arguments.walk(); + arguments + .named_children(&mut cursor) + .filter(|node| node.kind() == "object") + .find(|node| !syntax.is_incomplete(*node)) + }); + let anchor = syntax.range(config_call).map_or_else( + || fallback_anchor(path, source), + |range| range_anchor(path, range), + ); + let mut stages = Vec::new(); + let mut handler_reference = "route".to_owned(); + if let Some(config) = config { + let mut pairs = config.walk(); + for pair in config + .named_children(&mut pairs) + .filter(|node| node.kind() == "pair" && !syntax.is_incomplete(*node)) + { + let Some(name) = syntax.property_name(pair) else { + continue; + }; + let Some(value) = pair + .child_by_field_name("value") + .or_else(|| pair.named_child(1)) + else { + continue; + }; + let Some(reference) = static_reference(syntax, value) else { + continue; + }; + let Some(role) = stage_role(&name) else { + continue; + }; + if matches!(role, RawRouteStageRole::RouteComponent) { + handler_reference.clone_from(&reference); + } + let stage_anchor = syntax + .range(pair) + .map_or_else(|| anchor.clone(), |range| range_anchor(path, range)); + stages.push(RawRouteStageFact { + role, + position: u32::try_from(stages.len()).unwrap_or(u32::MAX), + reference, + anchor: stage_anchor, + origin: RawFrameworkOrigin::Ast, + detail: Map::from_iter([( + "factory".to_owned(), + Value::String(factory.clone()), + )]), + }); + } + } + if stages.is_empty() { + stages.push(RawRouteStageFact { + role: RawRouteStageRole::RouteComponent, + position: 0, + reference: handler_reference.clone(), + anchor: anchor.clone(), + origin: RawFrameworkOrigin::Ast, + detail: Map::new(), + }); + } + let mut detail = Map::from_iter([ + ("factory".to_owned(), Value::String(factory.clone())), + ("route_path".to_owned(), Value::String(route_path.clone())), + ]); + detail.insert( + "source_file".to_owned(), + Value::String(path.to_string_lossy().replace('\\', "/")), + ); + facts.push(RawFrameworkFact::Route(RawRouteFact { + framework: "tanstack-router".to_owned(), + operation: if route_path == "/" { + "ROOT".to_owned() + } else { + "PAGE".to_owned() + }, + raw_path: route_path.clone(), + normalized_path: normalize_path(&route_path), + declaring_scope: path.to_string_lossy().replace('\\', "/"), + anchor, + handler_reference, + middleware_references: Vec::new(), + stages, + origin: RawFrameworkOrigin::Ast, + rule: Some("tanstack-route-factory".to_owned()), + detail, + })); + } + if !facts + .iter() + .any(|fact| matches!(fact, RawFrameworkFact::Route(_))) + && let Some(route) = detect_file_route( + path, + source, + syntax, + project, + has_react_router_import(syntax), + ) + { + facts.push(RawFrameworkFact::Route(route)); + } + facts.sort_by_key(|fact| (fact.anchor().start_byte, fact.framework().to_owned())); + facts +} + +fn detect_file_route( + path: &Path, + source: &[u8], + syntax: TypeScriptSyntax<'_, '_>, + project: Option<&crate::ProjectEvidence>, + has_react_router_import: bool, +) -> Option { + // A workspace may intentionally install TanStack and React Router side by + // side. Do not let TanStack's directory convention claim a React Router + // route module merely because it happens to live under `src/routes`. + // Import identity is the strongest negative signal available at this + // per-file boundary; package-level activation alone is not sufficient. + if has_react_router_import { + return None; + } + let relative = project + .and_then(|project| path.strip_prefix(project.project_root()).ok()) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/")); + let marker = ["src/routes/", "routes/"] + .into_iter() + .find(|marker| relative.starts_with(marker))?; + let route_file = relative.strip_prefix(marker)?; + if route_file.is_empty() + || route_file.contains("node_modules/") + || route_file.contains("/.tanstack/") + || route_file.ends_with("routeTree.gen.ts") + || route_file.ends_with("routeTree.gen.tsx") + || route_file.ends_with(".d.ts") + { + return None; + } + let stem = route_file + .rsplit_once('/') + .map_or(route_file, |(_, file)| file); + let stem = trim_typescript_extension(stem); + let mut segments = route_file + .split('/') + .filter(|segment| !segment.is_empty()) + .map(trim_typescript_extension) + .collect::>(); + let last = segments.last_mut()?; + *last = stem; + let route_segments = segments + .into_iter() + .filter_map(|segment| { + if segment == "index" || segment == "__root" { + None + } else if let Some(name) = segment.strip_prefix('$') { + (!name.is_empty()).then(|| format!("{{{name}}}")) + } else { + Some(segment.to_owned()) + } + }) + .collect::>(); + let normalized_path = if route_segments.is_empty() { + "/".to_owned() + } else { + format!("/{}", route_segments.join("/")) + }; + let has_route_export = syntax.descendants(syntax.root()).into_iter().any(|node| { + node.kind() == "export_statement" + && syntax.text(node).is_some_and(|text| text.contains("Route")) + }); + if !has_route_export { + return None; + } + let anchor = syntax.range(syntax.root()).map_or_else( + || fallback_anchor(path, source), + |range| range_anchor(path, range), + ); + Some(RawRouteFact { + framework: "tanstack-router".to_owned(), + operation: if normalized_path == "/" { + "ROOT".to_owned() + } else { + "PAGE".to_owned() + }, + raw_path: normalized_path.clone(), + normalized_path, + declaring_scope: relative.clone(), + anchor: anchor.clone(), + handler_reference: "Route".to_owned(), + middleware_references: Vec::new(), + stages: vec![RawRouteStageFact { + role: RawRouteStageRole::RouteComponent, + position: 0, + reference: "Route".to_owned(), + anchor, + origin: RawFrameworkOrigin::Convention, + detail: Map::from_iter([("generated_tree".to_owned(), Value::Bool(false))]), + }], + origin: RawFrameworkOrigin::Convention, + rule: Some("tanstack-file-route-convention".to_owned()), + detail: Map::from_iter([ + ("file_route".to_owned(), Value::Bool(true)), + ("route_file".to_owned(), Value::String(relative)), + ]), + }) +} + +fn has_react_router_import(syntax: TypeScriptSyntax<'_, '_>) -> bool { + ["react-router", "react-router-dom"] + .into_iter() + .any(|module| { + ["*", "default"] + .into_iter() + .any(|imported| !syntax.imported_local_names(module, imported).is_empty()) + || [ + "Route", + "createBrowserRouter", + "createHashRouter", + "createMemoryRouter", + ] + .into_iter() + .any(|imported| !syntax.imported_local_names(module, imported).is_empty()) + }) +} + +fn trim_typescript_extension(value: &str) -> &str { + [".tsx", ".jsx", ".ts", ".js", ".mts", ".mjs"] + .iter() + .find_map(|extension| value.strip_suffix(extension)) + .unwrap_or(value) +} + +pub(super) fn detect_start( + path: &Path, + source: &[u8], + root: Node<'_>, + project: Option<&crate::ProjectEvidence>, +) -> Vec { + if source.is_empty() + || !project.is_none_or(|project| project.has_any_dependency(TANSTACK_START_DEPENDENCIES)) + { + return Vec::new(); + } + let syntax = TypeScriptSyntax::new(root, source); + let imports = syntax_imported_factory_names(syntax, TANSTACK_START_DEPENDENCIES); + let mut facts = Vec::new(); + for call in syntax + .descendants(root) + .into_iter() + .filter(|node| node.kind() == "call_expression") + { + let Some(local_factory) = syntax.call_callee(call) else { + continue; + }; + let Some(factory) = imports + .iter() + .find_map(|(local, canonical)| (local == &local_factory).then_some(canonical)) + else { + continue; + }; + let Some(range) = syntax.range(call) else { + continue; + }; + let role = if factory == "createMiddleware" { + "middleware" + } else { + "server_function" + }; + facts.push(RawFrameworkFact::Role(super::RawFrameworkRoleFact { + pack_id: "tanstack-start".to_owned(), + framework: "tanstack-start".to_owned(), + role: role.to_owned(), + subject_reference: None, + context: Some(path.to_string_lossy().replace('\\', "/")), + anchor: range_anchor(path, range), + origin: RawFrameworkOrigin::Ast, + evidence_class: "exact".to_owned(), + detail: Map::from_iter([("factory".to_owned(), Value::String(factory.clone()))]), + })); + } + facts.sort_by_key(|fact| (fact.anchor().start_byte, fact.framework().to_owned())); + facts +} + +fn syntax_imported_factory_names( + syntax: TypeScriptSyntax<'_, '_>, + modules: &[&str], +) -> Vec<(String, String)> { + let factories = [ + "createFileRoute", + "createLazyFileRoute", + "createRoute", + "createRootRoute", + "createRootRouteWithContext", + "createServerFn", + "createServerOnlyFn", + "createMiddleware", + ]; + let mut names = modules + .iter() + .flat_map(|module| { + let direct = factories.iter().flat_map(|factory| { + syntax + .imported_local_names(module, factory) + .into_iter() + .map(move |local| (local, (*factory).to_owned())) + }); + let namespace = syntax + .imported_local_names(module, "*") + .into_iter() + .flat_map(|name| { + factories + .iter() + .map(move |factory| (format!("{name}.{factory}"), (*factory).to_owned())) + }); + direct.chain(namespace) + }) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +fn stage_role(name: &str) -> Option { + match name { + "component" | "pendingComponent" | "errorComponent" | "notFoundComponent" => { + Some(if name == "component" { + RawRouteStageRole::RouteComponent + } else { + RawRouteStageRole::Boundary + }) + } + "loader" | "beforeLoad" => Some(RawRouteStageRole::Loader), + "action" => Some(RawRouteStageRole::Action), + _ => None, + } +} + +fn static_reference(syntax: TypeScriptSyntax<'_, '_>, node: Node<'_>) -> Option { + if syntax.is_incomplete(node) { + return None; + } + match syntax.static_value(node) { + StaticValue::String(value) => Some(value), + StaticValue::Incomplete => { + let text = syntax.text(node)?.trim(); + (text + .chars() + .next() + .is_some_and(|first| first == '_' || first.is_ascii_alphabetic()) + && text.chars().all(|character| { + character == '_' || character == '$' || character.is_ascii_alphanumeric() + })) + .then(|| text.to_owned()) + } + _ => None, + } +} + +fn normalize_path(path: &str) -> String { + let mut output = String::from("/"); + output.push_str(path.trim_matches('/')); + output +} + +fn range_anchor(path: &Path, range: super::typescript_syntax::SyntaxRange) -> RawFrameworkAnchor { + RawFrameworkAnchor { + source_file: path.to_string_lossy().replace('\\', "/"), + start_byte: u64::try_from(range.start_byte).unwrap_or(u64::MAX), + end_byte: u64::try_from(range.end_byte).unwrap_or(u64::MAX), + start_line: range.start_line, + start_column: range.start_column, + end_line: range.end_line, + end_column: range.end_column, + } +} + +fn fallback_anchor(path: &Path, source: &[u8]) -> RawFrameworkAnchor { + RawFrameworkAnchor { + source_file: path.to_string_lossy().replace('\\', "/"), + start_byte: 0, + end_byte: u64::try_from(source.len()).unwrap_or(u64::MAX), + start_line: 1, + start_column: 0, + end_line: u32::try_from(source.iter().filter(|byte| **byte == b'\n').count() + 1) + .unwrap_or(u32::MAX), + end_column: 0, + } +} diff --git a/crates/compass-languages/src/frameworks/typescript.rs b/crates/compass-languages/src/frameworks/typescript.rs index 923313cb..53531f7e 100644 --- a/crates/compass-languages/src/frameworks/typescript.rs +++ b/crates/compass-languages/src/frameworks/typescript.rs @@ -8,6 +8,7 @@ use tree_sitter::Node; use super::evidence::{EvidenceKind, EvidenceSet}; use super::{ RawDomainFact, RawFrameworkAnchor, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, + RawRouteStageFact, RawRouteStageRole, }; use crate::{Extraction, RawNodeRecord, make_id}; @@ -485,6 +486,7 @@ fn collect_node_router_routes( anchor: anchor(path, node), handler_reference: handler.clone(), middleware_references: middleware.clone(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: None, detail: detail.clone(), @@ -556,6 +558,7 @@ fn collect_node_router_routes( anchor: anchor(path, node), handler_reference: handler.clone(), middleware_references: middleware.clone(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: None, detail: detail.clone(), @@ -740,12 +743,6 @@ pub(super) fn detect_non_express( EvidenceKind::Import, "@nestjs/", ) - .direct_if( - imports_module("react-router"), - "react-router", - EvidenceKind::Import, - "react-router", - ) .direct_if( imports_module("vue-router"), "vue-router", @@ -758,15 +755,231 @@ pub(super) fn detect_non_express( if evidence.activates("angular-router") { collect_angular_router_routes(root, source, path, &mut facts); } - if evidence.activates("react-router") { - collect_react_router_routes(root, source, path, &mut facts, ""); - } if evidence.activates("vue-router") { collect_vue_router_routes(root, source, path, &mut facts); } facts } +/// Dedicated React Router owner used after the universal pack cut. The +/// broader `typescript-web` adapter retains Angular/Nest/Vue behavior and +/// import identity, but no longer publishes React Router route facts. +pub(super) fn detect_react_router( + path: &Path, + source: &[u8], + root: Node<'_>, + extraction: &mut Extraction, +) -> Vec { + if source.is_empty() { + return Vec::new(); + } + let mut imports = Vec::new(); + collect_import_aliases(path, root, source, &mut imports); + attach_import_aliases(path, source, root, extraction, &imports); + let file_route = detect_react_router_file_route(path, source, root); + let active = imports.iter().any(|alias| { + alias.module == "react-router" + || alias.module == "react-router-dom" + || alias.module.starts_with("react-router/") + || alias.module.starts_with("react-router-dom/") + }); + if !active { + return file_route + .map(|route| vec![RawFrameworkFact::Route(route)]) + .unwrap_or_default(); + } + let mut facts = Vec::new(); + if let Some(route) = file_route { + facts.push(RawFrameworkFact::Route(route)); + } + collect_react_router_routes(root, source, path, &mut facts, ""); + facts +} + +/// React Router's framework mode uses a file-based route convention in +/// `app/routes` or `src/routes`. Those modules frequently import only generated +/// route types, so the import-gated config detector above cannot activate for +/// them. Emit the route inventory and named loader/action stages from the +/// stable file convention while leaving target selection to the resolver. +fn detect_react_router_file_route( + path: &Path, + source: &[u8], + root: Node<'_>, +) -> Option { + let portable = path.to_string_lossy().replace('\\', "/"); + let (marker, relative) = ["app/routes/", "src/routes/"].iter().find_map(|marker| { + portable + .find(marker) + .map(|index| (*marker, &portable[index + marker.len()..])) + })?; + let extension = Path::new(relative) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!( + extension, + "ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs" + ) { + return None; + } + let route_file = relative + .rsplit_once('.') + .map_or(relative, |(stem, _)| stem) + .trim_matches('/'); + if route_file.is_empty() || route_file.ends_with("routes") { + return None; + } + let normalized_path = react_router_file_path(route_file); + let syntax = super::typescript_syntax::TypeScriptSyntax::new(root, source); + let default_reference = react_router_default_export_reference(syntax); + let mut stages = Vec::new(); + let default_anchor = react_router_named_declaration_anchor(syntax, path, &default_reference) + .unwrap_or_else(|| anchor(path, root)); + stages.push(RawRouteStageFact { + role: RawRouteStageRole::RouteComponent, + position: 0, + reference: default_reference.clone(), + anchor: default_anchor, + origin: RawFrameworkOrigin::Convention, + detail: Map::from_iter([( + "convention".to_owned(), + Value::String("react-router-file-route".to_owned()), + )]), + }); + for (name, role) in [ + ("loader", RawRouteStageRole::Loader), + ("action", RawRouteStageRole::Action), + ] { + if !react_router_has_declaration(syntax, name) { + continue; + } + let stage_anchor = react_router_named_declaration_anchor(syntax, path, name) + .unwrap_or_else(|| anchor(path, root)); + stages.push(RawRouteStageFact { + role, + position: u32::try_from(stages.len()).unwrap_or(u32::MAX), + reference: name.to_owned(), + anchor: stage_anchor, + origin: RawFrameworkOrigin::Ast, + detail: Map::from_iter([("property".to_owned(), Value::String(name.to_owned()))]), + }); + } + Some(RawRouteFact { + framework: "react-router".to_owned(), + operation: "PAGE".to_owned(), + raw_path: normalized_path.clone(), + normalized_path, + declaring_scope: marker.trim_end_matches('/').to_owned(), + anchor: anchor(path, root), + handler_reference: default_reference, + middleware_references: Vec::new(), + stages, + origin: RawFrameworkOrigin::Convention, + rule: Some("react-router-file-route-convention".to_owned()), + detail: Map::from_iter([("file_route".to_owned(), Value::Bool(true))]), + }) +} + +fn react_router_file_path(route_file: &str) -> String { + let mut segments = Vec::new(); + for piece in route_file.split('/') { + let mut pieces = piece.split('.').filter(|part| !part.is_empty()); + for part in pieces.by_ref() { + if part == "_index" || part.starts_with('_') { + continue; + } + if part == "route" { + continue; + } + let segment = part + .strip_prefix('$') + .map_or_else(|| part.to_owned(), |name| format!("{{{name}}}")); + if !segment.is_empty() { + segments.push(segment); + } + } + } + if segments.is_empty() { + "/".to_owned() + } else { + format!("/{}", segments.join("/")) + } +} + +fn react_router_default_export_reference( + syntax: super::typescript_syntax::TypeScriptSyntax<'_, '_>, +) -> String { + for statement in syntax + .descendants(syntax.root()) + .into_iter() + .filter(|node| node.kind() == "export_statement") + { + if !syntax + .text(statement) + .is_some_and(|text| text.trim_start().starts_with("export default")) + { + continue; + } + let mut cursor = statement.walk(); + for child in statement.named_children(&mut cursor) { + if let Some(name) = child + .child_by_field_name("name") + .and_then(|name| syntax.text(name)) + && (child.kind().contains("function") || child.kind().contains("class")) + { + return name.to_owned(); + } + if matches!(child.kind(), "identifier" | "member_expression") { + return syntax.text(child).unwrap_or("default").to_owned(); + } + } + } + "default".to_owned() +} + +fn react_router_has_declaration( + syntax: super::typescript_syntax::TypeScriptSyntax<'_, '_>, + expected: &str, +) -> bool { + syntax.descendants(syntax.root()).into_iter().any(|node| { + matches!( + node.kind(), + "function_declaration" | "class_declaration" | "variable_declarator" + ) && node + .child_by_field_name("name") + .and_then(|name| syntax.text(name)) + .is_some_and(|name| name == expected) + }) +} + +fn react_router_named_declaration_anchor( + syntax: super::typescript_syntax::TypeScriptSyntax<'_, '_>, + path: &Path, + expected: &str, +) -> Option { + syntax + .descendants(syntax.root()) + .into_iter() + .find_map(|node| { + if !matches!( + node.kind(), + "function_declaration" | "class_declaration" | "variable_declarator" + ) { + return None; + } + let name = node.child_by_field_name("name")?; + (syntax.text(name) == Some(expected)).then(|| RawFrameworkAnchor { + source_file: path.to_string_lossy().into_owned(), + start_byte: name.start_byte() as u64, + end_byte: name.end_byte() as u64, + start_line: u32::try_from(name.start_position().row + 1).unwrap_or(u32::MAX), + start_column: u32::try_from(name.start_position().column).unwrap_or(u32::MAX), + end_line: u32::try_from(name.end_position().row + 1).unwrap_or(u32::MAX), + end_column: u32::try_from(name.end_position().column).unwrap_or(u32::MAX), + }) + }) +} + fn attach_import_aliases( path: &Path, source: &[u8], @@ -1149,6 +1362,7 @@ fn collect_express_routes( anchor: anchor(path, node), handler_reference: handler, middleware_references: middleware, + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: None, detail, @@ -1665,11 +1879,67 @@ fn collect_route_config_with_parent( && let Some(raw_path) = direct_object_string_property(node, source, "path") { current_parent = join_paths(parent_path, &raw_path); - if let Some((handler, opaque_handler)) = direct_object_handler_property(node, source) { - let middleware = ["loader", "action"] - .into_iter() - .filter_map(|property| direct_object_identifier_property(node, source, property)) - .collect(); + if let Some((handler, opaque_handler, handler_node, handler_property)) = + direct_object_handler_property(node, source) + { + let mut stages = Vec::new(); + let mut legacy_middleware = Vec::new(); + for (property, role) in [ + ("loader", RawRouteStageRole::Loader), + ("action", RawRouteStageRole::Action), + ("errorElement", RawRouteStageRole::Boundary), + ("errorComponent", RawRouteStageRole::Boundary), + ] { + let Some((raw_value, value_node)) = + direct_object_value_property(node, source, property) + else { + continue; + }; + let raw_value = raw_value.trim(); + let reference = if is_reference(raw_value) { + raw_value.to_owned() + } else if role == RawRouteStageRole::Boundary { + jsx_tag_reference(raw_value).unwrap_or_else(|| { + format!("opaque_route_handler_at_{}", value_node.start_byte()) + }) + } else { + continue; + }; + if role == RawRouteStageRole::Loader { + // Preserve the legacy projection while the typed stage + // remains authoritative for resolver/publication code. + legacy_middleware.push(reference.clone()); + } + stages.push(RawRouteStageFact { + role, + position: u32::try_from(stages.len()).unwrap_or(u32::MAX), + reference, + anchor: anchor(path, value_node), + origin: RawFrameworkOrigin::Ast, + detail: Map::from_iter([( + "property".to_owned(), + Value::String(property.to_owned()), + )]), + }); + } + // Preserve the established handler stage for Angular/Vue route + // config adapters. React Router framework mode is the one pack + // that needs the more specific route-component stage; changing + // unrelated adapters would silently break their public route + // contract and downstream qualification fixtures. + let component_role = if framework == "react-router" { + RawRouteStageRole::RouteComponent + } else { + RawRouteStageRole::Handler + }; + stages.push(RawRouteStageFact { + role: component_role, + position: u32::try_from(stages.len()).unwrap_or(u32::MAX), + reference: handler.clone(), + anchor: anchor(path, handler_node), + origin: RawFrameworkOrigin::Ast, + detail: Map::from_iter([("property".to_owned(), Value::String(handler_property))]), + }); let detail = if opaque_handler { Map::from_iter([("opaque_handler".into(), Value::Bool(true))]) } else { @@ -1685,9 +1955,15 @@ fn collect_route_config_with_parent( detail, ) { RawFrameworkFact::Route(route) => route, - RawFrameworkFact::Domain(_) | RawFrameworkFact::Annotation(_) => unreachable!(), + RawFrameworkFact::Domain(_) + | RawFrameworkFact::Annotation(_) + | RawFrameworkFact::Role(_) + | RawFrameworkFact::Relation(_) + | RawFrameworkFact::Configuration(_) + | RawFrameworkFact::FileSet(_) => unreachable!(), }; - fact.middleware_references = middleware; + fact.middleware_references = legacy_middleware; + fact.stages = stages; facts.push(RawFrameworkFact::Route(fact)); } } @@ -1724,6 +2000,7 @@ fn route_fact( anchor: anchor(source_path, node), handler_reference: handler.to_owned(), middleware_references: Vec::new(), + stages: Vec::new(), origin: RawFrameworkOrigin::Ast, rule: None, detail, @@ -1938,12 +2215,39 @@ fn direct_object_string_property(node: Node<'_>, source: &[u8], name: &str) -> O direct_object_property(node, source, name).and_then(string_literal) } -fn direct_object_identifier_property(node: Node<'_>, source: &[u8], name: &str) -> Option { - let value = direct_object_property(node, source, name)?.trim(); - is_reference(value).then(|| value.to_owned()) +fn direct_object_value_property<'tree>( + node: Node<'tree>, + source: &[u8], + name: &str, +) -> Option<(String, Node<'tree>)> { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .filter(|child| child.kind() == "pair") + .find_map(|pair| { + let key = pair.child_by_field_name("key")?; + let key = node_text(key, source).trim().trim_matches(['"', '\'', '`']); + if key != name { + return None; + } + let value = pair + .child_by_field_name("value") + .or_else(|| pair.named_child(1))?; + Some((node_text(value, source).to_owned(), value)) + }) +} + +fn jsx_tag_reference(value: &str) -> Option { + let value = value.trim().strip_prefix('<')?; + let tag = value + .split(|character: char| character.is_whitespace() || matches!(character, '/' | '>')) + .next()?; + is_reference(tag).then(|| tag.to_owned()) } -fn direct_object_handler_property(node: Node<'_>, source: &[u8]) -> Option<(String, bool)> { +fn direct_object_handler_property<'tree>( + node: Node<'tree>, + source: &[u8], +) -> Option<(String, bool, Node<'tree>, String)> { for name in [ "component", "element", @@ -1951,12 +2255,12 @@ fn direct_object_handler_property(node: Node<'_>, source: &[u8]) -> Option<(Stri "loadComponent", "loadChildren", ] { - let Some(value) = direct_object_property(node, source, name) else { + let Some((value, value_node)) = direct_object_value_property(node, source, name) else { continue; }; let value = value.trim(); if is_reference(value) { - return Some((value.to_owned(), false)); + return Some((value.to_owned(), false, value_node, name.to_owned())); } if let Some(tag) = value.strip_prefix('<').and_then(|value| { value @@ -1966,12 +2270,14 @@ fn direct_object_handler_property(node: Node<'_>, source: &[u8]) -> Option<(Stri .next() }) && is_reference(tag) { - return Some((tag.to_owned(), false)); + return Some((tag.to_owned(), false, value_node, name.to_owned())); } if value.contains("=>") || value.contains("import(") || value.starts_with("lazy(") { return Some(( format!("opaque_route_handler_at_{}", node.start_byte()), true, + value_node, + name.to_owned(), )); } } diff --git a/crates/compass-languages/src/frameworks/typescript_syntax.rs b/crates/compass-languages/src/frameworks/typescript_syntax.rs new file mode 100644 index 00000000..6554d3f1 --- /dev/null +++ b/crates/compass-languages/src/frameworks/typescript_syntax.rs @@ -0,0 +1,559 @@ +//! Bounded, parser-backed syntax views shared by TypeScript/JavaScript +//! framework packs. +//! +//! The view deliberately exposes copied source values and exact ranges rather +//! than Tree-sitter handles to callers that need to retain facts. Framework +//! packs may inspect the borrowed tree during extraction, but they must not +//! persist parser nodes or recover semantics by scanning source text. + +use tree_sitter::Node; + +pub(crate) const SYNTAX_VIEW_VERSION: &str = "compass.frontend-syntax/2"; +pub(crate) const MAX_SYNTAX_DEPTH: usize = 256; +pub(crate) const MAX_SYNTAX_NODES: usize = 100_000; +pub(crate) const MAX_STATIC_DEPTH: usize = 32; +pub(crate) const MAX_STATIC_ITEMS: usize = 2_048; +pub(crate) const MAX_STATIC_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct SyntaxRange { + pub start_byte: usize, + pub end_byte: usize, + pub start_line: u32, + pub start_column: u32, + pub end_line: u32, + pub end_column: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum StaticValue { + String(String), + Boolean(bool), + Number(String), + Null, + Regex(String), + Array(Vec), + Object(Vec<(String, StaticValue)>), + Incomplete, +} + +impl StaticValue { + #[must_use] + pub(crate) fn as_string(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + #[must_use] + pub(crate) fn object(&self) -> Option<&[(String, StaticValue)]> { + match self { + Self::Object(values) => Some(values), + _ => None, + } + } + + #[must_use] + pub(crate) fn array(&self) -> Option<&[StaticValue]> { + match self { + Self::Array(values) => Some(values), + _ => None, + } + } +} + +#[derive(Clone, Copy)] +pub(crate) struct TypeScriptSyntax<'tree, 'source> { + root: Node<'tree>, + source: &'source [u8], +} + +impl<'tree, 'source> TypeScriptSyntax<'tree, 'source> { + #[must_use] + pub(crate) const fn new(root: Node<'tree>, source: &'source [u8]) -> Self { + Self { root, source } + } + + #[must_use] + pub(crate) const fn root(self) -> Node<'tree> { + self.root + } + + #[must_use] + pub(crate) fn text(self, node: Node<'tree>) -> Option<&'source str> { + node.utf8_text(self.source).ok() + } + + #[must_use] + pub(crate) fn range(self, node: Node<'tree>) -> Option { + let start = node.start_position(); + let end = node.end_position(); + Some(SyntaxRange { + start_byte: node.start_byte(), + end_byte: node.end_byte(), + start_line: u32::try_from(start.row.saturating_add(1)).ok()?, + start_column: u32::try_from(start.column).ok()?, + end_line: u32::try_from(end.row.saturating_add(1)).ok()?, + end_column: u32::try_from(end.column).ok()?, + }) + } + + #[must_use] + pub(crate) fn is_incomplete(self, node: Node<'tree>) -> bool { + if node.has_error() || node.is_missing() { + return true; + } + if !self.root.has_error() { + return false; + } + let Some(range) = self.range(node) else { + return true; + }; + // Tree-sitter may attach a recovery node to a broad ancestor while + // leaving a neighboring declaration's `has_error` bit clear. Reject + // only syntax whose byte range actually overlaps an ERROR/missing + // recovery region; unrelated declarations in the same file remain + // usable evidence. + self.descendants(self.root).into_iter().any(|candidate| { + if candidate.kind() != "ERROR" && !candidate.is_missing() { + return false; + } + candidate.start_byte() < range.end_byte && range.start_byte < candidate.end_byte() + }) + } + + #[must_use] + pub(crate) fn contains_kind(self, node: Node<'tree>, kind: &str) -> bool { + self.descendants(node) + .into_iter() + .any(|candidate| candidate.kind() == kind) + } + + #[must_use] + pub(crate) fn descendants(self, node: Node<'tree>) -> Vec> { + let mut output = Vec::new(); + let mut stack = vec![(node, 0usize)]; + while let Some((current, depth)) = stack.pop() { + if output.len() >= MAX_SYNTAX_NODES || depth > MAX_SYNTAX_DEPTH { + break; + } + output.push(current); + let mut children = current + .named_children(&mut current.walk()) + .collect::>(); + children.reverse(); + for child in children { + stack.push((child, depth.saturating_add(1))); + } + } + output + } + + #[must_use] + pub(crate) fn node_count_and_depth(self) -> (usize, usize) { + let mut count = 0usize; + let mut max_depth = 0usize; + let mut stack = vec![(self.root, 0usize)]; + while let Some((current, depth)) = stack.pop() { + count = count.saturating_add(1); + max_depth = max_depth.max(depth); + if count >= MAX_SYNTAX_NODES || depth >= MAX_SYNTAX_DEPTH { + continue; + } + let mut cursor = current.walk(); + for child in current.named_children(&mut cursor) { + stack.push((child, depth.saturating_add(1))); + } + } + (count, max_depth) + } + + #[must_use] + pub(crate) fn top_level_directive(self, expected: &str) -> bool { + let mut cursor = self.root.walk(); + for statement in self.root.named_children(&mut cursor) { + if statement.kind() != "expression_statement" { + break; + } + let Some(expression) = statement.named_child(0) else { + continue; + }; + let Some(value) = self.literal_string(expression) else { + break; + }; + if value == expected { + return true; + } + } + false + } + + #[must_use] + pub(crate) fn literal_string(self, node: Node<'tree>) -> Option { + let text = self.text(node)?; + match node.kind() { + "string" | "string_fragment" => unquote_static(text), + "template_string" => { + if text.contains("${") { + None + } else { + unquote_static(text) + } + } + _ => None, + } + } + + #[must_use] + pub(crate) fn property_name(self, node: Node<'tree>) -> Option { + let key = node + .child_by_field_name("key") + .or_else(|| node.child_by_field_name("name")) + .or_else(|| node.named_child(0))?; + if key.kind() == "computed_property_name" { + return None; + } + self.literal_string(key).or_else(|| { + matches!( + key.kind(), + "identifier" | "property_identifier" | "shorthand_property_identifier_pattern" + ) + .then(|| self.text(key).map(str::to_owned)) + .flatten() + }) + } + + #[must_use] + pub(crate) fn call_callee(self, node: Node<'tree>) -> Option { + if node.kind() != "call_expression" { + return None; + } + let function = node.child_by_field_name("function")?; + if self.contains_kind(function, "subscript_expression") + || self.contains_kind(function, "computed_property_name") + { + return None; + } + matches!( + function.kind(), + "identifier" | "member_expression" | "nested_identifier" | "this" + ) + .then(|| self.text(function).map(str::to_owned)) + .flatten() + } + + /// Return local bindings imported from one static module. This is used + /// for framework factory activation so a shadowed identifier such as a + /// local `defineConfig` cannot masquerade as the framework API. + #[must_use] + pub(crate) fn imported_local_names(self, module: &str, imported: &str) -> Vec { + let mut names = Vec::new(); + for statement in self + .descendants(self.root) + .into_iter() + .filter(|node| node.kind() == "import_statement") + { + let source_module = self + .descendants(statement) + .into_iter() + .find_map(|node| self.literal_string(node)); + if source_module.as_deref() != Some(module) { + continue; + } + for node in self.descendants(statement) { + let local = node + .child_by_field_name("alias") + .or_else(|| node.child_by_field_name("name")); + let Some(local) = local else { + continue; + }; + let imported_name = node + .child_by_field_name("name") + .and_then(|name| self.text(name)) + .unwrap_or_default(); + let local_name = self.text(local).unwrap_or_default(); + let matches = match imported { + "*" => node.kind() == "namespace_import", + "default" => node.kind() == "import_clause" || node.kind() == "default_import", + _ => imported_name == imported, + }; + if matches && !local_name.is_empty() { + names.push(local_name.to_owned()); + } + } + } + names.sort(); + names.dedup(); + names + } + + /// Find the statically recoverable object returned to a configuration + /// factory. Direct object arguments and arrow/function bodies are + /// supported; arbitrary nested call arguments remain incomplete. + #[must_use] + pub(crate) fn config_object_from_call(self, call: Node<'tree>) -> Option> { + let arguments = call.child_by_field_name("arguments")?; + let mut cursor = arguments.walk(); + for argument in arguments.named_children(&mut cursor) { + if let Some(object) = self.config_object_in(argument, 0) { + return Some(object); + } + } + None + } + + #[must_use] + pub(crate) fn exported_default_config_object(self) -> Option> { + for statement in self + .descendants(self.root) + .into_iter() + .filter(|node| node.kind() == "export_statement") + { + let mut cursor = statement.walk(); + for child in statement.named_children(&mut cursor) { + if child.kind() == "object" && !self.is_incomplete(child) { + return Some(child); + } + if child.kind() == "call_expression" + && self.call_callee(child).is_some() + && let Some(object) = self.config_object_from_call(child) + { + return Some(object); + } + } + } + None + } + + fn config_object_in(self, node: Node<'tree>, depth: usize) -> Option> { + if depth > 8 || self.is_incomplete(node) { + return None; + } + if node.kind() == "object" { + return Some(node); + } + if matches!( + node.kind(), + "arrow_function" + | "function" + | "function_expression" + | "parenthesized_expression" + | "as_expression" + | "satisfies_expression" + | "return_statement" + ) { + let child = node + .child_by_field_name("body") + .or_else(|| node.child_by_field_name("expression")) + .or_else(|| node.named_child(0)); + return child.and_then(|child| self.config_object_in(child, depth + 1)); + } + None + } + + #[must_use] + pub(crate) fn static_value(self, node: Node<'tree>) -> StaticValue { + static_value(self, node, 0, 0) + } +} + +fn static_value<'tree, 'source>( + syntax: TypeScriptSyntax<'tree, 'source>, + node: Node<'tree>, + depth: usize, + items: usize, +) -> StaticValue { + if depth > MAX_STATIC_DEPTH || items > MAX_STATIC_ITEMS { + return StaticValue::Incomplete; + } + let Some(text) = syntax.text(node) else { + return StaticValue::Incomplete; + }; + if text.len() > MAX_STATIC_BYTES { + return StaticValue::Incomplete; + } + if syntax.is_incomplete(node) { + return StaticValue::Incomplete; + } + match node.kind() { + "string" | "string_fragment" | "template_string" => syntax + .literal_string(node) + .map_or(StaticValue::Incomplete, StaticValue::String), + "true" => StaticValue::Boolean(true), + "false" => StaticValue::Boolean(false), + "null" => StaticValue::Null, + "number" | "regex_pattern" => { + if node.kind() == "regex_pattern" { + StaticValue::Regex(text.to_owned()) + } else { + StaticValue::Number(text.to_owned()) + } + } + "regex" => StaticValue::Regex(text.to_owned()), + "parenthesized_expression" | "as_expression" | "satisfies_expression" => node + .named_child(0) + .map_or(StaticValue::Incomplete, |child| { + static_value(syntax, child, depth.saturating_add(1), items) + }), + "array" => { + let mut cursor = node.walk(); + let children = node.named_children(&mut cursor).collect::>(); + if children.len().saturating_add(items) > MAX_STATIC_ITEMS { + return StaticValue::Incomplete; + } + StaticValue::Array( + children + .into_iter() + .map(|child| static_value(syntax, child, depth.saturating_add(1), items + 1)) + .collect(), + ) + } + "object" => { + let mut cursor = node.walk(); + let children = node.named_children(&mut cursor).collect::>(); + if children.len().saturating_add(items) > MAX_STATIC_ITEMS { + return StaticValue::Incomplete; + } + let mut values = Vec::new(); + for child in children { + let Some(key) = syntax.property_name(child) else { + return StaticValue::Incomplete; + }; + let Some(value_node) = child + .child_by_field_name("value") + .or_else(|| child.named_child(1)) + else { + return StaticValue::Incomplete; + }; + let value = static_value( + syntax, + value_node, + depth.saturating_add(1), + items.saturating_add(1), + ); + if value == StaticValue::Incomplete { + return StaticValue::Incomplete; + } + values.push((key, value)); + } + StaticValue::Object(values) + } + _ => StaticValue::Incomplete, + } +} + +fn unquote_static(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() < 2 || bytes[0] != *bytes.last()? { + return None; + } + if !matches!(bytes[0], b'\'' | b'"' | b'`') { + return None; + } + let value = &text[1..text.len().saturating_sub(1)]; + if value.contains('\\') || value.contains("${") || value.len() > MAX_STATIC_BYTES { + return None; + } + Some(value.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn parse( + language_name: &str, + source: &[u8], + ) -> Result> { + let language = tree_sitter_language_pack::get_language(language_name)?; + let mut parser = Parser::new(); + parser.set_language(&language)?; + parser + .parse(source, None) + .ok_or_else(|| "parser returned no tree".into()) + } + + #[test] + fn exact_directive_and_static_object_values() -> Result<(), Box> { + let source = br#""use client"; +const config = { root: "src", enabled: true }; +"#; + let tree = parse("typescript", source)?; + let syntax = TypeScriptSyntax::new(tree.root_node(), source); + assert!(syntax.top_level_directive("use client")); + let object = syntax + .descendants(tree.root_node()) + .into_iter() + .find(|node| node.kind() == "object") + .ok_or("object not found")?; + let static_object = syntax.static_value(object); + let values = static_object + .object() + .ok_or("object was not statically recoverable")?; + assert_eq!( + values + .iter() + .find(|(key, _)| key == "root") + .and_then(|(_, value)| value.as_string()), + Some("src") + ); + assert_eq!( + values + .iter() + .find(|(key, _)| key == "enabled") + .map(|(_, value)| value), + Some(&StaticValue::Boolean(true)) + ); + Ok(()) + } + + #[test] + fn static_view_rejects_dynamic_values_and_exposes_callee() + -> Result<(), Box> { + let source = b"const value = makeConfig(process.env.MODE);"; + let tree = parse("typescript", source)?; + let syntax = TypeScriptSyntax::new(tree.root_node(), source); + let call = syntax + .descendants(tree.root_node()) + .into_iter() + .find(|node| node.kind() == "call_expression") + .ok_or("call not found")?; + assert_eq!(syntax.call_callee(call).as_deref(), Some("makeConfig")); + assert_eq!(syntax.static_value(call), StaticValue::Incomplete); + Ok(()) + } + + #[test] + fn recovered_trees_never_become_complete_values() -> Result<(), Box> { + let source = b"const App = () => + ); + })} + + +
+ {visibleBlocks.length > 0 ? ( +
+ {visibleBlocks.map((block) => ( + { + if (isOcrBlock(block)) setPreviewId(block.id); + onFocus(block.id); + }} + /> + ))} +
+ ) : ( +
+
+ )} +
+ + {previewBlock && ( + { + setPreviewId(nodeId); + onFocus(nodeId); + }} + /> + )} + + ); +} + +function OcrStatusChip({ + mode, + coverage, + profile +}: { + mode: string; + coverage: string; + profile?: string; +}) { + const isHealthy = coverage === "complete"; + const isRequested = mode !== "off"; + const statusLabel = `OCR ${isRequested ? modeLabel(mode) : "Off"} · ${ + isRequested ? coverageLabel(coverage) : "Native only" + }${profile ? ` · ${profile}` : ""}`; + return ( + + {isHealthy ? + ); +} + +function DocumentBlockCard({ + block, + active, + onClick +}: { + block: GraphNode; + active: boolean; + onClick(): void; +}) { + const ocr = ocrOrigin(block); + const text = block.document?.text ?? block.label; + const locator = block.document?.locator; + return ( + + ); +} + +function ConfidenceBadge({ confidence }: { confidence: number }) { + const percentage = Math.max(0, Math.min(100, confidence / 100)); + const band = percentage >= 85 ? "high" : percentage >= 60 ? "medium" : "low"; + return ( + + {percentage.toFixed(2)}% + + ); +} + +function OcrPreview({ + block, + siblings, + onFocus +}: { + block: OcrBlock; + siblings: OcrBlock[]; + onFocus(nodeId: string): void; +}) { + const locator = block.document.locator; + const width = boundedDimension(locator.width); + const height = boundedDimension(locator.height); + const candidateId = locator.candidate_id; + const regions = siblings.filter((candidate) => { + const candidateLocator = candidate.document.locator; + return candidateLocator.candidate_id === candidateId + && candidateLocator.width === locator.width + && candidateLocator.height === locator.height; + }); + if (!width || !height) return null; + return ( +
+
+ + Visual region + {formatLocator(locator.owner)} · {width} × {height}px + + OCR boxes +
+ + + + + + + + + {regions.map((candidate) => { + const candidateLocator = candidate.document.locator; + const points = polygonPoints(candidateLocator.polygon, width, height); + if (!points) return null; + const active = candidate.id === block.id; + return ( + onFocus(candidate.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onFocus(candidate.id); + } + }} + > + {candidate.document.text ?? candidate.label} + + ); + })} + +

Click a highlighted region to inspect its text and confidence.

+
+ ); +} + +function isOcrBlock(block: GraphNode): block is OcrBlock { + return ocrOrigin(block) !== undefined && block.document?.locator?.kind === "ocr"; +} + +function ocrOrigin(block: GraphNode): Extract | undefined { + return block.document?.origin?.kind === "ocr" ? block.document.origin : undefined; +} + +function documentBlockOrder(left: GraphNode, right: GraphNode): number { + const leftOrdinal = left.document?.ordinal ?? Number.MAX_SAFE_INTEGER; + const rightOrdinal = right.document?.ordinal ?? Number.MAX_SAFE_INTEGER; + return leftOrdinal - rightOrdinal || left.id.localeCompare(right.id); +} + +function profileName(profile: Record | undefined): string | undefined { + const value = profile?.profile; + return typeof value === "string" ? prettyProfile(value) : undefined; +} + +function prettyProfile(value: string): string { + return value + .replace(/^pp-/, "PP-") + .replace(/ocrv(\d+)/i, "OCRv$1") + .replace(/-(small|medium)$/i, (_, size: string) => ` ${size[0]?.toUpperCase()}${size.slice(1)}`); +} + +function modeLabel(value: string): string { + return value.length > 0 ? `${value[0]?.toUpperCase()}${value.slice(1)}` : "Off"; +} + +function coverageLabel(value: string): string { + return { + complete: "Complete", + partial: "Partial coverage", + failed: "Failed", + not_requested: "Not requested" + }[value] ?? "Unknown"; +} + +function formatDocumentName(sourceFile: string | undefined, format: string | undefined): string { + const name = sourceFile?.split(/[\\/]/).pop() ?? "Document"; + return format ? `${name} · ${format.toUpperCase()}` : name; +} + +function formatBlockKind(value: string | undefined): string { + if (!value) return "Native text"; + return value + .replaceAll("_", " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function formatLocator(locator: DocumentLocator | undefined): string { + if (!locator) return "Location unavailable"; + switch (locator.kind) { + case "ocr": { + const owner = field(locator, "owner"); + const occurrence = numberValue(field(locator, "occurrence")) ?? 0; + return `${formatLocator(isLocator(owner) ? owner : undefined)} · Region ${occurrence + 1}`; + } + case "pdf": + return `Page ${numberValue(field(locator, "page")) ?? "?"}`; + case "slide": + return `Slide ${numberValue(field(locator, "slide")) ?? "?"}${field(locator, "shape") !== undefined ? ` · Shape ${String(field(locator, "shape"))}` : ""}`; + case "spreadsheet": { + const sheet = field(locator, "sheet"); + return `${typeof sheet === "string" ? sheet : "Sheet"}!${columnName(numberValue(field(locator, "column")) ?? 1)}${numberValue(field(locator, "row")) ?? "?"}`; + } + case "package": { + const part = field(locator, "part"); + const path = field(locator, "path"); + const partText = typeof part === "string" ? part : "Package part"; + if (/\/(?:media|embeddings)\//i.test(partText) || /\.(?:avif|bmp|gif|jpe?g|png|tiff?|webp)$/i.test(partText)) { + return "Embedded image"; + } + const shortPart = partText.split(/[\\/]/).pop() || "Package part"; + return typeof path === "string" && path.length > 0 + ? `${shortPart} · ${path}` + : shortPart; + } + case "text_range": { + const startLine = numberValue(field(locator, "start_line")); + const endLine = numberValue(field(locator, "end_line")); + return startLine !== undefined + ? `Lines ${startLine}${endLine !== undefined && endLine !== startLine ? `–${endLine}` : ""}` + : "Text range"; + } + default: + return "Document location"; + } +} + +function field(value: object, key: string): unknown { + return (value as Record)[key]; +} + +function isLocator(value: unknown): value is DocumentLocator { + return typeof value === "object" + && value !== null + && typeof field(value, "kind") === "string"; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function columnName(value: number): string { + let current = Math.max(1, Math.floor(value)); + let output = ""; + while (current > 0) { + const remainder = (current - 1) % 26; + output = String.fromCharCode(65 + remainder) + output; + current = Math.floor((current - 1) / 26); + } + return output; +} + +function boundedDimension(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 && value <= 10_000 + ? value + : undefined; +} + +function polygonPoints(value: unknown, width: number, height: number): string | undefined { + if (!Array.isArray(value)) return undefined; + const points = value.slice(0, 256).flatMap((point) => { + if (typeof point !== "object" || point === null) return []; + const x = numberValue(field(point, "x")); + const y = numberValue(field(point, "y")); + return x !== undefined && y !== undefined + ? [`${clamp(x, 0, width)},${clamp(y, 0, height)}`] + : []; + }); + return points.length >= 3 ? points.join(" ") : undefined; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/compass-viewer/src/graph/GraphInspector.tsx b/packages/compass-viewer/src/graph/GraphInspector.tsx index d995d13f..104ab4eb 100644 --- a/packages/compass-viewer/src/graph/GraphInspector.tsx +++ b/packages/compass-viewer/src/graph/GraphInspector.tsx @@ -16,6 +16,10 @@ import type { CodeQueryResponse } from "../contracts/codeQuery"; import { ChangeEvidence, type GraphSourceRevisions } from "./ChangeEvidence"; import { ChangedSymbolList } from "./ChangedSymbolList"; import { CodeEvidence } from "./CodeEvidence"; +import { + documentContextForNode, + DocumentOcrPanel +} from "./DocumentOcrPanel"; import { navigableSource } from "./sourceNavigation"; export const COMMUNITY_CONTROL_LIMIT = 200; @@ -148,6 +152,7 @@ export function GraphInspector({ const selectedQueryNode = selected ? queryResult?.nodes.find((node) => node.id === selected.id) : undefined; + const documentContext = selected ? documentContextForNode(model, selected) : undefined; const selectedCodeEvidence = selectedQueryNode?.evidence ?? selected?.codeEvidence ?? []; const relationshipCodeEvidence = selected ? (queryResult @@ -368,6 +373,13 @@ export function GraphInspector({ {selected.signature && ( {selected.signature} )} + {documentContext && ( + + )} {onQueryNode && (
+ {ocrModel && }